diff --git a/.env b/.env new file mode 100755 index 000000000..d18705780 --- /dev/null +++ b/.env @@ -0,0 +1,51 @@ +## Version +TAG=latest + +## Image Registry Path +# Docker Hub: (aka registry-1.docker.io/) leave REGISTRY_PATH value empty! +# Docker Hub Proxy: scm.cms.hu-berlin.de:443/iqb/dependency_proxy/containers/ +# GitLab: scm.cms.hu-berlin.de:4567/iqb/coding-box/ +REGISTRY_PATH=scm.cms.hu-berlin.de:443/iqb/dependency_proxy/containers/ + +## Redis +REDIS_PORT=6379 + +## Database +POSTGRES_PORT=5432 +POSTGRES_USER=root +POSTGRES_PASSWORD=root-password +POSTGRES_DB=coding-box + +## Backend +API_PORT=3333 +JWT_SECRET=random_string +AUTH_MODE=dev + +## Frontend +HTTP_PORT=4200 + +## Infrastructure +SERVER_NAME=localhost + +# OpenID Connect (OIDC) +OIDC_PROVIDER_URL=https://keycloak.kodierbox.iqb.hu-berlin.de +OIDC_ISSUER=https://keycloak.kodierbox.iqb.hu-berlin.de/realms/coding-box +OIDC_ACCOUNT_ENDPOINT=https://keycloak.kodierbox.iqb.hu-berlin.de/realms/coding-box/account +OIDC_AUTHORIZATION_ENDPOINT=https://keycloak.kodierbox.iqb.hu-berlin.de/realms/coding-box/protocol/openid-connect/auth +OIDC_TOKEN_ENDPOINT=https://keycloak.kodierbox.iqb.hu-berlin.de/realms/coding-box/protocol/openid-connect/token +OIDC_USERINFO_ENDPOINT=https://keycloak.kodierbox.iqb.hu-berlin.de/realms/coding-box/protocol/openid-connect/userinfo +OIDC_END_SESSION_ENDPOINT=https://keycloak.kodierbox.iqb.hu-berlin.de/realms/coding-box/protocol/openid-connect/logout +OIDC_JWKS_URI=https://keycloak.kodierbox.iqb.hu-berlin.de/realms/coding-box/protocol/openid-connect/certs +OAUTH2_CLIENT_ID=coding-box +OAUTH2_CLIENT_SECRET= +OAUTH2_REDIRECT_URL=//localhost:3333/api/auth/callback + +# Keycloak +## Realm Admin +ADMIN_NAME=admin +ADMIN_PASSWORD=change_me + +# Keycloak DB +KEYCLOAK_DB_USER=keycloak +KEYCLOAK_DB_PASSWORD=change_me +KEYCLOAK_DB_NAME=keycloak diff --git a/.env.coding-box.template b/.env.coding-box.template index 9d8f8ff4a..a09b31e97 100644 --- a/.env.coding-box.template +++ b/.env.coding-box.template @@ -9,7 +9,7 @@ REGISTRY_PATH= ## Database POSTGRES_USER=root -POSTGRES_PASSWORD=root-password +POSTGRES_PASSWORD=change_me POSTGRES_DB=coding-box ## Backend @@ -41,3 +41,16 @@ EXPORT_WORKER_MEMORY_LIMIT=3G ## Infrastructure SERVER_NAME=hostname.de TRAEFIK_DIR= + +# OpenID Connect (OIDC) +OIDC_PROVIDER_URL=https://keycloak.${SERVER_NAME} +OIDC_ISSUER=https://keycloak.${SERVER_NAME}/auth/realms/iqb +OIDC_ACCOUNT_ENDPOINT=https://keycloak.${SERVER_NAME}/auth/realms/iqb/account +OIDC_AUTHORIZATION_ENDPOINT=https://keycloak.${SERVER_NAME}/auth/realms/iqb/protocol/openid-connect/auth +OIDC_TOKEN_ENDPOINT=https://keycloak.${SERVER_NAME}/auth/realms/iqb/protocol/openid-connect/token +OIDC_USERINFO_ENDPOINT=https://keycloak.${SERVER_NAME}/auth/realms/iqb/protocol/openid-connect/userinfo +OIDC_END_SESSION_ENDPOINT=https://keycloak.${SERVER_NAME}/auth/realms/iqb/protocol/openid-connect/logout +OIDC_JWKS_URI=https://keycloak.${SERVER_NAME}/auth/realms/iqb/protocol/openid-connect/certs +OAUTH2_CLIENT_ID=coding-box +OAUTH2_CLIENT_SECRET=change_me +OAUTH2_REDIRECT_URL=//${SERVER_NAME}/api/auth/callback diff --git a/.env.dev.template b/.env.dev.template index 39cb59769..fda2b4871 100644 --- a/.env.dev.template +++ b/.env.dev.template @@ -19,24 +19,7 @@ POSTGRES_DB=coding-box ## Backend API_PORT=3333 JWT_SECRET=random_string -KEYCLOAK_URL=https://keycloak.kodierbox.iqb.hu-berlin.de/ -KEYCLOAK_REALM=coding-box -KEYCLOAK_CLIENT_ID=coding-box -KEYCLOAK_CLIENT_SECRET=change_me -OIDC_ISSUER=https://keycloak.kodierbox.iqb.hu-berlin.de/realms/coding-box -OIDC_JWKS_URI=https://keycloak.kodierbox.iqb.hu-berlin.de/realms/coding-box/protocol/openid-connect/certs -WORKSPACE_TOKEN_REPLAY_READ_MAX_DURATION_DAYS=90 -ALLOW_LEGACY_WORKSPACE_REPLAY_TOKENS=true -# API backend PostgreSQL connection pool size. -POSTGRES_POOL_MAX=10 -# Export worker DB session options. The default disables PostgreSQL JIT for large export queries. -EXPORT_WORKER_PGOPTIONS=-c jit=off -# Dedicated export worker PostgreSQL connection pool size. -EXPORT_WORKER_POSTGRES_POOL_MAX=4 -# Coding-list export DB batch size. Smaller batches improve cancel responsiveness for large exports. -EXPORT_CODING_LIST_BATCH_SIZE=500 -# Versioned export DB batch size. Smaller batches improve cancel responsiveness for large exports. -EXPORT_VERSIONED_BATCH_SIZE=250 +AUTH_MODE=dev # Optional override if GeoGebra changes the public bundle URL. # GEOGEBRA_BUNDLE_DOWNLOAD_URL=https://download.geogebra.org/package/geogebra-math-apps-bundle @@ -45,3 +28,32 @@ HTTP_PORT=4200 ## Infrastructure SERVER_NAME=localhost + +# OpenID Connect (OIDC) +OIDC_PROVIDER_URL=http://${SERVER_NAME}:8080 +OIDC_ISSUER=http://${SERVER_NAME}:8080/realms/coding-box +OIDC_ACCOUNT_ENDPOINT=http://${SERVER_NAME}:8080/realms/coding-box/account +OIDC_AUTHORIZATION_ENDPOINT=http://${SERVER_NAME}:8080/realms/coding-box/protocol/openid-connect/auth +OIDC_TOKEN_ENDPOINT=http://keycloak:8080/realms/coding-box/protocol/openid-connect/token +OIDC_USERINFO_ENDPOINT=http://keycloak:8080/realms/coding-box/protocol/openid-connect/userinfo +OIDC_END_SESSION_ENDPOINT=http://keycloak:8080/realms/coding-box/protocol/openid-connect/logout +OIDC_JWKS_URI=http://keycloak:8080/realms/coding-box/protocol/openid-connect/certs +OAUTH2_CLIENT_ID=coding-box +OAUTH2_CLIENT_SECRET=change_me +OAUTH2_REDIRECT_URL=//${SERVER_NAME}:${API_PORT}/api/auth/callback + +# Keycloak +## Realm Admin +ADMIN_NAME=admin +ADMIN_PASSWORD=change_me + +# Keycloak DB +KEYCLOAK_DB_USER=keycloak +KEYCLOAK_DB_PASSWORD=change_me +KEYCLOAK_DB_NAME=keycloak + +# Keycloak Backend Configuration +KEYCLOAK_URL=http://${SERVER_NAME}:8080/ +KEYCLOAK_REALM=coding-box +KEYCLOAK_CLIENT_ID=coding-box +KEYCLOAK_CLIENT_SECRET=change_me diff --git a/.gitignore b/.gitignore index a2084c4c9..f90913d68 100755 --- a/.gitignore +++ b/.gitignore @@ -49,6 +49,9 @@ docker.env .DS_Store Thumbs.db +# Keycloak files +config/keycloak/realm/coding-box-realm.config + # Backend files auth.constants.ts database.constants.ts diff --git a/README.md b/README.md index e410b5e06..093650aa6 100755 --- a/README.md +++ b/README.md @@ -51,12 +51,39 @@ Stellen Sie sicher, dass die folgenden Tools auf Ihrem System installiert sind: cp .env.dev.template .env.dev ``` -2. Installieren Sie die Abhängigkeiten: +2. Kopieren Sie die Vorlage der Keycloak-Realm-Konfiguration: + + ``` + cp config/keycloak/realm/coding-box-realm.config.template config/keycloak/realm/coding-box-realm.config + ``` + +3. Ersetzen Sie den Shell-Befehl für den Timestamp durch einen numerischen Wert: + + Öffnen Sie die Datei `config/keycloak/realm/coding-box-realm.config` und ersetzen Sie + `CODING_BOX_ADMIN_CREATED_TIMESTAMP=date --utc +"%s%3N"` + durch einen aktuellen Timestamp in Millisekunden, z.B.: + `CODING_BOX_ADMIN_CREATED_TIMESTAMP=1775907461877` + +4. Installieren Sie die Abhängigkeiten: ``` npm install ``` +5. **Standard-Anmeldedaten für die Entwicklung** + + Nach dem Start der Umgebung sind folgende Standard-Anmeldedaten verfügbar: + + - **Keycloak Admin Console** (http://localhost:8080/admin): + - Benutzername: `admin` + - Passwort: `change_me` + + - **Kodierbox Realm** (http://localhost:8080/realms/coding-box): + - Benutzername: `coding-box-admin` + - Passwort: `change_me` + + **Wichtig:** Ändern Sie diese Passwörter nach dem ersten Login aus Sicherheitsgründen. + --- ## Entwicklungsprozess @@ -214,6 +241,30 @@ und geben Sie danach folgenden Befehl zum Hochfahren der Webanwendung ein: make coding-box-up ``` +## Authentication + +### Keycloak Auth Flow (OIDC + PKCE) + +Die Anwendung nutzt **Keycloak** als Identity Provider mit dem **OAuth2 Authorization Code Flow** und **PKCE**: + +1. **Login starten** (`GET /api/auth/login`): Backend erzeugt `state` und PKCE (`code_verifier`, `code_challenge`) und leitet zum Keycloak‑Login weiter. +2. **Benutzer‑Login**: Nutzer meldet sich bei Keycloak an (Passwort, SSO, etc.). +3. **Callback** (`GET /api/auth/callback`): Keycloak liefert `code` und `state` zurück. +4. **Token‑Exchange**: Backend tauscht `code` gegen Tokens am Keycloak‑Token‑Endpoint, mit PKCE `code_verifier` (ohne Client‑Secret). +5. **Userinfo**: Backend ruft User‑Profil via `userinfo`‑Endpoint ab. +6. **User‑Persistenz**: Nutzer wird in der lokalen DB gespeichert (Identität über `sub`). +7. **Token‑Weitergabe**: Backend leitet den Nutzer zur Frontend‑URL zurück und hängt `token`, `id_token`, `refresh_token` als Query‑Params an. +8. **Frontend‑Session**: Frontend speichert Tokens in `localStorage` und lädt `auth-data` (Arbeitsbereiche). + +Details: +- `state` enthält optional die Ziel‑URL (`redirect_uri`) und wird serverseitig geprüft. +- PKCE‑Verifier wird serverseitig kurzzeitig gespeichert (TTL 5 Minuten). +- API‑Requests senden den Access‑Token als `Authorization: Bearer `. +- Logout nutzt `POST /api/auth/logout` und invalidiert die SSO‑Session bei Keycloak. + +--- + +## Additional Information Nachdem die Prozesse dieser Befehle beendet sind, ist der Edge-Router und das Monitoring aktiv, die Kodierbox Datenbank eingerichtet, die Kodierbox API und Web-Site ansprechbar. Ein Zugriff auf den Server über einen Browser sollte dann sofort möglich sein. diff --git a/apps/backend/src/app/admin/admin.guard.spec.ts b/apps/backend/src/app/admin/admin.guard.spec.ts index 47adc2cac..35cdc8ee8 100644 --- a/apps/backend/src/app/admin/admin.guard.spec.ts +++ b/apps/backend/src/app/admin/admin.guard.spec.ts @@ -4,15 +4,20 @@ import { import { ExecutionContext, UnauthorizedException } from '@nestjs/common'; import { AdminGuard } from './admin.guard'; import { AuthService } from '../auth/service/auth.service'; +import { UsersService } from '../database/services/users'; describe('AdminGuard (Backend)', () => { let guard: AdminGuard; let authService: jest.Mocked; + let usersService: jest.Mocked; beforeEach(async () => { const mockAuthService = { isAdminUser: jest.fn() }; + const mockUsersService = { + findUserByIdentity: jest.fn() + }; const module: TestingModule = await Test.createTestingModule({ providers: [ @@ -20,48 +25,92 @@ describe('AdminGuard (Backend)', () => { { provide: AuthService, useValue: mockAuthService + }, + { + provide: UsersService, + useValue: mockUsersService } ] }).compile(); guard = module.get(AdminGuard); authService = module.get(AuthService); + usersService = module.get(UsersService); }); - const createMockExecutionContext = (userId: number): ExecutionContext => ({ + const createMockExecutionContext = (userId: string, isAdmin = false): ExecutionContext => ({ switchToHttp: () => ({ getRequest: () => ({ - user: { id: userId } + user: { id: userId, isAdmin } }) }) } as unknown as ExecutionContext); describe('Security Validation - Admin Access', () => { + it('should allow access directly from JWT admin claim', async () => { + const context = createMockExecutionContext('oidc-admin', true); + + const result = await guard.canActivate(context); + + expect(result).toBe(true); + expect(usersService.findUserByIdentity).not.toHaveBeenCalled(); + expect(authService.isAdminUser).not.toHaveBeenCalled(); + }); + it('should allow access for admin user', async () => { + usersService.findUserByIdentity.mockResolvedValue({ id: 1 } as never); authService.isAdminUser.mockResolvedValue(true); - const context = createMockExecutionContext(1); + const context = createMockExecutionContext('oidc-1'); const result = await guard.canActivate(context); expect(result).toBe(true); + expect(usersService.findUserByIdentity).toHaveBeenCalledWith('oidc-1'); expect(authService.isAdminUser).toHaveBeenCalledWith(1); }); + it('should check admin access by database id for validated JWT users', async () => { + authService.isAdminUser.mockResolvedValue(true); + const context = { + switchToHttp: () => ({ + getRequest: () => ({ + user: { + id: 42, + userId: 42, + identity: 'oidc-42', + sub: 'oidc-42', + isAdmin: false + } + }) + }) + } as unknown as ExecutionContext; + + const result = await guard.canActivate(context); + + expect(result).toBe(true); + expect(usersService.findUserByIdentity).not.toHaveBeenCalled(); + expect(authService.isAdminUser).toHaveBeenCalledWith(42); + }); + it('should deny access for non-admin user', async () => { + usersService.findUserByIdentity.mockResolvedValue({ id: 2 } as never); authService.isAdminUser.mockResolvedValue(false); - const context = createMockExecutionContext(2); + const context = createMockExecutionContext('oidc-2'); await expect(guard.canActivate(context)).rejects.toThrow(UnauthorizedException); await expect(guard.canActivate(context)).rejects.toThrow('Admin privileges required'); + expect(usersService.findUserByIdentity).toHaveBeenCalledWith('oidc-2'); expect(authService.isAdminUser).toHaveBeenCalledWith(2); }); - it('should validate user ID from request', async () => { + it('should resolve database user ID from request identity', async () => { + usersService.findUserByIdentity.mockResolvedValue({ id: 42 } as never); authService.isAdminUser.mockResolvedValue(true); - const context = createMockExecutionContext(42); + const context = createMockExecutionContext('4fecd283-bd64-4930-aee8-07e58df323bf'); await guard.canActivate(context); + expect(usersService.findUserByIdentity).toHaveBeenCalledWith('4fecd283-bd64-4930-aee8-07e58df323bf'); expect(authService.isAdminUser).toHaveBeenCalledWith(42); }); }); @@ -74,7 +123,7 @@ describe('AdminGuard (Backend)', () => { }) } as unknown as ExecutionContext; - await expect(guard.canActivate(context)).rejects.toThrow(); + await expect(guard.canActivate(context)).rejects.toThrow('User not found'); }); it('should handle missing user ID', async () => { @@ -86,7 +135,7 @@ describe('AdminGuard (Backend)', () => { }) } as unknown as ExecutionContext; - await expect(guard.canActivate(context)).rejects.toThrow(); + await expect(guard.canActivate(context)).rejects.toThrow('User not found'); }); it('should handle null user', async () => { @@ -98,7 +147,7 @@ describe('AdminGuard (Backend)', () => { }) } as unknown as ExecutionContext; - await expect(guard.canActivate(context)).rejects.toThrow(); + await expect(guard.canActivate(context)).rejects.toThrow('User not found'); }); it('should handle undefined user', async () => { @@ -110,30 +159,41 @@ describe('AdminGuard (Backend)', () => { }) } as unknown as ExecutionContext; - await expect(guard.canActivate(context)).rejects.toThrow(); + await expect(guard.canActivate(context)).rejects.toThrow('User not found'); + }); + + it('should reject when identity is not mapped to a database user', async () => { + usersService.findUserByIdentity.mockResolvedValue(null); + const context = createMockExecutionContext('missing-oidc-user'); + + await expect(guard.canActivate(context)).rejects.toThrow('User not found'); + expect(authService.isAdminUser).not.toHaveBeenCalled(); }); }); describe('Edge Cases', () => { - it('should handle zero user ID', async () => { + it('should handle zero database user ID', async () => { + usersService.findUserByIdentity.mockResolvedValue({ id: 0 } as never); authService.isAdminUser.mockResolvedValue(false); - const context = createMockExecutionContext(0); + const context = createMockExecutionContext('oidc-zero'); await expect(guard.canActivate(context)).rejects.toThrow(UnauthorizedException); expect(authService.isAdminUser).toHaveBeenCalledWith(0); }); - it('should handle negative user ID', async () => { + it('should handle negative database user ID', async () => { + usersService.findUserByIdentity.mockResolvedValue({ id: -1 } as never); authService.isAdminUser.mockResolvedValue(false); - const context = createMockExecutionContext(-1); + const context = createMockExecutionContext('oidc-negative'); await expect(guard.canActivate(context)).rejects.toThrow(UnauthorizedException); expect(authService.isAdminUser).toHaveBeenCalledWith(-1); }); - it('should handle very large user ID', async () => { + it('should handle very large database user ID', async () => { + usersService.findUserByIdentity.mockResolvedValue({ id: Number.MAX_SAFE_INTEGER } as never); authService.isAdminUser.mockResolvedValue(true); - const context = createMockExecutionContext(Number.MAX_SAFE_INTEGER); + const context = createMockExecutionContext('oidc-large'); const result = await guard.canActivate(context); @@ -141,49 +201,48 @@ describe('AdminGuard (Backend)', () => { expect(authService.isAdminUser).toHaveBeenCalledWith(Number.MAX_SAFE_INTEGER); }); - it('should handle string user ID', async () => { - const context = { - switchToHttp: () => ({ - getRequest: () => ({ - user: { id: '123' as unknown as number } - }) - }) - } as unknown as ExecutionContext; - + it('should handle UUID identity values', async () => { + usersService.findUserByIdentity.mockResolvedValue({ id: 123 } as never); + const context = createMockExecutionContext('123e4567-e89b-12d3-a456-426614174000'); authService.isAdminUser.mockResolvedValue(true); const result = await guard.canActivate(context); expect(result).toBe(true); - expect(authService.isAdminUser).toHaveBeenCalledWith('123'); + expect(usersService.findUserByIdentity).toHaveBeenCalledWith('123e4567-e89b-12d3-a456-426614174000'); + expect(authService.isAdminUser).toHaveBeenCalledWith(123); }); }); describe('Security - Service Errors', () => { it('should propagate auth service errors', async () => { + usersService.findUserByIdentity.mockResolvedValue({ id: 1 } as never); authService.isAdminUser.mockRejectedValue(new Error('Database error')); - const context = createMockExecutionContext(1); + const context = createMockExecutionContext('oidc-1'); await expect(guard.canActivate(context)).rejects.toThrow('Database error'); }); it('should handle auth service async response', async () => { + usersService.findUserByIdentity.mockResolvedValue({ id: 1 } as never); authService.isAdminUser.mockResolvedValue(false); - const context = createMockExecutionContext(1); + const context = createMockExecutionContext('oidc-1'); await expect(guard.canActivate(context)).rejects.toThrow(UnauthorizedException); }); it('should handle auth service returning null', async () => { + usersService.findUserByIdentity.mockResolvedValue({ id: 1 } as never); authService.isAdminUser.mockResolvedValue(null as unknown as boolean); - const context = createMockExecutionContext(1); + const context = createMockExecutionContext('oidc-1'); await expect(guard.canActivate(context)).rejects.toThrow(UnauthorizedException); }); it('should handle auth service returning undefined', async () => { + usersService.findUserByIdentity.mockResolvedValue({ id: 1 } as never); authService.isAdminUser.mockResolvedValue(undefined as unknown as boolean); - const context = createMockExecutionContext(1); + const context = createMockExecutionContext('oidc-1'); await expect(guard.canActivate(context)).rejects.toThrow(UnauthorizedException); }); @@ -191,9 +250,11 @@ describe('AdminGuard (Backend)', () => { describe('Security - Privilege Escalation Prevention', () => { it('should not cache admin status between requests', async () => { - const context1 = createMockExecutionContext(1); - const context2 = createMockExecutionContext(2); + const context1 = createMockExecutionContext('oidc-1'); + const context2 = createMockExecutionContext('oidc-2'); + usersService.findUserByIdentity.mockResolvedValueOnce({ id: 1 } as never); + usersService.findUserByIdentity.mockResolvedValueOnce({ id: 2 } as never); authService.isAdminUser.mockResolvedValueOnce(true); authService.isAdminUser.mockResolvedValueOnce(false); @@ -204,8 +265,9 @@ describe('AdminGuard (Backend)', () => { }); it('should always verify admin status from auth service', async () => { + usersService.findUserByIdentity.mockResolvedValue({ id: 1 } as never); authService.isAdminUser.mockResolvedValue(true); - const context = createMockExecutionContext(1); + const context = createMockExecutionContext('oidc-1'); await guard.canActivate(context); await guard.canActivate(context); @@ -215,8 +277,9 @@ describe('AdminGuard (Backend)', () => { }); it('should not allow access if isAdminUser returns false even once', async () => { + usersService.findUserByIdentity.mockResolvedValue({ id: 1 } as never); authService.isAdminUser.mockResolvedValue(false); - const context = createMockExecutionContext(1); + const context = createMockExecutionContext('oidc-1'); await expect(guard.canActivate(context)).rejects.toThrow(UnauthorizedException); await expect(guard.canActivate(context)).rejects.toThrow(UnauthorizedException); @@ -225,8 +288,9 @@ describe('AdminGuard (Backend)', () => { describe('Error Messages', () => { it('should provide clear error message for unauthorized access', async () => { + usersService.findUserByIdentity.mockResolvedValue({ id: 1 } as never); authService.isAdminUser.mockResolvedValue(false); - const context = createMockExecutionContext(1); + const context = createMockExecutionContext('oidc-1'); await expect(guard.canActivate(context)).rejects.toThrow(UnauthorizedException); await expect(guard.canActivate(context)).rejects.toThrow('Admin privileges required'); diff --git a/apps/backend/src/app/admin/admin.guard.ts b/apps/backend/src/app/admin/admin.guard.ts index f5e6317ba..0f1d2d0c5 100644 --- a/apps/backend/src/app/admin/admin.guard.ts +++ b/apps/backend/src/app/admin/admin.guard.ts @@ -2,18 +2,50 @@ import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from '@nestjs/common'; import { AuthService } from '../auth/service/auth.service'; +import { UsersService } from '../database/services/users'; + +interface RequestUser { + id?: number | string; + userId?: number | string; + identity?: string; + sub?: string; + isAdmin?: boolean; +} @Injectable() export class AdminGuard implements CanActivate { constructor( - private authService: AuthService + private authService: AuthService, + private usersService: UsersService ) {} async canActivate( context: ExecutionContext ): Promise { const req = context.switchToHttp().getRequest(); - const userId = req.user.id; + const requestUser = req.user as RequestUser | undefined; + + // Prefer the role claim from the validated JWT. + // This avoids blocking admin actions when the DB flag is stale. + if (requestUser?.isAdmin === true) { + return true; + } + + let userId = getDatabaseUserId(requestUser); + + if (!userId) { + const userIdentity = getUserIdentity(requestUser); + if (!userIdentity) { + throw new UnauthorizedException('User not found'); + } + + const user = await this.usersService.findUserByIdentity(userIdentity); + if (!user) { + throw new UnauthorizedException('User not found'); + } + + userId = user.id; + } const isAdmin = await this.authService.isAdminUser(userId); if (!isAdmin) { @@ -23,3 +55,31 @@ export class AdminGuard implements CanActivate { return true; } } + +function getDatabaseUserId(requestUser: RequestUser | undefined): number | undefined { + return requestUser ? + parsePositiveInteger(requestUser.userId) ?? parsePositiveInteger(requestUser.id) : + undefined; +} + +function getUserIdentity(requestUser: RequestUser | undefined): string | undefined { + if (!requestUser) { + return undefined; + } + + const identity = requestUser.identity ?? requestUser.sub; + if (identity?.trim()) { + return identity.trim(); + } + + if (typeof requestUser.id === 'string' && !parsePositiveInteger(requestUser.id) && requestUser.id.trim()) { + return requestUser.id.trim(); + } + + return undefined; +} + +function parsePositiveInteger(value: number | string | undefined): number | undefined { + const numericValue = Number(value); + return Number.isInteger(numericValue) && numericValue > 0 ? numericValue : undefined; +} diff --git a/apps/backend/src/app/admin/code-book/codebook-docx-generator.integration.spec.ts b/apps/backend/src/app/admin/code-book/codebook-docx-generator.integration.spec.ts index 8d6807a0e..87e2e02a0 100644 --- a/apps/backend/src/app/admin/code-book/codebook-docx-generator.integration.spec.ts +++ b/apps/backend/src/app/admin/code-book/codebook-docx-generator.integration.spec.ts @@ -5,6 +5,8 @@ import { } from './codebook.interfaces'; import { CodebookDocxGenerator } from './codebook-docx-generator.class'; +jest.setTimeout(30000); + const defaultSettings: CodeBookContentSetting = { exportFormat: 'docx', missingsProfile: '', diff --git a/apps/backend/src/app/admin/coding-job/dto/update-coding-job-comment.dto.ts b/apps/backend/src/app/admin/coding-job/dto/update-coding-job-comment.dto.ts new file mode 100644 index 000000000..a1acd29cd --- /dev/null +++ b/apps/backend/src/app/admin/coding-job/dto/update-coding-job-comment.dto.ts @@ -0,0 +1,11 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsString } from 'class-validator'; + +export class UpdateCodingJobCommentDto { + @ApiProperty({ + description: 'Comment for the coding job', + example: 'This coding job requires special attention' + }) + @IsString() + comment: string; +} diff --git a/apps/backend/src/app/admin/coding-job/dto/update-coding-job-status.dto.ts b/apps/backend/src/app/admin/coding-job/dto/update-coding-job-status.dto.ts new file mode 100644 index 000000000..f034fe7ae --- /dev/null +++ b/apps/backend/src/app/admin/coding-job/dto/update-coding-job-status.dto.ts @@ -0,0 +1,15 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsEnum } from 'class-validator'; + +export const codingJobReplayStatuses = ['active', 'paused', 'completed'] as const; +export type CodingJobReplayStatus = typeof codingJobReplayStatuses[number]; + +export class UpdateCodingJobStatusDto { + @ApiProperty({ + description: 'Replay-safe status of the coding job', + example: 'active', + enum: codingJobReplayStatuses + }) + @IsEnum(codingJobReplayStatuses) + status: CodingJobReplayStatus; +} diff --git a/apps/backend/src/app/admin/replay-statistics/replay-statistics.controller.ts b/apps/backend/src/app/admin/replay-statistics/replay-statistics.controller.ts index d973569da..8b7765787 100644 --- a/apps/backend/src/app/admin/replay-statistics/replay-statistics.controller.ts +++ b/apps/backend/src/app/admin/replay-statistics/replay-statistics.controller.ts @@ -18,6 +18,7 @@ import { ApiTags } from '@nestjs/swagger'; import { JwtAuthGuard } from '../../auth/jwt-auth.guard'; +import { JwtOrWorkspaceTokenAuthGuard } from '../../auth/jwt-or-workspace-token-auth.guard'; import { AllowAnyWorkspaceTokenScopes, isWorkspaceApiTokenUser, @@ -68,7 +69,7 @@ export class ReplayStatisticsController { WORKSPACE_TOKEN_SCOPE_REPLAY_READ, WORKSPACE_TOKEN_SCOPE_REPLAY_STATISTICS_WRITE ) - @UseGuards(JwtAuthGuard, WorkspaceGuard) + @UseGuards(JwtOrWorkspaceTokenAuthGuard, WorkspaceGuard) async storeReplayStatistics( @Param('workspace_id') workspaceId: string, @Body(new ValidationPipe({ transform: true, whitelist: true })) diff --git a/apps/backend/src/app/admin/variable-bundle/variable-bundle.module.ts b/apps/backend/src/app/admin/variable-bundle/variable-bundle.module.ts index a5bc7bbc6..381d9a6dd 100644 --- a/apps/backend/src/app/admin/variable-bundle/variable-bundle.module.ts +++ b/apps/backend/src/app/admin/variable-bundle/variable-bundle.module.ts @@ -4,11 +4,13 @@ import { VariableBundle } from '../../database/entities/variable-bundle.entity'; import { VariableBundleService } from '../../database/services/coding'; import { VariableBundleController } from './variable-bundle.controller'; import { AuthModule } from '../../auth/auth.module'; +import { DatabaseModule } from '../../database/database.module'; @Module({ imports: [ TypeOrmModule.forFeature([VariableBundle]), - AuthModule + AuthModule, + DatabaseModule ], controllers: [VariableBundleController], providers: [VariableBundleService], diff --git a/apps/backend/src/app/admin/workspace-info/workspace-info-admin.module.ts b/apps/backend/src/app/admin/workspace-info/workspace-info-admin.module.ts index 637e5f026..aceb83340 100644 --- a/apps/backend/src/app/admin/workspace-info/workspace-info-admin.module.ts +++ b/apps/backend/src/app/admin/workspace-info/workspace-info-admin.module.ts @@ -3,6 +3,7 @@ import { DatabaseModule } from '../../database/database.module'; import { WorkspaceModule } from '../../workspace/workspace.module'; import { AuthModule } from '../../auth/auth.module'; import { CodingModule } from '../../coding/coding.module'; +import { UserModule } from '../../user/user.module'; import { WorkspaceController } from '../workspace/workspace.controller'; import { JournalController } from '../workspace/journal.controller'; import { ValidationTaskController } from '../workspace/validation-task.controller'; @@ -19,6 +20,7 @@ import { JobQueueModule } from '../../job-queue/job-queue.module'; WorkspaceModule, AuthModule, CodingModule, + UserModule, JobQueueModule ], controllers: [ diff --git a/apps/backend/src/app/admin/workspace/access-level.guard.spec.ts b/apps/backend/src/app/admin/workspace/access-level.guard.spec.ts index feb7a6940..b756393d1 100644 --- a/apps/backend/src/app/admin/workspace/access-level.guard.spec.ts +++ b/apps/backend/src/app/admin/workspace/access-level.guard.spec.ts @@ -22,7 +22,8 @@ describe('AccessLevelGuard (Backend)', () => { const mockUsersService = { getUserIsAdmin: jest.fn(), - getUserAccessLevel: jest.fn() + getUserAccessLevel: jest.fn(), + findUserByIdentity: jest.fn() }; const module: TestingModule = await Test.createTestingModule({ @@ -45,15 +46,18 @@ describe('AccessLevelGuard (Backend)', () => { }); const createMockExecutionContext = ( - userId: number, + userId: number | string, workspaceId: string, requiredLevel?: number, - userOverrides: Record = {} + userOptions: boolean | Record = false ): ExecutionContext => { + const user = typeof userOptions === 'boolean' ? + { id: userId, isAdmin: userOptions } : + { id: userId, ...userOptions }; const context = { switchToHttp: () => ({ getRequest: () => ({ - user: { id: userId, ...userOverrides }, + user, params: { workspace_id: workspaceId } }) }), @@ -91,6 +95,18 @@ describe('AccessLevelGuard (Backend)', () => { }); describe('Security Validation - System Admin Bypass', () => { + it('should allow access when JWT admin claim is true', async () => { + reflector.get.mockReturnValue(4); + const context = createMockExecutionContext('4fecd283-bd64-4930-aee8-07e58df323bf', '123', 4, true); + + const result = await guard.canActivate(context); + + expect(result).toBe(true); + expect(usersService.findUserByIdentity).not.toHaveBeenCalled(); + expect(usersService.getUserIsAdmin).not.toHaveBeenCalled(); + expect(usersService.getUserAccessLevel).not.toHaveBeenCalled(); + }); + it('should allow system admin to access any workspace', async () => { reflector.get.mockReturnValue(4); usersService.getUserIsAdmin.mockResolvedValue(true); @@ -119,6 +135,20 @@ describe('AccessLevelGuard (Backend)', () => { usersService.getUserIsAdmin.mockResolvedValue(false); }); + it('should resolve DB user id from UUID identity', async () => { + reflector.get.mockReturnValue(2); + usersService.findUserByIdentity.mockResolvedValue({ id: 42 } as never); + usersService.getUserAccessLevel.mockResolvedValue(2); + const context = createMockExecutionContext('4fecd283-bd64-4930-aee8-07e58df323bf', '123', 2); + + const result = await guard.canActivate(context); + + expect(result).toBe(true); + expect(usersService.findUserByIdentity).toHaveBeenCalledWith('4fecd283-bd64-4930-aee8-07e58df323bf'); + expect(usersService.getUserIsAdmin).toHaveBeenCalledWith(42); + expect(usersService.getUserAccessLevel).toHaveBeenCalledWith(42, 123); + }); + it('should allow access when user has exact required access level', async () => { reflector.get.mockReturnValue(3); usersService.getUserAccessLevel.mockResolvedValue(3); @@ -277,6 +307,15 @@ describe('AccessLevelGuard (Backend)', () => { await expect(guard.canActivate(context)).rejects.toThrow(UnauthorizedException); }); + it('should deny access when identity cannot be resolved', async () => { + reflector.get.mockReturnValue(1); + usersService.findUserByIdentity.mockResolvedValue(null); + const context = createMockExecutionContext('missing-identity', '123', 1); + + await expect(guard.canActivate(context)).rejects.toThrow(UnauthorizedException); + await expect(guard.canActivate(context)).rejects.toThrow('User not found'); + }); + it('should deny access when workspace ID is missing', async () => { reflector.get.mockReturnValue(1); const context = { diff --git a/apps/backend/src/app/admin/workspace/access-level.guard.ts b/apps/backend/src/app/admin/workspace/access-level.guard.ts index 1f980bfd9..233d81bd0 100644 --- a/apps/backend/src/app/admin/workspace/access-level.guard.ts +++ b/apps/backend/src/app/admin/workspace/access-level.guard.ts @@ -35,7 +35,7 @@ export class AccessLevelGuard implements CanActivate { this.reflector.get('accessLevel', context.getHandler()); const request = context.switchToHttp().getRequest(); - const userId = request.user?.id; + const requestUserId = request.user?.id; assertWorkspaceApiTokenScopes(context, this.reflector, request.user); // If no access level is specified, allow access @@ -43,10 +43,31 @@ export class AccessLevelGuard implements CanActivate { return true; } - if (!userId) { + if (!requestUserId) { throw new UnauthorizedException('User ID not found in request'); } + // Prefer the admin claim from validated JWT data. + if (request.user?.isAdmin === true) { + return true; + } + + let userId: number; + if (typeof requestUserId === 'number') { + userId = requestUserId; + } else { + const numericUserId = Number(requestUserId); + if (Number.isInteger(numericUserId) && numericUserId > 0) { + userId = numericUserId; + } else { + const user = await this.usersService.findUserByIdentity(requestUserId); + if (!user) { + throw new UnauthorizedException('User not found'); + } + userId = user.id; + } + } + // Get workspace ID from route params const workspaceId = parseWorkspaceId(request.params.workspace_id); diff --git a/apps/backend/src/app/admin/workspace/booklet-info.controller.ts b/apps/backend/src/app/admin/workspace/booklet-info.controller.ts index 6a8bb49c5..56c602b75 100644 --- a/apps/backend/src/app/admin/workspace/booklet-info.controller.ts +++ b/apps/backend/src/app/admin/workspace/booklet-info.controller.ts @@ -10,7 +10,7 @@ import { ApiResponse, ApiTags } from '@nestjs/swagger'; -import { JwtAuthGuard } from '../../auth/jwt-auth.guard'; +import { JwtOrWorkspaceTokenAuthGuard } from '../../auth/jwt-or-workspace-token-auth.guard'; import { AllowWorkspaceTokenScopes, WORKSPACE_TOKEN_SCOPE_REPLAY_READ @@ -21,7 +21,7 @@ import { BookletInfoDto } from '../../../../../../api-dto/booklet-info/booklet-i @ApiTags('Booklet Info') @Controller('admin/workspace/:workspaceId/booklet') -@UseGuards(JwtAuthGuard, WorkspaceGuard) +@UseGuards(JwtOrWorkspaceTokenAuthGuard, WorkspaceGuard) export class BookletInfoController { constructor(private readonly bookletInfoService: BookletInfoService) {} diff --git a/apps/backend/src/app/admin/workspace/unit-info.controller.ts b/apps/backend/src/app/admin/workspace/unit-info.controller.ts index 8955a53b1..d62e0f17e 100644 --- a/apps/backend/src/app/admin/workspace/unit-info.controller.ts +++ b/apps/backend/src/app/admin/workspace/unit-info.controller.ts @@ -10,7 +10,7 @@ import { ApiResponse, ApiTags } from '@nestjs/swagger'; -import { JwtAuthGuard } from '../../auth/jwt-auth.guard'; +import { JwtOrWorkspaceTokenAuthGuard } from '../../auth/jwt-or-workspace-token-auth.guard'; import { AllowWorkspaceTokenScopes, WORKSPACE_TOKEN_SCOPE_REPLAY_READ @@ -21,7 +21,7 @@ import { UnitInfoDto } from '../../../../../../api-dto/unit-info/unit-info.dto'; @ApiTags('Unit Info') @Controller('admin/workspace/:workspaceId/unit') -@UseGuards(JwtAuthGuard, WorkspaceGuard) +@UseGuards(JwtOrWorkspaceTokenAuthGuard, WorkspaceGuard) export class UnitInfoController { constructor(private readonly unitInfoService: UnitInfoService) {} diff --git a/apps/backend/src/app/admin/workspace/workspace-coding-replay.controller.ts b/apps/backend/src/app/admin/workspace/workspace-coding-replay.controller.ts index 8d8920f06..a69ce1600 100644 --- a/apps/backend/src/app/admin/workspace/workspace-coding-replay.controller.ts +++ b/apps/backend/src/app/admin/workspace/workspace-coding-replay.controller.ts @@ -16,7 +16,7 @@ import { } from '@nestjs/swagger'; import { Request, Response } from 'express'; import { parseStringPromise } from 'xml2js'; -import { JwtAuthGuard } from '../../auth/jwt-auth.guard'; +import { JwtOrWorkspaceTokenAuthGuard } from '../../auth/jwt-or-workspace-token-auth.guard'; import { AllowWorkspaceTokenScopes, WORKSPACE_TOKEN_SCOPE_REPLAY_READ @@ -74,7 +74,8 @@ export class WorkspaceCodingReplayController { } @Get(':workspace_id/coding/responses/:responseId/replay-url') - @UseGuards(JwtAuthGuard, WorkspaceGuard) + @AllowWorkspaceTokenScopes(WORKSPACE_TOKEN_SCOPE_REPLAY_READ) + @UseGuards(JwtOrWorkspaceTokenAuthGuard, WorkspaceGuard) @ApiTags('coding') @ApiParam({ name: 'workspace_id', type: Number }) @ApiParam({ @@ -110,7 +111,7 @@ export class WorkspaceCodingReplayController { @Get(':workspace_id/replay-payload/:testPerson/:unitId') @AllowWorkspaceTokenScopes(WORKSPACE_TOKEN_SCOPE_REPLAY_READ) - @UseGuards(JwtAuthGuard, WorkspaceGuard) + @UseGuards(JwtOrWorkspaceTokenAuthGuard, WorkspaceGuard) @ApiTags('coding') @ApiParam({ name: 'workspace_id', type: Number }) @ApiParam({ @@ -170,7 +171,7 @@ export class WorkspaceCodingReplayController { @Get(':workspace_id/replay-assets/:unitId') @AllowWorkspaceTokenScopes(WORKSPACE_TOKEN_SCOPE_REPLAY_READ) - @UseGuards(JwtAuthGuard, WorkspaceGuard) + @UseGuards(JwtOrWorkspaceTokenAuthGuard, WorkspaceGuard) @ApiTags('coding') @ApiParam({ name: 'workspace_id', type: Number }) @ApiParam({ @@ -212,7 +213,7 @@ export class WorkspaceCodingReplayController { @Get(':workspace_id/replay-response/:testPerson/:unitId') @AllowWorkspaceTokenScopes(WORKSPACE_TOKEN_SCOPE_REPLAY_READ) - @UseGuards(JwtAuthGuard, WorkspaceGuard) + @UseGuards(JwtOrWorkspaceTokenAuthGuard, WorkspaceGuard) @ApiTags('coding') @ApiParam({ name: 'workspace_id', type: Number }) @ApiParam({ diff --git a/apps/backend/src/app/admin/workspace/workspace-coding.controller.ts b/apps/backend/src/app/admin/workspace/workspace-coding.controller.ts index 0be82c20e..320aafe91 100644 --- a/apps/backend/src/app/admin/workspace/workspace-coding.controller.ts +++ b/apps/backend/src/app/admin/workspace/workspace-coding.controller.ts @@ -19,6 +19,7 @@ import { } from '@nestjs/swagger'; import { CodingStatistics } from '../../database/services/shared'; import { JwtAuthGuard } from '../../auth/jwt-auth.guard'; +import { JwtOrWorkspaceTokenAuthGuard } from '../../auth/jwt-or-workspace-token-auth.guard'; import { AllowWorkspaceTokenScopes, WORKSPACE_TOKEN_SCOPE_CODING_JOB_OPERATE @@ -258,7 +259,7 @@ export class WorkspaceCodingController { @Get(':workspace_id/coding-job/:codingJobId/notes') @AllowWorkspaceTokenScopes(WORKSPACE_TOKEN_SCOPE_CODING_JOB_OPERATE) - @UseGuards(JwtAuthGuard, WorkspaceGuard) + @UseGuards(JwtOrWorkspaceTokenAuthGuard, WorkspaceGuard) @ApiTags('coding') @ApiParam({ name: 'workspace_id', type: Number }) @ApiParam({ diff --git a/apps/backend/src/app/admin/workspace/workspace-player.controller.ts b/apps/backend/src/app/admin/workspace/workspace-player.controller.ts index b71180085..7a4cc335d 100644 --- a/apps/backend/src/app/admin/workspace/workspace-player.controller.ts +++ b/apps/backend/src/app/admin/workspace/workspace-player.controller.ts @@ -5,6 +5,7 @@ import { ApiOperation, ApiParam, ApiQuery, ApiResponse, ApiTags } from '@nestjs/swagger'; import { JwtAuthGuard } from '../../auth/jwt-auth.guard'; +import { JwtOrWorkspaceTokenAuthGuard } from '../../auth/jwt-or-workspace-token-auth.guard'; import { AllowWorkspaceTokenScopes, WORKSPACE_TOKEN_SCOPE_REPLAY_READ @@ -27,7 +28,7 @@ export class WorkspacePlayerController { @Get(':workspace_id/player/:playerName') @AllowWorkspaceTokenScopes(WORKSPACE_TOKEN_SCOPE_REPLAY_READ) @ApiParam({ name: 'workspace_id', type: Number }) - @UseGuards(JwtAuthGuard, WorkspaceGuard) + @UseGuards(JwtOrWorkspaceTokenAuthGuard, WorkspaceGuard) async findPlayer( @WorkspaceId() workspaceId: number, @Param('playerName') playerName: string @@ -54,7 +55,7 @@ export class WorkspacePlayerController { @Get(':workspace_id/:unit/unitDef') @AllowWorkspaceTokenScopes(WORKSPACE_TOKEN_SCOPE_REPLAY_READ) - @UseGuards(JwtAuthGuard, WorkspaceGuard) + @UseGuards(JwtOrWorkspaceTokenAuthGuard, WorkspaceGuard) @ApiParam({ name: 'workspace_id', type: Number }) async findUnitDef( @Param('workspace_id') workspace_id: number, @@ -69,7 +70,7 @@ export class WorkspacePlayerController { @Get(':workspace_id/unit/:unitId') @AllowWorkspaceTokenScopes(WORKSPACE_TOKEN_SCOPE_REPLAY_READ) - @UseGuards(JwtAuthGuard, WorkspaceGuard) + @UseGuards(JwtOrWorkspaceTokenAuthGuard, WorkspaceGuard) @ApiParam({ name: 'workspace_id', type: Number }) async findUnit( @WorkspaceId() id: number, @@ -81,7 +82,7 @@ export class WorkspacePlayerController { @Get(':workspace_id/booklet/:bookletId/units') @AllowWorkspaceTokenScopes(WORKSPACE_TOKEN_SCOPE_REPLAY_READ) - @UseGuards(JwtAuthGuard, WorkspaceGuard) + @UseGuards(JwtOrWorkspaceTokenAuthGuard, WorkspaceGuard) @ApiParam({ name: 'workspace_id', type: Number }) @ApiParam({ name: 'bookletId', type: String }) @ApiQuery({ name: 'testPerson', type: String, required: false }) diff --git a/apps/backend/src/app/admin/workspace/workspace-test-results-response.controller.ts b/apps/backend/src/app/admin/workspace/workspace-test-results-response.controller.ts index d715bcf70..c112958e5 100644 --- a/apps/backend/src/app/admin/workspace/workspace-test-results-response.controller.ts +++ b/apps/backend/src/app/admin/workspace/workspace-test-results-response.controller.ts @@ -24,6 +24,7 @@ import { } from '@nestjs/swagger'; import { Repository } from 'typeorm'; import { JwtAuthGuard } from '../../auth/jwt-auth.guard'; +import { JwtOrWorkspaceTokenAuthGuard } from '../../auth/jwt-or-workspace-token-auth.guard'; import { AllowWorkspaceTokenScopes, WORKSPACE_TOKEN_SCOPE_REPLAY_READ @@ -188,7 +189,7 @@ export class WorkspaceTestResultsResponseController { @Get(':workspace_id/responses/:testPerson/:unitId') @AllowWorkspaceTokenScopes(WORKSPACE_TOKEN_SCOPE_REPLAY_READ) - @UseGuards(JwtAuthGuard, WorkspaceGuard) + @UseGuards(JwtOrWorkspaceTokenAuthGuard, WorkspaceGuard) @ApiParam({ name: 'workspace_id', type: Number }) async findResponse( @WorkspaceId() id: number, diff --git a/apps/backend/src/app/admin/workspace/workspace-users.controller.spec.ts b/apps/backend/src/app/admin/workspace/workspace-users.controller.spec.ts index 050863aa0..4843f7bc2 100644 --- a/apps/backend/src/app/admin/workspace/workspace-users.controller.spec.ts +++ b/apps/backend/src/app/admin/workspace/workspace-users.controller.spec.ts @@ -1,10 +1,5 @@ -import { - BadRequestException, - ExecutionContext, - INestApplication, - InternalServerErrorException -} from '@nestjs/common'; -import { GUARDS_METADATA } from '@nestjs/common/constants'; +import { BadRequestException, ExecutionContext, INestApplication } from '@nestjs/common'; +import { GUARDS_METADATA, PATH_METADATA } from '@nestjs/common/constants'; import { Test, TestingModule } from '@nestjs/testing'; import { WorkspaceUsersController } from './workspace-users.controller'; import { WorkspaceUsersService } from '../../database/services/workspace/workspace-users.service'; @@ -221,119 +216,16 @@ describe('WorkspaceUsersController', () => { ); }); - describe('findUsers', () => { - it('throws when workspace users cannot be retrieved', async () => { - workspaceUsersService.findUsers.mockRejectedValue(new Error('database unavailable')); - - await expect(controller.findUsers(3, 1, 500)).rejects.toThrow(InternalServerErrorException); - }); - - it('parses the workspace id from the route before retrieving users', async () => { - workspaceUsersService.findUsers.mockResolvedValue([[], 0]); - let app: INestApplication | undefined; - - try { - app = await createTestApp(); - - const response = await fetch(`${await app.getUrl()}/admin/workspace/3/users?page=1&limit=500`); - - expect(response.status).toBe(200); - await expect(response.json()).resolves.toEqual({ - data: [], - total: 0, - page: 1, - limit: 500 - }); - expect(workspaceUsersService.findUsers).toHaveBeenCalledWith(3, { page: 1, limit: 500 }); - } finally { - await app?.close(); - } - }); - - it('returns an HTTP error when workspace users cannot be retrieved', async () => { - workspaceUsersService.findUsers.mockRejectedValue(new Error('database unavailable')); - let app: INestApplication | undefined; - - try { - app = await createTestApp(); - - const response = await fetch(`${await app.getUrl()}/admin/workspace/3/users?page=1&limit=500`); - - expect(response.status).toBe(500); - } finally { - await app?.close(); - } - }); - }); - - describe('findCoders', () => { - it('parses the workspace id from the route before retrieving coders', async () => { - workspaceUsersService.findCoders.mockResolvedValue([[], 0]); - let app: INestApplication | undefined; - - try { - app = await createTestApp(); - - const response = await fetch(`${await app.getUrl()}/admin/workspace/3/coders`); - - expect(response.status).toBe(200); - await expect(response.json()).resolves.toEqual({ - data: [], - total: 0 - }); - expect(workspaceUsersService.findCoders).toHaveBeenCalledWith(3); - } finally { - await app?.close(); - } - }); - }); - - describe('findCodersByCodingJob', () => { - it('rejects a non-numeric coding job id', async () => { - workspaceUsersService.findCoders.mockResolvedValue([[], 0]); - let app: INestApplication | undefined; - - try { - app = await createTestApp(); - - const response = await fetch(`${await app.getUrl()}/admin/workspace/3/coding-jobs/not-a-number/coders`); - - expect(response.status).toBe(400); - expect(workspaceUsersService.findCoders).not.toHaveBeenCalled(); - } finally { - await app?.close(); - } - }); - }); - describe('setWorkspaceUsers', () => { - it('delegates workspace user assignment', async () => { - workspaceUsersService.setWorkspaceUsers.mockResolvedValue(true); - - await expect(controller.setWorkspaceUsers([7, 8], 3)).resolves.toBe(true); - - expect(workspaceUsersService.setWorkspaceUsers).toHaveBeenCalledWith(3, [7, 8]); - }); - - it('parses the workspace id from the route before delegating', async () => { - workspaceUsersService.setWorkspaceUsers.mockResolvedValue(true); - let app: INestApplication | undefined; - - try { - app = await createTestApp(); - - const response = await fetch(`${await app.getUrl()}/admin/workspace/3/users`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify([7, 8]) - }); + it('requires workspace admin access level metadata and uses workspace_id route param', () => { + const guards = Reflect.getMetadata( + GUARDS_METADATA, + WorkspaceUsersController.prototype.setWorkspaceUsers + ); - expect(response.status).toBe(201); - await expect(response.json()).resolves.toBe(true); - expect(workspaceUsersService.setWorkspaceUsers).toHaveBeenCalledWith(3, [7, 8]); - } finally { - await app?.close(); - } + expect(guards).toEqual([JwtAuthGuard, WorkspaceGuard, AccessLevelGuard]); + expect(Reflect.getMetadata('accessLevel', WorkspaceUsersController.prototype.setWorkspaceUsers)).toBe(3); + expect(Reflect.getMetadata(PATH_METADATA, WorkspaceUsersController.prototype.setWorkspaceUsers)).toBe(':workspace_id/users'); }); }); }); diff --git a/apps/backend/src/app/admin/workspace/workspace-users.controller.ts b/apps/backend/src/app/admin/workspace/workspace-users.controller.ts index b4331b0bb..f1646b01b 100644 --- a/apps/backend/src/app/admin/workspace/workspace-users.controller.ts +++ b/apps/backend/src/app/admin/workspace/workspace-users.controller.ts @@ -218,7 +218,8 @@ export class WorkspaceUsersController { @Post(':workspace_id/users') @ApiBearerAuth() - @UseGuards(JwtAuthGuard, WorkspaceGuard) + @UseGuards(JwtAuthGuard, WorkspaceGuard, AccessLevelGuard) + @RequireAccessLevel(3) @ApiOperation({ summary: 'Set workspace users', description: 'Assigns users to a workspace' }) @ApiParam({ name: 'workspace_id', type: Number, description: 'ID of the workspace' }) @ApiBody({ diff --git a/apps/backend/src/app/admin/workspace/workspace.controller.spec.ts b/apps/backend/src/app/admin/workspace/workspace.controller.spec.ts index d16e5c66f..080f9ac8b 100755 --- a/apps/backend/src/app/admin/workspace/workspace.controller.spec.ts +++ b/apps/backend/src/app/admin/workspace/workspace.controller.spec.ts @@ -4,12 +4,13 @@ import { WorkspaceController } from './workspace.controller'; import { AuthService } from '../../auth/service/auth.service'; import { UsersService } from '../../database/services/users'; import { TestcenterService, UploadResultsService } from '../../database/services/test-results'; -import { WorkspaceCoreService } from '../../database/services/workspace'; +import { WorkspaceCoreService, WorkspaceUsersService } from '../../database/services/workspace'; import { AccessRightsMatrixService } from './access-rights-matrix.service'; describe('WorkspaceController', () => { let controller: WorkspaceController; let workspaceCoreService: jest.Mocked; + let usersService: jest.Mocked; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ @@ -35,6 +36,10 @@ describe('WorkspaceController', () => { provide: WorkspaceCoreService, useValue: createMock() }, + { + provide: WorkspaceUsersService, + useValue: createMock() + }, { provide: AccessRightsMatrixService, useValue: createMock() @@ -44,6 +49,7 @@ describe('WorkspaceController', () => { controller = module.get(WorkspaceController); workspaceCoreService = module.get>(WorkspaceCoreService); + usersService = module.get>(UsersService); }); it('should be defined', () => { @@ -52,12 +58,18 @@ describe('WorkspaceController', () => { it('passes the authenticated user id when creating a workspace', async () => { workspaceCoreService.create.mockResolvedValueOnce(12); + usersService.findUserByIdentity.mockResolvedValueOnce({ + id: 5, + username: 'creator', + isAdmin: true + }); await expect(controller.create( { name: 'New workspace' } as never, - { user: { id: '5' } } as never + { user: { id: 'identity-5' } } as never )).resolves.toBe(12); + expect(usersService.findUserByIdentity).toHaveBeenCalledWith('identity-5'); expect(workspaceCoreService.create).toHaveBeenCalledWith({ name: 'New workspace' }, 5); }); }); diff --git a/apps/backend/src/app/admin/workspace/workspace.controller.ts b/apps/backend/src/app/admin/workspace/workspace.controller.ts index 53e33328a..0660ab893 100755 --- a/apps/backend/src/app/admin/workspace/workspace.controller.ts +++ b/apps/backend/src/app/admin/workspace/workspace.controller.ts @@ -9,7 +9,7 @@ import { Patch, Post, Query, - Req, + Request, UseGuards } from '@nestjs/common'; import { @@ -33,10 +33,12 @@ import { WorkspaceGuard } from './workspace.guard'; import { AdminGuard } from '../admin.guard'; import { AccessRightsMatrixService } from './access-rights-matrix.service'; import { AccessRightsMatrixDto } from '../../../../../../api-dto/workspaces/access-rights-matrix-dto'; +import { UsersService } from '../../database/services/users'; interface RequestWithUser { - user: { - id: string | number; + user?: { + id?: string | number; + identity?: string; }; } @@ -47,7 +49,8 @@ export class WorkspaceController { constructor( private workspaceCoreService: WorkspaceCoreService, - private accessRightsMatrixService: AccessRightsMatrixService + private accessRightsMatrixService: AccessRightsMatrixService, + private usersService: UsersService ) {} @Get() @@ -197,7 +200,18 @@ export class WorkspaceController { }) @ApiBadRequestResponse({ description: 'Invalid workspace data' }) @ApiTags('admin workspaces') - async create(@Body() createWorkspaceDto: CreateWorkspaceDto, @Req() request: RequestWithUser) { - return this.workspaceCoreService.create(createWorkspaceDto, Number(request.user.id)); + async create(@Body() createWorkspaceDto: CreateWorkspaceDto, @Request() req: RequestWithUser): Promise { + const userIdentity = req.user?.identity ?? req.user?.id; + + if (!userIdentity) { + throw new BadRequestException('Missing user identity'); + } + + const user = await this.usersService.findUserByIdentity(String(userIdentity)); + if (!user) { + throw new BadRequestException('Creating user not found'); + } + + return this.workspaceCoreService.create(createWorkspaceDto, user.id); } } diff --git a/apps/backend/src/app/admin/workspace/workspace.guard.spec.ts b/apps/backend/src/app/admin/workspace/workspace.guard.spec.ts index b0918090c..b3b8eae46 100644 --- a/apps/backend/src/app/admin/workspace/workspace.guard.spec.ts +++ b/apps/backend/src/app/admin/workspace/workspace.guard.spec.ts @@ -5,463 +5,135 @@ import { ExecutionContext, UnauthorizedException } from '@nestjs/common'; import { Reflector } from '@nestjs/core'; import { WorkspaceGuard } from './workspace.guard'; import { AuthService } from '../../auth/service/auth.service'; -import { - AllowWorkspaceTokenScopes, - WORKSPACE_API_TOKEN_TYPE, - WORKSPACE_TOKEN_SCOPE_REPLAY_READ, - WORKSPACE_TOKEN_SCOPE_CODING_JOB_OPERATE -} from '../../auth/workspace-token'; +import { UsersService } from '../../database/services/users'; describe('WorkspaceGuard (Backend)', () => { let guard: WorkspaceGuard; let authService: jest.Mocked; + let usersService: jest.Mocked; beforeEach(async () => { - const mockAuthService = { - canAccessWorkSpace: jest.fn() - }; - const module: TestingModule = await Test.createTestingModule({ providers: [ WorkspaceGuard, Reflector, { provide: AuthService, - useValue: mockAuthService + useValue: { + canAccessWorkSpace: jest.fn() + } + }, + { + provide: UsersService, + useValue: { + findUserByIdentity: jest.fn() + } } ] }).compile(); guard = module.get(WorkspaceGuard); authService = module.get(AuthService); + usersService = module.get(UsersService); }); - const createMockExecutionContext = ( - userId: number, - workspaceId: string, - tokenWorkspaceId?: string | number, - userOverrides: Record = {}, - handler: () => void = jest.fn() - ): ExecutionContext => ({ - switchToHttp: () => ({ - getRequest: () => ({ - user: { - id: userId, - ...(tokenWorkspaceId === undefined ? {} : { workspace: tokenWorkspaceId }), - ...userOverrides - }, - params: { workspace_id: workspaceId } + const createContext = ( + user: Record | undefined, + workspaceId: string | undefined = '123' + ): ExecutionContext => { + const request = { + user, + params: workspaceId === undefined ? {} : { workspace_id: workspaceId } + }; + return { + switchToHttp: () => ({ + getRequest: () => request }) - }), - getHandler: () => handler, - getClass: () => class TestController {} - } as unknown as ExecutionContext); - - describe('Security Validation - Workspace Access', () => { - it('should allow access when user can access workspace', async () => { - authService.canAccessWorkSpace.mockResolvedValue(true); - const context = createMockExecutionContext(1, '123'); - - const result = await guard.canActivate(context); - - expect(result).toBe(true); - expect(authService.canAccessWorkSpace).toHaveBeenCalledWith(1, 123); - }); - - it('should deny access when user cannot access workspace', async () => { - authService.canAccessWorkSpace.mockResolvedValue(false); - const context = createMockExecutionContext(1, '123'); - - await expect(guard.canActivate(context)).rejects.toThrow(UnauthorizedException); - expect(authService.canAccessWorkSpace).toHaveBeenCalledWith(1, 123); - }); - - it('should validate both user ID and workspace ID', async () => { - authService.canAccessWorkSpace.mockResolvedValue(true); - const context = createMockExecutionContext(42, '789'); - - await guard.canActivate(context); - - expect(authService.canAccessWorkSpace).toHaveBeenCalledWith(42, 789); + } as unknown as ExecutionContext; + }; + + it('allows an OIDC user with workspace access and normalizes the request user id', async () => { + const requestUser = { id: 'oidc-1', isAdmin: false }; + const context = createContext(requestUser); + usersService.findUserByIdentity.mockResolvedValue({ id: 1 } as never); + authService.canAccessWorkSpace.mockResolvedValue(true); + + const result = await guard.canActivate(context); + + expect(result).toBe(true); + expect(usersService.findUserByIdentity).toHaveBeenCalledWith('oidc-1'); + expect(authService.canAccessWorkSpace).toHaveBeenCalledWith(1, 123); + expect(context.switchToHttp().getRequest().user).toEqual({ + id: 1, + userId: 1, + identity: 'oidc-1', + isAdmin: false }); }); - describe('Security Validation - Workspace Isolation', () => { - it('should prevent access to different workspace', async () => { - authService.canAccessWorkSpace.mockResolvedValue(false); - const context = createMockExecutionContext(1, '999'); - - await expect(guard.canActivate(context)).rejects.toThrow(UnauthorizedException); - }); - - it('should verify workspace access for each request', async () => { - const context1 = createMockExecutionContext(1, '123'); - const context2 = createMockExecutionContext(1, '456'); - - authService.canAccessWorkSpace.mockResolvedValueOnce(true); - authService.canAccessWorkSpace.mockResolvedValueOnce(false); - - await guard.canActivate(context1); - await expect(guard.canActivate(context2)).rejects.toThrow(UnauthorizedException); - - expect(authService.canAccessWorkSpace).toHaveBeenCalledTimes(2); - expect(authService.canAccessWorkSpace).toHaveBeenNthCalledWith(1, 1, 123); - expect(authService.canAccessWorkSpace).toHaveBeenNthCalledWith(2, 1, 456); - }); - - it('should not allow cross-user workspace access', async () => { - authService.canAccessWorkSpace.mockResolvedValue(false); - const context = createMockExecutionContext(2, '123'); - - await expect(guard.canActivate(context)).rejects.toThrow(UnauthorizedException); - expect(authService.canAccessWorkSpace).toHaveBeenCalledWith(2, 123); - }); - }); - - describe('Security Validation - Request Structure', () => { - it('should handle missing user object', async () => { - const context = { - switchToHttp: () => ({ - getRequest: () => ({ - params: { workspace_id: '123' } - }) - }) - } as unknown as ExecutionContext; - - await expect(guard.canActivate(context)).rejects.toThrow(); - }); - - it('should handle missing params object', async () => { - const context = { - switchToHttp: () => ({ - getRequest: () => ({ - user: { id: 1 } - }) - }) - } as unknown as ExecutionContext; - - await expect(guard.canActivate(context)).rejects.toThrow(); - }); - - it('should handle missing workspace_id in params', async () => { - const context = { - switchToHttp: () => ({ - getRequest: () => ({ - user: { id: 1 }, - params: {} - }) - }) - } as unknown as ExecutionContext; - - authService.canAccessWorkSpace.mockResolvedValue(false); - - await expect(guard.canActivate(context)).rejects.toThrow(); - }); - - it('should handle null user', async () => { - const context = { - switchToHttp: () => ({ - getRequest: () => ({ - user: null, - params: { workspace_id: '123' } - }) - }) - } as unknown as ExecutionContext; - - await expect(guard.canActivate(context)).rejects.toThrow(); - }); - - it('should handle undefined user', async () => { - const context = { - switchToHttp: () => ({ - getRequest: () => ({ - user: undefined, - params: { workspace_id: '123' } - }) - }) - } as unknown as ExecutionContext; - - await expect(guard.canActivate(context)).rejects.toThrow(); - }); - }); - - describe('Edge Cases - Workspace ID Formats', () => { - it('should handle numeric workspace ID', async () => { - authService.canAccessWorkSpace.mockResolvedValue(true); - const context = createMockExecutionContext(1, '123'); - - await guard.canActivate(context); - - expect(authService.canAccessWorkSpace).toHaveBeenCalledWith(1, 123); - }); - - it('should reject non-numeric workspace ID', async () => { - const context = createMockExecutionContext(1, 'workspace-abc'); - - await expect(guard.canActivate(context)).rejects.toThrow(UnauthorizedException); - - expect(authService.canAccessWorkSpace).not.toHaveBeenCalled(); - }); - - it('should handle zero workspace ID', async () => { - const context = createMockExecutionContext(1, '0'); - - await expect(guard.canActivate(context)).rejects.toThrow(UnauthorizedException); - expect(authService.canAccessWorkSpace).not.toHaveBeenCalled(); - }); - - it('should handle negative workspace ID', async () => { - const context = createMockExecutionContext(1, '-1'); - - await expect(guard.canActivate(context)).rejects.toThrow(UnauthorizedException); - expect(authService.canAccessWorkSpace).not.toHaveBeenCalled(); - }); - - it('should handle very large workspace ID', async () => { - authService.canAccessWorkSpace.mockResolvedValue(true); - const context = createMockExecutionContext(1, '999999999999'); - - await guard.canActivate(context); - - expect(authService.canAccessWorkSpace).toHaveBeenCalledWith(1, 999999999999); - }); - - it('should handle workspace ID with special characters', async () => { - const context = createMockExecutionContext(1, 'ws-123-abc'); - - await expect(guard.canActivate(context)).rejects.toThrow(UnauthorizedException); - expect(authService.canAccessWorkSpace).not.toHaveBeenCalled(); - }); - - it.each(['1e2', '0x10', '1.5', '123abc', '00123'])( - 'should reject ambiguous workspace ID format %s', - async workspaceId => { - const context = createMockExecutionContext(1, workspaceId); - - await expect(guard.canActivate(context)).rejects.toThrow(UnauthorizedException); - expect(authService.canAccessWorkSpace).not.toHaveBeenCalled(); - } - ); - - it('should accept a matching workspace claim', async () => { - authService.canAccessWorkSpace.mockResolvedValue(true); - const context = createMockExecutionContext(1, '123', 123); - - await expect(guard.canActivate(context)).resolves.toBe(true); - expect(authService.canAccessWorkSpace).toHaveBeenCalledWith(1, 123); - }); - - it('should reject a mismatching workspace claim before service lookup', async () => { - const context = createMockExecutionContext(1, '123', 456); - - await expect(guard.canActivate(context)).rejects.toThrow(UnauthorizedException); - expect(authService.canAccessWorkSpace).not.toHaveBeenCalled(); - }); - - it('should reject ambiguous token workspace claims before service lookup', async () => { - const context = createMockExecutionContext(1, '123', '1e2'); - - await expect(guard.canActivate(context)).rejects.toThrow(UnauthorizedException); - expect(authService.canAccessWorkSpace).not.toHaveBeenCalled(); - }); - }); - - describe('Security Validation - Workspace API Token Scopes', () => { - const createScopedHandler = () => { - const handler = jest.fn(); - AllowWorkspaceTokenScopes(WORKSPACE_TOKEN_SCOPE_REPLAY_READ)(handler); - return handler; - }; - - const createCodingScopedHandler = () => { - const handler = jest.fn(); - AllowWorkspaceTokenScopes(WORKSPACE_TOKEN_SCOPE_CODING_JOB_OPERATE)(handler); - return handler; + it('allows a validated JWT user with database id and identity without another identity lookup', async () => { + const requestUser = { + id: 42, + userId: 42, + identity: 'oidc-42', + sub: 'oidc-42', + isAdmin: false }; + const context = createContext(requestUser); + authService.canAccessWorkSpace.mockResolvedValue(true); - it('should reject workspace API tokens for endpoints without allowed scopes', async () => { - const context = createMockExecutionContext( - 1, - '123', - 123, - { - tokenType: WORKSPACE_API_TOKEN_TYPE, - scopes: [WORKSPACE_TOKEN_SCOPE_REPLAY_READ] - } - ); - - await expect(guard.canActivate(context)).rejects.toThrow(UnauthorizedException); - expect(authService.canAccessWorkSpace).not.toHaveBeenCalled(); - }); - - it('should allow workspace API tokens when the endpoint allows the token scope', async () => { - authService.canAccessWorkSpace.mockResolvedValue(true); - const context = createMockExecutionContext( - 1, - '123', - 123, - { - tokenType: WORKSPACE_API_TOKEN_TYPE, - scopes: [WORKSPACE_TOKEN_SCOPE_REPLAY_READ] - }, - createScopedHandler() - ); - - await expect(guard.canActivate(context)).resolves.toBe(true); - expect(authService.canAccessWorkSpace).toHaveBeenCalledWith(1, 123); - }); - - it('should reject workspace API tokens with a missing required scope', async () => { - const context = createMockExecutionContext( - 1, - '123', - 123, - { - tokenType: WORKSPACE_API_TOKEN_TYPE, - scopes: [WORKSPACE_TOKEN_SCOPE_CODING_JOB_OPERATE] - }, - createScopedHandler() - ); - - await expect(guard.canActivate(context)).rejects.toThrow(UnauthorizedException); - expect(authService.canAccessWorkSpace).not.toHaveBeenCalled(); - }); - - it('should reject replay-only workspace API tokens for coding job endpoints', async () => { - const context = createMockExecutionContext( - 1, - '123', - 123, - { - tokenType: WORKSPACE_API_TOKEN_TYPE, - scopes: [WORKSPACE_TOKEN_SCOPE_REPLAY_READ] - }, - createCodingScopedHandler() - ); - - await expect(guard.canActivate(context)).rejects.toThrow(UnauthorizedException); - expect(authService.canAccessWorkSpace).not.toHaveBeenCalled(); - }); - - it('should reject correctly scoped workspace API tokens for a different workspace', async () => { - const context = createMockExecutionContext( - 1, - '123', - 456, - { - tokenType: WORKSPACE_API_TOKEN_TYPE, - scopes: [WORKSPACE_TOKEN_SCOPE_CODING_JOB_OPERATE] - }, - createCodingScopedHandler() - ); + const result = await guard.canActivate(context); - await expect(guard.canActivate(context)).rejects.toThrow(UnauthorizedException); - expect(authService.canAccessWorkSpace).not.toHaveBeenCalled(); - }); + expect(result).toBe(true); + expect(usersService.findUserByIdentity).not.toHaveBeenCalled(); + expect(authService.canAccessWorkSpace).toHaveBeenCalledWith(42, 123); + expect(context.switchToHttp().getRequest().user).toEqual(requestUser); }); - describe('Edge Cases - User ID Formats', () => { - it('should handle zero user ID', async () => { - authService.canAccessWorkSpace.mockResolvedValue(false); - const context = createMockExecutionContext(0, '123'); - - await expect(guard.canActivate(context)).rejects.toThrow(UnauthorizedException); - expect(authService.canAccessWorkSpace).toHaveBeenCalledWith(0, 123); - }); - - it('should handle negative user ID', async () => { - authService.canAccessWorkSpace.mockResolvedValue(false); - const context = createMockExecutionContext(-1, '123'); - - await expect(guard.canActivate(context)).rejects.toThrow(UnauthorizedException); - expect(authService.canAccessWorkSpace).toHaveBeenCalledWith(-1, 123); - }); + it('denies an OIDC user without workspace access', async () => { + usersService.findUserByIdentity.mockResolvedValue({ id: 1 } as never); + authService.canAccessWorkSpace.mockResolvedValue(false); - it('should handle very large user ID', async () => { - authService.canAccessWorkSpace.mockResolvedValue(true); - const context = createMockExecutionContext(Number.MAX_SAFE_INTEGER, '123'); - - await guard.canActivate(context); - - expect(authService.canAccessWorkSpace).toHaveBeenCalledWith(Number.MAX_SAFE_INTEGER, 123); - }); + await expect(guard.canActivate(createContext({ id: 'oidc-1' }))).rejects.toThrow(UnauthorizedException); + expect(authService.canAccessWorkSpace).toHaveBeenCalledWith(1, 123); }); - describe('Security - Service Errors', () => { - it('should propagate auth service errors', async () => { - authService.canAccessWorkSpace.mockRejectedValue(new Error('Database error')); - const context = createMockExecutionContext(1, '123'); - - await expect(guard.canActivate(context)).rejects.toThrow('Database error'); - }); - - it('should handle auth service async response', async () => { - authService.canAccessWorkSpace.mockResolvedValue(false); - const context = createMockExecutionContext(1, '123'); - - await expect(guard.canActivate(context)).rejects.toThrow(UnauthorizedException); - }); + it('denies an OIDC user that is unknown locally', async () => { + usersService.findUserByIdentity.mockResolvedValue(null); - it('should handle auth service returning null', async () => { - authService.canAccessWorkSpace.mockResolvedValue(null as unknown as boolean); - const context = createMockExecutionContext(1, '123'); - - await expect(guard.canActivate(context)).rejects.toThrow(UnauthorizedException); - }); - - it('should handle auth service returning undefined', async () => { - authService.canAccessWorkSpace.mockResolvedValue(undefined as unknown as boolean); - const context = createMockExecutionContext(1, '123'); - - await expect(guard.canActivate(context)).rejects.toThrow(UnauthorizedException); - }); + await expect(guard.canActivate(createContext({ id: 'oidc-1' }))).rejects.toThrow(UnauthorizedException); + expect(authService.canAccessWorkSpace).not.toHaveBeenCalled(); }); - describe('Security - Privilege Escalation Prevention', () => { - it('should not cache workspace access between requests', async () => { - const context1 = createMockExecutionContext(1, '123'); - const context2 = createMockExecutionContext(1, '123'); - - authService.canAccessWorkSpace.mockResolvedValueOnce(true); - authService.canAccessWorkSpace.mockResolvedValueOnce(false); - - await guard.canActivate(context1); - await expect(guard.canActivate(context2)).rejects.toThrow(UnauthorizedException); - - expect(authService.canAccessWorkSpace).toHaveBeenCalledTimes(2); - }); - - it('should always verify workspace access from auth service', async () => { - authService.canAccessWorkSpace.mockResolvedValue(true); - const context = createMockExecutionContext(1, '123'); + it('allows a workspace token for its own workspace', async () => { + authService.canAccessWorkSpace.mockResolvedValue(true); - await guard.canActivate(context); - await guard.canActivate(context); - await guard.canActivate(context); - - expect(authService.canAccessWorkSpace).toHaveBeenCalledTimes(3); - }); + const result = await guard.canActivate(createContext({ + id: 12, + workspace: 123, + tokenUse: 'workspace', + isWorkspaceToken: true + })); - it('should verify access for each unique workspace', async () => { - authService.canAccessWorkSpace.mockResolvedValue(true); + expect(result).toBe(true); + expect(usersService.findUserByIdentity).not.toHaveBeenCalled(); + expect(authService.canAccessWorkSpace).toHaveBeenCalledWith(12, 123); + }); - await guard.canActivate(createMockExecutionContext(1, '123')); - await guard.canActivate(createMockExecutionContext(1, '456')); - await guard.canActivate(createMockExecutionContext(1, '789')); + it('denies a workspace token for another workspace', async () => { + await expect(guard.canActivate(createContext({ + id: 12, + workspace: 456, + tokenUse: 'workspace', + isWorkspaceToken: true + }))).rejects.toThrow(UnauthorizedException); - expect(authService.canAccessWorkSpace).toHaveBeenCalledTimes(3); - expect(authService.canAccessWorkSpace).toHaveBeenNthCalledWith(1, 1, 123); - expect(authService.canAccessWorkSpace).toHaveBeenNthCalledWith(2, 1, 456); - expect(authService.canAccessWorkSpace).toHaveBeenNthCalledWith(3, 1, 789); - }); + expect(authService.canAccessWorkSpace).not.toHaveBeenCalled(); }); - describe('Error Messages', () => { - it('should throw UnauthorizedException for unauthorized access', async () => { - authService.canAccessWorkSpace.mockResolvedValue(false); - const context = createMockExecutionContext(1, '123'); - - await expect(guard.canActivate(context)).rejects.toThrow(UnauthorizedException); - }); + it('denies malformed requests', async () => { + await expect(guard.canActivate(createContext(undefined))).rejects.toThrow(UnauthorizedException); + await expect(guard.canActivate(createContext({ id: 'oidc-1' }, undefined))).rejects.toThrow(UnauthorizedException); + await expect(guard.canActivate(createContext({ id: 'oidc-1' }, 'workspace-abc'))).rejects.toThrow(UnauthorizedException); }); }); diff --git a/apps/backend/src/app/admin/workspace/workspace.guard.ts b/apps/backend/src/app/admin/workspace/workspace.guard.ts index a0006aa81..4c04b409e 100644 --- a/apps/backend/src/app/admin/workspace/workspace.guard.ts +++ b/apps/backend/src/app/admin/workspace/workspace.guard.ts @@ -1,44 +1,75 @@ import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from '@nestjs/common'; -import { Reflector } from '@nestjs/core'; import { AuthService } from '../../auth/service/auth.service'; -import { assertWorkspaceApiTokenScopes } from '../../auth/workspace-token'; -import { parseWorkspaceId } from './workspace-id.util'; +import { UsersService } from '../../database/services/users'; +import { WORKSPACE_TOKEN_USE } from '../../auth/workspace-token.constants'; + +interface RequestUser { + id?: number | string; + userId?: number | string; + identity?: string; + sub?: string; + workspace?: number | string; + tokenUse?: string; + isWorkspaceToken?: boolean; +} @Injectable() export class WorkspaceGuard implements CanActivate { constructor( private authService: AuthService, - private reflector: Reflector + private usersService: UsersService ) {} async canActivate( context: ExecutionContext ) { const req = context.switchToHttp().getRequest(); - const userId = req.user.id; + const requestUser = req.user as RequestUser | undefined; const params = req.params; - const workspaceId = parseWorkspaceId(params.workspace_id ?? params.workspaceId); + const workspaceId = Number(params?.workspace_id); - if (!workspaceId) { + if (!Number.isInteger(workspaceId) || workspaceId <= 0 || !requestUser) { throw new UnauthorizedException(); } - const rawTokenWorkspaceId = req.user.workspace; - const tokenWorkspaceId = parseWorkspaceId(rawTokenWorkspaceId); - if ( - rawTokenWorkspaceId !== undefined && - rawTokenWorkspaceId !== null && - rawTokenWorkspaceId !== '' && - !tokenWorkspaceId - ) { - throw new UnauthorizedException(); + if (requestUser.isWorkspaceToken || requestUser.tokenUse === WORKSPACE_TOKEN_USE) { + const tokenWorkspace = Number(requestUser.workspace); + const userId = getDatabaseUserId(requestUser); + if (tokenWorkspace !== workspaceId || !Number.isInteger(userId) || userId <= 0) { + throw new UnauthorizedException(); + } + + const canAccess = await this.authService.canAccessWorkSpace(userId, workspaceId); + if (!canAccess) { + throw new UnauthorizedException(); + } + return true; } - if (tokenWorkspaceId && tokenWorkspaceId !== workspaceId) { - throw new UnauthorizedException(); + + let userId = getDatabaseUserId(requestUser); + const userIdentity = getUserIdentity(requestUser); + + if (!userId) { + if (!userIdentity) { + throw new UnauthorizedException('User not found'); + } + + const user = await this.usersService.findUserByIdentity(userIdentity); + if (!user) { + throw new UnauthorizedException('User not found'); + } + + userId = user.id; } - assertWorkspaceApiTokenScopes(context, this.reflector, req.user); + + req.user = { + ...requestUser, + id: userId, + userId, + ...(userIdentity ? { identity: userIdentity } : {}) + }; const canAccess = await this.authService.canAccessWorkSpace(userId, workspaceId); if (!canAccess) { @@ -47,3 +78,25 @@ export class WorkspaceGuard implements CanActivate { return true; } } + +function getDatabaseUserId(requestUser: RequestUser): number | undefined { + return parsePositiveInteger(requestUser.userId) ?? parsePositiveInteger(requestUser.id); +} + +function getUserIdentity(requestUser: RequestUser): string | undefined { + const identity = requestUser.identity ?? requestUser.sub; + if (identity?.trim()) { + return identity.trim(); + } + + if (typeof requestUser.id === 'string' && !parsePositiveInteger(requestUser.id) && requestUser.id.trim()) { + return requestUser.id.trim(); + } + + return undefined; +} + +function parsePositiveInteger(value: number | string | undefined): number | undefined { + const numericValue = Number(value); + return Number.isInteger(numericValue) && numericValue > 0 ? numericValue : undefined; +} diff --git a/apps/backend/src/app/auth/auth.controller.spec.ts b/apps/backend/src/app/auth/auth.controller.spec.ts new file mode 100644 index 000000000..a37ce8707 --- /dev/null +++ b/apps/backend/src/app/auth/auth.controller.spec.ts @@ -0,0 +1,236 @@ +import { HttpStatus } from '@nestjs/common'; +import { Response } from 'express'; +import { AuthController } from './auth.controller'; +import { AuthService } from './service/auth.service'; +import { OAuth2ClientCredentialsService } from './service/oauth2-client-credentials.service'; +import { OidcAuthService } from './service/oidc-auth.service'; + +describe('AuthController', () => { + let controller: AuthController; + let oidcAuthService: jest.Mocked>; + let authService: jest.Mocked>; + let response: Response; + let redirect: jest.Mock; + let json: jest.Mock; + let status: jest.Mock; + let originalOAuth2RedirectUrl: string | undefined; + let originalNodeEnv: string | undefined; + let originalHttpPort: string | undefined; + let originalFrontendUrl: string | undefined; + + const encodedState = (redirectUri: string): string => `state:${encodeURIComponent(redirectUri)}`; + + beforeEach(() => { + originalOAuth2RedirectUrl = process.env.OAUTH2_REDIRECT_URL; + originalNodeEnv = process.env.NODE_ENV; + originalHttpPort = process.env.HTTP_PORT; + originalFrontendUrl = process.env.FRONTEND_URL; + process.env.OAUTH2_REDIRECT_URL = '//app.example.test/api/auth/callback'; + process.env.NODE_ENV = 'production'; + delete process.env.HTTP_PORT; + delete process.env.FRONTEND_URL; + + oidcAuthService = { + generatePkcePair: jest.fn().mockReturnValue({ + codeVerifier: 'code-verifier', + codeChallenge: 'code-challenge' + }), + storePkceVerifier: jest.fn().mockResolvedValue(true), + getAuthorizationUrl: jest.fn().mockReturnValue('https://oidc.example.test/auth'), + consumePkceVerifier: jest.fn().mockResolvedValue('code-verifier'), + storeTokenExchange: jest.fn().mockResolvedValue('exchange-code'), + consumeTokenExchange: jest.fn().mockResolvedValue({ + access_token: 'access-token', + token_type: 'Bearer', + expires_in: 3600, + id_token: 'id-token', + refresh_token: 'refresh-token' + }), + refreshToken: jest.fn().mockResolvedValue({ + access_token: 'fresh-access-token', + token_type: 'Bearer', + expires_in: 300, + refresh_token: 'rotated-refresh-token' + }), + exchangeCodeForToken: jest.fn().mockResolvedValue({ + access_token: 'access-token', + token_type: 'Bearer', + expires_in: 3600, + id_token: 'id-token', + refresh_token: 'refresh-token' + }), + getUserInfo: jest.fn().mockResolvedValue({ + sub: 'subject', + preferred_username: 'tester', + given_name: 'Test', + family_name: 'User', + email: 'tester@example.test', + realm_access: { roles: [] } + }), + getProfileUrl: jest.fn((redirectUri?: string) => ( + redirectUri ? + `https://oidc.example.test/account?referrer_uri=${encodeURIComponent(redirectUri)}` : + 'https://oidc.example.test/account' + )) + }; + + authService = { + storeOidcProviderUser: jest.fn().mockResolvedValue(undefined) + }; + + controller = new AuthController( + {} as OAuth2ClientCredentialsService, + oidcAuthService as unknown as OidcAuthService, + authService as unknown as AuthService + ); + + redirect = jest.fn(); + json = jest.fn(); + status = jest.fn().mockReturnThis(); + response = { redirect, json, status } as unknown as Response; + }); + + afterEach(() => { + if (originalOAuth2RedirectUrl === undefined) { + delete process.env.OAUTH2_REDIRECT_URL; + } else { + process.env.OAUTH2_REDIRECT_URL = originalOAuth2RedirectUrl; + } + + if (originalNodeEnv === undefined) { + delete process.env.NODE_ENV; + } else { + process.env.NODE_ENV = originalNodeEnv; + } + + if (originalHttpPort === undefined) { + delete process.env.HTTP_PORT; + } else { + process.env.HTTP_PORT = originalHttpPort; + } + + if (originalFrontendUrl === undefined) { + delete process.env.FRONTEND_URL; + } else { + process.env.FRONTEND_URL = originalFrontendUrl; + } + }); + + it('falls back to the login page when an error redirect points to another origin', async () => { + await controller.callback('', encodedState('https://evil.example.test/phish'), response); + + expect(redirect).toHaveBeenCalledWith('/login?error=authentication_failed'); + }); + + it('preserves allowed same-origin error redirects', async () => { + await controller.callback('', encodedState('https://app.example.test/workspace/1?tab=checks'), response); + + expect(redirect).toHaveBeenCalledWith('https://app.example.test/workspace/1?tab=checks&error=authentication_failed'); + }); + + it('redirects successful callbacks only to same-origin URLs', async () => { + await controller.callback('auth-code', encodedState('/workspace/1'), response); + + expect(redirect).toHaveBeenCalledWith( + 'https://app.example.test/workspace/1?auth_code=exchange-code' + ); + expect(oidcAuthService.storeTokenExchange).toHaveBeenCalledWith(expect.objectContaining({ + access_token: 'access-token', + refresh_token: 'refresh-token' + })); + expect(json).not.toHaveBeenCalled(); + }); + + it('allows the local frontend origin as a login redirect in development', async () => { + process.env.NODE_ENV = 'development'; + process.env.OAUTH2_REDIRECT_URL = '//localhost:3333/api/auth/callback'; + process.env.HTTP_PORT = '4200'; + const frontendRedirectUri = 'http://localhost:4200/#/workspace-admin/1/test-results'; + + await controller.login(response, frontendRedirectUri); + + expect(oidcAuthService.storePkceVerifier).toHaveBeenCalledWith( + expect.stringContaining(encodeURIComponent(frontendRedirectUri)), + 'code-verifier' + ); + + await controller.callback('auth-code', encodedState(frontendRedirectUri), response); + + expect(redirect).toHaveBeenLastCalledWith( + 'http://localhost:4200/?auth_code=exchange-code#/workspace-admin/1/test-results' + ); + }); + + it('does not return tokens for successful callbacks with a disallowed redirect URL', async () => { + await controller.callback('auth-code', encodedState('https://evil.example.test/phish'), response); + + expect(redirect).toHaveBeenCalledWith('/login?error=authentication_failed'); + expect(json).not.toHaveBeenCalled(); + expect(oidcAuthService.storeTokenExchange).not.toHaveBeenCalled(); + }); + + it('exchanges a one-time login code for stored tokens', async () => { + await expect(controller.exchangeLoginCode({ code: 'exchange-code' })).resolves.toEqual( + expect.objectContaining({ + access_token: 'access-token', + refresh_token: 'refresh-token' + }) + ); + + expect(oidcAuthService.consumeTokenExchange).toHaveBeenCalledWith('exchange-code'); + }); + + it('refreshes OIDC tokens through the backend refresh endpoint', async () => { + await expect(controller.refreshToken({ refresh_token: 'refresh-token' })).resolves.toEqual({ + access_token: 'fresh-access-token', + token_type: 'Bearer', + expires_in: 300, + refresh_token: 'rotated-refresh-token' + }); + + expect(oidcAuthService.refreshToken).toHaveBeenCalledWith('refresh-token'); + }); + + it('rejects expired one-time login codes', async () => { + oidcAuthService.consumeTokenExchange?.mockResolvedValue(null); + + await expect(controller.exchangeLoginCode({ code: 'expired-code' })).rejects.toThrow('Invalid or expired login code'); + }); + + it('does not store disallowed login redirect URLs in the state parameter', async () => { + await controller.login(response, 'https://evil.example.test/phish'); + + expect(oidcAuthService.storePkceVerifier).toHaveBeenCalledWith(expect.not.stringContaining('evil.example.test'), 'code-verifier'); + expect(oidcAuthService.getAuthorizationUrl).toHaveBeenCalledWith( + expect.not.stringContaining('evil.example.test'), + 'https://app.example.test/api/auth/callback', + 'code-challenge' + ); + expect(redirect).toHaveBeenCalledWith('https://oidc.example.test/auth'); + }); + + it('returns an error when login cannot store the PKCE verifier', async () => { + oidcAuthService.storePkceVerifier?.mockResolvedValue(false); + + await controller.login(response, '/workspace/1'); + + expect(status).toHaveBeenCalledWith(HttpStatus.INTERNAL_SERVER_ERROR); + expect(json).toHaveBeenCalledWith({ error: 'Failed to initiate login' }); + }); + + it('allows only same-origin profile return redirects', async () => { + await controller.redirectToProfile(response, 'https://app.example.test/workspace/1'); + + expect(oidcAuthService.getProfileUrl).toHaveBeenCalledWith('https://app.example.test/workspace/1'); + expect(redirect).toHaveBeenCalledWith( + 'https://oidc.example.test/account?referrer_uri=https%3A%2F%2Fapp.example.test%2Fworkspace%2F1' + ); + }); + + it('drops disallowed profile return redirects', async () => { + await controller.redirectToProfile(response, 'https://evil.example.test/phish'); + + expect(oidcAuthService.getProfileUrl).toHaveBeenCalledWith(undefined); + expect(redirect).toHaveBeenCalledWith('https://oidc.example.test/account'); + }); +}); diff --git a/apps/backend/src/app/auth/auth.controller.ts b/apps/backend/src/app/auth/auth.controller.ts new file mode 100644 index 000000000..bbdab80be --- /dev/null +++ b/apps/backend/src/app/auth/auth.controller.ts @@ -0,0 +1,528 @@ +import { + Controller, Post, Body, Logger, HttpCode, HttpStatus, Get, Query, Res, + UnauthorizedException +} from '@nestjs/common'; +import { + ApiTags, ApiOkResponse, ApiBadRequestResponse, ApiUnauthorizedResponse, ApiBody, ApiQuery, ApiOperation +} from '@nestjs/swagger'; +import { Response } from 'express'; +import { OAuth2ClientCredentialsService, ClientCredentialsRequest, ClientCredentialsTokenResponse } from './service/oauth2-client-credentials.service'; +import { OidcAuthService, OidcTokenResponse, OidcUserInfo } from './service/oidc-auth.service'; +import { AuthService } from './service/auth.service'; +import { CreateUserDto } from '../../../../../api-dto/user/create-user-dto'; + +@ApiTags('Authentication') +@Controller('auth') +export class AuthController { + private readonly logger = new Logger(AuthController.name); + + constructor( + private readonly oauth2ClientCredentialsService: OAuth2ClientCredentialsService, + private readonly oidcAuthService: OidcAuthService, + private readonly authService: AuthService + ) {} + + /** + * Helper method to construct complete Url for the client-side OAuth2 endpoint. + * @returns The complete Url for the client-side OAuth2 endpoint. + */ + private getOAuth2Endpoint(): string { + const relativeOAuth2Url = process.env.OAUTH2_REDIRECT_URL; + const scheme = process.env.NODE_ENV === 'production' ? 'https:' : 'http:'; + + return scheme + relativeOAuth2Url; + } + + private getDefaultLoginErrorRedirect(): string { + const frontendOrigin = this.getDefaultFrontendOrigin(); + if (frontendOrigin) { + return `${frontendOrigin}/?error=authentication_failed`; + } + + return '/login?error=authentication_failed'; + } + + private getDefaultFrontendOrigin(): string | null { + if (process.env.FRONTEND_URL) { + try { + return new URL(process.env.FRONTEND_URL).origin; + } catch { + return null; + } + } + + if (process.env.NODE_ENV === 'production') { + return null; + } + + try { + const oAuth2Url = new URL(this.getOAuth2Endpoint()); + const frontendPort = process.env.HTTP_PORT || '4200'; + + return `${oAuth2Url.protocol}//${oAuth2Url.hostname}:${frontendPort}`; + } catch { + return null; + } + } + + private getAllowedRedirectOrigins(oAuth2Url: URL): Set { + const allowedOrigins = new Set([oAuth2Url.origin]); + const frontendOrigin = this.getDefaultFrontendOrigin(); + if (frontendOrigin) { + allowedOrigins.add(frontendOrigin); + } + + if (process.env.NODE_ENV !== 'production') { + const frontendPort = process.env.HTTP_PORT || '4200'; + allowedOrigins.add(`http://localhost:${frontendPort}`); + allowedOrigins.add(`http://127.0.0.1:${frontendPort}`); + } + + return allowedOrigins; + } + + private resolveAllowedRedirectUrl(redirectUri?: string): URL | null { + if (!redirectUri || typeof redirectUri !== 'string') { + return null; + } + + const normalizedRedirectUri = redirectUri.trim(); + if (!normalizedRedirectUri || normalizedRedirectUri.startsWith('//') || normalizedRedirectUri.startsWith('/\\')) { + return null; + } + + try { + const oAuth2Url = new URL(this.getOAuth2Endpoint()); + const redirectUrl = new URL(normalizedRedirectUri, oAuth2Url.origin); + const isHttpUrl = redirectUrl.protocol === 'http:' || redirectUrl.protocol === 'https:'; + const isAllowedOrigin = this.getAllowedRedirectOrigins(oAuth2Url).has(redirectUrl.origin); + + return isHttpUrl && isAllowedOrigin ? redirectUrl : null; + } catch { + return null; + } + } + + private buildErrorRedirectUrl(redirectUri?: string): string { + const redirectUrl = this.resolveAllowedRedirectUrl(redirectUri); + if (!redirectUrl) { + return this.getDefaultLoginErrorRedirect(); + } + + redirectUrl.searchParams.set('error', 'authentication_failed'); + return redirectUrl.toString(); + } + + /** + * Exchange client credentials for an access token using OAuth2 Client Credentials Flow + * @param credentials - Client ID and secret + * @returns Access token response + */ + @Post('token') + @HttpCode(HttpStatus.OK) + @ApiBody({ + description: 'Client credentials for OAuth2 Client Credentials Flow', + schema: { + type: 'object', + required: ['client_id', 'client_secret'], + properties: { + client_id: { + type: 'string', + description: 'The client identifier', + example: 'my-application' + }, + client_secret: { + type: 'string', + description: 'The client secret', + example: 'my-secret-key' + }, + scope: { + type: 'string', + description: 'Optional scope for the access token', + example: 'read write' + } + } + } + }) + @ApiOkResponse({ + description: 'Successfully obtained access token', + schema: { + type: 'object', + properties: { + access_token: { + type: 'string', + description: 'The access token' + }, + token_type: { + type: 'string', + description: 'The type of token (usually "Bearer")', + example: 'Bearer' + }, + expires_in: { + type: 'number', + description: 'Token expiration time in seconds', + example: 3600 + }, + scope: { + type: 'string', + description: 'The scope of the access token' + } + } + } + }) + @ApiBadRequestResponse({ + description: 'Invalid request parameters' + }) + @ApiUnauthorizedResponse({ + description: 'Invalid client credentials' + }) + async getClientCredentialsToken( + @Body() credentials: ClientCredentialsRequest + ): Promise { + this.logger.log(`Client credentials token request for client: ${credentials.client_id}`); + + return this.oauth2ClientCredentialsService.getAccessToken(credentials); + } + + /** + * Validate an access token against OpenID Connect Provider + * @param tokenData - Object containing the access token + * @returns User information from the token + */ + @Post('validate') + @HttpCode(HttpStatus.OK) + @ApiBody({ + description: 'Access token to validate', + schema: { + type: 'object', + required: ['access_token'], + properties: { + access_token: { + type: 'string', + description: 'The access token to validate' + } + } + } + }) + @ApiOkResponse({ + description: 'Token is valid, returns user information', + schema: { + type: 'object', + description: 'User information from OpenID Connect Provider' + } + }) + @ApiUnauthorizedResponse({ + description: 'Invalid or expired token' + }) + async validateToken( + @Body() tokenData: { access_token: string } + ): Promise { + this.logger.log('Token validation request'); + + return this.oauth2ClientCredentialsService.validateAccessToken(tokenData.access_token); + } + + /** + * Initiate OpenID Connect Provider login using Authorization Code flow + * @param res - Express response object for redirecting + * @param redirectUri - Optional redirect URI after successful authentication + */ + @Get('login') + @ApiOperation({ + summary: 'Initiate OpenID Connect Provider login', + description: 'Redirects user to OpenID Connect Provider login page using Authorization Code flow' + }) + @ApiQuery({ + name: 'redirect_uri', + required: false, + description: 'URL to redirect to after successful authentication' + }) + async login( + @Res() res: Response, + @Query('redirect_uri') redirectUri?: string + ): Promise { + this.logger.log('Initiating OpenID Connect Provider login'); + + // Encode redirect URI in state parameter to avoid duplicate redirect_uri parameters + const baseState = Math.random().toString(36).substring(2, 15); + const allowedRedirectUrl = this.resolveAllowedRedirectUrl(redirectUri); + const state = allowedRedirectUrl ? `${baseState}:${encodeURIComponent(allowedRedirectUrl.toString())}` : baseState; + const oAuth2Endpoint = this.getOAuth2Endpoint(); + + const { codeVerifier, codeChallenge } = this.oidcAuthService.generatePkcePair(); + const stored = await this.oidcAuthService.storePkceVerifier(state, codeVerifier); + if (!stored) { + this.logger.error('Failed to store PKCE verifier'); + res.status(HttpStatus.INTERNAL_SERVER_ERROR).json({ error: 'Failed to initiate login' }); + return; + } + + // Get authorization URL with proper OAuth redirect_uri (callback) + const authUrl = this.oidcAuthService.getAuthorizationUrl(state, oAuth2Endpoint, codeChallenge); + res.redirect(authUrl); + } + + /** + * Handle OpenID Connect Provider callback after authentication + * @param code - Authorization code from OpenID Connect Provider + * @param state - State parameter for security + * @param res - Express response object + */ + @Get('callback') + @ApiOperation({ + summary: 'Handle OpenID Connect Provider authentication callback', + description: 'Processes the authorization code and creates user session' + }) + @ApiQuery({ name: 'code', description: 'Authorization code from OpenID Connect Provider' }) + @ApiQuery({ name: 'state', description: 'State parameter for security' }) + async callback( + @Query('code') code: string, + @Query('state') state: string, + @Res() res: Response + ): Promise { + this.logger.log('Processing OpenID Connect Provider callback'); + + try { + if (!code) { + this.logger.error('Authorization code is required'); + let errorRedirectUri; + if (state && typeof state === 'string' && state.includes(':')) { + const [, encodedRedirectUri] = state.split(':', 2); + try { + errorRedirectUri = decodeURIComponent(encodedRedirectUri); + } catch (error) { + this.logger.warn('Invalid encoded redirect URI in state'); + } + } + + res.redirect(this.buildErrorRedirectUrl(errorRedirectUri)); + return; + } + + let finalRedirectUri; + if (state && typeof state === 'string' && state.includes(':')) { + const [, encodedRedirectUri] = state.split(':', 2); + try { + finalRedirectUri = decodeURIComponent(encodedRedirectUri); + } catch (error) { + this.logger.warn('Invalid encoded redirect URI in state'); + } + } + + if (!state) { + this.logger.error('State parameter is required for PKCE flow'); + res.redirect(this.buildErrorRedirectUrl(finalRedirectUri)); + return; + } + + const oAuth2Endpoint = this.getOAuth2Endpoint(); + const codeVerifier = await this.oidcAuthService.consumePkceVerifier(state); + if (!codeVerifier) { + this.logger.error('PKCE verifier missing or expired'); + res.redirect(this.buildErrorRedirectUrl(finalRedirectUri)); + return; + } + + const tokenResponse = await this.oidcAuthService.exchangeCodeForToken(code, oAuth2Endpoint, codeVerifier); + + const userInfo = await this.oidcAuthService.getUserInfo(tokenResponse.access_token); + + const userData: CreateUserDto = { + identity: userInfo.sub, + username: userInfo.preferred_username, + firstName: userInfo.given_name || '', + lastName: userInfo.family_name || '', + email: userInfo.email || '', + issuer: 'coding-box', + isAdmin: userInfo.realm_access?.roles?.includes('admin') || false + }; + + // Store user in database but use OpenID Connect Provider access token directly + await this.authService.storeOidcProviderUser(userData); + + const redirectUrl = this.resolveAllowedRedirectUrl(finalRedirectUri); + if (!redirectUrl) { + this.logger.warn('Refusing to return OIDC tokens for a disallowed redirect URI'); + res.redirect(this.getDefaultLoginErrorRedirect()); + return; + } + + const exchangeCode = await this.oidcAuthService.storeTokenExchange(tokenResponse); + if (!exchangeCode) { + this.logger.error('Failed to store token exchange data'); + res.redirect(this.buildErrorRedirectUrl(finalRedirectUri)); + return; + } + + redirectUrl.searchParams.set('auth_code', exchangeCode); + res.redirect(redirectUrl.toString()); + } catch (error) { + this.logger.error('OpenID Connect Provider callback failed:', error); + // Decode redirect URI from state for error handling too + let errorRedirectUri; + if (state && typeof state === 'string' && state.includes(':')) { + const [, encodedRedirectUri] = state.split(':', 2); + try { + errorRedirectUri = decodeURIComponent(encodedRedirectUri); + } catch (decodeError) { + this.logger.warn('Invalid encoded redirect URI in state during error handling'); + } + } + + res.redirect(this.buildErrorRedirectUrl(errorRedirectUri)); + } + } + + @Post('exchange') + @HttpCode(HttpStatus.OK) + @ApiOperation({ + summary: 'Exchange a short-lived login code', + description: 'Exchanges the one-time login code from the callback redirect for OIDC tokens' + }) + @ApiBody({ + schema: { + type: 'object', + properties: { + code: { + type: 'string', + description: 'Short-lived one-time login code from the frontend redirect' + } + }, + required: ['code'] + } + }) + @ApiOkResponse({ + description: 'Successfully exchanged the one-time login code' + }) + @ApiUnauthorizedResponse({ + description: 'Invalid or expired login code' + }) + async exchangeLoginCode( + @Body() exchangeData: { code: string } + ): Promise { + const tokenResponse = await this.oidcAuthService.consumeTokenExchange(exchangeData?.code); + if (!tokenResponse) { + throw new UnauthorizedException('Invalid or expired login code'); + } + + return tokenResponse; + } + + @Post('refresh') + @HttpCode(HttpStatus.OK) + @ApiOperation({ + summary: 'Refresh an OIDC access token', + description: 'Uses a refresh token to obtain a fresh OIDC access token' + }) + @ApiBody({ + schema: { + type: 'object', + properties: { + refresh_token: { + type: 'string', + description: 'Refresh token from the OIDC provider' + } + }, + required: ['refresh_token'] + } + }) + @ApiOkResponse({ + description: 'Successfully refreshed the access token' + }) + @ApiUnauthorizedResponse({ + description: 'Invalid or expired refresh token' + }) + async refreshToken( + @Body() refreshData: { refresh_token: string } + ): Promise { + return this.oidcAuthService.refreshToken(refreshData?.refresh_token); + } + + @Post('logout') + @ApiOperation({ + summary: 'Logout from OpenID Connect Provider SSO', + description: 'Performs POST logout to OpenID Connect Provider to terminate SSO session using refresh token' + }) + @ApiBody({ + schema: { + type: 'object', + properties: { + refresh_token: { + type: 'string', + description: 'Refresh token to invalidate the SSO session' + } + }, + required: ['refresh_token'] + } + }) + @HttpCode(HttpStatus.OK) + async logout( + @Body() logoutData: { refresh_token: string }, + @Res() res: Response + ): Promise { + this.logger.log('Processing OpenID Connect Provider SSO logout'); + + try { + if (!logoutData.refresh_token) { + this.logger.error('Refresh token is required for logout'); + res.status(HttpStatus.UNAUTHORIZED).json({ + error: 'Refresh token is required for logout', + success: false + }); + return; + } + + await this.oidcAuthService.logoutWithRefreshToken(logoutData.refresh_token); + + this.logger.log('Successfully logged out from OpenID Connect Provider SSO session'); + res.json({ + message: 'Successfully logged out from OpenID Connect Provider SSO session', + success: true + }); + } catch (error) { + this.logger.error('Logout failed:', error); + if (error instanceof UnauthorizedException) { + res.status(HttpStatus.UNAUTHORIZED).json({ + error: error.message, + success: false + }); + } else { + res.status(HttpStatus.INTERNAL_SERVER_ERROR).json({ + error: 'Logout failed', + success: false + }); + } + } + } + + /** + * Redirect to OpenID Connect Provider profile management page + * @param res - Express response object for redirecting + * @param redirectUri - Optional redirect URI to return to after profile management + */ + @Get('profile') + @ApiOperation({ + summary: 'Redirect to profile management', + description: 'Redirects user to OpenID Connect Provider account management page for profile editing' + }) + @ApiQuery({ + name: 'redirect_uri', + required: false, + description: 'URL to redirect to after profile management' + }) + async redirectToProfile( + @Res() res: Response, + @Query('redirect_uri') redirectUri?: string + ): Promise { + this.logger.log('Redirecting to OpenID Connect Provider profile management'); + + try { + const allowedRedirectUrl = this.resolveAllowedRedirectUrl(redirectUri); + const profileUrl = this.oidcAuthService.getProfileUrl(allowedRedirectUrl?.toString()); + res.redirect(profileUrl); + } catch (error) { + this.logger.error('Profile redirect failed:', error); + res.status(500).json({ error: 'Failed to redirect to profile management' }); + } + } +} diff --git a/apps/backend/src/app/auth/auth.module.ts b/apps/backend/src/app/auth/auth.module.ts index 4587565b8..fabbbbc27 100755 --- a/apps/backend/src/app/auth/auth.module.ts +++ b/apps/backend/src/app/auth/auth.module.ts @@ -1,27 +1,34 @@ import { PassportModule } from '@nestjs/passport'; import { Module } from '@nestjs/common'; -import { JwtModule } from '@nestjs/jwt'; import { HttpModule } from '@nestjs/axios'; +import { JwtModule } from '@nestjs/jwt'; import { ConfigService } from '@nestjs/config'; import { AuthService } from './service/auth.service'; +import { OAuth2ClientCredentialsService } from './service/oauth2-client-credentials.service'; +import { OidcAuthService } from './service/oidc-auth.service'; +import { AuthController } from './auth.controller'; +import { DatabaseModule } from '../database/database.module'; import { UserModule } from '../user/user.module'; +import { CacheModule } from '../cache/cache.module'; import { JwtStrategy } from './jwt.strategy'; -import { KeycloakJwksService } from './keycloak-jwks.service'; +import { WorkspaceTokenStrategy } from './workspace-token.strategy'; @Module({ imports: [ PassportModule, - UserModule, + DatabaseModule, HttpModule, + UserModule, + CacheModule, JwtModule.registerAsync({ - useFactory: async (configService: ConfigService) => ({ - secret: configService.get('JWT_SECRET'), - signOptions: { expiresIn: '30m' } - }), - inject: [ConfigService] + inject: [ConfigService], + useFactory: (configService: ConfigService) => ({ + secret: configService.get('JWT_SECRET') + }) }) ], - providers: [AuthService, JwtStrategy, KeycloakJwksService], - exports: [AuthService, UserModule] + controllers: [AuthController], + providers: [AuthService, OAuth2ClientCredentialsService, OidcAuthService, JwtStrategy, WorkspaceTokenStrategy], + exports: [AuthService, OAuth2ClientCredentialsService, OidcAuthService, UserModule] }) export class AuthModule { } diff --git a/apps/backend/src/app/auth/jwt-or-workspace-token-auth.guard.spec.ts b/apps/backend/src/app/auth/jwt-or-workspace-token-auth.guard.spec.ts new file mode 100644 index 000000000..c2c71897e --- /dev/null +++ b/apps/backend/src/app/auth/jwt-or-workspace-token-auth.guard.spec.ts @@ -0,0 +1,113 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { AuthGuard } from '@nestjs/passport'; +import { ExecutionContext, UnauthorizedException } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { JwtOrWorkspaceTokenAuthGuard } from './jwt-or-workspace-token-auth.guard'; +import { + AllowAnyWorkspaceTokenScopes, + AllowWorkspaceTokenScopes, + WORKSPACE_API_TOKEN_TYPE, + WORKSPACE_TOKEN_SCOPE_REPLAY_READ, + WORKSPACE_TOKEN_SCOPE_REPLAY_STATISTICS_WRITE +} from './workspace-token'; +import { WORKSPACE_TOKEN_STRATEGY } from './workspace-token.constants'; + +describe('JwtOrWorkspaceTokenAuthGuard (Backend)', () => { + let guard: JwtOrWorkspaceTokenAuthGuard; + let passportCanActivateSpy: jest.SpyInstance; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + JwtOrWorkspaceTokenAuthGuard, + Reflector + ] + }).compile(); + + guard = module.get(JwtOrWorkspaceTokenAuthGuard); + passportCanActivateSpy = jest + .spyOn(Object.getPrototypeOf(JwtOrWorkspaceTokenAuthGuard.prototype), 'canActivate') + .mockResolvedValue(true); + }); + + afterEach(() => { + passportCanActivateSpy.mockRestore(); + }); + + const createContext = ( + user: Record, + handler: () => void = jest.fn() + ): ExecutionContext => ({ + switchToHttp: () => ({ + getRequest: () => ({ user }) + }), + getHandler: () => handler, + getClass: () => class TestController {} + } as unknown as ExecutionContext); + + it('should be defined', () => { + expect(guard).toBeDefined(); + }); + + it('should extend AuthGuard with jwt and workspace token strategies', () => { + expect(guard).toBeInstanceOf(AuthGuard(['jwt', WORKSPACE_TOKEN_STRATEGY])); + }); + + it('should allow regular authenticated users without workspace token scope metadata', async () => { + await expect(guard.canActivate(createContext({ id: 1 }))).resolves.toBe(true); + }); + + it('should reject workspace API tokens without allowed scope metadata', async () => { + await expect(guard.canActivate(createContext({ + id: 1, + tokenType: WORKSPACE_API_TOKEN_TYPE, + scopes: [WORKSPACE_TOKEN_SCOPE_REPLAY_READ] + }))).rejects.toThrow(UnauthorizedException); + }); + + it('should allow workspace API tokens when the endpoint allows the token scope', async () => { + const handler = jest.fn(); + AllowWorkspaceTokenScopes(WORKSPACE_TOKEN_SCOPE_REPLAY_READ)(handler); + + await expect(guard.canActivate(createContext({ + id: 1, + tokenType: WORKSPACE_API_TOKEN_TYPE, + scopes: [WORKSPACE_TOKEN_SCOPE_REPLAY_READ] + }, handler))).resolves.toBe(true); + }); + + it('should allow workspace API tokens when any configured scope alternative matches', async () => { + const handler = jest.fn(); + AllowAnyWorkspaceTokenScopes( + WORKSPACE_TOKEN_SCOPE_REPLAY_READ, + WORKSPACE_TOKEN_SCOPE_REPLAY_STATISTICS_WRITE + )(handler); + + await expect(guard.canActivate(createContext({ + id: 1, + tokenType: WORKSPACE_API_TOKEN_TYPE, + scopes: [WORKSPACE_TOKEN_SCOPE_REPLAY_STATISTICS_WRITE] + }, handler))).resolves.toBe(true); + }); + + it('should reject workspace API tokens without a matching token scope', async () => { + const handler = jest.fn(); + AllowWorkspaceTokenScopes(WORKSPACE_TOKEN_SCOPE_REPLAY_READ)(handler); + + await expect(guard.canActivate(createContext({ + id: 1, + tokenType: WORKSPACE_API_TOKEN_TYPE, + scopes: [WORKSPACE_TOKEN_SCOPE_REPLAY_STATISTICS_WRITE] + }, handler))).rejects.toThrow(UnauthorizedException); + }); + + it('should return false when passport authentication fails without checking scopes', async () => { + passportCanActivateSpy.mockResolvedValue(false); + + await expect(guard.canActivate(createContext({ + id: 1, + tokenType: WORKSPACE_API_TOKEN_TYPE, + scopes: [WORKSPACE_TOKEN_SCOPE_REPLAY_READ] + }))).resolves.toBe(false); + }); +}); diff --git a/apps/backend/src/app/auth/jwt-or-workspace-token-auth.guard.ts b/apps/backend/src/app/auth/jwt-or-workspace-token-auth.guard.ts new file mode 100644 index 000000000..d2c769826 --- /dev/null +++ b/apps/backend/src/app/auth/jwt-or-workspace-token-auth.guard.ts @@ -0,0 +1,23 @@ +import { ExecutionContext, Injectable } from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; +import { Reflector } from '@nestjs/core'; +import { WORKSPACE_TOKEN_STRATEGY } from './workspace-token.constants'; +import { assertWorkspaceApiTokenScopes } from './workspace-token'; + +@Injectable() +export class JwtOrWorkspaceTokenAuthGuard extends AuthGuard(['jwt', WORKSPACE_TOKEN_STRATEGY]) { + constructor(private readonly reflector: Reflector) { + super(); + } + + override async canActivate(context: ExecutionContext): Promise { + const canActivate = await super.canActivate(context); + if (!canActivate) { + return false; + } + + const request = context.switchToHttp().getRequest(); + assertWorkspaceApiTokenScopes(context, this.reflector, request.user); + return true; + } +} diff --git a/apps/backend/src/app/auth/jwt.strategy.spec.ts b/apps/backend/src/app/auth/jwt.strategy.spec.ts index 6b68739b0..2c2f25963 100644 --- a/apps/backend/src/app/auth/jwt.strategy.spec.ts +++ b/apps/backend/src/app/auth/jwt.strategy.spec.ts @@ -1,201 +1,87 @@ -import { UnauthorizedException } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; +import { UnauthorizedException } from '@nestjs/common'; import { JwtStrategy } from './jwt.strategy'; -import { KeycloakJwksService } from './keycloak-jwks.service'; import { UsersService } from '../database/services/users'; -import { - ALLOW_LEGACY_WORKSPACE_REPLAY_TOKENS_ENV, - WORKSPACE_API_TOKEN_TYPE, - WORKSPACE_TOKEN_SCOPE_REPLAY_READ -} from './workspace-token'; describe('JwtStrategy', () => { - const issuer = 'https://sso.example.test/realms/coding-box'; - let strategy: JwtStrategy; - let usersService: { - syncKeycloakUser: jest.Mock; - }; + let usersService: jest.Mocked>; - const createStrategy = (configOverrides: Record = {}) => { - const configValues: Record = { - JWT_SECRET: 'test-secret', - OIDC_ISSUER: issuer, - KEYCLOAK_CLIENT_ID: 'coding-box', - ...configOverrides + const createStrategy = () => { + usersService = { + createOidcProviderUser: jest.fn().mockResolvedValue(42) }; - const configService = { - get: jest.fn((key: string) => configValues[key]) - } as unknown as ConfigService; - - return new JwtStrategy( - configService, - {} as KeycloakJwksService, - usersService as unknown as UsersService - ); + return new JwtStrategy({ + get: jest.fn((key: string) => ({ + OIDC_ISSUER: 'https://issuer.example.test', + OIDC_JWKS_URI: 'https://issuer.example.test/certs', + OAUTH2_CLIENT_ID: 'coding-box' + })[key]) + } as unknown as ConfigService, usersService as unknown as UsersService); }; - beforeEach(() => { - usersService = { - syncKeycloakUser: jest.fn().mockResolvedValue(42) - }; - - strategy = createStrategy(); - }); + it('accepts tokens issued for the configured client through azp', async () => { + const strategy = createStrategy(); - it('validates Keycloak tokens and syncs the user from trusted claims', async () => { await expect(strategy.validate({ - iss: issuer, - sub: 'user-subject', - preferred_username: 'alice', - aud: ['account', 'coding-box'], - email: 'alice@example.test', - given_name: 'Alice', - family_name: 'Example', - realm_access: { - roles: ['offline_access', 'admin'] - }, - resource_access: { - 'coding-box': { - roles: ['coder'] - } - } - })).resolves.toEqual({ - userId: 42, + sub: 'oidc-user-id', + iss: 'https://issuer.example.test', + preferred_username: 'tester', + azp: 'coding-box', + email: 'tester@example.test', + given_name: 'Test', + family_name: 'User', + realm_access: { roles: ['admin'] } + })).resolves.toEqual(expect.objectContaining({ id: 42, - name: 'alice', - workspace: '', - identity: 'user-subject', - issuer, - roles: ['offline_access', 'admin', 'coder'] - }); - - expect(usersService.syncKeycloakUser).toHaveBeenCalledWith({ - identity: 'user-subject', - issuer, - username: 'alice', + userId: 42, + identity: 'oidc-user-id', isAdmin: true, - email: 'alice@example.test', - firstName: 'Alice', - lastName: 'Example' + sub: 'oidc-user-id' + })); + expect(usersService.createOidcProviderUser).toHaveBeenCalledWith({ + identity: 'oidc-user-id', + issuer: 'https://issuer.example.test', + username: 'tester', + email: 'tester@example.test', + firstName: 'Test', + lastName: 'User', + isAdmin: true }); }); - it('rejects Keycloak tokens from untrusted issuers', async () => { - await expect(strategy.validate({ - iss: 'https://other.example.test/realms/coding-box', - sub: 'user-subject', - preferred_username: 'alice', - aud: 'coding-box' - })).rejects.toBeInstanceOf(UnauthorizedException); - - expect(usersService.syncKeycloakUser).not.toHaveBeenCalled(); - }); - - it('rejects Keycloak tokens for a different client', async () => { - await expect(strategy.validate({ - iss: issuer, - sub: 'user-subject', - preferred_username: 'alice', - aud: 'other-client', - azp: 'other-client' - })).rejects.toBeInstanceOf(UnauthorizedException); - - expect(usersService.syncKeycloakUser).not.toHaveBeenCalled(); - }); - - it('keeps scoped workspace tokens valid for API special-token flows', async () => { - await expect(strategy.validate({ - userId: 7, - username: 'workspace-user', - workspace: 11, - tokenType: WORKSPACE_API_TOKEN_TYPE, - scopes: [WORKSPACE_TOKEN_SCOPE_REPLAY_READ], - sub: { - identity: 'identity-1' - } - })).resolves.toEqual({ - userId: 7, - id: 7, - name: 'workspace-user', - workspace: 11, - identity: 'identity-1', - tokenType: WORKSPACE_API_TOKEN_TYPE, - scopes: [WORKSPACE_TOKEN_SCOPE_REPLAY_READ] - }); - - expect(usersService.syncKeycloakUser).not.toHaveBeenCalled(); - }); + it('accepts tokens issued for the configured client through aud', async () => { + const strategy = createStrategy(); - it('rejects old internal session tokens without workspace scope', async () => { await expect(strategy.validate({ - userId: 7, - username: 'session-user', - sub: { - identity: 'identity-1' - } - })).rejects.toBeInstanceOf(UnauthorizedException); + sub: 'oidc-user-id', + preferred_username: 'tester', + aud: ['account', 'coding-box'] + })).resolves.toEqual(expect.objectContaining({ + id: 42, + identity: 'oidc-user-id' + })); }); - it('maps legacy workspace tokens to replay-read only during the migration window', async () => { - await expect(strategy.validate({ - userId: 7, - username: 'workspace-user', - workspace: 11, - sub: { - identity: 'identity-1' - } - })).resolves.toEqual({ - userId: 7, - id: 7, - name: 'workspace-user', - workspace: 11, - identity: 'identity-1', - tokenType: WORKSPACE_API_TOKEN_TYPE, - scopes: [WORKSPACE_TOKEN_SCOPE_REPLAY_READ] - }); - }); + it('rejects tokens for another client', async () => { + const strategy = createStrategy(); - it('does not apply the legacy replay-read fallback to malformed workspace token scopes', async () => { await expect(strategy.validate({ - userId: 7, - username: 'workspace-user', - workspace: 11, - scopes: null, - sub: { - identity: 'identity-1' - } - })).resolves.toEqual({ - userId: 7, - id: 7, - name: 'workspace-user', - workspace: 11, - identity: 'identity-1', - tokenType: WORKSPACE_API_TOKEN_TYPE, - scopes: [] - }); + sub: 'oidc-user-id', + preferred_username: 'tester', + azp: 'other-client', + aud: ['account'] + })).rejects.toThrow(UnauthorizedException); + expect(usersService.createOidcProviderUser).not.toHaveBeenCalled(); }); - it('keeps legacy workspace tokens without endpoint scopes when migration fallback is disabled', async () => { - strategy = createStrategy({ - [ALLOW_LEGACY_WORKSPACE_REPLAY_TOKENS_ENV]: 'false' - }); + it('rejects tokens without a subject', async () => { + const strategy = createStrategy(); await expect(strategy.validate({ - userId: 7, - username: 'workspace-user', - workspace: 11, - sub: { - identity: 'identity-1' - } - })).resolves.toEqual({ - userId: 7, - id: 7, - name: 'workspace-user', - workspace: 11, - identity: 'identity-1', - tokenType: WORKSPACE_API_TOKEN_TYPE, - scopes: [] - }); + preferred_username: 'tester', + azp: 'coding-box' + })).rejects.toThrow(UnauthorizedException); + expect(usersService.createOidcProviderUser).not.toHaveBeenCalled(); }); }); diff --git a/apps/backend/src/app/auth/jwt.strategy.ts b/apps/backend/src/app/auth/jwt.strategy.ts index 3f86ec87f..89d7e6627 100755 --- a/apps/backend/src/app/auth/jwt.strategy.ts +++ b/apps/backend/src/app/auth/jwt.strategy.ts @@ -2,248 +2,99 @@ import { ExtractJwt, Strategy } from 'passport-jwt'; import { PassportStrategy } from '@nestjs/passport'; import { Injectable, UnauthorizedException } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; -import { KeycloakJwksService } from './keycloak-jwks.service'; +import { passportJwtSecret } from 'jwks-rsa'; import { UsersService } from '../database/services/users'; -import { - ALLOW_LEGACY_WORKSPACE_REPLAY_TOKENS_ENV, - WORKSPACE_API_TOKEN_TYPE, - WORKSPACE_TOKEN_SCOPE_REPLAY_READ, - WorkspaceTokenScope -} from './workspace-token'; -type JwtSubject = string | number | { - identity?: string; -}; - -type JwtPayload = { - userId?: string | number; - sub?: JwtSubject; - username?: string; - workspace?: string | number; - tokenType?: string; - scopes?: unknown; +interface OidcJwtPayload { + sub?: string; iss?: string; - aud?: string | string[]; - azp?: string; preferred_username?: string; - email?: string; given_name?: string; family_name?: string; - realm_access?: { - roles?: string[]; - }; - resource_access?: Record; -}; - -type JwtHeader = { - alg?: string; - kid?: string; -}; - -const ADMIN_ROLES = ['admin', 'system-admin', 'sys-admin', 'administrator']; + email?: string; + aud?: string | string[]; + azp?: string; + realm_access?: { roles: string[] }; +} @Injectable() export class JwtStrategy extends PassportStrategy(Strategy) { - private readonly expectedIssuer?: string; - private readonly keycloakClientId?: string; - private readonly allowLegacyWorkspaceReplayTokens: boolean; + private readonly oAuth2ClientId?: string; constructor( configService: ConfigService, - keycloakJwksService: KeycloakJwksService, private readonly usersService: UsersService ) { - const jwtSecret = configService.get('JWT_SECRET'); - const expectedIssuer = resolveExpectedIssuer(configService); - const keycloakClientId = configService.get('KEYCLOAK_CLIENT_ID')?.trim(); + const oidcIssuer = configService.get('OIDC_ISSUER'); + const oidcJwksUri = configService.get('OIDC_JWKS_URI'); + const oAuth2ClientId = configService.get('OAUTH2_CLIENT_ID'); super({ jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), ignoreExpiration: false, - algorithms: ['RS256', 'HS256'], - secretOrKeyProvider: async (_request, token, done) => { - try { - const header = decodeJwtPart(token, 0); - if (header.alg === 'RS256') { - done(null, await keycloakJwksService.getSigningKey(header.kid)); - return; - } - - if (header.alg === 'HS256' && jwtSecret) { - done(null, jwtSecret); - return; - } - - done(new UnauthorizedException('Unsupported JWT signing algorithm')); - } catch (error) { - done(error); - } - } + secretOrKeyProvider: passportJwtSecret({ + cache: true, + rateLimit: true, + jwksRequestsPerMinute: 5, + jwksUri: `${oidcJwksUri}` + }), + issuer: `${oidcIssuer}`, + algorithms: ['RS256'] }); - this.expectedIssuer = expectedIssuer; - this.keycloakClientId = keycloakClientId; - this.allowLegacyWorkspaceReplayTokens = resolveAllowLegacyWorkspaceReplayTokens(configService); - } - - async validate( - payload: JwtPayload - ) { - if (this.isKeycloakPayload(payload)) { - return this.validateKeycloakPayload(payload); - } - - if (!this.isWorkspaceTokenPayload(payload)) { - throw new UnauthorizedException('JWT is not a valid Keycloak or workspace token'); - } - - return { - userId: payload.userId, - id: payload.userId, - name: payload.username, - workspace: payload.workspace || '', - identity: this.getIdentity(payload.sub), - tokenType: WORKSPACE_API_TOKEN_TYPE, - scopes: this.getWorkspaceTokenScopes(payload.scopes) - }; + this.oAuth2ClientId = oAuth2ClientId; } - private async validateKeycloakPayload(payload: JwtPayload) { - if (this.expectedIssuer && payload.iss !== this.expectedIssuer) { - throw new UnauthorizedException('JWT issuer is not trusted'); + async validate(payload: OidcJwtPayload) { + if (!this.isTokenForConfiguredClient(payload)) { + throw new UnauthorizedException('JWT client does not match configured OAuth2 client'); } - if (this.keycloakClientId && !this.hasExpectedAudience(payload)) { - throw new UnauthorizedException('JWT audience is not trusted'); - } - - const identity = this.getKeycloakIdentity(payload); - const username = this.getKeycloakUsername(payload); - const roles = this.getKeycloakRoles(payload); - const userId = await this.usersService.syncKeycloakUser({ + const identity = this.getIdentity(payload); + const username = this.getUsername(payload, identity); + const isAdmin = payload.realm_access?.roles?.includes('admin') || false; + const userId = await this.usersService.createOidcProviderUser({ identity, issuer: payload.iss || '', username, - isAdmin: roles.some(role => ADMIN_ROLES.includes(role.toLowerCase())), email: payload.email, firstName: payload.given_name, - lastName: payload.family_name + lastName: payload.family_name, + isAdmin }); return { userId, id: userId, name: username, - workspace: '', + firstName: payload.given_name, + lastName: payload.family_name, + email: payload.email, + isAdmin, + sub: identity, identity, - issuer: payload.iss, - roles + issuer: payload.iss }; } - private isKeycloakPayload(payload: JwtPayload): boolean { - return typeof payload.iss === 'string' && - typeof payload.sub === 'string' && - (!!payload.preferred_username || !!payload.azp || !!payload.aud); - } - - private isWorkspaceTokenPayload(payload: JwtPayload): boolean { - return payload.userId !== undefined && - typeof payload.username === 'string' && - payload.workspace !== undefined && - payload.workspace !== null && - payload.workspace !== ''; - } - - private getWorkspaceTokenScopes(scopes: unknown): WorkspaceTokenScope[] { - if (scopes === undefined) { - return this.allowLegacyWorkspaceReplayTokens ? - [WORKSPACE_TOKEN_SCOPE_REPLAY_READ] : - []; - } - - if (!Array.isArray(scopes)) { - return []; - } - - return scopes.filter((scope): scope is WorkspaceTokenScope => typeof scope === 'string'); - } - - private getIdentity(subject?: JwtSubject): string | undefined { - if (subject && typeof subject === 'object' && typeof subject.identity === 'string') { - return subject.identity; + private isTokenForConfiguredClient(payload: OidcJwtPayload): boolean { + if (!this.oAuth2ClientId) { + return false; } - return undefined; + const audiences = Array.isArray(payload.aud) ? payload.aud : [payload.aud].filter(Boolean); + return payload.azp === this.oAuth2ClientId || audiences.includes(this.oAuth2ClientId); } - private getKeycloakIdentity(payload: JwtPayload): string { - if (typeof payload.sub !== 'string' || !payload.sub.trim()) { + private getIdentity(payload: OidcJwtPayload): string { + if (!payload.sub || !payload.sub.trim()) { throw new UnauthorizedException('JWT subject is missing'); } - return payload.sub; - } - - private getKeycloakUsername(payload: JwtPayload): string { - const username = payload.preferred_username || this.getKeycloakIdentity(payload); - return username.trim(); - } - - private hasExpectedAudience(payload: JwtPayload): boolean { - if (!this.keycloakClientId) { - return true; - } - - const audience = Array.isArray(payload.aud) ? payload.aud : [payload.aud]; - return payload.azp === this.keycloakClientId || - audience.includes(this.keycloakClientId); - } - - private getKeycloakRoles(payload: JwtPayload): string[] { - const realmRoles = payload.realm_access?.roles || []; - const clientRoles = this.keycloakClientId ? - (payload.resource_access?.[this.keycloakClientId]?.roles || []) : - []; - - return Array.from(new Set([...realmRoles, ...clientRoles] - .filter(role => typeof role === 'string') - .map(role => role.trim()) - .filter(Boolean))); - } -} - -function decodeJwtPart(token: string, partIndex: number): T { - const part = token.split('.')[partIndex]; - if (!part) { - throw new UnauthorizedException('Malformed JWT'); - } - - return JSON.parse(Buffer.from(part, 'base64url').toString('utf8')) as T; -} - -function resolveExpectedIssuer(configService: ConfigService): string | undefined { - const configuredIssuer = configService.get('OIDC_ISSUER')?.trim(); - if (configuredIssuer) { - return configuredIssuer.replace(/\/+$/, ''); + return payload.sub.trim(); } - const keycloakUrl = configService.get('KEYCLOAK_URL')?.trim(); - const realm = configService.get('KEYCLOAK_REALM')?.trim(); - if (!keycloakUrl || !realm) { - return undefined; + private getUsername(payload: OidcJwtPayload, identity: string): string { + return payload.preferred_username?.trim() || identity; } - - return `${keycloakUrl.replace(/\/+$/, '')}/realms/${realm}`; -} - -function resolveAllowLegacyWorkspaceReplayTokens(configService: ConfigService): boolean { - const configuredValue = configService.get(ALLOW_LEGACY_WORKSPACE_REPLAY_TOKENS_ENV); - if (configuredValue === undefined || configuredValue === null || configuredValue.trim() === '') { - return true; - } - - return !['false', '0', 'no', 'off'].includes(configuredValue.trim().toLowerCase()); } diff --git a/apps/backend/src/app/auth/service/auth.service.spec.ts b/apps/backend/src/app/auth/service/auth.service.spec.ts index 131b71fff..5310b2a63 100755 --- a/apps/backend/src/app/auth/service/auth.service.spec.ts +++ b/apps/backend/src/app/auth/service/auth.service.spec.ts @@ -75,18 +75,20 @@ describe('AuthService', () => { expect(jwtService.sign).toHaveBeenCalledWith( { + token_use: 'workspace', userId: 5, username: 'study-manager', - sub: { - id: 5, - username: 'study-manager', - isAdmin: false - }, + sub: '5', workspace: 7, tokenType: WORKSPACE_API_TOKEN_TYPE, scopes: [WORKSPACE_TOKEN_SCOPE_REPLAY_READ] }, - { expiresIn: '1d' } + { + expiresIn: '1d', + issuer: 'coding-box', + audience: 'coding-box-workspace-token', + algorithm: 'HS256' + } ); expect(usersService.getUserIsAdmin).not.toHaveBeenCalled(); expect(usersService.getUserAccessLevel).not.toHaveBeenCalled(); @@ -169,7 +171,7 @@ describe('AuthService', () => { expect.objectContaining({ scopes: [WORKSPACE_TOKEN_SCOPE_REPLAY_READ] }), - { expiresIn: '90d' } + expect.objectContaining({ expiresIn: '90d' }) ); }); @@ -265,13 +267,10 @@ describe('AuthService', () => { expect(usersService.findUserById).toHaveBeenCalledWith(12); expect(jwtService.sign).toHaveBeenCalledWith( { + token_use: 'workspace', userId: 12, username: 'coder', - sub: { - id: 12, - username: 'coder', - isAdmin: false - }, + sub: '12', workspace: 7, tokenType: WORKSPACE_API_TOKEN_TYPE, scopes: [ @@ -279,7 +278,12 @@ describe('AuthService', () => { WORKSPACE_TOKEN_SCOPE_REPLAY_STATISTICS_WRITE ] }, - { expiresIn: '1d' } + { + expiresIn: '1d', + issuer: 'coding-box', + audience: 'coding-box-workspace-token', + algorithm: 'HS256' + } ); }); diff --git a/apps/backend/src/app/auth/service/auth.service.ts b/apps/backend/src/app/auth/service/auth.service.ts index 0eb2dc139..74a9f57fa 100755 --- a/apps/backend/src/app/auth/service/auth.service.ts +++ b/apps/backend/src/app/auth/service/auth.service.ts @@ -2,11 +2,13 @@ import { BadRequestException, ForbiddenException, Injectable, + Logger, NotFoundException } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; import { ConfigService } from '@nestjs/config'; import { UsersService } from '../../database/services/users'; +import { CreateUserDto } from '../../../../../../api-dto/user/create-user-dto'; import { UserFullDto } from '../../../../../../api-dto/user/user-full-dto'; import { createWorkspaceTokenPolicy, @@ -18,9 +20,16 @@ import { WorkspaceTokenPolicy, WorkspaceTokenScope } from '../workspace-token'; +import { + WORKSPACE_TOKEN_AUDIENCE, + WORKSPACE_TOKEN_ISSUER, + WORKSPACE_TOKEN_USE +} from '../workspace-token.constants'; @Injectable() export class AuthService { + private readonly logger = new Logger(AuthService.name); + constructor( private usersService: UsersService, private jwtService: JwtService, @@ -32,6 +41,23 @@ export class AuthService { return createWorkspaceTokenPolicy(this.getReplayReadMaxDurationDays()); } + async storeOidcProviderUser(user: CreateUserDto) { + const { + username, lastName, firstName, email, identity, issuer, isAdmin + } = user; + const userId = await this.usersService.createOidcProviderUser({ + identity: identity, + username: username, + email: email, + lastName: lastName, + firstName: firstName, + issuer: issuer, + isAdmin: isAdmin + }); + this.logger.log(`OIDC Provider User with id '${userId}' stored in database.`); + return userId; + } + async createToken( identity: string, workspaceId: number, @@ -78,14 +104,20 @@ export class AuthService { this.validateWorkspaceTokenScopes(scopes); this.validateWorkspaceTokenDuration(duration, scopes); const payload = { + token_use: WORKSPACE_TOKEN_USE, userId: user.id, username: user.username, - sub: user, + sub: String(user.id), workspace: workspaceId, tokenType: WORKSPACE_API_TOKEN_TYPE, scopes: Array.from(new Set(scopes)) }; - const token = this.jwtService.sign(payload, { expiresIn: `${duration}d` }); + const token = this.jwtService.sign(payload, { + expiresIn: `${duration}d`, + issuer: WORKSPACE_TOKEN_ISSUER, + audience: WORKSPACE_TOKEN_AUDIENCE, + algorithm: 'HS256' + }); return JSON.stringify(token); } diff --git a/apps/backend/src/app/auth/service/keycloak-auth.service.ts b/apps/backend/src/app/auth/service/keycloak-auth.service.ts new file mode 100644 index 000000000..08803116d --- /dev/null +++ b/apps/backend/src/app/auth/service/keycloak-auth.service.ts @@ -0,0 +1,232 @@ +import { Injectable, Logger, UnauthorizedException } from '@nestjs/common'; +import { HttpService } from '@nestjs/axios'; +import { ConfigService } from '@nestjs/config'; +import { firstValueFrom } from 'rxjs'; + +export interface KeycloakTokenResponse { + access_token: string; + token_type: string; + expires_in: number; + refresh_token: string; + scope?: string; + id_token?: string; +} + +export interface KeycloakUserInfo { + sub: string; + preferred_username: string; + given_name?: string; + family_name?: string; + email?: string; + realm_access?: { + roles: string[]; + }; +} + +@Injectable() +export class KeycloakAuthService { + private readonly logger = new Logger(KeycloakAuthService.name); + + constructor( + private readonly httpService: HttpService, + private readonly configService: ConfigService + ) {} + + /** + * Generate the Keycloak authorization URL for the Authorization Code flow + * @param state - Random state parameter for security + * @param redirectUri - Callback URL after authentication + * @returns Authorization URL + */ + getAuthorizationUrl(state: string, redirectUri: string): string { + const keycloakUrl = this.configService.get('KEYCLOAK_URL'); + const keycloakRealm = this.configService.get('KEYCLOAK_REALM'); + const clientId = this.configService.get('KEYCLOAK_CLIENT_ID'); + + if (!keycloakUrl || !keycloakRealm || !clientId) { + throw new UnauthorizedException('Keycloak configuration is missing'); + } + + const authUrl = `${keycloakUrl}realms/${keycloakRealm}/protocol/openid-connect/auth`; + const params = new URLSearchParams({ + response_type: 'code', + client_id: clientId, + redirect_uri: redirectUri, + state: state, + scope: 'openid profile email' + }); + + return `${authUrl}?${params.toString()}`; + } + + /** + * Exchange authorization code for access token + * @param code - Authorization code from Keycloak + * @param redirectUri - The same redirect URI used in authorization request + * @returns Token response from Keycloak + */ + async exchangeCodeForToken(code: string, redirectUri: string): Promise { + const keycloakUrl = this.configService.get('KEYCLOAK_URL'); + const keycloakRealm = this.configService.get('KEYCLOAK_REALM'); + const clientId = this.configService.get('KEYCLOAK_CLIENT_ID'); + const clientSecret = this.configService.get('KEYCLOAK_CLIENT_SECRET'); + + if (!keycloakUrl || !keycloakRealm || !clientId || !clientSecret) { + throw new UnauthorizedException('Keycloak configuration is missing'); + } + + const tokenEndpoint = `${keycloakUrl}realms/${keycloakRealm}/protocol/openid-connect/token`; + + const params = new URLSearchParams({ + grant_type: 'authorization_code', + client_id: clientId, + client_secret: clientSecret, + code: code, + redirect_uri: redirectUri + }); + + try { + this.logger.log('Exchanging authorization code for access token'); + + const response = await firstValueFrom( + this.httpService.post(tokenEndpoint, params.toString(), { + headers: { + 'Content-Type': 'application/x-www-form-urlencoded' + } + }) + ); + + this.logger.log('Successfully obtained access token from authorization code'); + return response.data; + } catch (error) { + this.logger.error('Failed to exchange authorization code for token:', error.response?.data || error.message); + throw new UnauthorizedException('Failed to exchange authorization code for token'); + } + } + + /** + * Get user information from Keycloak using access token + * @param accessToken - Access token from Keycloak + * @returns User information + */ + async getUserInfo(accessToken: string): Promise { + const keycloakUrl = this.configService.get('KEYCLOAK_URL'); + const keycloakRealm = this.configService.get('KEYCLOAK_REALM'); + + if (!keycloakUrl || !keycloakRealm) { + throw new UnauthorizedException('Keycloak configuration is missing'); + } + + const userinfoEndpoint = `${keycloakUrl}realms/${keycloakRealm}/protocol/openid-connect/userinfo`; + + try { + const response = await firstValueFrom( + this.httpService.get(userinfoEndpoint, { + headers: { + Authorization: `Bearer ${accessToken}` + } + }) + ); + return response.data; + } catch (error) { + this.logger.error('Failed to get user info:', error.response?.data || error.message); + throw new UnauthorizedException('Failed to get user information'); + } + } + + /** + * Generate Keycloak logout URL + * @param idToken - ID token for proper logout + * @param redirectUri - URL to redirect after logout + * @returns Logout URL + */ + getLogoutUrl(idToken: string, redirectUri: string): string { + const keycloakUrl = this.configService.get('KEYCLOAK_URL'); + const keycloakRealm = this.configService.get('KEYCLOAK_REALM'); + const clientId = this.configService.get('KEYCLOAK_CLIENT_ID'); + + if (!keycloakUrl || !keycloakRealm || !clientId) { + throw new UnauthorizedException('Keycloak configuration is missing'); + } + + const logoutUrl = `${keycloakUrl}realms/${keycloakRealm}/protocol/openid-connect/logout`; + const params = new URLSearchParams({ + client_id: clientId, + id_token_hint: idToken, + post_logout_redirect_uri: redirectUri + }); + + return `${logoutUrl}?${params.toString()}`; + } + + /** + * POST logout to Keycloak to terminate SSO session + * @param refreshToken - Refresh token to invalidate + * @returns Promise that resolves when logout is complete + */ + async logoutWithRefreshToken(refreshToken: string): Promise { + const keycloakUrl = this.configService.get('KEYCLOAK_URL'); + const keycloakRealm = this.configService.get('KEYCLOAK_REALM'); + const clientId = this.configService.get('KEYCLOAK_CLIENT_ID'); + const clientSecret = this.configService.get('KEYCLOAK_CLIENT_SECRET'); + + if (!keycloakUrl || !keycloakRealm || !clientId) { + throw new UnauthorizedException('Keycloak configuration is missing'); + } + + const logoutEndpoint = `${keycloakUrl}realms/${keycloakRealm}/protocol/openid-connect/logout`; + + const params = new URLSearchParams({ + client_id: clientId, + refresh_token: refreshToken + }); + + // Add client_secret only for confidential clients + if (clientSecret) { + params.append('client_secret', clientSecret); + } + + try { + this.logger.log('Performing POST logout to Keycloak'); + + await firstValueFrom( + this.httpService.post(logoutEndpoint, params.toString(), { + headers: { + 'Content-Type': 'application/x-www-form-urlencoded' + } + }) + ); + + this.logger.log('Successfully logged out from Keycloak SSO session'); + } catch (error) { + this.logger.error('Failed to logout from Keycloak:', error.response?.data || error.message); + throw new UnauthorizedException('Failed to logout from Keycloak'); + } + } + + /** + * Generate Keycloak profile management URL + * @param redirectUri - Optional URL to redirect back to after profile management + * @returns Profile management URL + */ + getProfileUrl(redirectUri?: string): string { + const keycloakUrl = this.configService.get('KEYCLOAK_URL'); + const keycloakRealm = this.configService.get('KEYCLOAK_REALM'); + const clientId = this.configService.get('KEYCLOAK_CLIENT_ID'); + + if (!keycloakUrl || !keycloakRealm || !clientId) { + throw new UnauthorizedException('Keycloak configuration is missing'); + } + + const profileUrl = `${keycloakUrl}realms/${keycloakRealm}/account`; + if (redirectUri) { + const params = new URLSearchParams({ + referrer: clientId, + referrer_uri: redirectUri + }); + return `${profileUrl}?${params.toString()}`; + } + + return profileUrl; + } +} diff --git a/apps/backend/src/app/auth/service/oauth2-client-credentials.service.ts b/apps/backend/src/app/auth/service/oauth2-client-credentials.service.ts new file mode 100644 index 000000000..3aaf99099 --- /dev/null +++ b/apps/backend/src/app/auth/service/oauth2-client-credentials.service.ts @@ -0,0 +1,95 @@ +import { Injectable, Logger, UnauthorizedException } from '@nestjs/common'; +import { HttpService } from '@nestjs/axios'; +import { ConfigService } from '@nestjs/config'; +import { firstValueFrom } from 'rxjs'; +import { OidcUserInfo } from './oidc-auth.service'; + +export interface ClientCredentialsTokenResponse { + access_token: string; + token_type: string; + expires_in: number; + scope?: string; +} + +export interface ClientCredentialsRequest { + client_id: string; + client_secret: string; + scope?: string; +} + +@Injectable() +export class OAuth2ClientCredentialsService { + private readonly logger = new Logger(OAuth2ClientCredentialsService.name); + + constructor( + private readonly httpService: HttpService, + private readonly configService: ConfigService + ) {} + + /** + * Exchange client credentials for an access token using OAuth2 Client Credentials Flow + * @param clientCredentials - The client ID and secret + * @returns Promise + */ + async getAccessToken(clientCredentials: ClientCredentialsRequest): Promise { + const oidcTokenEndpoint = this.configService.get('OIDC_TOKEN_ENDPOINT'); + + if (!oidcTokenEndpoint) { + throw new UnauthorizedException('OpenID Connect token endpoint configuration is missing'); + } + + const params = new URLSearchParams(); + params.append('grant_type', 'client_credentials'); + params.append('client_id', clientCredentials.client_id); + params.append('client_secret', clientCredentials.client_secret); + + if (clientCredentials.scope) { + params.append('scope', clientCredentials.scope); + } + + try { + this.logger.log(`Requesting access token for client: ${clientCredentials.client_id}`); + + const response = await firstValueFrom( + this.httpService.post(oidcTokenEndpoint, params.toString(), { + headers: { + 'Content-Type': 'application/x-www-form-urlencoded' + } + }) + ); + + this.logger.log(`Successfully obtained access token for client: ${clientCredentials.client_id}`); + return response.data; + } catch (error) { + this.logger.error(`Failed to obtain access token for client ${clientCredentials.client_id}:`, error.response?.data || error.message); + throw new UnauthorizedException('Failed to authenticate with client credentials'); + } + } + + /** + * Validate an access token against OIDC Provider userinfo endpoint + * @param accessToken - The access token to validate + * @returns Promise - User info from OIDC Provider + */ + async validateAccessToken(accessToken: string): Promise { + const oidcUserInfoEndpoint = this.configService.get('OIDC_USERINFO_ENDPOINT'); + + if (!oidcUserInfoEndpoint) { + throw new UnauthorizedException('OpenID Connect userinfo endpoint configuration is missing'); + } + + try { + const response = await firstValueFrom( + this.httpService.get(oidcUserInfoEndpoint, { + headers: { + Authorization: `Bearer ${accessToken}` + } + }) + ); + return response.data; + } catch (error) { + this.logger.error('Failed to validate access token:', error.response?.data || error.message); + throw new UnauthorizedException('Invalid access token'); + } + } +} diff --git a/apps/backend/src/app/auth/service/oidc-auth.service.spec.ts b/apps/backend/src/app/auth/service/oidc-auth.service.spec.ts new file mode 100644 index 000000000..c2442c236 --- /dev/null +++ b/apps/backend/src/app/auth/service/oidc-auth.service.spec.ts @@ -0,0 +1,90 @@ +import { HttpService } from '@nestjs/axios'; +import { ConfigService } from '@nestjs/config'; +import { of, throwError } from 'rxjs'; +import { OidcAuthService } from './oidc-auth.service'; +import { CacheService } from '../../cache/cache.service'; + +describe('OidcAuthService', () => { + let service: OidcAuthService; + let cacheService: jest.Mocked>; + let httpService: jest.Mocked>; + + beforeEach(() => { + cacheService = { + set: jest.fn().mockResolvedValue(true), + getAndDelete: jest.fn() + }; + httpService = { + post: jest.fn() + }; + + service = new OidcAuthService( + httpService as unknown as HttpService, + { + get: jest.fn((key: string) => ({ + OIDC_TOKEN_ENDPOINT: 'https://issuer.example.test/token', + OAUTH2_CLIENT_ID: 'coding-box', + OAUTH2_CLIENT_SECRET: 'client-secret' + })[key] || '') + } as unknown as ConfigService, + cacheService as unknown as CacheService + ); + }); + + it('stores PKCE verifiers in the shared cache', async () => { + await expect(service.storePkceVerifier('state-1', 'verifier-1')).resolves.toBe(true); + + expect(cacheService.set).toHaveBeenCalledWith( + expect.stringMatching(/^oidc:pkce:[a-f0-9]{64}$/), + { codeVerifier: 'verifier-1' }, + 300 + ); + }); + + it('consumes PKCE verifiers atomically from the shared cache', async () => { + cacheService.getAndDelete.mockResolvedValue({ codeVerifier: 'verifier-1' }); + + await expect(service.consumePkceVerifier('state-1')).resolves.toBe('verifier-1'); + + expect(cacheService.getAndDelete).toHaveBeenCalledWith( + expect.stringMatching(/^oidc:pkce:[a-f0-9]{64}$/) + ); + }); + + it('returns null when a PKCE verifier is missing or expired', async () => { + cacheService.getAndDelete.mockResolvedValue(null); + + await expect(service.consumePkceVerifier('state-1')).resolves.toBeNull(); + }); + + it('refreshes access tokens through the OIDC token endpoint', async () => { + httpService.post.mockReturnValue(of({ + data: { + access_token: 'fresh-access-token', + token_type: 'Bearer', + expires_in: 300, + refresh_token: 'rotated-refresh-token' + } + }) as never); + + await expect(service.refreshToken('refresh-token')).resolves.toEqual({ + access_token: 'fresh-access-token', + token_type: 'Bearer', + expires_in: 300, + refresh_token: 'rotated-refresh-token' + }); + expect(httpService.post).toHaveBeenCalledWith( + 'https://issuer.example.test/token', + expect.stringContaining('grant_type=refresh_token'), + { headers: { 'Content-Type': 'application/x-www-form-urlencoded' } } + ); + expect(httpService.post.mock.calls[0][1]).toContain('refresh_token=refresh-token'); + expect(httpService.post.mock.calls[0][1]).toContain('client_secret=client-secret'); + }); + + it('rejects failed token refreshes', async () => { + httpService.post.mockReturnValue(throwError(() => new Error('refresh failed')) as never); + + await expect(service.refreshToken('refresh-token')).rejects.toThrow('Failed to refresh access token'); + }); +}); diff --git a/apps/backend/src/app/auth/service/oidc-auth.service.ts b/apps/backend/src/app/auth/service/oidc-auth.service.ts new file mode 100644 index 000000000..b84f0f6c3 --- /dev/null +++ b/apps/backend/src/app/auth/service/oidc-auth.service.ts @@ -0,0 +1,334 @@ +import { Injectable, Logger, UnauthorizedException } from '@nestjs/common'; +import { HttpService } from '@nestjs/axios'; +import { ConfigService } from '@nestjs/config'; +import { firstValueFrom } from 'rxjs'; +import { createHash, randomBytes } from 'crypto'; +import { CacheService } from '../../cache/cache.service'; + +export interface OidcConfiguration { + issuer: string; + account_endpoint: string; + authorization_endpoint: string; + token_endpoint: string; + userinfo_endpoint: string; + end_session_endpoint: string; + jwks_uri: string; +} + +export interface OidcTokenResponse { + access_token: string; + token_type: string; + expires_in: number; + refresh_token?: string; + scope?: string; + id_token?: string; +} + +export interface OidcUserInfo { + sub: string; + preferred_username: string; + given_name?: string; + family_name?: string; + email?: string; + realm_access?: { + roles: string[]; + }; +} + +@Injectable() +export class OidcAuthService { + private readonly logger = new Logger(OidcAuthService.name); + private readonly oidcConfiguration: OidcConfiguration; + private readonly oAuth2ClientId: string; + private readonly oAuth2ClientSecret?: string; + private readonly pkceTtlSeconds = 5 * 60; + private readonly tokenExchangeTtlSeconds = 60; + + constructor( + private readonly httpService: HttpService, + private readonly configService: ConfigService, + private readonly cacheService: CacheService + ) { + this.oidcConfiguration = { + issuer: this.configService.get('OIDC_ISSUER') ?? '', + account_endpoint: this.configService.get('OIDC_ACCOUNT_ENDPOINT') ?? '', + authorization_endpoint: this.configService.get('OIDC_AUTHORIZATION_ENDPOINT') ?? '', + token_endpoint: this.configService.get('OIDC_TOKEN_ENDPOINT') ?? '', + userinfo_endpoint: this.configService.get('OIDC_USERINFO_ENDPOINT') ?? '', + end_session_endpoint: this.configService.get('OIDC_END_SESSION_ENDPOINT') ?? '', + jwks_uri: this.configService.get('OIDC_JWKS_URI') ?? '' + }; + this.oAuth2ClientId = this.configService.get('OAUTH2_CLIENT_ID'); + this.oAuth2ClientSecret = this.configService.get('OAUTH2_CLIENT_SECRET'); + } + + /** + * Generate the OpenID Connect authorization URL for the Authorization Code flow + * @param state - Random state parameter for security + * @param redirectUri - Callback URL after authentication + * @returns Authorization URL + */ + getAuthorizationUrl(state: string, redirectUri: string, codeChallenge?: string): string { + if (!this.oidcConfiguration.authorization_endpoint || !this.oAuth2ClientId) { + throw new UnauthorizedException('OpenID Connect configuration is missing'); + } + + const params = new URLSearchParams({ + response_type: 'code', + client_id: this.oAuth2ClientId, + redirect_uri: redirectUri, + state: state, + scope: 'openid profile email' + }); + + if (codeChallenge) { + params.set('code_challenge', codeChallenge); + params.set('code_challenge_method', 'S256'); + } + + return `${this.oidcConfiguration.authorization_endpoint}?${params.toString()}`; + } + + /** + * Exchange authorization code for access token + * @param code - Authorization code from OpenID Connect Provider + * @param redirectUri - The same redirect URI used in authorization request + * @returns Token response from OpenID Connect Provider + */ + async exchangeCodeForToken(code: string, redirectUri: string, codeVerifier?: string): Promise { + if (!this.oidcConfiguration.token_endpoint || !this.oAuth2ClientId) { + throw new UnauthorizedException('OpenID Connect token endpoint configuration is missing'); + } + + if (!codeVerifier) { + throw new UnauthorizedException('PKCE code verifier is missing'); + } + + const params = new URLSearchParams({ + grant_type: 'authorization_code', + client_id: this.oAuth2ClientId, + code: code, + redirect_uri: redirectUri, + code_verifier: codeVerifier + }); + + if (this.oAuth2ClientSecret) { + params.append('client_secret', this.oAuth2ClientSecret); + } + + try { + this.logger.log('Exchanging authorization code for access token'); + + const response = await firstValueFrom( + this.httpService.post(this.oidcConfiguration.token_endpoint, params.toString(), { + headers: { + 'Content-Type': 'application/x-www-form-urlencoded' + } + }) + ); + + this.logger.log('Successfully obtained access token from authorization code'); + return response.data; + } catch (error) { + this.logger.error('Failed to exchange authorization code for token:', error.response?.data || error.message); + throw new UnauthorizedException('Failed to exchange authorization code for token'); + } + } + + /** + * Refresh an access token using an OpenID Connect refresh token. + * @param refreshToken - Refresh token from the provider + * @returns Refreshed token response from OpenID Connect Provider + */ + async refreshToken(refreshToken: string): Promise { + if (!this.oidcConfiguration.token_endpoint || !this.oAuth2ClientId) { + throw new UnauthorizedException('OpenID Connect token endpoint configuration is missing'); + } + + if (!refreshToken) { + throw new UnauthorizedException('Refresh token is required'); + } + + const params = new URLSearchParams({ + grant_type: 'refresh_token', + client_id: this.oAuth2ClientId, + refresh_token: refreshToken + }); + + if (this.oAuth2ClientSecret) { + params.append('client_secret', this.oAuth2ClientSecret); + } + + try { + this.logger.log('Refreshing OpenID Connect access token'); + + const response = await firstValueFrom( + this.httpService.post(this.oidcConfiguration.token_endpoint, params.toString(), { + headers: { + 'Content-Type': 'application/x-www-form-urlencoded' + } + }) + ); + + return response.data; + } catch (error) { + this.logger.error('Failed to refresh access token:', error.response?.data || error.message); + throw new UnauthorizedException('Failed to refresh access token'); + } + } + + /** + * Get user information from OpenID Connect Provider using access token + * @param accessToken - Access token from OpenID Connect Provider + * @returns User information + */ + async getUserInfo(accessToken: string): Promise { + if (!this.oidcConfiguration.userinfo_endpoint) { + throw new UnauthorizedException('OpenID Connect userinfo endpoint configuration is missing'); + } + + try { + const response = await firstValueFrom( + this.httpService.get(this.oidcConfiguration.userinfo_endpoint, { + headers: { + Authorization: `Bearer ${accessToken}` + } + }) + ); + return response.data; + } catch (error) { + this.logger.error('Failed to get user info:', error.response?.data || error.message); + throw new UnauthorizedException('Failed to get user information'); + } + } + + /** + * Generate OpenID Connect Provider logout URL + * @param idToken - ID token for proper logout + * @param redirectUri - URL to redirect after logout + * @returns Logout URL + */ + getLogoutUrl(idToken: string, redirectUri: string): string { + if (!this.oidcConfiguration.end_session_endpoint || !this.oAuth2ClientId) { + throw new UnauthorizedException('OpenID Connect end session endpoint configuration is missing'); + } + + const params = new URLSearchParams({ + client_id: this.oAuth2ClientId, + id_token_hint: idToken, + post_logout_redirect_uri: redirectUri + }); + + return `${this.oidcConfiguration.end_session_endpoint}?${params.toString()}`; + } + + /** + * POST logout to OpenID Connect Provider to terminate SSO session + * @param refreshToken - Refresh token to invalidate + * @returns Promise that resolves when logout is complete + */ + async logoutWithRefreshToken(refreshToken: string): Promise { + if (!this.oidcConfiguration.end_session_endpoint || !this.oAuth2ClientId) { + throw new UnauthorizedException('OpenID Connect end session endpoint configuration is missing'); + } + + const params = new URLSearchParams({ + client_id: this.oAuth2ClientId, + refresh_token: refreshToken + }); + + // Add client_secret only for confidential clients + if (this.oAuth2ClientSecret) { + params.append('client_secret', this.oAuth2ClientSecret); + } + + try { + this.logger.log('Performing POST logout to OpenID Connect Provider'); + + await firstValueFrom( + this.httpService.post(this.oidcConfiguration.end_session_endpoint, params.toString(), { + headers: { + 'Content-Type': 'application/x-www-form-urlencoded' + } + }) + ); + + this.logger.log('Successfully logged out from OpenID Connect Provider SSO session'); + } catch (error) { + this.logger.error('Failed to logout from OpenID Connect Provider:', error.response?.data || error.message); + throw new UnauthorizedException('Failed to logout from OpenID Connect Provider'); + } + } + + /** + * Generate OpenID Connect Provider profile management URL + * @param redirectUri - Optional URL to redirect back to after profile management + * @returns Profile management URL + */ + getProfileUrl(redirectUri?: string): string { + if (!this.oidcConfiguration.account_endpoint || !this.oAuth2ClientId) { + throw new UnauthorizedException('OpenID Connect account endpoint configuration is missing'); + } + + if (redirectUri) { + const params = new URLSearchParams({ + referrer: this.oAuth2ClientId, + referrer_uri: redirectUri + }); + return `${this.oidcConfiguration.account_endpoint}?${params.toString()}`; + } + + return this.oidcConfiguration.account_endpoint; + } + + generatePkcePair(): { codeVerifier: string; codeChallenge: string } { + const codeVerifier = randomBytes(32).toString('base64url'); + const codeChallenge = createHash('sha256').update(codeVerifier).digest('base64url'); + return { codeVerifier, codeChallenge }; + } + + async storePkceVerifier(state: string, codeVerifier: string): Promise { + return this.cacheService.set( + this.pkceCacheKey(state), + { codeVerifier }, + this.pkceTtlSeconds + ); + } + + async consumePkceVerifier(state: string): Promise { + const cached = await this.cacheService.getAndDelete<{ codeVerifier: string }>( + this.pkceCacheKey(state) + ); + return cached?.codeVerifier ?? null; + } + + async storeTokenExchange(tokenResponse: OidcTokenResponse): Promise { + const code = randomBytes(32).toString('base64url'); + const stored = await this.cacheService.set( + this.tokenExchangeCacheKey(code), + tokenResponse, + this.tokenExchangeTtlSeconds + ); + + return stored ? code : null; + } + + async consumeTokenExchange(code: string): Promise { + if (!code || typeof code !== 'string') { + return null; + } + + const cacheKey = this.tokenExchangeCacheKey(code); + return this.cacheService.getAndDelete(cacheKey); + } + + private pkceCacheKey(state: string): string { + const digest = createHash('sha256').update(state).digest('hex'); + return `oidc:pkce:${digest}`; + } + + private tokenExchangeCacheKey(code: string): string { + const digest = createHash('sha256').update(code).digest('hex'); + return `oidc:token-exchange:${digest}`; + } +} diff --git a/apps/backend/src/app/auth/workspace-token.constants.ts b/apps/backend/src/app/auth/workspace-token.constants.ts new file mode 100644 index 000000000..c89777e0f --- /dev/null +++ b/apps/backend/src/app/auth/workspace-token.constants.ts @@ -0,0 +1,4 @@ +export const WORKSPACE_TOKEN_STRATEGY = 'workspace-token'; +export const WORKSPACE_TOKEN_ISSUER = 'coding-box'; +export const WORKSPACE_TOKEN_AUDIENCE = 'coding-box-workspace-token'; +export const WORKSPACE_TOKEN_USE = 'workspace'; diff --git a/apps/backend/src/app/auth/workspace-token.strategy.spec.ts b/apps/backend/src/app/auth/workspace-token.strategy.spec.ts new file mode 100644 index 000000000..3edbc42bf --- /dev/null +++ b/apps/backend/src/app/auth/workspace-token.strategy.spec.ts @@ -0,0 +1,50 @@ +import { ConfigService } from '@nestjs/config'; +import { UnauthorizedException } from '@nestjs/common'; +import { WorkspaceTokenStrategy } from './workspace-token.strategy'; +import { WORKSPACE_API_TOKEN_TYPE, WORKSPACE_TOKEN_SCOPE_CODING_JOB_OPERATE } from './workspace-token'; + +describe('WorkspaceTokenStrategy', () => { + const createStrategy = () => new WorkspaceTokenStrategy({ + get: jest.fn().mockReturnValue('jwt-secret') + } as unknown as ConfigService); + + it('maps a valid workspace token payload to the request user', async () => { + const strategy = createStrategy(); + + await expect(strategy.validate({ + token_use: 'workspace', + userId: 12, + username: 'coder', + workspace: 7, + scopes: [WORKSPACE_TOKEN_SCOPE_CODING_JOB_OPERATE] + })).resolves.toEqual({ + userId: 12, + id: 12, + name: 'coder', + workspace: 7, + tokenUse: 'workspace', + tokenType: WORKSPACE_API_TOKEN_TYPE, + scopes: [WORKSPACE_TOKEN_SCOPE_CODING_JOB_OPERATE], + isWorkspaceToken: true + }); + }); + + it('rejects non-workspace tokens', async () => { + const strategy = createStrategy(); + + await expect(strategy.validate({ + userId: 12, + workspace: 7 + })).rejects.toThrow(UnauthorizedException); + }); + + it('rejects malformed workspace token payloads', async () => { + const strategy = createStrategy(); + + await expect(strategy.validate({ + token_use: 'workspace', + userId: 'not-a-number', + workspace: 7 + })).rejects.toThrow(UnauthorizedException); + }); +}); diff --git a/apps/backend/src/app/auth/workspace-token.strategy.ts b/apps/backend/src/app/auth/workspace-token.strategy.ts new file mode 100644 index 000000000..3d7e343ca --- /dev/null +++ b/apps/backend/src/app/auth/workspace-token.strategy.ts @@ -0,0 +1,59 @@ +import { ExtractJwt, Strategy } from 'passport-jwt'; +import { PassportStrategy } from '@nestjs/passport'; +import { Injectable, UnauthorizedException } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { + WORKSPACE_TOKEN_AUDIENCE, + WORKSPACE_TOKEN_ISSUER, + WORKSPACE_TOKEN_STRATEGY, + WORKSPACE_TOKEN_USE +} from './workspace-token.constants'; +import { WORKSPACE_API_TOKEN_TYPE } from './workspace-token'; + +interface WorkspaceTokenPayload { + token_use?: string; + userId?: number | string; + username?: string; + workspace?: number | string; + scopes?: unknown; +} + +@Injectable() +export class WorkspaceTokenStrategy extends PassportStrategy(Strategy, WORKSPACE_TOKEN_STRATEGY) { + constructor(configService: ConfigService) { + super({ + jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), + ignoreExpiration: false, + secretOrKey: configService.get('JWT_SECRET'), + issuer: WORKSPACE_TOKEN_ISSUER, + audience: WORKSPACE_TOKEN_AUDIENCE, + algorithms: ['HS256'] + }); + } + + // eslint-disable-next-line class-methods-use-this + async validate(payload: WorkspaceTokenPayload) { + if (payload.token_use !== WORKSPACE_TOKEN_USE) { + throw new UnauthorizedException('Invalid workspace token type'); + } + + const userId = Number(payload.userId); + const workspace = Number(payload.workspace); + if (!Number.isInteger(userId) || userId <= 0 || !Number.isInteger(workspace) || workspace <= 0) { + throw new UnauthorizedException('Invalid workspace token payload'); + } + + return { + userId, + id: userId, + name: payload.username, + workspace, + tokenUse: WORKSPACE_TOKEN_USE, + tokenType: WORKSPACE_API_TOKEN_TYPE, + scopes: Array.isArray(payload.scopes) ? + payload.scopes.filter((scope): scope is string => typeof scope === 'string') : + [], + isWorkspaceToken: true + }; + } +} diff --git a/apps/backend/src/app/cache/cache.service.spec.ts b/apps/backend/src/app/cache/cache.service.spec.ts index d29193db7..26ce4f4ee 100644 --- a/apps/backend/src/app/cache/cache.service.spec.ts +++ b/apps/backend/src/app/cache/cache.service.spec.ts @@ -11,6 +11,7 @@ describe('CacheService', () => { del: jest.fn(), exists: jest.fn(), incr: jest.fn(), + eval: jest.fn(), scan: jest.fn() }; service = new CacheService(redis as never); @@ -73,6 +74,24 @@ describe('CacheService', () => { await expect(service.exists('c')).resolves.toBe(false); }); + it('gets and deletes values atomically with a redis script', async () => { + redis.eval + .mockResolvedValueOnce(JSON.stringify({ ok: true })) + .mockResolvedValueOnce(null) + .mockResolvedValueOnce('not-json') + .mockRejectedValueOnce(new Error('redis down')); + + await expect(service.getAndDelete('json')).resolves.toEqual({ ok: true }); + expect(redis.eval).toHaveBeenCalledWith( + expect.stringContaining('redis.call("GET", KEYS[1])'), + 1, + 'json' + ); + await expect(service.getAndDelete('missing')).resolves.toBeNull(); + await expect(service.getAndDelete('broken')).resolves.toBeNull(); + await expect(service.getAndDelete('error')).resolves.toBeNull(); + }); + it('stores and pages validation results', async () => { const results = [{ unitName: 'U1' }, { unitName: 'U2' }, { unitName: 'U3' }] as never; const metadata = { total: 3, missing: 1, timestamp: 100 }; diff --git a/apps/backend/src/app/cache/cache.service.ts b/apps/backend/src/app/cache/cache.service.ts index afccab467..5dc4f647e 100644 --- a/apps/backend/src/app/cache/cache.service.ts +++ b/apps/backend/src/app/cache/cache.service.ts @@ -70,6 +70,26 @@ export class CacheService { } } + async getAndDelete(key: string): Promise { + try { + const cachedValue = await this.redis.eval( + 'local value = redis.call("GET", KEYS[1]); if value then redis.call("DEL", KEYS[1]); end; return value', + 1, + key + ); + if (!cachedValue || typeof cachedValue !== 'string') { + return null; + } + return JSON.parse(cachedValue) as T; + } catch (error) { + this.logger.error( + `Error getting and deleting value from cache: ${error.message}`, + error.stack + ); + return null; + } + } + /** * Set a value in the cache * @param key The cache key diff --git a/apps/backend/src/app/database/database.module.ts b/apps/backend/src/app/database/database.module.ts index 834c5451e..a4999cb4e 100755 --- a/apps/backend/src/app/database/database.module.ts +++ b/apps/backend/src/app/database/database.module.ts @@ -40,6 +40,7 @@ import { CoderTrainingBundle } from './entities/coder-training-bundle.entity'; import { CoderTrainingCoder } from './entities/coder-training-coder.entity'; import { CoderTrainingDiscussionResult } from './entities/coder-training-discussion-result.entity'; import { CodingUnitFreshness } from './entities/coding-unit-freshness.entity'; +import { TestPersonCodingJob } from './entities/test-person-coding-job.entity'; export function parsePostgresPoolMax(value: string | number | undefined, fallback = 10): number { const parsed = Number.parseInt(String(value ?? ''), 10); @@ -96,7 +97,8 @@ export function parsePostgresPoolMax(value: string | number | undefined, fallbac CoderTrainingCoder, CoderTrainingDiscussionResult, CodingUnitFreshness, - MissingsProfile + MissingsProfile, + TestPersonCodingJob ], synchronize: false, extra: { diff --git a/apps/backend/src/app/database/services/coding/coding-job.service.spec.ts b/apps/backend/src/app/database/services/coding/coding-job.service.spec.ts index 1f768ed41..66e4ea024 100644 --- a/apps/backend/src/app/database/services/coding/coding-job.service.spec.ts +++ b/apps/backend/src/app/database/services/coding/coding-job.service.spec.ts @@ -232,12 +232,8 @@ describe('CodingJobService', () => { getVariablePageMap: jest.fn().mockResolvedValue(new Map()) }; missingsProfilesService = { - resolveMissingsProfileId: jest.fn( - async (_workspaceId: number, profileId?: number | null) => profileId || 55 - ), - getMissingByIdForProfileOrDefault: jest - .fn() - .mockResolvedValue({ code: 99 }) + resolveMissingsProfileId: jest.fn(async (_workspaceId: number, profileId?: number | null) => profileId || 55), + getMissingByIdForProfileOrDefault: jest.fn().mockResolvedValue({ code: -99 }) }; coderTrainingDiscussionResultRepository.count.mockResolvedValue(0); diff --git a/apps/backend/src/app/database/services/test-results/person.service.spec.ts b/apps/backend/src/app/database/services/test-results/person.service.spec.ts index 196f350db..a86b82509 100644 --- a/apps/backend/src/app/database/services/test-results/person.service.spec.ts +++ b/apps/backend/src/app/database/services/test-results/person.service.spec.ts @@ -889,10 +889,7 @@ describe('PersonService', () => { const map = new Map([['group1', true]]); mockQueryService.getGroupsWithBookletLogs.mockResolvedValue(map); const result = await service.getGroupsWithBookletLogs(1); - expect(mockQueryService.getGroupsWithBookletLogs).toHaveBeenCalledWith( - 1, - undefined - ); + expect(mockQueryService.getGroupsWithBookletLogs).toHaveBeenCalledWith(1, undefined); expect(result).toEqual(map); }); diff --git a/apps/backend/src/app/database/services/users/users.service.spec.ts b/apps/backend/src/app/database/services/users/users.service.spec.ts index aee7c3c0a..7af05e0dd 100755 --- a/apps/backend/src/app/database/services/users/users.service.spec.ts +++ b/apps/backend/src/app/database/services/users/users.service.spec.ts @@ -509,7 +509,7 @@ describe('UsersService', () => { await expect(service.createUser({ username: 'new' } as never)).resolves.toBe(77); }); - it('handles admin checks and keycloak users', async () => { + it('handles admin checks and OIDC provider users', async () => { usersRepository.findOne .mockResolvedValueOnce({ isAdmin: true }) .mockResolvedValueOnce(null) @@ -520,21 +520,21 @@ describe('UsersService', () => { await expect(service.getUserIsAdmin(1)).resolves.toBe(true); await expect(service.getUserIsAdmin(2)).resolves.toBe(false); - await expect(service.createKeycloakUser({ + await expect(service.createOidcProviderUser({ username: 'u', identity: 'new', issuer: 'iss', isAdmin: true } as never)).resolves.toBe(10); expect(usersRepository.update).toHaveBeenCalledWith({ id: 10 }, { identity: 'new', isAdmin: true }); - await expect(service.createKeycloakUser({ + await expect(service.createOidcProviderUser({ username: 'fresh', identity: 'id', issuer: 'iss', isAdmin: false } as never)).resolves.toBe(77); }); - it('does not demote existing database admins during keycloak login', async () => { + it('does not demote existing database admins during OIDC login', async () => { usersRepository.findOne.mockResolvedValueOnce({ id: 10, username: 'u', identity: 'old', issuer: 'iss', isAdmin: true }); - await expect(service.createKeycloakUser({ + await expect(service.createOidcProviderUser({ username: 'u', identity: 'new', issuer: 'iss', isAdmin: false } as never)).resolves.toBe(10); diff --git a/apps/backend/src/app/database/services/users/users.service.ts b/apps/backend/src/app/database/services/users/users.service.ts index 99a5161f5..0e4768591 100755 --- a/apps/backend/src/app/database/services/users/users.service.ts +++ b/apps/backend/src/app/database/services/users/users.service.ts @@ -330,7 +330,6 @@ export class UsersService { }); this.logger.log(`Workspaces successfully set for user with ID: ${userId}`); - // Return true if at least one entry was saved return savedEntries.length > 0; } catch (error) { if (error instanceof BadRequestException) { @@ -448,6 +447,42 @@ export class UsersService { }); } + async createOidcProviderUser(oidcPdUser: CreateUserDto): Promise { + const { + username, identity, issuer, isAdmin + } = oidcPdUser; + const existingUser = await this.usersRepository.findOne({ + where: [ + { identity, issuer }, + { username } + ], + select: { + id: true, username: true, identity: true, issuer: true, isAdmin: true + } + }); + + if (existingUser) { + const updatedFields: Partial = {}; + const nextIsAdmin = existingUser.isAdmin || !!isAdmin; + if (identity && existingUser.identity !== identity) updatedFields.identity = identity; + if (issuer && existingUser.issuer !== issuer) updatedFields.issuer = issuer; + if (username && existingUser.username !== username) updatedFields.username = username; + if (existingUser.isAdmin !== nextIsAdmin) updatedFields.isAdmin = nextIsAdmin; + + if (Object.keys(updatedFields).length > 0) { + await this.usersRepository.update({ id: existingUser.id }, updatedFields); + this.logger.log(`Updating existing user: ${JSON.stringify({ ...existingUser, ...updatedFields })}`); + } + + return existingUser.id; + } + this.logger.log(`Creating new OIDC Provider user: ${JSON.stringify(oidcPdUser)}`); + const newUser = this.usersRepository.create(oidcPdUser); + await this.usersRepository.save(newUser); + + return newUser.id; + } + async syncKeycloakUser(keycloakUser: CreateUserDto): Promise { const { username, identity, issuer, isAdmin = false diff --git a/apps/backend/src/app/wsg-admin/coding-job/coding-job.controller.spec.ts b/apps/backend/src/app/wsg-admin/coding-job/coding-job.controller.spec.ts index 8047b5363..99437030b 100644 --- a/apps/backend/src/app/wsg-admin/coding-job/coding-job.controller.spec.ts +++ b/apps/backend/src/app/wsg-admin/coding-job/coding-job.controller.spec.ts @@ -2,6 +2,8 @@ import 'reflect-metadata'; import { BadRequestException } from '@nestjs/common'; import { GUARDS_METADATA } from '@nestjs/common/constants'; import { JwtAuthGuard } from '../../auth/jwt-auth.guard'; +import { JwtOrWorkspaceTokenAuthGuard } from '../../auth/jwt-or-workspace-token-auth.guard'; +import { WORKSPACE_TOKEN_SCOPE_CODING_JOB_OPERATE } from '../../auth/workspace-token'; import { AccessLevelGuard } from '../../admin/workspace/access-level.guard'; import { WorkspaceGuard } from '../../admin/workspace/workspace.guard'; import { WsgCodingJobController } from './coding-job.controller'; @@ -56,7 +58,7 @@ describe('WsgCodingJobController', () => { getCodingJobUnits: jest.fn().mockResolvedValue([]), getBulkCodingProgress: jest.fn().mockResolvedValue({}), createCodingJob: jest.fn().mockResolvedValue({ id: 124 }), - updateCodingJob: jest.fn(), + updateCodingJob: jest.fn().mockResolvedValue({ id: 123 }), pauseCodingJob: jest.fn().mockResolvedValue({ id: 123, status: 'paused' }), resumeCodingJob: jest.fn().mockResolvedValue({ id: 123, status: 'active' }), submitCodingJob: jest.fn().mockResolvedValue({ id: 123, status: 'completed' }), @@ -87,7 +89,6 @@ describe('WsgCodingJobController', () => { it.each([ 'transferCodingCases', 'createCodingJob', - 'updateCodingJob', 'deleteCodingJob', 'restartCodingJobWithOpenUnits' ] as const)('requires coding-manager access for %s', methodName => { @@ -101,6 +102,20 @@ describe('WsgCodingJobController', () => { expect(Reflect.getMetadata('accessLevel', handler)).toBe(2); }); + it('allows operate workspace tokens for updateCodingJob while keeping coding-manager access', () => { + const handler = WsgCodingJobController.prototype.updateCodingJob; + + expect(Reflect.getMetadata(GUARDS_METADATA, handler)).toEqual([ + JwtOrWorkspaceTokenAuthGuard, + WorkspaceGuard, + AccessLevelGuard + ]); + expect(Reflect.getMetadata('workspaceTokenScopes', handler)).toEqual([ + WORKSPACE_TOKEN_SCOPE_CODING_JOB_OPERATE + ]); + expect(Reflect.getMetadata('accessLevel', handler)).toBe(2); + }); + it.each([ 'pauseCodingJob', 'resumeCodingJob', @@ -109,12 +124,35 @@ describe('WsgCodingJobController', () => { const handler = WsgCodingJobController.prototype[methodName]; expect(Reflect.getMetadata(GUARDS_METADATA, handler)).toEqual([ - JwtAuthGuard, + JwtOrWorkspaceTokenAuthGuard, WorkspaceGuard ]); + expect(Reflect.getMetadata('workspaceTokenScopes', handler)).toEqual([ + WORKSPACE_TOKEN_SCOPE_CODING_JOB_OPERATE + ]); expect(Reflect.getMetadata('accessLevel', handler)).toBeUndefined(); }); + it.each([ + 'getCodingJob', + 'updateCodingJobStatus', + 'updateCodingJobComment', + 'saveCodingProgress', + 'saveCodingNotes', + 'getCodingProgress', + 'getCodingJobUnits' + ] as const)('allows scoped workspace tokens for %s', methodName => { + const handler = WsgCodingJobController.prototype[methodName]; + + expect(Reflect.getMetadata(GUARDS_METADATA, handler)).toEqual([ + JwtOrWorkspaceTokenAuthGuard, + WorkspaceGuard + ]); + expect(Reflect.getMetadata('workspaceTokenScopes', handler)).toEqual([ + WORKSPACE_TOKEN_SCOPE_CODING_JOB_OPERATE + ]); + }); + it('passes onlyOpen=true to the coding job service when requested', async () => { await controller.getCodingJobUnits(47, 123, req, 'true'); @@ -166,20 +204,20 @@ describe('WsgCodingJobController', () => { } ); - it('allows assigned coders to submit completed coding jobs for review', async () => { - codingJobService.updateCodingJob.mockResolvedValue({ + it('allows assigned coders to submit coding jobs', async () => { + codingJobService.submitCodingJob.mockResolvedValue({ id: 123, workspace_id: 47, name: 'Job', - status: 'review' + status: 'completed' }); await expect( - controller.submitCodingJobForReview(47, 123, req) + controller.submitCodingJob(47, 123, req) ).resolves.toMatchObject({ id: 123, workspace_id: 47, - status: 'review' + status: 'completed' }); expect(codingJobService.assertUserCanCodeCodingJob).toHaveBeenCalledWith( @@ -187,9 +225,7 @@ describe('WsgCodingJobController', () => { 47, 5 ); - expect(codingJobService.updateCodingJob).toHaveBeenCalledWith(123, 47, { - status: 'review' - }); + expect(codingJobService.submitCodingJob).toHaveBeenCalledWith(123, 47); }); it.each([ @@ -313,28 +349,40 @@ describe('WsgCodingJobController', () => { expect(codingJobService.saveCodingNotes).toHaveBeenCalled(); }); - it('uses manager access for saving coding issue review notes', async () => { - await controller.saveCodingNotes( + it('uses general access for regular coding job updates', async () => { + await controller.updateCodingJob(47, 123, { name: 'New name' } as never, req); + + expect(codingJobService.assertUserCanAccessCodingJob).toHaveBeenCalledWith(123, 47, 5); + expect(codingJobService.assertUserCanCodeCodingJob).not.toHaveBeenCalled(); + expect(codingJobService.updateCodingJob).toHaveBeenCalledWith( + 123, 47, + { name: 'New name' } + ); + }); + + it('uses coding access and only forwards status for replay status updates', async () => { + await controller.updateCodingJobStatus(47, 123, { status: 'paused' }, req); + + expect(codingJobService.assertUserCanCodeCodingJob).toHaveBeenCalledWith(123, 47, 5); + expect(codingJobService.assertUserCanAccessCodingJob).not.toHaveBeenCalled(); + expect(codingJobService.updateCodingJob).toHaveBeenCalledWith( 123, - { - testPerson: 'p@c@b', - unitId: 'u', - variableId: 'v', - notes: 'note', - issueReview: true - } as never, - req + 47, + { status: 'paused' } ); + }); + + it('uses general access and only forwards comment for replay comment updates', async () => { + await controller.updateCodingJobComment(47, 123, { comment: 'review note' }, req); + expect(codingJobService.assertUserCanAccessCodingJob).toHaveBeenCalledWith(123, 47, 5); expect(codingJobService.assertUserCanCodeCodingJob).not.toHaveBeenCalled(); - expect(usersService.getUserAccessLevel).toHaveBeenCalledWith(5, 47); - expect(codingJobService.saveCodingIssueReviewNotes).toHaveBeenCalledWith( + expect(codingJobService.updateCodingJob).toHaveBeenCalledWith( 123, - 5, - expect.objectContaining({ issueReview: true }) + 47, + { comment: 'review note' } ); - expect(codingJobService.saveCodingNotes).not.toHaveBeenCalled(); }); it('rejects jobDefinitionId on direct coding job creates', async () => { diff --git a/apps/backend/src/app/wsg-admin/coding-job/coding-job.controller.ts b/apps/backend/src/app/wsg-admin/coding-job/coding-job.controller.ts index a7c997e93..ae4d04fe8 100644 --- a/apps/backend/src/app/wsg-admin/coding-job/coding-job.controller.ts +++ b/apps/backend/src/app/wsg-admin/coding-job/coding-job.controller.ts @@ -30,6 +30,7 @@ import { ApiTags } from '@nestjs/swagger'; import { JwtAuthGuard } from '../../auth/jwt-auth.guard'; +import { JwtOrWorkspaceTokenAuthGuard } from '../../auth/jwt-or-workspace-token-auth.guard'; import { AllowWorkspaceTokenScopes, WORKSPACE_TOKEN_SCOPE_CODING_JOB_OPERATE @@ -52,6 +53,8 @@ import { import { CodingJobDto } from '../../admin/coding-job/dto/coding-job.dto'; import { CreateCodingJobDto } from '../../admin/coding-job/dto/create-coding-job.dto'; import { UpdateCodingJobDto } from '../../admin/coding-job/dto/update-coding-job.dto'; +import { UpdateCodingJobCommentDto } from '../../admin/coding-job/dto/update-coding-job-comment.dto'; +import { UpdateCodingJobStatusDto } from '../../admin/coding-job/dto/update-coding-job-status.dto'; import { SaveCodingProgressDto } from '../../admin/coding-job/dto/save-coding-progress.dto'; import { SaveCodingNotesDto } from '../../admin/coding-job/dto/save-coding-notes.dto'; import { TransferCodingCasesDto } from '../../admin/coding-job/dto/transfer-coding-cases.dto'; @@ -450,7 +453,7 @@ export class WsgCodingJobController { @Get(':id') @AllowWorkspaceTokenScopes(WORKSPACE_TOKEN_SCOPE_CODING_JOB_OPERATE) - @UseGuards(JwtAuthGuard, WorkspaceGuard) + @UseGuards(JwtOrWorkspaceTokenAuthGuard, WorkspaceGuard) @ApiBearerAuth() @ApiOperation({ summary: 'Get a coding job by ID', @@ -548,7 +551,7 @@ export class WsgCodingJobController { @Put(':id') @AllowWorkspaceTokenScopes(WORKSPACE_TOKEN_SCOPE_CODING_JOB_OPERATE) - @UseGuards(JwtAuthGuard, WorkspaceGuard, AccessLevelGuard) + @UseGuards(JwtOrWorkspaceTokenAuthGuard, WorkspaceGuard, AccessLevelGuard) @RequireAccessLevel(2) @ApiBearerAuth() @ApiOperation({ @@ -591,45 +594,62 @@ export class WsgCodingJobController { ); } - @Post(':id/submit-review') - @UseGuards(JwtAuthGuard, WorkspaceGuard) + @Put(':id/status') + @AllowWorkspaceTokenScopes(WORKSPACE_TOKEN_SCOPE_CODING_JOB_OPERATE) + @UseGuards(JwtOrWorkspaceTokenAuthGuard, WorkspaceGuard) @ApiBearerAuth() @ApiOperation({ - summary: 'Submit a completed coding job for review', - description: - 'Allows an assigned coder to submit a completed coding job for review' + summary: 'Update a coding job status from replay', + description: 'Updates only the replay-safe status field of a coding job' }) - @ApiParam({ - name: 'workspace_id', - type: Number, - required: true, - description: 'The ID of the workspace' + @ApiOkResponse({ + description: 'The coding job status has been successfully updated.', + type: CodingJobDto }) - @ApiParam({ - name: 'id', - type: Number, - required: true, - description: 'The ID of the coding job' + @ApiBadRequestResponse({ + description: 'Invalid input data.' + }) + async updateCodingJobStatus( + @WorkspaceId() workspaceId: number, + @Param('id', ParseIntPipe) id: number, + @Body(new ValidationPipe({ transform: true, whitelist: true, forbidNonWhitelisted: true })) updateCodingJobStatusDto: UpdateCodingJobStatusDto, + @Req() req: Request + ): Promise { + await this.assertCodingJobCodingAccess(workspaceId, id, req); + return this.codingJobService.updateCodingJob( + id, + workspaceId, + { status: updateCodingJobStatusDto.status } + ); + } + + @Put(':id/comment') + @AllowWorkspaceTokenScopes(WORKSPACE_TOKEN_SCOPE_CODING_JOB_OPERATE) + @UseGuards(JwtOrWorkspaceTokenAuthGuard, WorkspaceGuard) + @ApiBearerAuth() + @ApiOperation({ + summary: 'Update a coding job comment from replay', + description: 'Updates only the replay-safe comment field of a coding job' }) @ApiOkResponse({ - description: 'The coding job has been submitted for review.', + description: 'The coding job comment has been successfully updated.', type: CodingJobDto }) @ApiBadRequestResponse({ - description: 'Coding job is not completed or cannot be submitted.' + description: 'Invalid input data.' }) - async submitCodingJobForReview( + async updateCodingJobComment( @WorkspaceId() workspaceId: number, @Param('id', ParseIntPipe) id: number, + @Body(new ValidationPipe({ transform: true, whitelist: true, forbidNonWhitelisted: true })) updateCodingJobCommentDto: UpdateCodingJobCommentDto, @Req() req: Request ): Promise { - await this.assertCodingJobCodingAccess(workspaceId, id, req); - const codingJob = await this.codingJobService.updateCodingJob( + await this.assertCodingJobAccess(workspaceId, id, req); + return this.codingJobService.updateCodingJob( id, workspaceId, - { status: 'review' } + { comment: updateCodingJobCommentDto.comment } ); - return CodingJobDto.fromEntity(codingJob); } @Post(':id/start') @@ -686,7 +706,7 @@ export class WsgCodingJobController { @Post(':id/pause') @AllowWorkspaceTokenScopes(WORKSPACE_TOKEN_SCOPE_CODING_JOB_OPERATE) - @UseGuards(JwtAuthGuard, WorkspaceGuard) + @UseGuards(JwtOrWorkspaceTokenAuthGuard, WorkspaceGuard) @ApiBearerAuth() @ApiOperation({ summary: 'Pause an assigned coding job', @@ -723,7 +743,7 @@ export class WsgCodingJobController { @Post(':id/resume') @AllowWorkspaceTokenScopes(WORKSPACE_TOKEN_SCOPE_CODING_JOB_OPERATE) - @UseGuards(JwtAuthGuard, WorkspaceGuard) + @UseGuards(JwtOrWorkspaceTokenAuthGuard, WorkspaceGuard) @ApiBearerAuth() @ApiOperation({ summary: 'Resume an assigned coding job', @@ -760,7 +780,7 @@ export class WsgCodingJobController { @Post(':id/submit') @AllowWorkspaceTokenScopes(WORKSPACE_TOKEN_SCOPE_CODING_JOB_OPERATE) - @UseGuards(JwtAuthGuard, WorkspaceGuard) + @UseGuards(JwtOrWorkspaceTokenAuthGuard, WorkspaceGuard) @ApiBearerAuth() @ApiOperation({ summary: 'Submit an assigned coding job', @@ -878,7 +898,7 @@ export class WsgCodingJobController { @Post(':id/progress') @AllowWorkspaceTokenScopes(WORKSPACE_TOKEN_SCOPE_CODING_JOB_OPERATE) - @UseGuards(JwtAuthGuard, WorkspaceGuard) + @UseGuards(JwtOrWorkspaceTokenAuthGuard, WorkspaceGuard) @ApiBearerAuth() @ApiOperation({ summary: 'Save coding progress', @@ -936,7 +956,7 @@ export class WsgCodingJobController { @Post(':id/notes') @AllowWorkspaceTokenScopes(WORKSPACE_TOKEN_SCOPE_CODING_JOB_OPERATE) - @UseGuards(JwtAuthGuard, WorkspaceGuard) + @UseGuards(JwtOrWorkspaceTokenAuthGuard, WorkspaceGuard) @ApiBearerAuth() @ApiOperation({ summary: 'Save coding notes', @@ -1037,7 +1057,7 @@ export class WsgCodingJobController { @Get(':id/progress') @AllowWorkspaceTokenScopes(WORKSPACE_TOKEN_SCOPE_CODING_JOB_OPERATE) - @UseGuards(JwtAuthGuard, WorkspaceGuard) + @UseGuards(JwtOrWorkspaceTokenAuthGuard, WorkspaceGuard) @ApiBearerAuth() @ApiOperation({ summary: 'Get coding progress', @@ -1135,7 +1155,7 @@ export class WsgCodingJobController { @Get(':id/units') @AllowWorkspaceTokenScopes(WORKSPACE_TOKEN_SCOPE_CODING_JOB_OPERATE) - @UseGuards(JwtAuthGuard, WorkspaceGuard) + @UseGuards(JwtOrWorkspaceTokenAuthGuard, WorkspaceGuard) @ApiBearerAuth() @ApiOperation({ summary: 'Get coding job units', diff --git a/apps/backend/src/main.ts b/apps/backend/src/main.ts index 05cb91528..e87df22a1 100755 --- a/apps/backend/src/main.ts +++ b/apps/backend/src/main.ts @@ -28,7 +28,7 @@ async function bootstrap() { const app = await NestFactory.create(AppModule); const configService = app.get(ConfigService); - const host = configService.get('API_HOST') || 'localhost'; + const host = configService.get('API_HOST') || '0.0.0.0'; const port = 3333; const globalPrefix = 'api'; const slowRequestThresholdMs = parseSlowRequestThresholdMs( diff --git a/apps/frontend/Dockerfile b/apps/frontend/Dockerfile index 5dbeb5a9f..a9fa8defa 100644 --- a/apps/frontend/Dockerfile +++ b/apps/frontend/Dockerfile @@ -23,10 +23,6 @@ ARG PROJECT USER root RUN chown -R nginx:root /usr/share/nginx/html -# Kopieren und ausführbar machen des Konfigurationsscripts -COPY --chown=nginx:root config/frontend/runtime-config.sh /docker-entrypoint.d/ -RUN chmod +x /docker-entrypoint.d/runtime-config.sh - USER nginx COPY --chown=nginx:root config/frontend/default.conf.http-template /etc/nginx/templates/default.conf.template diff --git a/apps/frontend/jest.config.ts b/apps/frontend/jest.config.ts index 317d4c41b..7ec3ed0fe 100755 --- a/apps/frontend/jest.config.ts +++ b/apps/frontend/jest.config.ts @@ -14,7 +14,7 @@ export default { } ] }, - transformIgnorePatterns: ['node_modules/(?!.*\\.mjs$|@iqb/metadata-resolver|d3-[^/]*|keycloak-js)'], + transformIgnorePatterns: ['node_modules/(?!.*\\.mjs$|@iqb/metadata-resolver|d3-[^/]*)'], snapshotSerializers: [ 'jest-preset-angular/build/serializers/no-ng-attributes', 'jest-preset-angular/build/serializers/ng-snapshot', @@ -22,7 +22,6 @@ export default { ], moduleNameMapper: { '^@swimlane/ngx-charts$': '/src/test-setup.ts', - '^keycloak-js$': '/src/mocks/keycloak-js.mock.ts', '^@iqb/metadata-resolver$': '/../../node_modules/@iqb/metadata-resolver/dist/index.mjs', '^@iqb/metadata-resolver/(.*)$': '/../../node_modules/@iqb/metadata-resolver/dist/$1' }, diff --git a/apps/frontend/src/app/app.component.spec.ts b/apps/frontend/src/app/app.component.spec.ts new file mode 100644 index 000000000..51d2a80bc --- /dev/null +++ b/apps/frontend/src/app/app.component.spec.ts @@ -0,0 +1,138 @@ +import { TestBed } from '@angular/core/testing'; +import { LocationStrategy } from '@angular/common'; +import { Router, NavigationEnd } from '@angular/router'; +import { Observable, Subject, of } from 'rxjs'; +import { AppComponent } from './app.component'; +import { AppService } from './core/services/app.service'; +import { AuthService } from './core/services/auth.service'; +import { AuthSessionActivityService } from './core/services/auth-session-activity.service'; +import { AuthDataDto } from '../../../../api-dto/auth-data-dto'; + +describe('AppComponent', () => { + let authService: { + exchangeLoginCode: jest.Mock; + setToken: jest.Mock; + setIdToken: jest.Mock; + setRefreshToken: jest.Mock; + getValidToken: jest.Mock; + isLoggedIn: jest.Mock; + getLoggedUser: jest.Mock; + getRoles: jest.Mock; + }; + let appService: { + appLogo: { bodyBackground: string; data: string; alt: string }; + dataLoading: boolean; + authData$: Observable; + processMessagePost: jest.Mock; + refreshAuthData: jest.Mock; + setAuthBootstrapStatus: jest.Mock; + normalizeInternalRoute: jest.Mock; + isLoggedIn: boolean; + loggedUser?: unknown; + }; + let router: { + events: Subject; + url: string; + navigateByUrl: jest.Mock; + }; + let authSessionActivity: { + start: jest.Mock; + stop: jest.Mock; + }; + + beforeEach(async () => { + authService = { + exchangeLoginCode: jest.fn().mockReturnValue(of({ + access_token: 'access-token', + token_type: 'Bearer', + expires_in: 300, + id_token: 'id-token', + refresh_token: 'refresh-token' + })), + setToken: jest.fn(), + setIdToken: jest.fn(), + setRefreshToken: jest.fn(), + getValidToken: jest.fn().mockResolvedValue('access-token'), + isLoggedIn: jest.fn().mockReturnValue(true), + getLoggedUser: jest.fn().mockReturnValue({ sub: 'oidc-user-id', preferred_username: 'tester' }), + getRoles: jest.fn().mockReturnValue([]) + }; + appService = { + appLogo: { bodyBackground: '', data: '', alt: '' }, + dataLoading: false, + authData$: of(AppService.defaultAuthData), + processMessagePost: jest.fn(), + refreshAuthData: jest.fn(), + setAuthBootstrapStatus: jest.fn(), + normalizeInternalRoute: jest.fn((returnUrl?: string) => ( + returnUrl && + returnUrl.startsWith('/') && + !returnUrl.startsWith('//') && + returnUrl !== '/' && + !returnUrl.startsWith('/home') ? + returnUrl : + undefined + )), + isLoggedIn: false + }; + router = { + events: new Subject(), + url: '/home?auth=session-expired&returnUrl=%2Fworkspace-admin%2F1%2Ftest-results', + navigateByUrl: jest.fn().mockResolvedValue(true) + }; + authSessionActivity = { + start: jest.fn(), + stop: jest.fn() + }; + + await TestBed.configureTestingModule({ + imports: [AppComponent], + providers: [ + { provide: AppService, useValue: appService }, + { provide: AuthService, useValue: authService }, + { provide: AuthSessionActivityService, useValue: authSessionActivity }, + { provide: Router, useValue: router }, + { provide: LocationStrategy, useValue: { path: jest.fn().mockReturnValue('/') } } + ] + }) + .overrideComponent(AppComponent, { + set: { template: '' } + }) + .compileComponents(); + }); + + afterEach(() => { + window.history.replaceState(null, '', '/'); + }); + + it('exchanges auth_code callbacks and restores the protected return URL without duplicating the hash', async () => { + window.history.replaceState( + null, + '', + '/?auth_code=exchange-code#/home?auth=session-expired&returnUrl=%2Fworkspace-admin%2F1%2Ftest-results' + ); + + const fixture = TestBed.createComponent(AppComponent); + await fixture.componentInstance.ngOnInit(); + + expect(authService.exchangeLoginCode).toHaveBeenCalledWith('exchange-code'); + expect(authService.setToken).toHaveBeenCalledWith('access-token'); + expect(authService.setIdToken).toHaveBeenCalledWith('id-token'); + expect(authService.setRefreshToken).toHaveBeenCalledWith('refresh-token'); + expect(appService.refreshAuthData).toHaveBeenCalled(); + expect(authSessionActivity.start).toHaveBeenCalled(); + expect(router.navigateByUrl).toHaveBeenCalledWith('/workspace-admin/1/test-results'); + expect(window.location.href).toBe('http://localhost/#/workspace-admin/1/test-results'); + expect(window.location.href).not.toContain('auth_code='); + expect(window.location.href).not.toContain('#/#'); + }); + + it('stops auth session activity on destroy', async () => { + const fixture = TestBed.createComponent(AppComponent); + await fixture.componentInstance.ngOnInit(); + + fixture.destroy(); + + expect(authSessionActivity.stop).toHaveBeenCalled(); + }); +}); diff --git a/apps/frontend/src/app/app.component.ts b/apps/frontend/src/app/app.component.ts old mode 100755 new mode 100644 index e62f40752..f6a7520db --- a/apps/frontend/src/app/app.component.ts +++ b/apps/frontend/src/app/app.component.ts @@ -1,5 +1,5 @@ import { - Component, OnInit, OnDestroy, effect, inject + Component, OnInit, OnDestroy, inject } from '@angular/core'; import { Router, RouterLink, RouterOutlet, NavigationEnd @@ -10,28 +10,35 @@ import { TranslateModule } from '@ngx-translate/core'; import { MatTooltip } from '@angular/material/tooltip'; import { MatButton } from '@angular/material/button'; import { LocationStrategy } from '@angular/common'; -import { KeycloakProfile } from 'keycloak-js'; -import { KEYCLOAK_EVENT_SIGNAL } from 'keycloak-angular'; import { Subscription, filter, firstValueFrom } from 'rxjs'; -import { MatSnackBar } from '@angular/material/snack-bar'; import { AppService } from './core/services/app.service'; import { AuthService } from './core/services/auth.service'; +import { AuthDataDto } from '../../../../api-dto/auth-data-dto'; import { WrappedIconComponent } from './shared/wrapped-icon/wrapped-icon.component'; import { UserMenuComponent } from './sys-admin/components/user-menu/user-menu.component'; -import { AuthDataDto } from '../../../../api-dto/auth-data-dto'; import { ExportToastComponent } from './components/export-toast/export-toast.component'; import { ErrorMessageDisplayComponent } from './shared/components/error-message-display/error-message-display.component'; -import { handleKeycloakSessionEvent } from './core/services/keycloak-session-events'; import { hasAdminBypass } from './core/guards/admin-access'; import { AuthSessionActivityService } from './core/services/auth-session-activity.service'; @Component({ selector: 'app-root', - imports: [RouterOutlet, MatSlideToggleModule, MatProgressSpinner, RouterLink, TranslateModule, MatTooltip, MatButton, UserMenuComponent, WrappedIconComponent, ExportToastComponent, ErrorMessageDisplayComponent], + imports: [ + RouterOutlet, + MatSlideToggleModule, + MatProgressSpinner, + RouterLink, + TranslateModule, + MatTooltip, + MatButton, + UserMenuComponent, + WrappedIconComponent, + ExportToastComponent, + ErrorMessageDisplayComponent + ], templateUrl: './app.component.html', - styleUrl: './app.component.scss', - providers: [AuthService] + styleUrl: './app.component.scss' }) export class AppComponent implements OnInit, OnDestroy { appService = inject(AppService); @@ -39,28 +46,18 @@ export class AppComponent implements OnInit, OnDestroy { url = inject(LocationStrategy); private router = inject(Router); - private keycloakEvent = inject(KEYCLOAK_EVENT_SIGNAL); - private snackBar = inject(MatSnackBar); private authSessionActivity = inject(AuthSessionActivityService); title = 'IQB-Kodierbox'; - loggedInKeycloak: boolean = false; + isLoggedIn = false; errorMessage = ''; authData: AuthDataDto = AppService.defaultAuthData; currentWorkspaceName = ''; private routerSubscription: Subscription | null = null; + private authDataSubscription: Subscription | null = null; constructor() { - effect(() => { - handleKeycloakSessionEvent(this.keycloakEvent(), this.appService, this.router); - if (this.authService.isLoggedIn() && !this.appService.needsReAuthentication) { - this.authSessionActivity.start(); - } else { - this.authSessionActivity.restart(); - } - }); - - this.appService.authData$.subscribe(authData => { + this.authDataSubscription = this.appService.authData$.subscribe(authData => { this.authData = authData; this.updateCurrentWorkspaceName(); }); @@ -72,6 +69,32 @@ export class AppComponent implements OnInit, OnDestroy { }); } + async ngOnInit(): Promise { + const postLoginReturnUrl = await this.handleAuthCallback(); + const activeToken = await this.authService.getValidToken(0); + + if (activeToken) { + this.setAuthState(); + this.appService.refreshAuthData(); + this.authSessionActivity.start(); + if (postLoginReturnUrl) { + this.router.navigateByUrl(postLoginReturnUrl).catch(() => undefined); + } + } else { + this.appService.setAuthBootstrapStatus('ready'); + } + + window.addEventListener('message', event => { + this.appService.processMessagePost(event); + }, false); + } + + ngOnDestroy(): void { + this.routerSubscription?.unsubscribe(); + this.authDataSubscription?.unsubscribe(); + this.authSessionActivity.stop(); + } + private updateCurrentWorkspaceName(): void { const workspaceId = this.getWorkspaceIdFromUrl(); if (workspaceId > 0 && this.authData.workspaces) { @@ -88,70 +111,67 @@ export class AppComponent implements OnInit, OnDestroy { return match ? parseInt(match[1], 10) : 0; } - ngOnDestroy(): void { - this.routerSubscription?.unsubscribe(); - this.authSessionActivity.stop(); + private setAuthState(): void { + this.isLoggedIn = true; + this.appService.isLoggedIn = true; + this.appService.loggedUser = this.authService.getLoggedUser(); } - async loadAuthData(identity: string): Promise { - this.errorMessage = ''; - this.appService.errorMessagesDisabled = true; - + private async handleAuthCallback(): Promise { try { - const success = await firstValueFrom(this.appService.loadAuthenticatedUser(identity)); - if (success) { - this.snackBar.dismiss(); - } else { - this.snackBar.open( - 'Ihre Anmeldung wurde erkannt, aber die Sitzungsdaten konnten nicht geladen werden. Bitte laden Sie die Seite neu oder melden Sie sich erneut an.', - 'Schließen', - { - duration: 8000, - panelClass: ['snackbar-error'] - } - ); - } - return success; - } finally { - this.appService.errorMessagesDisabled = false; - } - } + const urlParams = new URLSearchParams(window.location.search); + const authCode = urlParams.get('auth_code'); - async ngOnInit(): Promise { - if (this.authService.isLoggedIn()) { - this.setAuthState(); + const hasLegacyTokenParams = urlParams.has('token') || urlParams.has('id_token') || urlParams.has('refresh_token'); + + if (authCode) { + const tokenResponse = await firstValueFrom(this.authService.exchangeLoginCode(authCode)); + this.authService.setToken(tokenResponse.access_token); - try { - const keycloakUserProfile = await this.authService.loadUserProfile(); - this.appService.userProfile = keycloakUserProfile; - const identity = this.authService.getIdentity(); + if (tokenResponse.id_token) { + this.authService.setIdToken(tokenResponse.id_token); + } - if (this.isValidUserProfile(keycloakUserProfile) && identity) { - this.appService.keycloakIdentity = identity; - await this.loadAuthData(identity); - } else { - this.appService.markAuthDataFailed(); + if (tokenResponse.refresh_token) { + this.authService.setRefreshToken(tokenResponse.refresh_token); } - } catch { - this.appService.requireReAuthentication(this.router.url); + + const postLoginReturnUrl = this.getPostLoginReturnUrl(); + this.removeAuthCallbackParams(postLoginReturnUrl); + return postLoginReturnUrl; } - } else { - this.appService.setAuthBootstrapStatus('ready'); + if (hasLegacyTokenParams) { + this.removeAuthCallbackParams(); + } + } catch { + this.authService.login(); } - - window.addEventListener('message', event => { - this.appService.processMessagePost(event); - }, false); + return undefined; } - private setAuthState(): void { - this.loggedInKeycloak = true; - this.appService.isLoggedInKeycloak = true; - this.appService.loggedUser = this.authService.getLoggedUser(); + private getPostLoginReturnUrl(): string | undefined { + const hash = window.location.hash.startsWith('#') ? window.location.hash.slice(1) : window.location.hash; + const queryStart = hash.indexOf('?'); + if (queryStart < 0) { + return undefined; + } + + const hashParams = new URLSearchParams(hash.slice(queryStart + 1)); + return this.appService.normalizeInternalRoute(hashParams.get('returnUrl') || undefined); } - private isValidUserProfile(userProfile: KeycloakProfile): boolean { - return !!userProfile?.id && !!userProfile?.username; + private removeAuthCallbackParams(postLoginReturnUrl?: string): void { + const url = new URL(window.location.href); + ['auth_code', 'token', 'id_token', 'refresh_token'].forEach(param => url.searchParams.delete(param)); + if (postLoginReturnUrl) { + url.hash = postLoginReturnUrl; + } + + window.history.replaceState( + window.history.state, + '', + `${url.pathname}${url.search}${url.hash}` + ); } isAdminUser(): boolean { diff --git a/apps/frontend/src/app/app.config.ts b/apps/frontend/src/app/app.config.ts index f11784a14..511e053de 100755 --- a/apps/frontend/src/app/app.config.ts +++ b/apps/frontend/src/app/app.config.ts @@ -9,71 +9,22 @@ import { provideHttpClient, withInterceptors } from '@angular/common/http'; -import { registerLocaleData, HashLocationStrategy, LocationStrategy } from '@angular/common'; -import localeDeAt from '@angular/common/locales/de-AT'; +import { TranslateHttpLoader } from '@ngx-translate/http-loader'; import { provideAnimationsAsync } from '@angular/platform-browser/animations/async'; -import { - AutoRefreshTokenService, createInterceptorCondition, - INCLUDE_BEARER_TOKEN_INTERCEPTOR_CONFIG, IncludeBearerTokenCondition, - provideKeycloak, - UserActivityService, - withAutoRefreshToken -} from 'keycloak-angular'; -import { Observable } from 'rxjs'; +import { HashLocationStrategy, LocationStrategy, registerLocaleData } from '@angular/common'; +import localeDeAt from '@angular/common/locales/de-AT'; import { routes } from './app.routes'; import { environment } from '../environments/environment'; import { authInterceptor } from './core/interceptors/auth.interceptor'; import { journalInterceptor } from './core/interceptors/journal-interceptor'; import { SERVER_URL } from './injection-tokens'; -import { AUTH_SESSION_IDLE_TIMEOUT_MS } from './core/services/auth-session.config'; - -const translationCacheBust = Date.now().toString(); -export class CacheBustingTranslateLoader implements TranslateLoader { - constructor(private http: HttpClient) {} - - getTranslation(lang: string): Observable> { - return this.http.get>( - `./assets/i18n/${lang}.json?v=${translationCacheBust}` - ); - } -} +registerLocaleData(localeDeAt); -export function createTranslateLoader(http: HttpClient): CacheBustingTranslateLoader { - return new CacheBustingTranslateLoader(http); +export function createTranslateLoader(http: HttpClient): TranslateHttpLoader { + return new TranslateHttpLoader(http, './assets/i18n/', '.json'); } -const allUrlsCondition = createInterceptorCondition({ - urlPattern: /^(https?:\/\/.*)(\/.*)?$/i // Match all URLs starting with http or https -}); - -export const provideKeycloakAngular = () => provideKeycloak({ - config: { - url: environment.keycloak.url, - realm: environment.keycloak.realm, - clientId: environment.keycloak.clientId - }, - initOptions: { - onLoad: 'check-sso', - checkLoginIframe: false - }, - features: [ - withAutoRefreshToken({ - onInactivityTimeout: 'none', - sessionTimeout: AUTH_SESSION_IDLE_TIMEOUT_MS - }) - ], - - providers: [ - { - provide: INCLUDE_BEARER_TOKEN_INTERCEPTOR_CONFIG, - useValue: allUrlsCondition - }, - AutoRefreshTokenService, UserActivityService] -}); - -registerLocaleData(localeDeAt); - export const appConfig: ApplicationConfig = { providers: [ provideHttpClient( @@ -87,7 +38,6 @@ export const appConfig: ApplicationConfig = { deps: [HttpClient] } })), - provideKeycloakAngular(), provideRouter(routes), provideAnimationsAsync(), { diff --git a/apps/frontend/src/app/coding/services/coding-job-backend.service.spec.ts b/apps/frontend/src/app/coding/services/coding-job-backend.service.spec.ts index 663ca0fdf..ba9d42c78 100644 --- a/apps/frontend/src/app/coding/services/coding-job-backend.service.spec.ts +++ b/apps/frontend/src/app/coding/services/coding-job-backend.service.spec.ts @@ -7,7 +7,6 @@ import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'; -import Keycloak from 'keycloak-js'; import { CodingJobBackendService } from './coding-job-backend.service'; import { SERVER_URL } from '../../injection-tokens'; import { CodingJob } from '../models/coding-job.model'; @@ -17,7 +16,6 @@ describe('CodingJobBackendService', () => { let service: CodingJobBackendService; let httpMock: HttpTestingController; let validationTaskStateServiceMock: { invalidateWorkspace: jest.Mock }; - let keycloak: { authenticated: boolean; token?: string; updateToken: jest.Mock }; let fetchMock: jest.Mock; let originalFetch: typeof globalThis.fetch | undefined; @@ -28,12 +26,6 @@ describe('CodingJobBackendService', () => { invalidateWorkspace: jest.fn() }; originalFetch = globalThis.fetch; - keycloak = { - authenticated: true, - token: 'keycloak-token', - updateToken: jest.fn().mockResolvedValue(true) - }; - Object.defineProperty(window, 'localStorage', { value: { getItem: jest.fn().mockReturnValue('mock-token') @@ -50,7 +42,6 @@ describe('CodingJobBackendService', () => { provide: ValidationTaskStateService, useValue: validationTaskStateServiceMock }, - { provide: Keycloak, useValue: keycloak }, { provide: SERVER_URL, useValue: mockServerUrl } ] }); @@ -81,17 +72,16 @@ describe('CodingJobBackendService', () => { globalThis.fetch = fetchMock as unknown as typeof globalThis.fetch; }); - it('attaches the current Keycloak token to keepalive job updates synchronously', () => { - service.updateCodingJobKeepalive(1, 2, { status: 'active' }); + it('attaches the stored auth token to keepalive job updates synchronously', () => { + service.updateCodingJobKeepalive(1, 2, 'active'); - expect(keycloak.updateToken).not.toHaveBeenCalled(); expect(fetchMock).toHaveBeenCalledWith( - `${mockServerUrl}wsg-admin/workspace/1/coding-job/2`, + `${mockServerUrl}wsg-admin/workspace/1/coding-job/2/status`, expect.objectContaining({ method: 'PUT', keepalive: true, headers: { - Authorization: 'Bearer keycloak-token', + Authorization: 'Bearer mock-token', 'Content-Type': 'application/json' }, body: JSON.stringify({ status: 'active' }) @@ -102,7 +92,6 @@ describe('CodingJobBackendService', () => { it('preserves explicit scoped tokens for keepalive pause requests', () => { service.pauseCodingJobKeepalive(1, 2, 'scoped-token'); - expect(keycloak.updateToken).not.toHaveBeenCalled(); expect(fetchMock).toHaveBeenCalledWith( `${mockServerUrl}wsg-admin/workspace/1/coding-job/2/pause`, expect.objectContaining({ @@ -347,7 +336,7 @@ describe('CodingJobBackendService', () => { ); expect(req.request.method).toBe('POST'); expect(req.request.body).toEqual({}); - expect(req.request.headers.get('Authorization')).toBeNull(); + expect(req.request.headers.get('Authorization')).toBe('Bearer mock-token'); req.flush({ success: true, jobsCreated: 3, @@ -382,7 +371,7 @@ describe('CodingJobBackendService', () => { `${mockServerUrl}admin/workspace/1/coding/job-definitions/42/create-job-preview` ); expect(req.request.method).toBe('GET'); - expect(req.request.headers.get('Authorization')).toBeNull(); + expect(req.request.headers.get('Authorization')).toBe('Bearer mock-token'); req.flush({ distribution: { 'Unit::Var': { Ada: 1 } }, distributionByCoderId: { 'Unit::Var': { 1: 1 } }, @@ -424,7 +413,7 @@ describe('CodingJobBackendService', () => { ); expect(req.request.method).toBe('GET'); expect(req.request.responseType).toBe('blob'); - expect(req.request.headers.get('Authorization')).toBeNull(); + expect(req.request.headers.get('Authorization')).toBe('Bearer mock-token'); req.flush(mockBlob); }); diff --git a/apps/frontend/src/app/coding/services/coding-job-backend.service.ts b/apps/frontend/src/app/coding/services/coding-job-backend.service.ts index 72106b2ec..6aefdc4d0 100644 --- a/apps/frontend/src/app/coding/services/coding-job-backend.service.ts +++ b/apps/frontend/src/app/coding/services/coding-job-backend.service.ts @@ -6,7 +6,6 @@ import { import { map, switchMap, tap } from 'rxjs/operators'; -import Keycloak from 'keycloak-js'; import { SERVER_URL } from '../../injection-tokens'; import { ValidationTaskStateService } from '../../shared/services/validation/validation-task-state.service'; import type { @@ -294,11 +293,10 @@ export interface ManualCodingScopeSummary { export class CodingJobBackendService { private readonly serverUrl = inject(SERVER_URL); private http = inject(HttpClient); - private keycloak = inject(Keycloak, { optional: true }); private validationTaskStateService = inject(ValidationTaskStateService); - private getAuthHeader(authToken?: string): Record { - return authToken ? { Authorization: `Bearer ${authToken}` } : {}; + private getAuthHeader(authToken?: string) { + return { Authorization: `Bearer ${authToken || localStorage.getItem('auth_token')}` }; } private get authHeader(): Record { @@ -306,12 +304,7 @@ export class CodingJobBackendService { } private getFetchAuthHeader(authToken?: string): Record { - if (authToken) { - return this.getAuthHeader(authToken); - } - - const token = this.keycloak?.authenticated ? this.keycloak.token : undefined; - return this.getAuthHeader(token); + return this.getAuthHeader(authToken); } getVariableBundles(workspaceId: number): Observable { @@ -534,6 +527,26 @@ export class CodingJobBackendService { }); } + updateCodingJobStatus( + workspaceId: number, + codingJobId: number, + status: 'active' | 'paused' | 'completed', + authToken?: string + ): Observable { + const url = `${this.serverUrl}wsg-admin/workspace/${workspaceId}/coding-job/${codingJobId}/status`; + return this.http.put(url, { status }, { headers: this.getAuthHeader(authToken) }); + } + + updateCodingJobComment( + workspaceId: number, + codingJobId: number, + comment: string, + authToken?: string + ): Observable { + const url = `${this.serverUrl}wsg-admin/workspace/${workspaceId}/coding-job/${codingJobId}/comment`; + return this.http.put(url, { comment }, { headers: this.getAuthHeader(authToken) }); + } + deleteCodingJob( workspaceId: number, codingJobId: number @@ -772,10 +785,10 @@ export class CodingJobBackendService { updateCodingJobKeepalive( workspaceId: number, codingJobId: number, - codingJob: Partial>, + status: 'active' | 'paused' | 'completed', authToken?: string ): void { - const url = `${this.serverUrl}wsg-admin/workspace/${workspaceId}/coding-job/${codingJobId}`; + const url = `${this.serverUrl}wsg-admin/workspace/${workspaceId}/coding-job/${codingJobId}/status`; fetch(url, { method: 'PUT', keepalive: true, @@ -783,7 +796,7 @@ export class CodingJobBackendService { ...this.getFetchAuthHeader(authToken), 'Content-Type': 'application/json' }, - body: JSON.stringify(codingJob) + body: JSON.stringify({ status }) }).catch(() => undefined); } diff --git a/apps/frontend/src/app/coding/services/coding-training-backend.service.ts b/apps/frontend/src/app/coding/services/coding-training-backend.service.ts index 758a5268d..09f947237 100644 --- a/apps/frontend/src/app/coding/services/coding-training-backend.service.ts +++ b/apps/frontend/src/app/coding/services/coding-training-backend.service.ts @@ -112,7 +112,7 @@ export class CodingTrainingBackendService { private coderTrainingsInFlight = new Map>(); private get authHeader() { - return {}; + return { Authorization: `Bearer ${localStorage.getItem('auth_token')}` }; } private getWithinTrainingComparisonCacheKey(workspaceId: number, trainingId: number): string { diff --git a/apps/frontend/src/app/coding/services/test-person-coding.service.spec.ts b/apps/frontend/src/app/coding/services/test-person-coding.service.spec.ts index f1c54caf1..e7ed56f1f 100644 --- a/apps/frontend/src/app/coding/services/test-person-coding.service.spec.ts +++ b/apps/frontend/src/app/coding/services/test-person-coding.service.spec.ts @@ -1,7 +1,6 @@ import { TestBed } from '@angular/core/testing'; import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'; -import Keycloak from 'keycloak-js'; import { TestPersonCodingService, CodingStatistics, @@ -13,12 +12,13 @@ import { import { SERVER_URL } from '../../injection-tokens'; import { ResponseMatchingFlag } from '../../ws-admin/services/workspace-settings.service'; import { CodingBackgroundJobsService } from './coding-background-jobs.service'; +import { AuthService } from '../../core/services/auth.service'; describe('TestPersonCodingService', () => { let service: TestPersonCodingService; let httpMock: HttpTestingController; let codingBackgroundJobsService: CodingBackgroundJobsService; - let keycloak: { authenticated: boolean; token?: string; updateToken: jest.Mock }; + let authService: { getValidToken: jest.Mock }; let fetchMock: jest.Mock; let originalFetch: typeof globalThis.fetch | undefined; const mockServerUrl = 'http://localhost:3000/'; @@ -27,11 +27,6 @@ describe('TestPersonCodingService', () => { beforeEach(() => { originalFetch = globalThis.fetch; - keycloak = { - authenticated: true, - token: 'keycloak-token', - updateToken: jest.fn().mockResolvedValue(true) - }; // Mock localStorage using Object.defineProperty Object.defineProperty(window, 'localStorage', { @@ -40,13 +35,16 @@ describe('TestPersonCodingService', () => { }, writable: true }); + authService = { + getValidToken: jest.fn().mockResolvedValue(mockAuthToken) + }; TestBed.configureTestingModule({ providers: [ provideHttpClient(withInterceptorsFromDi()), provideHttpClientTesting(), TestPersonCodingService, - { provide: Keycloak, useValue: keycloak }, + { provide: AuthService, useValue: authService }, { provide: SERVER_URL, useValue: mockServerUrl } ] }); @@ -87,7 +85,7 @@ describe('TestPersonCodingService', () => { const req = httpMock.expectOne(request => request.url === `${mockServerUrl}admin/workspace/${mockWorkspaceId}/coding` && request.params.get('testPersons') === mockTestPersonIds && request.params.get('autoCoderRun') === '1'); expect(req.request.method).toBe('GET'); - expect(req.request.headers.get('Authorization')).toBeNull(); + expect(req.request.headers.get('Authorization')).toBe(`Bearer ${mockAuthToken}`); req.flush(mockResponse); }); @@ -124,7 +122,7 @@ describe('TestPersonCodingService', () => { }); describe('importExternalCodingWithProgress', () => { - it('should attach a valid Keycloak token to streaming fetch imports', async () => { + it('should attach the stored auth token to streaming fetch imports', async () => { fetchMock = jest.fn().mockResolvedValue({ ok: false, status: 401, @@ -141,17 +139,17 @@ describe('TestPersonCodingService', () => { onError ); - expect(keycloak.updateToken).toHaveBeenCalledWith(30); expect(fetchMock).toHaveBeenCalledWith( `${mockServerUrl}admin/workspace/${mockWorkspaceId}/coding/external-coding-import/stream`, expect.objectContaining({ method: 'POST', headers: { 'Content-Type': 'application/json', - Authorization: 'Bearer keycloak-token' + Authorization: `Bearer ${mockAuthToken}` } }) ); + expect(authService.getValidToken).toHaveBeenCalled(); expect(onError).toHaveBeenCalledWith('HTTP 401: Unauthorized'); }); }); @@ -523,7 +521,7 @@ describe('TestPersonCodingService', () => { `${mockServerUrl}admin/workspace/${mockWorkspaceId}/coding/progress-overview` ); expect(req.request.method).toBe('GET'); - expect(req.request.headers.get('Authorization')).toBeNull(); + expect(req.request.headers.get('Authorization')).toBe(`Bearer ${mockAuthToken}`); req.flush(mockResponse); }); @@ -556,7 +554,7 @@ describe('TestPersonCodingService', () => { const req = httpMock.expectOne(`${mockServerUrl}admin/workspace/${mockWorkspaceId}/coding/aggregation-settings`); expect(req.request.method).toBe('GET'); - expect(req.request.headers.get('Authorization')).toBeNull(); + expect(req.request.headers.get('Authorization')).toBe(`Bearer ${mockAuthToken}`); req.flush(mockResponse); }); @@ -582,7 +580,7 @@ describe('TestPersonCodingService', () => { threshold: 9, flags: [ResponseMatchingFlag.NO_AGGREGATION] }); - expect(req.request.headers.get('Authorization')).toBeNull(); + expect(req.request.headers.get('Authorization')).toBe(`Bearer ${mockAuthToken}`); req.flush(mockResponse); }); }); diff --git a/apps/frontend/src/app/coding/services/test-person-coding.service.ts b/apps/frontend/src/app/coding/services/test-person-coding.service.ts index 8951a0ef2..6faa37ca0 100644 --- a/apps/frontend/src/app/coding/services/test-person-coding.service.ts +++ b/apps/frontend/src/app/coding/services/test-person-coding.service.ts @@ -11,9 +11,9 @@ import { tap, throwError } from 'rxjs'; -import Keycloak from 'keycloak-js'; import { SERVER_URL } from '../../injection-tokens'; import { suppressGlobalHttpErrorContext } from '../../core/interceptors/http-error-context'; +import { AuthService } from '../../core/services/auth.service'; import { ExpectedCombinationDto } from '../../../../../../api-dto/coding/expected-combination.dto'; import { ValidateCodingCompletenessResponseDto @@ -241,7 +241,7 @@ export interface AppliedResultsOverview { export class TestPersonCodingService { readonly serverUrl = inject(SERVER_URL); private http = inject(HttpClient); - private keycloak = inject(Keycloak, { optional: true }); + private authService = inject(AuthService); private codingBackgroundJobsService = inject(CodingBackgroundJobsService); private autoCodingCompletedSubject = new Subject(); private testResultsChangedSubject = new Subject(); @@ -265,16 +265,11 @@ export class TestPersonCodingService { testResultsChanged$ = this.testResultsChangedSubject.asObservable(); get authHeader() { - return {}; + return { Authorization: `Bearer ${localStorage.getItem('auth_token')}` }; } - private async getValidKeycloakToken(): Promise { - if (!this.keycloak?.authenticated) { - return undefined; - } - - await this.keycloak.updateToken(30); - return this.keycloak.token; + private async getValidAuthToken(): Promise { + return this.authService.getValidToken(); } private hasJobId(jobId: string | null | undefined): jobId is string { @@ -842,7 +837,7 @@ export class TestPersonCodingService { onError: (error: string) => void ): Promise { try { - const token = await this.getValidKeycloakToken(); + const token = await this.getValidAuthToken(); const response = await fetch( `${this.serverUrl}admin/workspace/${workspaceId}/coding/external-coding-import/stream`, { diff --git a/apps/frontend/src/app/components/home/home.component.html b/apps/frontend/src/app/components/home/home.component.html index d7214f423..065dec162 100755 --- a/apps/frontend/src/app/components/home/home.component.html +++ b/apps/frontend/src/app/components/home/home.component.html @@ -13,8 +13,7 @@ [appName]="'IQB-Kodierbox'" [appVersion]="'1.16.3'" [userName]="authData.userName" - [userLongName]="appService.userProfile.firstName + ' ' + appService.userProfile.lastName" [isUserLoggedIn]="Number(authData.userId) > 0" - [isAdmin]="authData.isAdmin"> + [isAdmin]="authData.isAdmin" [userLongName]=""> diff --git a/apps/frontend/src/app/core/guards/access-level.guard.spec.ts b/apps/frontend/src/app/core/guards/access-level.guard.spec.ts index eddbbb389..c048a6f95 100644 --- a/apps/frontend/src/app/core/guards/access-level.guard.spec.ts +++ b/apps/frontend/src/app/core/guards/access-level.guard.spec.ts @@ -9,18 +9,6 @@ import { AppService, AuthBootstrapStatus } from '../services/app.service'; import { AuthDataDto } from '../../../../../../api-dto/auth-data-dto'; import { CodingJobBackendService } from '../../coding/services/coding-job-backend.service'; -jest.mock('keycloak-angular', () => ({ - createAuthGuard: jest.fn(( - isAccessAllowed: ( - route: ActivatedRouteSnapshot, - state: RouterStateSnapshot, - authData: { authenticated: boolean } - ) => Promise - ) => (route: ActivatedRouteSnapshot, state: RouterStateSnapshot) => isAccessAllowed(route, state, { - authenticated: true - })) -})); - describe('Access Level Guard', () => { let mockAuthService: jest.Mocked; let mockUserService: jest.Mocked; @@ -43,6 +31,7 @@ describe('Access Level Guard', () => { authDataSubject = new BehaviorSubject(defaultAuthData); mockAuthService = { + isLoggedIn: jest.fn().mockReturnValue(true), getRoles: jest.fn() } as unknown as jest.Mocked; @@ -459,23 +448,16 @@ describe('Access Level Guard', () => { }); }); - describe('Integration with Keycloak', () => { - it('should use keycloak-angular createAuthGuard', async () => { - // The guard is created using keycloak-angular's createAuthGuard - // which handles authentication validation + describe('Integration with backend OIDC auth', () => { + it('should create the access-level guard', async () => { const { canActivateAccessLevel } = await import('./access-level.guard'); const guard = canActivateAccessLevel(1); expect(guard).toBeDefined(); }); it('should validate authentication status before checking access level', () => { - // The guard checks the authenticated property from AuthGuardData - // This is handled by keycloak-angular internally - const mockAuthData = { authenticated: true }; - expect(mockAuthData.authenticated).toBe(true); - - const mockUnauthData = { authenticated: false }; - expect(mockUnauthData.authenticated).toBe(false); + mockAuthService.isLoggedIn.mockReturnValue(false); + expect(mockAuthService.isLoggedIn()).toBe(false); }); }); }); diff --git a/apps/frontend/src/app/core/guards/access-level.guard.ts b/apps/frontend/src/app/core/guards/access-level.guard.ts index 24ebb9540..2f47ec9da 100644 --- a/apps/frontend/src/app/core/guards/access-level.guard.ts +++ b/apps/frontend/src/app/core/guards/access-level.guard.ts @@ -1,13 +1,12 @@ import { ActivatedRouteSnapshot, - RouterStateSnapshot, CanActivateFn, - UrlTree, - Router + Router, + RouterStateSnapshot, + UrlTree } from '@angular/router'; import { inject } from '@angular/core'; import { firstValueFrom } from 'rxjs'; -import { createAuthGuard, AuthGuardData } from 'keycloak-angular'; import { AuthService } from '../services/auth.service'; import { UserService } from '../../shared/services/user/user.service'; import { AppService } from '../services/app.service'; @@ -52,23 +51,20 @@ function createWorkspaceAccessGuard( isAllowed: (context: WorkspaceAccessGuardContext) => boolean | Promise, createDeniedRedirect: (context: WorkspaceAccessGuardContext) => UrlTree | Promise ): CanActivateFn { - const isAccessAllowed = async ( + return async ( route: ActivatedRouteSnapshot, - state: RouterStateSnapshot, - authData: AuthGuardData + state: RouterStateSnapshot ): Promise => { const appService = inject(AppService); + const authService = inject(AuthService); const router = inject(Router); - const { authenticated } = authData; - if (!authenticated) { + + if (!authService.isLoggedIn()) { return createReAuthenticationUrlTree(router, state.url); } - const authService = inject(AuthService); const userService = inject(UserService); const codingJobBackendService = inject(CodingJobBackendService); - - // Check if user is system admin (bypass access level check) const userRoles = authService.getRoles() || []; if (hasAdminBypass(userRoles)) { @@ -86,20 +82,13 @@ function createWorkspaceAccessGuard( return true; } - // Get workspace ID from route params const workspaceId = getWorkspaceId(route); if (!workspaceId) { return createAccessDeniedUrlTree(router, state.url); } const currentUserId = appService.authData.userId; - - // Fetch workspace users with access levels - const workspaceUsers = await firstValueFrom( - userService.getUsers(Number(workspaceId)) - ); - - // Find current user in workspace users list by ID + const workspaceUsers = await firstValueFrom(userService.getUsers(Number(workspaceId))); const currentUser = workspaceUsers.find(wu => wu.id === currentUserId); if (!currentUser) { @@ -120,12 +109,10 @@ function createWorkspaceAccessGuard( } return await createDeniedRedirect(context); - } catch (error) { + } catch { return createAuthDataFailedUrlTree(router, state.url); } }; - - return createAuthGuard(isAccessAllowed); } async function hasAssignedCodingJobs(context: WorkspaceAccessGuardContext): Promise { @@ -147,11 +134,6 @@ async function hasAssignedCodingJobs(context: WorkspaceAccessGuardContext): Prom } } -/** - * Guard factory that creates a route guard checking for minimum access level - * @param minLevel Minimum access level required (1=Coder, 2=Coding Manager, 3=Study Manager, 4=Admin) - * @returns CanActivateFn that checks if user has sufficient access level - */ export function canActivateAccessLevel(minLevel: number): CanActivateFn { return createWorkspaceAccessGuard( async context => { @@ -168,15 +150,12 @@ export function canActivateAccessLevel(minLevel: number): CanActivateFn { currentUser, router, state, userAccessLevel, workspaceId } = context; if (userAccessLevel === 2) { - // Coding Manager: redirect to coding section return router.createUrlTree([`/workspace-admin/${workspaceId}/coding`]); } if (getEffectiveCanCode(currentUser) || await hasAssignedCodingJobs(context)) { - // Coder: redirect to their jobs return router.createUrlTree([`/workspace-admin/${workspaceId}/coding/my-jobs`]); } - // No sufficient access: redirect to home return createAccessDeniedUrlTree(router, state.url); } ); diff --git a/apps/frontend/src/app/core/guards/admin.guard.ts b/apps/frontend/src/app/core/guards/admin.guard.ts index b13a7cb8b..d84aca124 100644 --- a/apps/frontend/src/app/core/guards/admin.guard.ts +++ b/apps/frontend/src/app/core/guards/admin.guard.ts @@ -1,7 +1,10 @@ import { - ActivatedRouteSnapshot, Router, RouterStateSnapshot, CanActivateFn, UrlTree + ActivatedRouteSnapshot, + CanActivateFn, + Router, + RouterStateSnapshot, + UrlTree } from '@angular/router'; -import { createAuthGuard, AuthGuardData } from 'keycloak-angular'; import { inject } from '@angular/core'; import { AppService } from '../services/app.service'; import { AuthService } from '../services/auth.service'; @@ -15,17 +18,16 @@ import { hasAdminBypass } from './admin-access'; const isAdminAccessAllowed = async ( _route: ActivatedRouteSnapshot, - state: RouterStateSnapshot, - authData: AuthGuardData + state: RouterStateSnapshot ): Promise => { const appService = inject(AppService); + const authService = inject(AuthService); const router = inject(Router); - const { authenticated } = authData; - if (!authenticated) { + + if (!authService.isLoggedIn()) { return createReAuthenticationUrlTree(router, state.url); } - const authService = inject(AuthService); const userRoles = authService.getRoles() || []; try { @@ -43,4 +45,4 @@ const isAdminAccessAllowed = async ( } }; -export const canActivateAdmin = createAuthGuard(isAdminAccessAllowed); +export const canActivateAdmin: CanActivateFn = isAdminAccessAllowed; diff --git a/apps/frontend/src/app/core/guards/auth.guard.spec.ts b/apps/frontend/src/app/core/guards/auth.guard.spec.ts index 2f1788450..7f4e96618 100644 --- a/apps/frontend/src/app/core/guards/auth.guard.spec.ts +++ b/apps/frontend/src/app/core/guards/auth.guard.spec.ts @@ -19,16 +19,12 @@ describe('Auth Guard', () => { }); describe('Security Validation', () => { - it('should use keycloak-angular createAuthGuard', async () => { - // The guard is created using keycloak-angular's createAuthGuard - // which handles authentication validation + it('should expose the backend OIDC auth guard', async () => { const { canActivateAuth } = await import('./auth.guard'); expect(canActivateAuth).toBeDefined(); }); it('should validate authentication status', () => { - // The guard checks the authenticated property from AuthGuardData - // This is handled by keycloak-angular internally const mockAuthData = { authenticated: true }; expect(mockAuthData.authenticated).toBe(true); @@ -55,10 +51,8 @@ describe('Auth Guard', () => { }); }); - describe('Integration with Keycloak', () => { - it('should use keycloak authentication mechanism', async () => { - // The guard delegates to keycloak-angular for authentication - // This ensures consistent authentication across the application + describe('Integration with backend OIDC auth', () => { + it('should use the application auth service mechanism', async () => { const { canActivateAuth } = await import('./auth.guard'); expect(canActivateAuth).toBeDefined(); }); diff --git a/apps/frontend/src/app/core/guards/auth.guard.ts b/apps/frontend/src/app/core/guards/auth.guard.ts index 66376e324..6db124be8 100644 --- a/apps/frontend/src/app/core/guards/auth.guard.ts +++ b/apps/frontend/src/app/core/guards/auth.guard.ts @@ -1,31 +1,34 @@ import { - ActivatedRouteSnapshot, Router, RouterStateSnapshot, CanActivateFn, UrlTree + ActivatedRouteSnapshot, + CanActivateFn, + Router, + RouterStateSnapshot, + UrlTree } from '@angular/router'; import { inject } from '@angular/core'; -import { createAuthGuard, AuthGuardData } from 'keycloak-angular'; import { AppService } from '../services/app.service'; +import { AuthService } from '../services/auth.service'; import { createAuthDataFailedUrlTree, createReAuthenticationUrlTree } from './auth-redirect'; import { createRequiredAuthDataGuardResult, waitForRequiredAuthData } from './auth-data-ready'; const isAccessAllowed = async ( _route: ActivatedRouteSnapshot, - state: RouterStateSnapshot, - authData: AuthGuardData + state: RouterStateSnapshot ): Promise => { const appService = inject(AppService); + const authService = inject(AuthService); const router = inject(Router); - const { authenticated } = authData; - if (!authenticated) { + if (!authService.isLoggedIn()) { return createReAuthenticationUrlTree(router, state.url); } try { const authDataStatus = await waitForRequiredAuthData(appService); return createRequiredAuthDataGuardResult(router, state.url, authDataStatus); - } catch (error) { + } catch { return createAuthDataFailedUrlTree(router, state.url); } }; -export const canActivateAuth = createAuthGuard(isAccessAllowed); +export const canActivateAuth: CanActivateFn = isAccessAllowed; diff --git a/apps/frontend/src/app/core/guards/personal-coding-jobs.guard.spec.ts b/apps/frontend/src/app/core/guards/personal-coding-jobs.guard.spec.ts index c82d04a64..181023ffc 100644 --- a/apps/frontend/src/app/core/guards/personal-coding-jobs.guard.spec.ts +++ b/apps/frontend/src/app/core/guards/personal-coding-jobs.guard.spec.ts @@ -58,7 +58,7 @@ describe('Personal Coding Jobs Guard', () => { expect(router.createUrlTree).toHaveBeenCalledWith(['/home']); }); - it('redirects Keycloak admins away from the personal coding jobs route', () => { + it('redirects OIDC admins away from the personal coding jobs route', () => { const result = createPersonalCodingJobsGuardResult(router, '/coding', 'ready', authData, ['admin']); expect(result).toEqual({ redirect: true }); diff --git a/apps/frontend/src/app/core/guards/personal-coding-jobs.guard.ts b/apps/frontend/src/app/core/guards/personal-coding-jobs.guard.ts index b486737d8..bf0d003b4 100644 --- a/apps/frontend/src/app/core/guards/personal-coding-jobs.guard.ts +++ b/apps/frontend/src/app/core/guards/personal-coding-jobs.guard.ts @@ -3,7 +3,6 @@ import { } from '@angular/router'; import { inject } from '@angular/core'; import { firstValueFrom, forkJoin } from 'rxjs'; -import { createAuthGuard, AuthGuardData } from 'keycloak-angular'; import { AuthDataDto } from '../../../../../../api-dto/auth-data-dto'; import { AppService } from '../services/app.service'; import { AuthService } from '../services/auth.service'; @@ -69,15 +68,14 @@ export function createPersonalCodingJobsGuardResult( const isAccessAllowed = async ( _route: ActivatedRouteSnapshot, - state: RouterStateSnapshot, - authGuardData: AuthGuardData + state: RouterStateSnapshot ): Promise => { const appService = inject(AppService); const authService = inject(AuthService); const userService = inject(UserService); const router = inject(Router); - if (!authGuardData.authenticated) { + if (!authService.isLoggedIn()) { return createReAuthenticationUrlTree(router, state.url); } @@ -117,4 +115,4 @@ const isAccessAllowed = async ( } }; -export const canActivatePersonalCodingJobs = createAuthGuard(isAccessAllowed); +export const canActivatePersonalCodingJobs: CanActivateFn = isAccessAllowed; diff --git a/apps/frontend/src/app/core/interceptors/auth.interceptor.spec.ts b/apps/frontend/src/app/core/interceptors/auth.interceptor.spec.ts index cc993c21e..dce3a2850 100644 --- a/apps/frontend/src/app/core/interceptors/auth.interceptor.spec.ts +++ b/apps/frontend/src/app/core/interceptors/auth.interceptor.spec.ts @@ -34,6 +34,7 @@ describe('authInterceptor', () => { reAuthenticationReturnUrl: undefined } as unknown as jest.Mocked; authService = { + getToken: jest.fn().mockReturnValue('keycloak-token'), getValidToken: jest.fn().mockResolvedValue('keycloak-token') } as unknown as jest.Mocked; @@ -93,6 +94,15 @@ describe('authInterceptor', () => { req.flush({}); }); + it('should not attach authorization or refresh recursively for auth refresh requests', () => { + http.post('/api/auth/refresh', { refresh_token: 'refresh-token' }).subscribe(); + + const req = httpMock.expectOne('/api/auth/refresh'); + expect(authService.getValidToken).not.toHaveBeenCalled(); + expect(req.request.headers.has('Authorization')).toBe(false); + req.flush({ access_token: 'fresh-token' }); + }); + it('should preserve explicit authorization headers for scoped API tokens', () => { http.get('/api/scoped', { headers: new HttpHeaders({ Authorization: 'Bearer scoped-token' }) @@ -104,6 +114,22 @@ describe('authInterceptor', () => { req.flush({}); }); + it('should replace stale stored-token authorization headers with a refreshed access token', async () => { + authService.getToken.mockReturnValue('stale-token'); + authService.getValidToken.mockResolvedValue('fresh-token'); + + http.get('/api/workspaces', { + headers: new HttpHeaders({ Authorization: 'Bearer stale-token' }) + }).subscribe(); + + await Promise.resolve(); + + const req = httpMock.expectOne('/api/workspaces'); + expect(authService.getValidToken).toHaveBeenCalled(); + expect(req.request.headers.get('Authorization')).toBe('Bearer fresh-token'); + req.flush({}); + }); + it('should not attach a Keycloak token while reauthentication is required', () => { appService.needsReAuthentication = true; diff --git a/apps/frontend/src/app/core/interceptors/auth.interceptor.ts b/apps/frontend/src/app/core/interceptors/auth.interceptor.ts index 1fab3283a..ee5f19363 100644 --- a/apps/frontend/src/app/core/interceptors/auth.interceptor.ts +++ b/apps/frontend/src/app/core/interceptors/auth.interceptor.ts @@ -26,14 +26,19 @@ import { AppService } from '../services/app.service'; import { AuthService } from '../services/auth.service'; import { SERVER_URL } from '../../injection-tokens'; -/** - * Functional interceptor for adding authentication headers and handling errors - */ +const AUTH_ENDPOINTS_WITHOUT_ACCESS_TOKEN = [ + '/auth/exchange', + '/auth/refresh', + '/auth/login', + '/auth/logout', + '/auth/profile' +]; + export const authInterceptor: HttpInterceptorFn = ( req: HttpRequest, next: HttpHandlerFn ): Observable> => { - const appService: AppService = inject(AppService); + const appService = inject(AppService); const authService = inject(AuthService); const snackBar = inject(MatSnackBar); const router = inject(Router); @@ -56,7 +61,17 @@ export const authInterceptor: HttpInterceptorFn = ( return; } - if ( + if (error.status === 500 || error.status === 999) { + appService.setBackendUnavailable(true); + snackBar.open( + 'Backend ist nicht verfügbar. Bitte versuchen Sie es später erneut.', + 'Schließen', + { + duration: 0, + panelClass: ['error-snackbar'] + } + ); + } else if ( (error.status === 401 || error.status === 403) && !req.context.get(SUPPRESS_AUTH_ERROR_REDIRECT) ) { @@ -105,7 +120,22 @@ function getRequestWithAuthHeader( router: Router, serverUrl: string ): Observable> { - if (req.headers.has('Authorization') || !isBackendRequest(req.url, serverUrl)) { + if (!isBackendRequest(req.url, serverUrl)) { + return of(req); + } + + if (isAuthEndpointWithoutAccessToken(req.url, serverUrl)) { + return of(req); + } + + const authorizationHeader = req.headers.get('Authorization'); + const shouldPreserveExplicitAuthorization = + authorizationHeader && + authorizationHeader !== `Bearer ${authService.getToken()}` && + authorizationHeader !== 'Bearer null' && + authorizationHeader !== 'Bearer undefined'; + + if (shouldPreserveExplicitAuthorization) { return of(req); } @@ -121,7 +151,9 @@ function getRequestWithAuthHeader( }), map(token => { if (!token) { - return req; + return authorizationHeader ? req.clone({ + headers: req.headers.delete('Authorization') + }) : req; } return req.clone({ @@ -157,6 +189,37 @@ function isBackendRequest(url: string, serverUrl: string): boolean { return false; } +function isAuthEndpointWithoutAccessToken(url: string, serverUrl: string): boolean { + const backendPath = getBackendRelativePath(url, serverUrl); + return AUTH_ENDPOINTS_WITHOUT_ACCESS_TOKEN.some(endpoint => backendPath === endpoint || + backendPath.startsWith(`${endpoint}/`)); +} + +function getBackendRelativePath(url: string, serverUrl: string): string { + const requestPath = getPathname(url); + const serverPath = getPathname(serverUrl).replace(/\/+$/, ''); + + if (serverPath && requestPath === serverPath) { + return '/'; + } + + if (serverPath && requestPath.startsWith(`${serverPath}/`)) { + return requestPath.slice(serverPath.length); + } + + return requestPath.startsWith('/') ? requestPath : `/${requestPath}`; +} + +function getPathname(url: string): string { + const baseUrl = typeof window === 'undefined' ? 'http://localhost' : window.location.origin; + + try { + return new URL(url, baseUrl).pathname; + } catch { + return url.split(/[?#]/, 1)[0]; + } +} + function shouldSuppressBackendLoginAuthDataError( req: HttpRequest, error: { status?: number }, diff --git a/apps/frontend/src/app/core/services/app.service.spec.ts b/apps/frontend/src/app/core/services/app.service.spec.ts index 7e1b8982a..9aa45f636 100644 --- a/apps/frontend/src/app/core/services/app.service.spec.ts +++ b/apps/frontend/src/app/core/services/app.service.spec.ts @@ -1,8 +1,7 @@ import { TestBed, fakeAsync, tick } from '@angular/core/testing'; +import { HttpErrorResponse, provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'; import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; -import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'; import { of } from 'rxjs'; -import { KeycloakTokenParsed } from 'keycloak-js'; import { AppService } from './app.service'; import { LogoService } from './logo.service'; import { SERVER_URL } from '../../injection-tokens'; @@ -12,6 +11,8 @@ import { BACKEND_CONNECTIVITY_ERROR_MESSAGE } from '../interceptors/app-http-error.class'; import { SUPPRESS_GLOBAL_HTTP_ERROR } from '../interceptors/http-error-context'; +import { DecodedToken } from './auth.models'; +import { CreateUserDto } from '../../../../../../api-dto/user/create-user-dto'; describe('AppService', () => { let service: AppService; @@ -89,112 +90,50 @@ describe('AppService', () => { }); }); - describe('getWorkspaceTokenPolicy', () => { - it('should request the workspace token policy', () => { - const policy = { - scopes: { - 'replay:read': { maxDurationDays: 90 }, - 'replay-statistics:write': { maxDurationDays: 1 }, - 'coding-job:operate': { maxDurationDays: 1 } - } - }; - - service.getWorkspaceTokenPolicy().subscribe(result => { - expect(result).toEqual(policy); - }); - - const req = httpMock.expectOne(`${mockServerUrl}admin/workspace/token-policy`); - expect(req.request.method).toBe('GET'); - req.flush(policy); - }); - }); - - describe('loadAuthenticatedUser', () => { - it('should fetch auth data on success', () => { - const mockAuthData = { userId: 1, userName: 'user' } as unknown as AuthDataDto; - - service.loadAuthenticatedUser('id1').subscribe(result => { - expect(result).toBe(true); - expect(service.authBootstrapStatus).toBe('ready'); - }); - - expect(service.authBootstrapStatus).toBe('backend-login-running'); - - const reqAuth = httpMock.expectOne(`${mockServerUrl}auth-data?identity=id1`); - expect(reqAuth.request.method).toBe('GET'); - expect(reqAuth.request.context.get(SUPPRESS_GLOBAL_HTTP_ERROR)).toBe(true); - reqAuth.flush(mockAuthData); - }); - + describe('auth data loading', () => { it('should retry auth data after transient backend errors', fakeAsync(() => { const mockAuthData = { userId: 1, userName: 'user' } as unknown as AuthDataDto; - let loginResult: boolean | undefined; + let result: boolean | undefined; + service.loggedUser = { sub: 'user1' } as DecodedToken; - service.loadAuthenticatedUser('id1').subscribe(result => { - loginResult = result; + service.retryAuthDataLoad().subscribe(loadResult => { + result = loadResult; }); - const firstAuthRequest = httpMock.expectOne(`${mockServerUrl}auth-data?identity=id1`); + const firstAuthRequest = httpMock.expectOne(`${mockServerUrl}auth-data?identity=user1`); + expect(firstAuthRequest.request.context.get(SUPPRESS_GLOBAL_HTTP_ERROR)).toBe(true); firstAuthRequest.flush('Service unavailable', { status: 503, statusText: 'Service Unavailable' }); tick(500); - const reqAuth = httpMock.expectOne(`${mockServerUrl}auth-data?identity=id1`); - expect(reqAuth.request.context.get(SUPPRESS_GLOBAL_HTTP_ERROR)).toBe(true); - reqAuth.flush(mockAuthData); + const secondAuthRequest = httpMock.expectOne(`${mockServerUrl}auth-data?identity=user1`); + expect(secondAuthRequest.request.context.get(SUPPRESS_GLOBAL_HTTP_ERROR)).toBe(true); + secondAuthRequest.flush(mockAuthData); - expect(loginResult).toBe(true); + expect(result).toBe(true); expect(service.authBootstrapStatus).toBe('ready'); })); it('should not retry auth data after authorization errors', fakeAsync(() => { - let loginResult: boolean | undefined; + let result: boolean | undefined; + service.loggedUser = { sub: 'user1' } as DecodedToken; - service.loadAuthenticatedUser('id1').subscribe(result => { - loginResult = result; + service.retryAuthDataLoad().subscribe(loadResult => { + result = loadResult; }); - const reqAuth = httpMock.expectOne(`${mockServerUrl}auth-data?identity=id1`); + const reqAuth = httpMock.expectOne(`${mockServerUrl}auth-data?identity=user1`); reqAuth.flush('Unauthorized', { status: 401, statusText: 'Unauthorized' }); tick(2000); - httpMock.expectNone(`${mockServerUrl}auth-data?identity=id1`); - expect(loginResult).toBe(false); + httpMock.expectNone(`${mockServerUrl}auth-data?identity=user1`); + expect(result).toBe(false); expect(service.authBootstrapStatus).toBe('auth-data-failed'); })); - it('should return false on auth data failure', () => { - service.loadAuthenticatedUser('id1').subscribe(result => { - expect(result).toBe(false); - expect(service.authBootstrapStatus).toBe('auth-data-failed'); - }); - - const reqAuth = httpMock.expectOne(`${mockServerUrl}auth-data?identity=id1`); - reqAuth.flush('Error', { status: 401, statusText: 'Unauthorized' }); - }); - }); - - describe('refreshAuthData', () => { - it('should suppress global HTTP errors while manually retrying auth data', () => { - service.loggedUser = { sub: 'user1' } as KeycloakTokenParsed; - const mockAuthData = { userId: 1 } as unknown as AuthDataDto; - - service.retryAuthDataLoad().subscribe(result => { - expect(result).toBe(true); - }); - - const req = httpMock.expectOne(`${mockServerUrl}auth-data?identity=user1`); - expect(req.request.method).toBe('GET'); - expect(req.request.context.get(SUPPRESS_GLOBAL_HTTP_ERROR)).toBe(true); - req.flush(mockAuthData); - - expect(service.authBootstrapStatus).toBe('ready'); - }); - it('should refresh data if user is logged in', () => { - service.loggedUser = { sub: 'user1' } as KeycloakTokenParsed; - service.setAuthBootstrapStatus('ready'); + service.loggedUser = { sub: 'user1' } as DecodedToken; const mockAuthData = { userId: 1 } as unknown as AuthDataDto; service.refreshAuthData(); @@ -204,35 +143,13 @@ describe('AppService', () => { expect(req.request.context.get(SUPPRESS_GLOBAL_HTTP_ERROR)).toBe(true); req.flush(mockAuthData); }); - - it('should mark auth data as failed without a global HTTP error when refresh cannot find the user', () => { - service.loggedUser = { sub: 'user1' } as KeycloakTokenParsed; - service.setAuthBootstrapStatus('ready'); - - service.refreshAuthData(); - - const req = httpMock.expectOne(`${mockServerUrl}auth-data?identity=user1`); - expect(req.request.context.get(SUPPRESS_GLOBAL_HTTP_ERROR)).toBe(true); - req.flush('Not found', { status: 404, statusText: 'Not Found' }); - - expect(service.authBootstrapStatus).toBe('auth-data-failed'); - }); - - it('should not refresh data while backend login is still running', () => { - service.loggedUser = { sub: 'user1' } as KeycloakTokenParsed; - service.setAuthBootstrapStatus('backend-login-running'); - - service.refreshAuthData(); - - httpMock.expectNone(`${mockServerUrl}auth-data?identity=user1`); - }); }); describe('auth state cleanup', () => { it('should clear stored auth state', () => { - service.loggedUser = { sub: 'user1' } as KeycloakTokenParsed; - service.isLoggedInKeycloak = true; - service.keycloakIdentity = 'user1'; + service.loggedUser = { sub: 'user1' } as DecodedToken; + service.isLoggedIn = true; + service.user = { username: 'user', isAdmin: false } as CreateUserDto; service.needsReAuthentication = true; service.sessionExpiryWarning = true; service.reAuthenticationReturnUrl = '/coding'; @@ -240,10 +157,12 @@ describe('AppService', () => { service.clearAuthState(); + expect(localStorage.removeItem).toHaveBeenCalledWith('auth_token'); expect(localStorage.removeItem).toHaveBeenCalledWith('id_token'); + expect(localStorage.removeItem).toHaveBeenCalledWith('refresh_token'); expect(service.loggedUser).toBeUndefined(); - expect(service.keycloakIdentity).toBeUndefined(); - expect(service.isLoggedInKeycloak).toBe(false); + expect(service.user).toBeUndefined(); + expect(service.isLoggedIn).toBe(false); expect(service.authData).toEqual(AppService.defaultAuthData); expect(service.needsReAuthentication).toBe(false); expect(service.sessionExpiryWarning).toBe(false); @@ -254,7 +173,7 @@ describe('AppService', () => { it('should clear auth state and mark reauthentication as required', () => { service.requireReAuthentication('/coding'); - expect(localStorage.removeItem).toHaveBeenCalledWith('id_token'); + expect(localStorage.removeItem).toHaveBeenCalledWith('auth_token'); expect(service.needsReAuthentication).toBe(true); expect(service.sessionExpiryWarning).toBe(false); expect(service.reAuthenticationReturnUrl).toBe('/coding'); @@ -268,121 +187,33 @@ describe('AppService', () => { expect(service.reAuthenticationReturnUrl).toBe('/workspace-admin/1'); }); - it('should clear the return URL when reauthentication is dismissed', () => { + it('should clear reauthentication and return URL when explicitly requested', () => { service.requireReAuthentication('/workspace-admin/1'); - - service.setNeedsReAuthentication(false); + service.clearAuthState({ clearReAuthentication: true, clearReturnUrl: true }); expect(service.needsReAuthentication).toBe(false); expect(service.reAuthenticationReturnUrl).toBeUndefined(); }); + }); - it('should reject external login redirect targets', () => { - expect(service.normalizeInternalRoute('https://example.test')).toBeUndefined(); - expect(service.normalizeInternalRoute('//example.test')).toBeUndefined(); - }); - - it('should create hash-based login redirect URIs for internal routes', () => { + describe('createLoginRedirectUri', () => { + it('should preserve internal return URLs in a hash route', () => { expect(service.createLoginRedirectUri('/coding')).toBe('http://localhost/#/coding'); }); - it('should clear stale authentication error messages after backend login succeeds', () => { - const authError = { status: 401 } as AppHttpError; - const otherError = { status: 500 } as AppHttpError; - service.errorMessages = [authError, otherError]; - - service.completeBackendLogin(); - - expect(service.errorMessages).toEqual([otherError]); - expect(service.needsReAuthentication).toBe(false); - expect(service.authBootstrapStatus).toBe('ready'); - }); - - it('should group repeated non-connectivity HTTP errors by status and request URL', () => { - service.addErrorMessage({ - status: 400, - method: 'GET', - urlWithParams: '/api/admin/workspace/5/coding/jobs', - message: 'Bad Request' - } as AppHttpError); - service.addErrorMessage({ - status: 400, - method: 'GET', - urlWithParams: '/api/admin/workspace/5/coding/jobs', - message: 'Bad Request' - } as AppHttpError); - service.addErrorMessage({ - status: 400, - method: 'GET', - urlWithParams: '/api/admin/workspace/5/coding/coder-trainings', - message: 'Bad Request' - } as AppHttpError); - - expect(service.errorMessages).toHaveLength(2); - expect(service.errorMessages[0].message).toBe('Bad Request'); - expect(service.errorMessages[0].requestCount).toBe(2); - }); - - it('should keep the displayed user message in sync when grouped errors append details', () => { - service.addErrorMessage({ - status: 400, - method: 'GET', - urlWithParams: '/api/admin/workspace/5/journal', - message: 'Das Von-Datum ist ungültig.', - userMessage: 'Das Von-Datum ist ungültig.', - requestId: 'request-1' - } as AppHttpError); - service.addErrorMessage({ - status: 400, - method: 'GET', - urlWithParams: '/api/admin/workspace/5/journal', - message: 'Das Bis-Datum ist ungültig.', - userMessage: 'Das Bis-Datum ist ungültig.', - requestId: 'request-2' - } as AppHttpError); - - expect(service.errorMessages).toHaveLength(1); - expect(service.errorMessages[0].message).toBe('Das Von-Datum ist ungültig.; Das Bis-Datum ist ungültig.'); - expect(service.errorMessages[0].userMessage).toBe('Das Von-Datum ist ungültig.; Das Bis-Datum ist ungültig.'); - expect(service.errorMessages[0].requestCount).toBe(2); - expect(service.errorMessages[0].affectedRequests).toEqual([ - { - method: 'GET', - urlWithParams: '/api/admin/workspace/5/journal', - requestId: 'request-1' - }, - { - method: 'GET', - urlWithParams: '/api/admin/workspace/5/journal', - requestId: 'request-2' - } - ]); + it('should return the current app origin for non-returnable routes', () => { + expect(service.createLoginRedirectUri('/home')).toBe('http://localhost/'); }); + }); - it('should group backend connectivity errors across different request URLs', () => { - service.addErrorMessage({ - status: 504, - method: 'GET', - urlWithParams: '/api/admin/users/access/5', - message: 'Gateway Timeout' - } as AppHttpError); - service.addErrorMessage({ - status: 504, - method: 'POST', - urlWithParams: '/api/admin/workspace/5/coding/statistics/job?version=v1', - message: 'Gateway Timeout' - } as AppHttpError); - service.addErrorMessage({ - status: 0, - method: 'GET', - urlWithParams: '/api/admin/workspace/5/coding/freshness', - message: 'Backend nicht erreichbar' - } as AppHttpError); + describe('addErrorMessage', () => { + it('should group backend connectivity errors under a user-friendly message', () => { + service.addErrorMessage(new AppHttpError(new HttpErrorResponse({ status: 0, error: 'Network Error' }))); + service.addErrorMessage(new AppHttpError(new HttpErrorResponse({ status: 503, error: 'Service unavailable' }))); expect(service.errorMessages).toHaveLength(1); expect(service.errorMessages[0].message).toBe(BACKEND_CONNECTIVITY_ERROR_MESSAGE); - expect(service.errorMessages[0].requestCount).toBe(3); - expect(service.errorMessages[0].affectedRequests).toHaveLength(3); + expect(service.errorMessages[0].requestCount).toBe(2); }); }); }); diff --git a/apps/frontend/src/app/core/services/app.service.ts b/apps/frontend/src/app/core/services/app.service.ts old mode 100755 new mode 100644 index 67e00cb56..64607dbf0 --- a/apps/frontend/src/app/core/services/app.service.ts +++ b/apps/frontend/src/app/core/services/app.service.ts @@ -11,9 +11,10 @@ import { throwError, timer } from 'rxjs'; -import { KeycloakProfile, KeycloakTokenParsed } from 'keycloak-js'; +import { DecodedToken } from './auth.models'; import { AppLogoDto } from '../../../../../../api-dto/app-logo-dto'; import { AuthDataDto } from '../../../../../../api-dto/auth-data-dto'; +import { CreateUserDto } from '../../../../../../api-dto/user/create-user-dto'; import { AppHttpError, BACKEND_CONNECTIVITY_ERROR_MESSAGE, @@ -57,15 +58,15 @@ export class AppService { workspaces: [] }; - keycloakIdentity?: string; - userProfile: KeycloakProfile = {}; - isLoggedInKeycloak = false; + user?: CreateUserDto; + userProfile: Partial = {}; + isLoggedIn = false; errorMessagesDisabled = false; selectedWorkspaceId = 0; dataLoading: boolean | number = false; appLogo: AppLogoDto = standardLogo; postMessage$ = new Subject(); - loggedUser: KeycloakTokenParsed | undefined; + loggedUser: DecodedToken | undefined; errorMessages: AppHttpError[] = []; errorMessageCounter = 0; backendUnavailable = false; @@ -74,31 +75,32 @@ export class AppService { reAuthenticationReturnUrl?: string; private explicitLogoutInProgress = false; private authBootstrapStatusSubject = new BehaviorSubject('checking'); + private authDataSubject = new BehaviorSubject(AppService.defaultAuthData); constructor() { this.loadLogoSettings(); } createOwnToken( - workspace_id: number, + workspaceId: number, duration: number, scopes: WorkspaceTokenScope[] ): Observable { return this.http.get( - `${this.serverUrl}admin/workspace/${workspace_id}/token/${duration}`, + `${this.serverUrl}admin/workspace/${workspaceId}/token/${duration}`, { params: this.createTokenScopeParams(scopes) } ); } createTokenForIdentity( - workspace_id: number, + workspaceId: number, identity: string, duration: number, scopes: WorkspaceTokenScope[] ): Observable { const encodedIdentity = encodeURIComponent(identity); return this.http.get( - `${this.serverUrl}admin/workspace/${workspace_id}/${encodedIdentity}/token/${duration}`, + `${this.serverUrl}admin/workspace/${workspaceId}/${encodedIdentity}/token/${duration}`, { params: this.createTokenScopeParams(scopes) } ); } @@ -109,42 +111,35 @@ export class AppService { ); } - loadAuthenticatedUser(identity: string): Observable { - this.setAuthBootstrapStatus('backend-login-running'); - this.keycloakIdentity = identity; - - return this.getAuthDataWithRetry(identity) - .pipe( - map(authData => { - this.updateAuthData(authData); - return true; - }), - catchError(() => of(false)), - map(success => { - if (success) { - this.completeBackendLogin(); - } else { - this.markAuthDataFailed(); - } - return success; - }) - ); - } - - getAuthData(id: string): Observable { + getAuthData(identity: string): Observable { return this.http.get( - this.createAuthDataUrl(id) + `${this.serverUrl}auth-data?identity=${encodeURIComponent(identity)}` ); } retryAuthDataLoad(): Observable { - const identity = this.loggedUser?.sub || this.keycloakIdentity || ''; - if (!identity) { + const identity = this.loggedUser?.sub || ''; + if (!identity || !this.hasStoredAuthToken()) { this.markAuthDataFailed(); return of(false); } + return this.loadAuthData(identity); + } + + refreshAuthData(): void { + const identity = this.loggedUser?.sub; + if (!identity) { + this.markAuthDataFailed(); + return; + } + + this.loadAuthData(identity).subscribe(); + } + + private loadAuthData(identity: string): Observable { this.setAuthBootstrapStatus('backend-login-running'); + return this.getAuthDataWithRetry(identity) .pipe( map(authData => { @@ -159,27 +154,10 @@ export class AppService { ); } - refreshAuthData(): void { - if (this.authBootstrapStatus !== 'ready') { - return; - } - - if (this.loggedUser?.sub) { - this.getAuthDataWithRetry(this.loggedUser.sub).subscribe({ - next: authData => { - this.updateAuthData(authData); - }, - error: () => { - this.markAuthDataFailed(); - } - }); - } - } - - private getAuthDataWithRetry(id: string): Observable { + private getAuthDataWithRetry(identity: string): Observable { return this.withAuthBootstrapRetry( this.http.get( - this.createAuthDataUrl(id), + `${this.serverUrl}auth-data?identity=${encodeURIComponent(identity)}`, { context: suppressGlobalHttpErrorContext() } ) ); @@ -216,8 +194,6 @@ export class AppService { }); } - private authDataSubject = new BehaviorSubject(AppService.defaultAuthData); - get authData$() { return this.authDataSubject.asObservable(); } @@ -295,7 +271,7 @@ export class AppService { } hasStoredAuthToken(): boolean { - return !!(this.loggedUser?.sub || this.keycloakIdentity || this.isLoggedInKeycloak); + return !!localStorage.getItem('auth_token'); } isBackendLoginRunning(): boolean { @@ -329,10 +305,10 @@ export class AppService { return returnUrl; } - createLoginRedirectUri(returnUrl?: string): string | undefined { + createLoginRedirectUri(returnUrl?: string): string { const normalizedReturnUrl = this.normalizeInternalRoute(returnUrl); if (!normalizedReturnUrl) { - return undefined; + return `${window.location.origin}${window.location.pathname}${window.location.search}`; } return `${window.location.origin}${window.location.pathname}${window.location.search}#${normalizedReturnUrl}`; @@ -349,10 +325,12 @@ export class AppService { } clearAuthState(options: { clearReAuthentication?: boolean; clearReturnUrl?: boolean } = {}): void { + localStorage.removeItem('auth_token'); localStorage.removeItem('id_token'); - this.keycloakIdentity = undefined; + localStorage.removeItem('refresh_token'); + this.user = undefined; this.userProfile = {}; - this.isLoggedInKeycloak = false; + this.isLoggedIn = false; this.loggedUser = undefined; this.updateAuthData(AppService.defaultAuthData); diff --git a/apps/frontend/src/app/core/services/auth-session-activity.service.spec.ts b/apps/frontend/src/app/core/services/auth-session-activity.service.spec.ts index e9ad430e9..acd2c4727 100644 --- a/apps/frontend/src/app/core/services/auth-session-activity.service.spec.ts +++ b/apps/frontend/src/app/core/services/auth-session-activity.service.spec.ts @@ -1,8 +1,8 @@ import { TestBed, fakeAsync, tick } from '@angular/core/testing'; import { Router } from '@angular/router'; -import Keycloak from 'keycloak-js'; import { AuthSessionActivityService } from './auth-session-activity.service'; import { AppService } from './app.service'; +import { AuthService } from './auth.service'; import { AUTH_SESSION_IDLE_TIMEOUT_MS, AUTH_SESSION_WARNING_DELAY_MS @@ -10,7 +10,7 @@ import { describe('AuthSessionActivityService', () => { let service: AuthSessionActivityService; - let keycloak: { authenticated: boolean; updateToken: jest.Mock }; + let authService: { isLoggedIn: jest.Mock; getValidToken: jest.Mock }; let appService: { needsReAuthentication: boolean; sessionExpiryWarning: boolean; @@ -19,9 +19,9 @@ describe('AuthSessionActivityService', () => { }; beforeEach(() => { - keycloak = { - authenticated: true, - updateToken: jest.fn().mockResolvedValue(true) + authService = { + isLoggedIn: jest.fn().mockReturnValue(true), + getValidToken: jest.fn().mockResolvedValue('token') }; appService = { needsReAuthentication: false, @@ -35,7 +35,7 @@ describe('AuthSessionActivityService', () => { TestBed.configureTestingModule({ providers: [ AuthSessionActivityService, - { provide: Keycloak, useValue: keycloak }, + { provide: AuthService, useValue: authService }, { provide: AppService, useValue: appService }, { provide: Router, useValue: { url: '/coding' } } ] @@ -45,7 +45,7 @@ describe('AuthSessionActivityService', () => { }); afterEach(() => { - service.stop(); + service?.stop(); }); it('should show an idle warning before the session expires', fakeAsync(() => { @@ -84,13 +84,13 @@ describe('AuthSessionActivityService', () => { service.stop(); })); - it('should force a token refresh when the user returns after the warning', fakeAsync(() => { + it('should keep the session active when the user returns with a valid token after the warning', fakeAsync(() => { service.start(); tick(AUTH_SESSION_WARNING_DELAY_MS); window.dispatchEvent(new Event('mousemove')); - expect(keycloak.updateToken).toHaveBeenCalledWith(-1); + expect(authService.getValidToken).toHaveBeenCalledWith(0); expect(appService.sessionExpiryWarning).toBe(false); service.stop(); })); diff --git a/apps/frontend/src/app/core/services/auth-session-activity.service.ts b/apps/frontend/src/app/core/services/auth-session-activity.service.ts index 910733be8..9daad6b4f 100644 --- a/apps/frontend/src/app/core/services/auth-session-activity.service.ts +++ b/apps/frontend/src/app/core/services/auth-session-activity.service.ts @@ -2,8 +2,8 @@ import { Injectable, NgZone, OnDestroy, inject } from '@angular/core'; import { Router } from '@angular/router'; -import Keycloak from 'keycloak-js'; import { AppService } from './app.service'; +import { AuthService } from './auth.service'; import { AUTH_SESSION_IDLE_TIMEOUT_MS, AUTH_SESSION_WARNING_DELAY_MS @@ -13,7 +13,7 @@ import { providedIn: 'root' }) export class AuthSessionActivityService implements OnDestroy { - private readonly keycloak = inject(Keycloak); + private readonly authService = inject(AuthService); private readonly appService = inject(AppService); private readonly router = inject(Router); private readonly ngZone = inject(NgZone); @@ -81,7 +81,13 @@ export class AuthSessionActivityService implements OnDestroy { this.restart(); if (warningWasVisible) { - this.keycloak.updateToken(-1).catch(() => this.expireSession()); + this.authService.getValidToken(0) + .then(token => { + if (!token) { + this.expireSession(); + } + }) + .catch(() => this.expireSession()); } } @@ -107,7 +113,7 @@ export class AuthSessionActivityService implements OnDestroy { } private isAuthenticatedSession(): boolean { - return !!this.keycloak.authenticated && !this.appService.needsReAuthentication; + return this.authService.isLoggedIn() && !this.appService.needsReAuthentication; } private clearTimers(): void { diff --git a/apps/frontend/src/app/core/services/auth.models.ts b/apps/frontend/src/app/core/services/auth.models.ts new file mode 100644 index 000000000..9294c88df --- /dev/null +++ b/apps/frontend/src/app/core/services/auth.models.ts @@ -0,0 +1,27 @@ +export interface UserProfile { + id?: string; + username?: string; + email?: string; + firstName?: string; + lastName?: string; +} + +export interface DecodedToken { + sub?: string; + email?: string; + preferred_username?: string; + given_name?: string; + family_name?: string; + realm_access?: { + roles: string[]; + }; + exp?: number; +} + +export interface AuthExchangeResponse { + access_token: string; + token_type: string; + expires_in: number; + id_token?: string; + refresh_token?: string; +} diff --git a/apps/frontend/src/app/core/services/auth.service.spec.ts b/apps/frontend/src/app/core/services/auth.service.spec.ts index 144842146..1aa2916cb 100644 --- a/apps/frontend/src/app/core/services/auth.service.spec.ts +++ b/apps/frontend/src/app/core/services/auth.service.spec.ts @@ -1,105 +1,160 @@ import { TestBed } from '@angular/core/testing'; -import Keycloak from 'keycloak-js'; +import { provideHttpClient } from '@angular/common/http'; +import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; import { AuthService } from './auth.service'; import { AppService } from './app.service'; describe('AuthService', () => { let service: AuthService; - let keycloak: { - authenticated?: boolean; - tokenParsed?: { sub?: string }; - idTokenParsed?: unknown; - token?: string; - realmAccess?: { roles: string[] }; - updateToken: jest.Mock; - login: jest.Mock; - logout: jest.Mock; - loadUserProfile: jest.Mock; - accountManagement: jest.Mock; + let httpMock: HttpTestingController; + let appService: jest.Mocked>; + let originalLocation: Location; + let storageMock: { + getItem: jest.Mock; + setItem: jest.Mock; + removeItem: jest.Mock; + }; + + const createToken = (expiresInSeconds: number): string => { + const payload = { + sub: 'oidc-user-id', + preferred_username: 'tester', + exp: Math.floor(Date.now() / 1000) + expiresInSeconds + }; + return `header.${Buffer.from(JSON.stringify(payload)).toString('base64url')}.signature`; }; - let appService: jest.Mocked; beforeEach(() => { - keycloak = { - authenticated: false, - tokenParsed: { sub: 'token-user' }, - idTokenParsed: { sub: 'user-1' }, - token: 'keycloak-token', - realmAccess: { roles: ['user'] }, - updateToken: jest.fn().mockResolvedValue(true), - login: jest.fn().mockResolvedValue(undefined), - logout: jest.fn().mockResolvedValue(undefined), - loadUserProfile: jest.fn().mockResolvedValue({ username: 'test' }), - accountManagement: jest.fn().mockResolvedValue(undefined) + originalLocation = window.location; + Object.defineProperty(window, 'location', { + value: { + ...originalLocation, + href: 'http://localhost/' + }, + writable: true + }); + + storageMock = { + getItem: jest.fn().mockReturnValue(null), + setItem: jest.fn(), + removeItem: jest.fn() }; + Object.defineProperty(window, 'localStorage', { + value: storageMock, + writable: true + }); appService = { + serverUrl: 'http://localhost:3333/api/', reAuthenticationReturnUrl: '/coding', createLoginRedirectUri: jest.fn().mockReturnValue('http://localhost/#/coding'), markExplicitLogoutInProgress: jest.fn(), clearAuthState: jest.fn() - } as unknown as jest.Mocked; + }; TestBed.configureTestingModule({ providers: [ + provideHttpClient(), + provideHttpClientTesting(), AuthService, - { provide: Keycloak, useValue: keycloak }, { provide: AppService, useValue: appService } ] }); service = TestBed.inject(AuthService); + httpMock = TestBed.inject(HttpTestingController); + }); + + afterEach(() => { + httpMock.verify(); + Object.defineProperty(window, 'location', { + value: originalLocation, + writable: true + }); }); it('should be created', () => { expect(service).toBeTruthy(); }); - it('should refresh and return the current Keycloak token for authenticated users', async () => { - keycloak.authenticated = true; + it('should redirect to the backend login endpoint with a sanitized return URL', () => { + service.login('/workspace-admin/1'); - await expect(service.getValidToken()).resolves.toBe('keycloak-token'); - - expect(keycloak.updateToken).toHaveBeenCalledWith(30); + expect(appService.createLoginRedirectUri).toHaveBeenCalledWith('/workspace-admin/1'); + expect(window.location.href).toBe( + 'http://localhost:3333/api/auth/login?redirect_uri=http%3A%2F%2Flocalhost%2F%23%2Fcoding' + ); }); - it('should not return a token when Keycloak is not authenticated', async () => { - await expect(service.getValidToken()).resolves.toBeUndefined(); + it('should fall back to the stored reauthentication return URL during login', () => { + service.login(); - expect(keycloak.updateToken).not.toHaveBeenCalled(); + expect(appService.createLoginRedirectUri).toHaveBeenCalledWith('/coding'); }); - it('should read the stable identity from the access token first', () => { - expect(service.getIdentity()).toBe('token-user'); + it('should clear local auth state before logout', () => { + service.logout(); + + expect(appService.markExplicitLogoutInProgress).toHaveBeenCalled(); + expect(appService.clearAuthState).toHaveBeenCalledWith({ clearReAuthentication: true }); }); - it('should pass a sanitized return URL as Keycloak redirect URI', async () => { - await service.login('/workspace-admin/1'); + it('should exchange one-time login codes through the backend', () => { + service.exchangeLoginCode('exchange-code').subscribe(response => { + expect(response.access_token).toBe('access-token'); + }); - expect(appService.createLoginRedirectUri).toHaveBeenCalledWith('/workspace-admin/1'); - expect(keycloak.login).toHaveBeenCalledWith({ redirectUri: 'http://localhost/#/coding' }); + const req = httpMock.expectOne('http://localhost:3333/api/auth/exchange'); + expect(req.request.method).toBe('POST'); + expect(req.request.body).toEqual({ code: 'exchange-code' }); + req.flush({ + access_token: 'access-token', + token_type: 'Bearer', + expires_in: 3600 + }); }); - it('should fall back to the stored reauthentication return URL during login', async () => { - await service.login(); + it('should refresh expired access tokens with the stored refresh token', async () => { + const expiredToken = createToken(-30); + const freshToken = createToken(300); + storageMock.getItem.mockImplementation((key: string) => ({ + auth_token: expiredToken, + refresh_token: 'refresh-token' + })[key] ?? null); + + const tokenPromise = service.getValidToken(); + + const req = httpMock.expectOne('http://localhost:3333/api/auth/refresh'); + expect(req.request.method).toBe('POST'); + expect(req.request.body).toEqual({ refresh_token: 'refresh-token' }); + req.flush({ + access_token: freshToken, + token_type: 'Bearer', + expires_in: 300, + id_token: 'fresh-id-token', + refresh_token: 'rotated-refresh-token' + }); - expect(appService.createLoginRedirectUri).toHaveBeenCalledWith('/coding'); + await expect(tokenPromise).resolves.toBe(freshToken); + expect(storageMock.setItem).toHaveBeenCalledWith('auth_token', freshToken); + expect(storageMock.setItem).toHaveBeenCalledWith('id_token', 'fresh-id-token'); + expect(storageMock.setItem).toHaveBeenCalledWith('refresh_token', 'rotated-refresh-token'); }); - it('should login without options when there is no return URL', async () => { - appService.reAuthenticationReturnUrl = undefined; - appService.createLoginRedirectUri.mockReturnValue(undefined); - - await service.login(); + it('should clear stored tokens when refresh fails', async () => { + storageMock.getItem.mockImplementation((key: string) => ({ + auth_token: createToken(-30), + refresh_token: 'refresh-token' + })[key] ?? null); - expect(keycloak.login).toHaveBeenCalledWith(undefined); - }); + const tokenPromise = service.getValidToken(); - it('should mark explicit logout and clear local auth state before Keycloak logout', async () => { - await service.logout(); + const req = httpMock.expectOne('http://localhost:3333/api/auth/refresh'); + req.flush('Unauthorized', { status: 401, statusText: 'Unauthorized' }); - expect(appService.markExplicitLogoutInProgress).toHaveBeenCalled(); - expect(appService.clearAuthState).toHaveBeenCalledWith({ clearReAuthentication: true }); - expect(keycloak.logout).toHaveBeenCalledWith({ redirectUri: window.location.origin }); + await expect(tokenPromise).resolves.toBeUndefined(); + expect(storageMock.removeItem).toHaveBeenCalledWith('auth_token'); + expect(storageMock.removeItem).toHaveBeenCalledWith('id_token'); + expect(storageMock.removeItem).toHaveBeenCalledWith('refresh_token'); }); }); diff --git a/apps/frontend/src/app/core/services/auth.service.ts b/apps/frontend/src/app/core/services/auth.service.ts old mode 100755 new mode 100644 index aba9d5319..07f406bba --- a/apps/frontend/src/app/core/services/auth.service.ts +++ b/apps/frontend/src/app/core/services/auth.service.ts @@ -1,66 +1,216 @@ import { inject, Injectable } from '@angular/core'; -import Keycloak, { KeycloakProfile, KeycloakTokenParsed } from 'keycloak-js'; +import { HttpClient } from '@angular/common/http'; +import { BehaviorSubject, firstValueFrom, Observable } from 'rxjs'; +import { jwtDecode } from 'jwt-decode'; import { AppService } from './app.service'; +import { AuthExchangeResponse, DecodedToken, UserProfile } from './auth.models'; @Injectable({ providedIn: 'root' }) export class AuthService { - private readonly keycloak = inject(Keycloak); + private readonly http = inject(HttpClient); private readonly appService = inject(AppService); - getLoggedUser(): KeycloakTokenParsed | undefined { - try { - return this.keycloak.tokenParsed || this.keycloak.idTokenParsed; - } catch (e) { - return undefined; + private readonly tokenKey = 'auth_token'; + private readonly idTokenKey = 'id_token'; + private readonly refreshTokenKey = 'refresh_token'; + private refreshInFlight?: Promise; + private isAuthenticatedSubject = new BehaviorSubject(this.hasStoredSession()); + + constructor() { + this.checkTokenValidity(); + } + + getLoggedUser(): DecodedToken | undefined { + const token = this.getToken(); + if (token) { + try { + return jwtDecode(token); + } catch { + return undefined; + } } + return undefined; } - getToken() { - const token = this.keycloak.token; - return token; + getToken(): string | null { + return localStorage.getItem(this.tokenKey); } async getValidToken(minValidity = 30): Promise { - if (!this.keycloak.authenticated) { - return undefined; + const token = this.getToken(); + if (!token) { + return this.refreshAccessToken(); } - await this.keycloak.updateToken(minValidity); - return this.keycloak.token; + if (this.isTokenValid(token, Math.max(0, minValidity))) { + return token; + } + + return this.refreshAccessToken(); + } + + getIdToken(): string | null { + return localStorage.getItem(this.idTokenKey); } - getIdentity(): string | undefined { - return this.keycloak.tokenParsed?.sub || this.keycloak.idTokenParsed?.sub; + getRefreshToken(): string | null { + return localStorage.getItem(this.refreshTokenKey); } - isLoggedIn(): boolean | undefined { - return this.keycloak.authenticated; + isLoggedIn(): boolean { + const hasStoredSession = this.hasStoredSession(); + if (hasStoredSession !== this.isAuthenticatedSubject.value) { + this.isAuthenticatedSubject.next(hasStoredSession); + } + return hasStoredSession; } - loadUserProfile(): Promise { - return this.keycloak.loadUserProfile(); + loadUserProfile(): Promise { + const decodedToken = this.getLoggedUser(); + if (decodedToken) { + return Promise.resolve({ + id: decodedToken.sub, + username: decodedToken.preferred_username, + email: decodedToken.email, + firstName: decodedToken.given_name, + lastName: decodedToken.family_name + }); + } + return Promise.reject(new Error('No valid token found')); } - async login(returnUrl?: string): Promise { + login(returnUrl?: string): void { const redirectUri = this.appService.createLoginRedirectUri(returnUrl || this.appService.reAuthenticationReturnUrl); - await this.keycloak.login(redirectUri ? { redirectUri } : undefined); + window.location.href = `${this.appService.serverUrl}auth/login?redirect_uri=${encodeURIComponent(redirectUri)}`; + } + + exchangeLoginCode(code: string): Observable { + return this.http.post(`${this.appService.serverUrl}auth/exchange`, { code }); + } + + refreshToken(refreshToken: string): Observable { + return this.http.post(`${this.appService.serverUrl}auth/refresh`, { refresh_token: refreshToken }); } - async logout(): Promise { + logout(): void { + const refreshToken = this.getRefreshToken(); this.appService.markExplicitLogoutInProgress(); this.appService.clearAuthState({ clearReAuthentication: true }); - await this.keycloak.logout({ redirectUri: window.location.origin }); + this.isAuthenticatedSubject.next(false); + + if (refreshToken) { + this.http.post(`${this.appService.serverUrl}auth/logout`, { refresh_token: refreshToken }).subscribe({ + next: () => { + window.location.href = window.location.origin; + }, + error: () => { + window.location.href = window.location.origin; + } + }); + } else { + window.location.href = window.location.origin; + } } - async redirectToProfile(): Promise { - await this.keycloak.accountManagement(); + redirectToProfile(): void { + const redirectUri = encodeURIComponent(window.location.origin); + window.location.href = `${this.appService.serverUrl}auth/profile?redirect_uri=${redirectUri}`; } getRoles(): string[] { - if (this.keycloak.realmAccess) { - return this.keycloak.realmAccess.roles; + const decodedToken = this.getLoggedUser(); + return decodedToken?.realm_access?.roles || []; + } + + setToken(token: string): void { + localStorage.setItem(this.tokenKey, token); + this.isAuthenticatedSubject.next(true); + } + + setIdToken(idToken: string): void { + localStorage.setItem(this.idTokenKey, idToken); + } + + setRefreshToken(refreshToken: string): void { + localStorage.setItem(this.refreshTokenKey, refreshToken); + } + + hasValidToken(): boolean { + const token = this.getToken(); + if (!token) { + return false; + } + + return this.isTokenValid(token); + } + + private async refreshAccessToken(): Promise { + if (this.refreshInFlight) { + return this.refreshInFlight; + } + + const refreshToken = this.getRefreshToken(); + if (!refreshToken) { + this.clearStoredTokens(); + return undefined; + } + + this.refreshInFlight = firstValueFrom(this.refreshToken(refreshToken)) + .then(tokenResponse => { + this.setToken(tokenResponse.access_token); + + if (tokenResponse.id_token) { + this.setIdToken(tokenResponse.id_token); + } + + if (tokenResponse.refresh_token) { + this.setRefreshToken(tokenResponse.refresh_token); + } + + return tokenResponse.access_token; + }) + .catch(() => { + this.clearStoredTokens(); + return undefined; + }) + .finally(() => { + this.refreshInFlight = undefined; + }); + + return this.refreshInFlight; + } + + private hasStoredSession(): boolean { + return this.hasValidToken() || !!this.getRefreshToken(); + } + + private clearStoredTokens(): void { + localStorage.removeItem(this.tokenKey); + localStorage.removeItem(this.idTokenKey); + localStorage.removeItem(this.refreshTokenKey); + this.isAuthenticatedSubject.next(false); + } + + private isTokenValid(token: string, minValidity = 0): boolean { + try { + const decoded = jwtDecode(token); + const now = Date.now() / 1000; + return decoded.exp ? decoded.exp > now + minValidity : false; + } catch { + return false; + } + } + + private checkTokenValidity(): void { + const token = this.getToken(); + if (token && !this.hasValidToken()) { + localStorage.removeItem(this.tokenKey); + localStorage.removeItem(this.idTokenKey); + } + + if (!this.hasStoredSession()) { + this.clearStoredTokens(); } - return []; } } diff --git a/apps/frontend/src/app/core/services/keycloak-session-events.spec.ts b/apps/frontend/src/app/core/services/keycloak-session-events.spec.ts deleted file mode 100644 index 9dec124b5..000000000 --- a/apps/frontend/src/app/core/services/keycloak-session-events.spec.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { Router } from '@angular/router'; -import { - KeycloakEvent, - KeycloakEventType -} from 'keycloak-angular'; -import { AppService } from './app.service'; -import { handleKeycloakSessionEvent } from './keycloak-session-events'; - -describe('handleKeycloakSessionEvent', () => { - let appService: jest.Mocked; - let router: jest.Mocked; - - beforeEach(() => { - appService = { - hasStoredAuthToken: jest.fn(), - requireReAuthentication: jest.fn(), - clearAuthState: jest.fn(), - setNeedsReAuthentication: jest.fn(), - setSessionExpiryWarning: jest.fn(), - consumeExplicitLogoutInProgress: jest.fn(), - normalizeInternalRoute: jest.fn((returnUrl?: string) => ( - returnUrl && returnUrl.startsWith('/home') ? undefined : returnUrl - )), - reAuthenticationReturnUrl: undefined - } as unknown as jest.Mocked; - - router = { - url: '/workspace-admin/1' - } as unknown as jest.Mocked; - }); - - it('should require reauthentication on refresh errors even when no backend token is stored', () => { - appService.hasStoredAuthToken.mockReturnValue(false); - - handleKeycloakSessionEvent( - { type: KeycloakEventType.AuthRefreshError } as KeycloakEvent, - appService, - router - ); - - expect(appService.requireReAuthentication).toHaveBeenCalledWith('/workspace-admin/1'); - expect(appService.clearAuthState).not.toHaveBeenCalled(); - }); - - it('should clear state silently for explicit logouts', () => { - appService.consumeExplicitLogoutInProgress.mockReturnValue(true); - - handleKeycloakSessionEvent( - { type: KeycloakEventType.AuthLogout } as KeycloakEvent, - appService, - router - ); - - expect(appService.clearAuthState).toHaveBeenCalledWith({ clearReAuthentication: true }); - expect(appService.requireReAuthentication).not.toHaveBeenCalled(); - }); - - it('should require reauthentication for non-explicit logout events', () => { - appService.consumeExplicitLogoutInProgress.mockReturnValue(false); - - handleKeycloakSessionEvent( - { type: KeycloakEventType.AuthLogout } as KeycloakEvent, - appService, - router - ); - - expect(appService.requireReAuthentication).toHaveBeenCalledWith('/workspace-admin/1'); - }); - - it('should require reauthentication when Keycloak is ready unauthenticated with a stale backend token', () => { - appService.hasStoredAuthToken.mockReturnValue(true); - - handleKeycloakSessionEvent( - { type: KeycloakEventType.Ready, args: false } as KeycloakEvent, - appService, - router - ); - - expect(appService.requireReAuthentication).toHaveBeenCalledWith('/workspace-admin/1'); - }); - - it('should clear reauthentication state after successful authentication', () => { - handleKeycloakSessionEvent( - { type: KeycloakEventType.AuthSuccess } as KeycloakEvent, - appService, - router - ); - - expect(appService.setNeedsReAuthentication).toHaveBeenCalledWith(false); - expect(appService.setSessionExpiryWarning).toHaveBeenCalledWith(false); - }); - - it('should keep reauthentication state after token refresh success', () => { - handleKeycloakSessionEvent( - { type: KeycloakEventType.AuthRefreshSuccess } as KeycloakEvent, - appService, - router - ); - - expect(appService.setNeedsReAuthentication).not.toHaveBeenCalled(); - expect(appService.setSessionExpiryWarning).toHaveBeenCalledWith(false); - }); -}); diff --git a/apps/frontend/src/app/core/services/keycloak-session-events.ts b/apps/frontend/src/app/core/services/keycloak-session-events.ts deleted file mode 100644 index eb847e922..000000000 --- a/apps/frontend/src/app/core/services/keycloak-session-events.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { Router } from '@angular/router'; -import { - KeycloakEvent, - KeycloakEventType, - ReadyArgs, - typeEventArgs -} from 'keycloak-angular'; -import { AppService } from './app.service'; - -function getReturnUrl(router: Router, appService: AppService): string | undefined { - return appService.normalizeInternalRoute(router.url) || appService.reAuthenticationReturnUrl; -} - -export function handleKeycloakSessionEvent( - keycloakEvent: KeycloakEvent, - appService: AppService, - router: Router -): void { - switch (keycloakEvent.type) { - case KeycloakEventType.Ready: { - const authenticated = typeEventArgs(keycloakEvent.args); - if (!authenticated && appService.hasStoredAuthToken()) { - appService.requireReAuthentication(getReturnUrl(router, appService)); - } - break; - } - case KeycloakEventType.AuthLogout: - if (appService.consumeExplicitLogoutInProgress()) { - appService.clearAuthState({ clearReAuthentication: true }); - } else { - appService.requireReAuthentication(getReturnUrl(router, appService)); - } - break; - case KeycloakEventType.AuthRefreshError: - appService.requireReAuthentication(getReturnUrl(router, appService)); - break; - case KeycloakEventType.AuthSuccess: - appService.setSessionExpiryWarning(false); - appService.setNeedsReAuthentication(false); - break; - case KeycloakEventType.AuthRefreshSuccess: - appService.setSessionExpiryWarning(false); - break; - default: - break; - } -} diff --git a/apps/frontend/src/app/core/services/system-settings.service.spec.ts b/apps/frontend/src/app/core/services/system-settings.service.spec.ts index fc41e8279..3822715d7 100644 --- a/apps/frontend/src/app/core/services/system-settings.service.spec.ts +++ b/apps/frontend/src/app/core/services/system-settings.service.spec.ts @@ -38,22 +38,22 @@ describe('SystemSettingsService', () => { req.flush({ html: '

Text

', isDefault: false }); }); - it('updates the legal notice without service-owned auth headers', () => { + it('updates the legal notice with the stored auth token', () => { service.updateLegalNotice({ html: '

Updated

' }).subscribe(); const req = httpMock.expectOne(`${serverUrl}legal-notice`); expect(req.request.method).toBe('PUT'); - expect(req.request.headers.get('Authorization')).toBeNull(); + expect(req.request.headers.get('Authorization')).toBe('Bearer token'); expect(req.request.body).toEqual({ html: '

Updated

' }); req.flush({ html: '

Updated

', isDefault: false }); }); - it('resets the legal notice without service-owned auth headers', () => { + it('resets the legal notice with the stored auth token', () => { service.resetLegalNotice().subscribe(); const req = httpMock.expectOne(`${serverUrl}legal-notice`); expect(req.request.method).toBe('DELETE'); - expect(req.request.headers.get('Authorization')).toBeNull(); + expect(req.request.headers.get('Authorization')).toBe('Bearer token'); req.flush({ html: '

Default

', isDefault: true }); }); }); diff --git a/apps/frontend/src/app/core/services/system-settings.service.ts b/apps/frontend/src/app/core/services/system-settings.service.ts index e8ccc4c77..3b880ee70 100644 --- a/apps/frontend/src/app/core/services/system-settings.service.ts +++ b/apps/frontend/src/app/core/services/system-settings.service.ts @@ -19,7 +19,7 @@ export class SystemSettingsService { private readonly serverUrl = inject(SERVER_URL); private get authHeader() { - return {}; + return { Authorization: `Bearer ${localStorage.getItem('auth_token')}` }; } getContentPoolSettings(): Observable { diff --git a/apps/frontend/src/app/replay/components/replay/replay.component.spec.ts b/apps/frontend/src/app/replay/components/replay/replay.component.spec.ts index 35301b2e4..4f73a93b3 100755 --- a/apps/frontend/src/app/replay/components/replay/replay.component.spec.ts +++ b/apps/frontend/src/app/replay/components/replay/replay.component.spec.ts @@ -135,6 +135,8 @@ describe('ReplayComponent', () => { let codingJobBackendServiceMock: { getCodingJobUnits: jest.Mock; updateCodingJob: jest.Mock; + updateCodingJobStatus: jest.Mock; + updateCodingJobComment: jest.Mock; pauseCodingJob: jest.Mock; resumeCodingJob: jest.Mock; submitCodingJob: jest.Mock; @@ -165,6 +167,8 @@ describe('ReplayComponent', () => { codingJobBackendServiceMock = { getCodingJobUnits: jest.fn().mockReturnValue(of([])), updateCodingJob: jest.fn().mockReturnValue(of({})), + updateCodingJobStatus: jest.fn().mockReturnValue(of({})), + updateCodingJobComment: jest.fn().mockReturnValue(of({})), pauseCodingJob: jest.fn().mockReturnValue(of({})), resumeCodingJob: jest.fn().mockReturnValue(of({})), submitCodingJob: jest.fn().mockReturnValue(of({})), diff --git a/apps/frontend/src/app/replay/components/replay/replay.component.ts b/apps/frontend/src/app/replay/components/replay/replay.component.ts index 7f0a4f376..be05c3a70 100755 --- a/apps/frontend/src/app/replay/components/replay/replay.component.ts +++ b/apps/frontend/src/app/replay/components/replay/replay.component.ts @@ -937,7 +937,7 @@ export class ReplayComponent implements OnInit, OnDestroy, OnChanges { } private getWorkspaceIdFromToken(): number | null { - const candidateTokens = [this.authToken] + const candidateTokens = [this.authToken, localStorage.getItem('auth_token')] .filter((token): token is string => !!token); for (const token of candidateTokens) { diff --git a/apps/frontend/src/app/replay/services/replay-backend.service.spec.ts b/apps/frontend/src/app/replay/services/replay-backend.service.spec.ts index f24765e37..19b9f3251 100644 --- a/apps/frontend/src/app/replay/services/replay-backend.service.spec.ts +++ b/apps/frontend/src/app/replay/services/replay-backend.service.spec.ts @@ -66,7 +66,7 @@ describe('ReplayBackendService', () => { const req = httpMock.expectOne(`${mockServerUrl}admin/workspace/1/replay-statistics`); expect(req.request.method).toBe('POST'); expect(req.request.body).toEqual(data); - expect(req.request.headers.get('Authorization')).toBeNull(); + expect(req.request.headers.get('Authorization')).toBe('Bearer mock-token'); expect(req.request.context.get(SUPPRESS_GLOBAL_HTTP_ERROR)).toBe(true); expect(req.request.context.get(SUPPRESS_AUTH_ERROR_REDIRECT)).toBe(true); req.flush({}); @@ -140,7 +140,7 @@ describe('ReplayBackendService', () => { }); describe('getReplayAssets', () => { - it('should fetch assets without a service-owned auth token when no URL token is supplied', () => { + it('should fetch assets with the stored auth token when no URL token is supplied', () => { service.getReplayAssets(1, 'unit-1').subscribe(); const req = httpMock.expectOne(request => { @@ -151,7 +151,7 @@ describe('ReplayBackendService', () => { expect(req.request.urlWithParams).toBe( `${mockServerUrl}admin/workspace/1/replay-assets/unit-1?replayPart=assets` ); - expect(req.request.headers.get('Authorization')).toBeNull(); + expect(req.request.headers.get('Authorization')).toBe('Bearer mock-token'); req.flush({ unitDef: [], player: [], vocs: [] }); }); }); diff --git a/apps/frontend/src/app/replay/services/replay-backend.service.ts b/apps/frontend/src/app/replay/services/replay-backend.service.ts index bd5a48e43..8f0d31943 100644 --- a/apps/frontend/src/app/replay/services/replay-backend.service.ts +++ b/apps/frontend/src/app/replay/services/replay-backend.service.ts @@ -71,7 +71,7 @@ export class ReplayBackendService { private http = inject(HttpClient); private get authHeader() { - return {}; + return { Authorization: `Bearer ${localStorage.getItem('auth_token')}` }; } storeReplayStatistics( diff --git a/apps/frontend/src/app/replay/services/replay-coding.service.spec.ts b/apps/frontend/src/app/replay/services/replay-coding.service.spec.ts index 085515bfc..65361383a 100644 --- a/apps/frontend/src/app/replay/services/replay-coding.service.spec.ts +++ b/apps/frontend/src/app/replay/services/replay-coding.service.spec.ts @@ -15,6 +15,8 @@ describe('ReplayCodingService', () => { beforeEach(() => { codingJobBackendServiceMock = { updateCodingJob: jest.fn(), + updateCodingJobStatus: jest.fn(), + updateCodingJobComment: jest.fn(), pauseCodingJob: jest.fn(), resumeCodingJob: jest.fn(), submitCodingJob: jest.fn(), @@ -52,21 +54,22 @@ describe('ReplayCodingService', () => { }); describe('updateCodingJobStatus', () => { - it('should resume active status via dedicated backend endpoint', async () => { - codingJobBackendServiceMock.resumeCodingJob.mockReturnValue(of({} as CodingJob)); + it('should update status via backend', async () => { + codingJobBackendServiceMock.updateCodingJobStatus.mockReturnValue(of({} as CodingJob)); await service.updateCodingJobStatus(1, 100, 'active'); - expect(codingJobBackendServiceMock.resumeCodingJob).toHaveBeenCalledWith(1, 100); + expect(codingJobBackendServiceMock.updateCodingJobStatus).toHaveBeenCalledWith(1, 100, 'active'); }); it('should pass the replay auth token to backend status updates', async () => { - codingJobBackendServiceMock.resumeCodingJob.mockReturnValue(of({} as CodingJob)); + codingJobBackendServiceMock.updateCodingJobStatus.mockReturnValue(of({} as CodingJob)); service.setAuthToken('replay-token'); await service.updateCodingJobStatus(1, 100, 'active'); - expect(codingJobBackendServiceMock.resumeCodingJob).toHaveBeenCalledWith( + expect(codingJobBackendServiceMock.updateCodingJobStatus).toHaveBeenCalledWith( 1, 100, + 'active', 'replay-token' ); }); @@ -674,7 +677,7 @@ describe('ReplayCodingService', () => { await service.pauseCodingJob(1, 100); - expect(codingJobBackendServiceMock.updateCodingJob).not.toHaveBeenCalled(); + expect(codingJobBackendServiceMock.updateCodingJobStatus).not.toHaveBeenCalled(); }); it('uses keepalive status update for unload pauses', () => { diff --git a/apps/frontend/src/app/replay/services/replay-coding.service.ts b/apps/frontend/src/app/replay/services/replay-coding.service.ts index a0f5d1442..8d230cbd3 100644 --- a/apps/frontend/src/app/replay/services/replay-coding.service.ts +++ b/apps/frontend/src/app/replay/services/replay-coding.service.ts @@ -106,13 +106,8 @@ export class ReplayCodingService { return this.authToken ? [this.authToken] : []; } - async updateCodingJobStatus(workspaceId: number, jobId: number, status: 'active' | 'paused' | 'completed' | 'open') { + async updateCodingJobStatus(workspaceId: number, jobId: number, status: 'active' | 'paused' | 'completed') { if (this.isReviewMode) return Promise.resolve(undefined); - if (status === 'active') { - return firstValueFrom( - this.codingJobBackendService.resumeCodingJob(workspaceId, jobId, ...this.authTokenArg) - ); - } if (status === 'paused') { return firstValueFrom( this.codingJobBackendService.pauseCodingJob(workspaceId, jobId, ...this.authTokenArg) @@ -123,8 +118,9 @@ export class ReplayCodingService { this.codingJobBackendService.submitCodingJob(workspaceId, jobId, ...this.authTokenArg) ); } + return firstValueFrom( - this.codingJobBackendService.updateCodingJob(workspaceId, jobId, { status }, ...this.authTokenArg) + this.codingJobBackendService.updateCodingJobStatus(workspaceId, jobId, status, ...this.authTokenArg) ); } @@ -609,7 +605,7 @@ export class ReplayCodingService { try { this.codingJobComment = comment; await firstValueFrom( - this.codingJobBackendService.updateCodingJob(workspaceId, this.codingJobId, { comment }, ...this.authTokenArg) + this.codingJobBackendService.updateCodingJobComment(workspaceId, this.codingJobId, comment, ...this.authTokenArg) ); } catch (error) { // Ignore errors when saving comment diff --git a/apps/frontend/src/app/services/backend.service.ts b/apps/frontend/src/app/services/backend.service.ts new file mode 100755 index 000000000..dccfebf9f --- /dev/null +++ b/apps/frontend/src/app/services/backend.service.ts @@ -0,0 +1,1395 @@ +import { Injectable, inject } from '@angular/core'; +import { HttpClient, HttpParams } from '@angular/common/http'; +import { catchError, Observable, of } from 'rxjs'; +import { map } from 'rxjs/operators'; +import { VariableInfo } from '@iqbspecs/variable-info/variable-info.interface'; +import { FilesInListDto } from 'api-dto/files/files-in-list.dto'; +import { UnitNoteDto } from 'api-dto/unit-notes/unit-note.dto'; +import { UnitTagDto } from 'api-dto/unit-tags/unit-tag.dto'; +import { CreateUnitTagDto } from 'api-dto/unit-tags/create-unit-tag.dto'; +import { CreateWorkspaceDto } from 'api-dto/workspaces/create-workspace-dto'; +import { PaginatedWorkspacesDto } from 'api-dto/workspaces/paginated-workspaces-dto'; +import { CodingJob, Variable, VariableBundle } from '../coding/models/coding-job.model'; +import { AppService } from './app.service'; +import { TestGroupsInfoDto } from '../../../../../api-dto/files/test-groups-info.dto'; +import { SERVER_URL } from '../injection-tokens'; +import { UserService } from './user.service'; +import { WorkspaceService } from './workspace.service'; +import { FileService, BookletUnit } from './file.service'; +import { CodingService } from './coding.service'; +import { UnitTagService } from './unit-tag.service'; +import { UnitNoteService } from './unit-note.service'; +import { ResponseService } from './response.service'; +import { TestResultService, PersonTestResult } from './test-result.service'; +import { ResourcePackageService } from './resource-package.service'; +import { ValidationService } from './validation.service'; +import { UnitService } from './unit.service'; +import { ImportService, ImportOptions, Result } from './import.service'; +import { AuthenticationService, ServerResponse } from './authentication.service'; +import { VariableAnalysisService, VariableAnalysisResultDto } from './variable-analysis.service'; +import { VariableAnalysisJobDto } from '../models/variable-analysis-job.dto'; +import { ValidationTaskDto } from '../models/validation-task.dto'; +import { FilesDto } from '../../../../../api-dto/files/files.dto'; +import { CreateUnitNoteDto } from '../../../../../api-dto/unit-notes/create-unit-note.dto'; +import { WorkspaceFullDto } from '../../../../../api-dto/workspaces/workspace-full-dto'; +import { CodingStatistics } from '../../../../../api-dto/coding/coding-statistics'; +import { FileValidationResultDto } from '../../../../../api-dto/files/file-validation-result.dto'; +import { FileDownloadDto } from '../../../../../api-dto/files/file-download.dto'; +import { PaginatedWorkspaceUserDto } from '../../../../../api-dto/workspaces/paginated-workspace-user-dto'; +import { UserFullDto } from '../../../../../api-dto/user/user-full-dto'; +import { CreateUserDto } from '../../../../../api-dto/user/create-user-dto'; +import { UserWorkspaceAccessDto } from '../../../../../api-dto/workspaces/user-workspace-access-dto'; +import { UserInListDto } from '../../../../../api-dto/user/user-in-list-dto'; +import { ResourcePackageDto } from '../../../../../api-dto/resource-package/resource-package-dto'; +import { TestTakersValidationDto } from '../../../../../api-dto/files/testtakers-validation.dto'; +import { ResponseDto } from '../../../../../api-dto/responses/response-dto'; +import { InvalidVariableDto } from '../../../../../api-dto/files/variable-validation.dto'; +import { BookletInfoDto } from '../../../../../api-dto/booklet-info/booklet-info.dto'; +import { UnitInfoDto } from '../../../../../api-dto/unit-info/unit-info.dto'; +import { CodeBookContentSetting } from '../../../../../api-dto/coding/codebook-content-setting'; +import { UnitVariableDetailsDto } from '../models/unit-variable-details.dto'; +import { MissingsProfilesDto } from '../../../../../api-dto/coding/missings-profiles.dto'; +import { VariableAnalysisItemDto } from '../../../../../api-dto/coding/variable-analysis-item.dto'; +import { ResponseEntity } from '../shared/models/response-entity.model'; + +type ReplayStatisticsResponse = { + id: number; + timestamp: string; + workspaceId: number; + unitId: string; + bookletId?: string; + testPersonLogin?: string; + testPersonCode?: string; + durationMilliseconds: number; + replayUrl?: string; + success?: boolean; + errorMessage?: string; +}; + +type AuthResponse = Required>; + +interface JobDefinitionApiResponse { + id?: number; + status?: 'draft' | 'pending_review' | 'approved'; + assigned_variables?: import('../coding/models/coding-job.model').Variable[]; + assigned_variable_bundles?: import('../coding/models/coding-job.model').VariableBundle[]; + assigned_coders?: number[]; + duration_seconds?: number; + max_coding_cases?: number; + double_coding_absolute?: number; + double_coding_percentage?: number; + case_ordering_mode?: 'continuous' | 'alternating'; + created_at?: Date; + updated_at?: Date; +} + +interface JobDefinition { + id?: number; + status?: 'draft' | 'pending_review' | 'approved'; + assignedVariables?: import('../coding/models/coding-job.model').Variable[]; + assignedVariableBundles?: import('../coding/models/coding-job.model').VariableBundle[]; + assignedCoders?: number[]; + durationSeconds?: number; + maxCodingCases?: number; + doubleCodingAbsolute?: number; + doubleCodingPercentage?: number; + createdAt?: Date; + updatedAt?: Date; +} + +interface PaginatedResponse { + data: T[]; + total: number; + page: number; + limit: number; +} + +@Injectable({ + providedIn: 'root' +}) +export class BackendService { + readonly serverUrl = inject(SERVER_URL); + appService = inject(AppService); + private http = inject(HttpClient); + private userService = inject(UserService); + private workspaceService = inject(WorkspaceService); + private fileService = inject(FileService); + private codingService = inject(CodingService); + private unitTagService = inject(UnitTagService); + private unitNoteService = inject(UnitNoteService); + private responseService = inject(ResponseService); + private testResultService = inject(TestResultService); + private resourcePackageService = inject(ResourcePackageService); + private validationService = inject(ValidationService); + private unitService = inject(UnitService); + private importService = inject(ImportService); + private authenticationService = inject(AuthenticationService); + private variableAnalysisService = inject(VariableAnalysisService); + + authHeader = { Authorization: `Bearer ${localStorage.getItem('auth_token')}` }; + + getAuthData(): Observable { + return this.http.get( + `${this.serverUrl}auth-data`, + { headers: this.authHeader } + ); + } + + getDirectDownloadLink(): string { + return this.fileService.getDirectDownloadLink(); + } + + getUsers(workspaceId: number): Observable { + return this.userService.getUsers(workspaceId); + } + + saveUsers(workspaceId: number, users: UserWorkspaceAccessDto[]): Observable { + return this.userService.saveUsers(workspaceId, users); + } + + getUsersFull(): Observable { + return this.userService.getUsersFull(); + } + + addUser(newUser: CreateUserDto): Observable { + return this.userService.addUser(newUser); + } + + changeUserData(userId: number, newData: UserFullDto): Observable { + return this.userService.changeUserData(userId, newData); + } + + deleteUsers(users: number[]): Observable { + return this.userService.deleteUsers(users); + } + + getAllWorkspacesList(): Observable { + return this.workspaceService.getAllWorkspacesList(); + } + + getWorkspacesByUserList(userId: number): Observable { + return this.userService.getWorkspacesByUserList(userId); + } + + getWorkspaceUsers(workspaceId: number): Observable { + return this.workspaceService.getWorkspaceUsers(workspaceId); + } + + addWorkspace(workspaceData: CreateWorkspaceDto): Observable { + return this.workspaceService.addWorkspace(workspaceData); + } + + deleteWorkspace(ids: number[]): Observable { + return this.workspaceService.deleteWorkspace(ids); + } + + deleteFiles(workspaceId: number, fileIds: number[]): Observable { + return this.fileService.deleteFiles(workspaceId, fileIds); + } + + downloadFile(workspaceId: number, fileId: number): Observable { + return this.fileService.downloadFile(workspaceId, fileId); + } + + validateFiles(workspace_id: number): Observable { + return this.fileService.validateFiles(workspace_id); + } + + deleteTestPersons(workspace_id: number, testPersonIds: number[]): Observable { + return this.responseService.deleteTestPersons(workspace_id, testPersonIds); + } + + codeTestPersons(workspace_id: number, testPersonIds: number[]): Observable<{ + totalResponses: number; + statusCounts: { + [key: string]: number; + }; + jobId?: string; + message?: string; + }> { + return this.codingService.codeTestPersons(workspace_id, testPersonIds); + } + + getCodingJobStatus(workspace_id: number, jobId: string): Observable<{ + status: 'pending' | 'processing' | 'completed' | 'failed' | 'cancelled' | 'paused'; + progress: number; + result?: { + totalResponses: number; + statusCounts: { + [key: string]: number; + }; + }; + error?: string; + }> { + return this.codingService.getCodingJobStatus(workspace_id, jobId); + } + + getCodingListAsCsv(workspace_id: number): Observable { + return this.codingService.getCodingListAsCsv(workspace_id); + } + + getCodingListAsExcel(workspace_id: number): Observable { + return this.codingService.getCodingListAsExcel(workspace_id); + } + + getCodingResultsByVersion(workspace_id: number, version: 'v1' | 'v2' | 'v3', includeReplayUrls: boolean = false): Observable { + return this.codingService.getCodingResultsByVersion(workspace_id, version, includeReplayUrls); + } + + getCodingResultsByVersionAsExcel(workspace_id: number, version: 'v1' | 'v2' | 'v3', includeReplayUrls: boolean = false): Observable { + return this.codingService.getCodingResultsByVersionAsExcel(workspace_id, version, includeReplayUrls); + } + + getCodingStatistics(workspace_id: number, version: 'v1' | 'v2' | 'v3' = 'v1'): Observable { + return this.codingService.getCodingStatistics(workspace_id, version); + } + + createCodingStatisticsJob(workspace_id: number): Observable<{ jobId: string; message: string }> { + return this.codingService.createCodingStatisticsJob(workspace_id); + } + + getVariableAnalysis( + workspace_id: number, + page: number = 1, + limit: number = 100, + unitId?: string, + variableId?: string, + derivation?: string + ): Observable> { + return this.codingService.getVariableAnalysis(workspace_id, page, limit, unitId, variableId, derivation); + } + + getResponsesByStatus(workspace_id: number, status: string, version: 'v1' | 'v2' | 'v3' = 'v1', page: number = 1, limit: number = 100): Observable> { + return this.codingService.getResponsesByStatus(workspace_id, status, version, page, limit); + } + + getReplayUrl(workspaceId: number, responseId: number, authToken: string): Observable<{ replayUrl: string }> { + return this.codingService.getReplayUrl(workspaceId, responseId, authToken); + } + + resetCodingVersion( + workspace_id: number, + version: 'v1' | 'v2' | 'v3', + unitFilters?: string[], + variableFilters?: string[] + ): Observable<{ + affectedResponseCount: number; + cascadeResetVersions: ('v2' | 'v3')[]; + message: string; + }> { + return this.codingService.resetCodingVersion(workspace_id, version, unitFilters, variableFilters); + } + + changeWorkspace(workspaceData: WorkspaceFullDto): Observable { + return this.workspaceService.changeWorkspace(workspaceData); + } + + uploadTestFiles(workspaceId: number, files: FileList | FormData | null): Observable { + return this.fileService.uploadTestFiles(workspaceId, files); + } + + uploadTestResults( + workspaceId: number, + files: FileList | null, + resultType: 'logs' | 'responses', + overwriteExisting: boolean = true + ): Observable { + return this.fileService.uploadTestResults(workspaceId, files, resultType, overwriteExisting); + } + + setUserWorkspaceAccessRight(userId: number, workspaceIds: number[]): Observable { + return this.userService.setUserWorkspaceAccessRight(userId, workspaceIds); + } + + createUnitTag(workspaceId: number, createUnitTagDto: CreateUnitTagDto): Observable { + return this.unitTagService.createUnitTag(workspaceId, createUnitTagDto); + } + + deleteUnitTag(workspaceId: number, tagId: number): Observable { + return this.unitTagService.deleteUnitTag(workspaceId, tagId); + } + + createUnitNote(workspaceId: number, createUnitNoteDto: CreateUnitNoteDto): Observable { + return this.unitNoteService.createUnitNote(workspaceId, createUnitNoteDto); + } + + getUnitNotes(workspaceId: number, unitId: number): Observable { + return this.unitNoteService.getUnitNotes(workspaceId, unitId); + } + + getNotesForMultipleUnits(workspaceId: number, unitIds: number[]): Observable<{ [unitId: number]: UnitNoteDto[] }> { + return this.unitNoteService.getNotesForMultipleUnits(workspaceId, unitIds); + } + + deleteUnitNote(workspaceId: number, noteId: number): Observable { + return this.unitNoteService.deleteUnitNote(workspaceId, noteId); + } + + setWorkspaceUsersAccessRight(workspaceId: number, userIds: number[]): Observable { + return this.workspaceService.setWorkspaceUsersAccessRight(workspaceId, userIds); + } + + getFilesList( + workspaceId: number, + page: number = 1, + limit: number = 10000, + fileType?: string, + fileSize?: string, + searchText?: string + ): Observable & { fileTypes: string[] }> { + return this.fileService.getFilesList(workspaceId, page, limit, fileType, fileSize, searchText); + } + + getUnitDef(workspaceId: number, unit: string, authToken?: string): Observable { + return this.fileService.getUnitDef(workspaceId, unit, authToken); + } + + getPlayer(workspaceId: number, player: string, authToken?: string): Observable { + return this.fileService.getPlayer(workspaceId, player, authToken); + } + + getResponses(workspaceId: number, testPerson: string, unitId: string, authToken?: string): Observable { + return this.responseService.getResponses(workspaceId, testPerson, unitId, authToken); + } + + getUnit(workspaceId: number, unitId: string, authToken?: string): Observable { + return this.fileService.getUnit(workspaceId, unitId, authToken); + } + + getVocs(workspaceId: number, unitId: string, authToken?: string): Observable { + const headers = authToken ? { Authorization: `Bearer ${authToken}` } : this.authHeader; + const url = `${this.serverUrl}admin/workspace/${workspaceId}/files/coding-scheme/${unitId}`; + return this.http.get(url, { headers }).pipe( + map(fileDownload => { + if (!fileDownload) { + return []; + } + const data = fileDownload?.base64Data; + return [{ file_id: fileDownload?.filename, data }]; + }), + catchError(() => of([])) + ); + } + + getBookletUnits(workspaceId: number, bookletId: string, authToken?: string): Observable { + return this.fileService.getBookletUnits(workspaceId, bookletId, authToken); + } + + getBookletInfo(workspaceId: number, bookletId: string, authToken?: string): Observable { + return this.fileService.getBookletInfo(workspaceId, bookletId, authToken); + } + + getUnitInfo(workspaceId: number, unitId: string, authToken?: string): Observable { + return this.fileService.getUnitInfo(workspaceId, unitId, authToken); + } + + getTestPersons(workspaceId: number): Observable { + return this.testResultService.getTestPersons(workspaceId); + } + + getExportOptions(workspaceId: number): Observable<{ + testPersons: { id: number; code: string; groupName: string; login: string }[]; + booklets: string[]; + units: string[]; + }> { + const url = `${this.serverUrl}admin/workspace/${workspaceId}/results/export/options`; + return this.http.get<{ + testPersons: { id: number; code: string; groupName: string; login: string }[]; + booklets: string[]; + units: string[]; + }>(url, { + headers: this.authHeader + }); + } + + startExportTestResultsJob( + workspaceId: number, + filters?: { groupNames?: string[]; bookletNames?: string[]; unitNames?: string[]; personIds?: number[] } + ): Observable<{ jobId: string; message: string }> { + const url = `${this.serverUrl}admin/workspace/${workspaceId}/results/export/job`; + return this.http.post<{ jobId: string; message: string }>(url, filters || {}, { + headers: this.authHeader + }); + } + + getExportTestResultsJobs(workspaceId: number): Observable> { + const url = `${this.serverUrl}admin/workspace/${workspaceId}/results/export/jobs`; + return this.http.get>(url, { + headers: this.authHeader + }); + } + + downloadExportTestResultsJob(workspaceId: number, jobId: string): Observable { + const url = `${this.serverUrl}admin/workspace/${workspaceId}/results/export/jobs/${jobId}/download`; + return this.http.get(url, { + responseType: 'blob', + headers: this.authHeader + }); + } + + deleteTestResultExportJob(workspaceId: number, jobId: string): Observable<{ success: boolean; message: string }> { + const url = `${this.serverUrl}admin/workspace/${workspaceId}/results/export/jobs/${jobId}`; + return this.http.delete<{ success: boolean; message: string }>(url, { + headers: this.authHeader + }); + } + + getPersonTestResults(workspaceId: number, personId: number): Observable { + return this.testResultService.getPersonTestResults(workspaceId, personId); + } + + authenticate(username:string, password:string, server:string, url:string): Observable { + return this.authenticationService.authenticate(username, password, server, url) as Observable; + } + + importWorkspaceFiles(workspace_id: number, + testCenterWorkspace: string, + server:string, + url:string, + token:string, + importOptions:ImportOptions, + testGroups: string[], + overwriteExistingLogs:boolean = false + ): Observable { + return this.importService.importWorkspaceFiles( + workspace_id, + testCenterWorkspace, + server, + url, + token, + importOptions, + testGroups, + overwriteExistingLogs + ); + } + + importTestcenterGroups(workspace_id: number, + testCenterWorkspace: string, + server:string, + url:string, + authToken:string + ): Observable { + return this.importService.importTestcenterGroups( + workspace_id, + testCenterWorkspace, + server, + url, + authToken + ); + } + + getResourcePackages(workspaceId:number): Observable { + return this.resourcePackageService.getResourcePackages(workspaceId); + } + + deleteResourcePackages(workspaceId:number, ids: number[]): Observable { + return this.resourcePackageService.deleteResourcePackages(workspaceId, ids); + } + + downloadResourcePackage(workspaceId:number, name: string): Observable { + return this.resourcePackageService.downloadResourcePackage(workspaceId, name); + } + + uploadResourcePackage(workspaceId:number, file: File): Observable { + return this.resourcePackageService.uploadResourcePackage(workspaceId, file); + } + + getCodingSchemeFile(workspaceId: number, codingSchemeRef: string): Observable { + return this.fileService.getCodingSchemeFile(workspaceId, codingSchemeRef); + } + + getUnitContentXml(workspaceId: number, unitId: string): Observable { + return this.fileService.getUnitContentXml(workspaceId, unitId); + } + + searchResponses( + workspaceId: number, + searchParams: { value?: string; variableId?: string; unitName?: string; bookletName?: string; status?: string; codedStatus?: string; group?: string; code?: string; version?: 'v1' | 'v2' | 'v3' }, + page?: number, + limit?: number + ): Observable<{ + data: { + responseId: number; + variableId: string; + value: string; + status: string; + code?: number; + score?: number; + codedStatus?: string; + unitId: number; + unitName: string; + unitAlias: string | null; + bookletId: number; + bookletName: string; + personId: number; + personLogin: string; + personCode: string; + personGroup: string; + }[]; + total: number; + }> { + return this.responseService.searchResponses(workspaceId, searchParams, page, limit); + } + + searchBookletsByName( + workspaceId: number, + bookletName: string, + page?: number, + limit?: number + ): Observable<{ + data: { + bookletId: number; + bookletName: string; + personId: number; + personLogin: string; + personCode: string; + personGroup: string; + units: { + unitId: number; + unitName: string; + unitAlias: string | null; + }[]; + }[]; + total: number; + }> { + return this.testResultService.searchBookletsByName(workspaceId, bookletName, page, limit); + } + + searchUnitsByName( + workspaceId: number, + unitName: string, + page?: number, + limit?: number + ): Observable<{ + data: { + unitId: number; + unitName: string; + unitAlias: string | null; + bookletId: number; + bookletName: string; + personId: number; + personLogin: string; + personCode: string; + personGroup: string; + tags: { id: number; unitId: number; tag: string; color?: string; createdAt: Date }[]; + responses: { variableId: string; value: string; status: string; code?: number; score?: number; codedStatus?: string }[]; + }[]; + total: number; + }> { + return this.testResultService.searchUnitsByName(workspaceId, unitName, page, limit); + } + + deleteUnit(workspaceId: number, unitId: number): Observable<{ + success: boolean; + report: { + deletedUnit: number | null; + warnings: string[]; + }; + }> { + return this.unitService.deleteUnit(workspaceId, unitId); + } + + deleteMultipleUnits(workspaceId: number, unitIds: number[]): Observable<{ + success: boolean; + report: { + deletedUnits: number[]; + warnings: string[]; + }; + }> { + return this.unitService.deleteMultipleUnits(workspaceId, unitIds); + } + + deleteResponse(workspaceId: number, responseId: number): Observable<{ + success: boolean; + report: { + deletedResponse: number | null; + warnings: string[]; + }; + }> { + return this.responseService.deleteResponse(workspaceId, responseId); + } + + deleteBooklet(workspaceId: number, bookletId: number): Observable<{ + success: boolean; + report: { + deletedBooklet: number | null; + warnings: string[]; + }; + }> { + return this.testResultService.deleteBooklet(workspaceId, bookletId); + } + + validateVariables(workspaceId: number, page: number = 1, limit: number = 10): Observable> { + return this.validationService.validateVariables(workspaceId, page, limit); + } + + validateVariableTypes(workspaceId: number, page: number = 1, limit: number = 10): Observable> { + return this.validationService.validateVariableTypes(workspaceId, page, limit); + } + + validateResponseStatus(workspaceId: number, page: number = 1, limit: number = 10): Observable> { + return this.validationService.validateResponseStatus(workspaceId, page, limit); + } + + validateTestTakers(workspaceId: number): Observable { + return this.validationService.validateTestTakers(workspaceId); + } + + validateGroupResponses(workspaceId: number, page: number = 1, limit: number = 10): Observable<{ + testTakersFound: boolean; + groupsWithResponses: { group: string; hasResponse: boolean }[]; + allGroupsHaveResponses: boolean; + total: number; + page: number; + limit: number; + }> { + return this.validationService.validateGroupResponses(workspaceId, page, limit); + } + + createVariableAnalysisJob( + workspaceId: number, + unitId?: number, + variableId?: string + ): Observable { + return this.variableAnalysisService.createAnalysisJob( + workspaceId, + unitId, + variableId + ); + } + + getVariableAnalysisResults( + workspaceId: number, + jobId: number + ): Observable { + return this.variableAnalysisService.getAnalysisResults(workspaceId, jobId); + } + + getAllVariableAnalysisJobs(workspaceId: number): Observable { + return this.variableAnalysisService.getAllJobs(workspaceId); + } + + cancelVariableAnalysisJob(workspaceId: number, jobId: number): Observable<{ success: boolean; message: string }> { + return this.variableAnalysisService.cancelJob(workspaceId, jobId); + } + + deleteVariableAnalysisJob(workspaceId: number, jobId: number): Observable<{ success: boolean; message: string }> { + return this.variableAnalysisService.deleteJob(workspaceId, jobId); + } + + createDistributedCodingJobs( + workspaceId: number, + selectedVariables: { unitName: string; variableId: string }[], + selectedCoders: { id: number; name: string; username: string }[], + doubleCodingAbsolute?: number, + doubleCodingPercentage?: number, + selectedVariableBundles?: { id: number; name: string; variables: { unitName: string; variableId: string }[] }[], + caseOrderingMode?: 'continuous' | 'alternating', + maxCodingCases?: number + ): Observable<{ + success: boolean; + jobsCreated: number; + message: string; + distribution: Record>; + doubleCodingInfo: Record }>; + aggregationInfo: Record; + matchingFlags: string[]; + jobs: { + coderId: number; + coderName: string; + variable: { unitName: string; variableId: string }; + jobId: number; + jobName: string; + caseCount: number; + }[]; + }> { + return this.codingService.createDistributedCodingJobs(workspaceId, selectedVariables, selectedCoders, doubleCodingAbsolute, doubleCodingPercentage, selectedVariableBundles, caseOrderingMode, maxCodingCases); + } + + calculateDistribution( + workspaceId: number, + selectedVariables: { unitName: string; variableId: string }[], + selectedCoders: { id: number; name: string; username: string }[], + doubleCodingAbsolute?: number, + doubleCodingPercentage?: number, + selectedVariableBundles?: { id: number; name: string; variables: { unitName: string; variableId: string }[] }[], + maxCodingCases?: number + ): Observable<{ + distribution: Record>; + doubleCodingInfo: Record }>; + aggregationInfo: Record; + matchingFlags: string[]; + warnings: Array<{ unitName: string; variableId: string; message: string; casesInJobs: number; availableCases: number }>; + }> { + return this.codingService.calculateDistribution(workspaceId, selectedVariables, selectedCoders, doubleCodingAbsolute, doubleCodingPercentage, selectedVariableBundles, maxCodingCases); + } + + createValidationTask( + workspaceId: number, + type: 'variables' | 'variableTypes' | 'responseStatus' | 'testTakers' | 'groupResponses' | 'deleteResponses' | 'deleteAllResponses' | 'duplicateResponses', + page?: number, + limit?: number, + additionalData?: Record + ): Observable { + return this.validationService.createValidationTask(workspaceId, type, page, limit, additionalData); + } + + createDeleteResponsesTask( + workspaceId: number, + responseIds: number[] + ): Observable { + return this.validationService.createDeleteResponsesTask(workspaceId, responseIds); + } + + createDeleteAllResponsesTask( + workspaceId: number, + validationType: 'variables' | 'variableTypes' | 'responseStatus' | 'duplicateResponses' + ): Observable { + return this.validationService.createDeleteAllResponsesTask(workspaceId, validationType); + } + + getValidationTask(workspaceId: number, taskId: number): Observable { + return this.validationService.getValidationTask(workspaceId, taskId); + } + + getValidationResults(workspaceId: number, taskId: number): Observable { + return this.validationService.getValidationResults(workspaceId, taskId); + } + + pollValidationTask( + workspaceId: number, + taskId: number, + pollInterval: number = 2000 + ): Observable { + return this.validationService.pollValidationTask(workspaceId, taskId, pollInterval); + } + + createDummyTestTakerFile(workspaceId: number): Observable { + return this.fileService.createDummyTestTakerFile(workspaceId); + } + + getMissingsProfiles(workspaceId: number): Observable<{ label: string; id: number }[]> { + return this.codingService.getMissingsProfiles(workspaceId); + } + + getMissingsProfileDetails(workspaceId: number, id: number | string): Observable { + return this.codingService.getMissingsProfileDetails(workspaceId, id); + } + + createMissingsProfile(workspaceId: number, profile: MissingsProfilesDto): Observable { + return this.codingService.createMissingsProfile(workspaceId, profile); + } + + updateMissingsProfile(workspaceId: number, label: string, profile: MissingsProfilesDto): Observable { + return this.codingService.updateMissingsProfile(workspaceId, label, profile); + } + + deleteMissingsProfile(workspaceId: number, label: string): Observable { + return this.codingService.deleteMissingsProfile(workspaceId, label); + } + + getCodingBook( + workspaceId: number, + missingsProfile: string, + contentOptions: CodeBookContentSetting, + unitList: number[] + ): Observable { + return this.codingService.getCodingBook(workspaceId, missingsProfile, contentOptions, unitList); + } + + getUnitsWithFileIds(workspaceId: number): Observable<{ id: number; unitId: string; fileName: string; data: string }[]> { + return this.fileService.getUnitsWithFileIds(workspaceId); + } + + getVariableInfoForScheme(workspaceId: number, schemeFileId: string): Observable { + const fileId = schemeFileId.endsWith('.vocs') ? + schemeFileId.slice(0, -5) : + schemeFileId; + + return this.fileService.getVariableInfoForScheme(workspaceId, fileId); + } + + storeReplayStatistics( + workspaceId: number, + data: { + unitId: string; + bookletId?: string; + testPersonLogin?: string; + testPersonCode?: string; + durationMilliseconds: number; + replayUrl?: string; + success?: boolean; + errorMessage?: string; + } + ): Observable { + const url = `${this.serverUrl}admin/workspace/${workspaceId}/replay-statistics`; + return this.http.post(url, data); + } + + getReplayFrequencyByUnit(workspaceId: number): Observable> { + const url = `${this.serverUrl}admin/workspace/${workspaceId}/replay-statistics/frequency`; + return this.http.get>(url); + } + + getReplayDurationStatistics( + workspaceId: number, + unitId?: string + ): Observable<{ + min: number; + max: number; + average: number; + distribution: Record; + unitAverages?: Record; + }> { + let url = `${this.serverUrl}admin/workspace/${workspaceId}/replay-statistics/duration`; + if (unitId) { + url += `?unitId=${encodeURIComponent(unitId)}`; + } + return this.http.get<{ + min: number; + max: number; + average: number; + distribution: Record; + unitAverages?: Record; + }>(url); + } + + getReplayDistributionByDay(workspaceId: number): Observable> { + const url = `${this.serverUrl}admin/workspace/${workspaceId}/replay-statistics/distribution/day`; + return this.http.get>(url); + } + + getReplayDistributionByHour(workspaceId: number): Observable> { + const url = `${this.serverUrl}admin/workspace/${workspaceId}/replay-statistics/distribution/hour`; + return this.http.get>(url); + } + + getReplayErrorStatistics(workspaceId: number): Observable<{ + successRate: number; + totalReplays: number; + successfulReplays: number; + failedReplays: number; + commonErrors: Array<{ message: string; count: number }>; + }> { + const url = `${this.serverUrl}admin/workspace/${workspaceId}/replay-statistics/errors`; + return this.http.get<{ + successRate: number; + totalReplays: number; + successfulReplays: number; + failedReplays: number; + commonErrors: Array<{ message: string; count: number }>; + }>(url); + } + + getFailureDistributionByUnit(workspaceId: number): Observable> { + const url = `${this.serverUrl}admin/workspace/${workspaceId}/replay-statistics/failures/unit`; + return this.http.get>(url); + } + + getFailureDistributionByDay(workspaceId: number): Observable> { + const url = `${this.serverUrl}admin/workspace/${workspaceId}/replay-statistics/failures/day`; + return this.http.get>(url); + } + + getFailureDistributionByHour(workspaceId: number): Observable> { + const url = `${this.serverUrl}admin/workspace/${workspaceId}/replay-statistics/failures/hour`; + return this.http.get>(url); + } + + getVariableBundles(workspaceId: number): Observable { + const url = `${this.serverUrl}admin/workspace/${workspaceId}/variable-bundle`; + return this.http.get>(url) + .pipe( + map(response => response.data) + ); + } + + private mapApiCodingJob(job: unknown): CodingJob { + if (!job) { + return job as CodingJob; + } + + const apiJob = job as Record; + + const mapped: Partial = { + ...apiJob, + assignedCoders: (apiJob.assignedCoders ?? apiJob.assigned_coders ?? []) as number[], + assignedVariables: (apiJob.assignedVariables ?? apiJob.assigned_variables ?? apiJob.variables ?? []) as Variable[], + variables: (apiJob.variables ?? apiJob.assigned_variables ?? apiJob.assignedVariables ?? []) as Variable[], + assignedVariableBundles: (apiJob.assignedVariableBundles ?? apiJob.assigned_variable_bundles ?? apiJob.variableBundles ?? apiJob.variable_bundles ?? []) as VariableBundle[], + variableBundles: (apiJob.variableBundles ?? apiJob.variable_bundles ?? apiJob.assigned_variable_bundles ?? apiJob.assignedVariableBundles ?? []) as VariableBundle[], + progress: (apiJob.progress ?? 0) as number, + codedUnits: (apiJob.codedUnits ?? apiJob.coded_units ?? apiJob.coded ?? 0) as number, + totalUnits: (apiJob.totalUnits ?? apiJob.total_units ?? apiJob.total ?? 0) as number, + openUnits: (apiJob.openUnits ?? apiJob.open_units ?? apiJob.open ?? 0) as number, + created_at: (apiJob.created_at ?? apiJob.createdAt) as Date, + updated_at: (apiJob.updated_at ?? apiJob.updatedAt) as Date, + workspace_id: (apiJob.workspace_id ?? apiJob.workspaceId) as number + }; + + return mapped as CodingJob; + } + + getCodingJobs( + workspaceId: number, + page?: number, + limit?: number + ): Observable> { + const url = `${this.serverUrl}wsg-admin/workspace/${workspaceId}/coding-job`; + let params = new HttpParams(); + + if (page !== undefined) { + params = params.set('page', page.toString()); + } + + if (limit !== undefined) { + params = params.set('limit', limit.toString()); + } + + return this.http.get>(url, { params }).pipe( + map(response => ({ + ...response, + data: (response.data || []).map((j: unknown) => this.mapApiCodingJob(j)) + })) + ); + } + + getCodingJob(workspaceId: number, codingJobId: number): Observable { + const url = `${this.serverUrl}wsg-admin/workspace/${workspaceId}/coding-job/${codingJobId}`; + return this.http.get(url).pipe( + map(job => this.mapApiCodingJob(job)) + ); + } + + createCodingJob(workspaceId: number, codingJob: Omit): Observable { + const url = `${this.serverUrl}wsg-admin/workspace/${workspaceId}/coding-job`; + return this.http.post(url, codingJob); + } + + updateCodingJob( + workspaceId: number, + codingJobId: number, + codingJob: Partial> + ): Observable { + const url = `${this.serverUrl}wsg-admin/workspace/${workspaceId}/coding-job/${codingJobId}`; + return this.http.put(url, codingJob); + } + + deleteCodingJob(workspaceId: number, codingJobId: number): Observable<{ success: boolean }> { + const url = `${this.serverUrl}wsg-admin/workspace/${workspaceId}/coding-job/${codingJobId}`; + return this.http.delete<{ success: boolean }>(url); + } + + startCodingJob( + workspaceId: number, + codingJobId: number + ): Observable<{ total: number; items: Array<{ responseId: number; unitName: string; unitAlias: string | null; variableId: string; variableAnchor: string; bookletName: string; personLogin: string; personCode: string; personGroup: string; replayUrl: string }> }> { + const url = `${this.serverUrl}wsg-admin/workspace/${workspaceId}/coding-job/${codingJobId}/start`; + return this.http.post<{ total: number; items: Array<{ responseId: number; unitName: string; unitAlias: string | null; variableId: string; variableAnchor: string; bookletName: string; personLogin: string; personCode: string; personGroup: string; replayUrl: string }> }>(url, {}); + } + + getCodingIncompleteVariables( + workspaceId: number, + unitName?: string + ): Observable<{ unitName: string; variableId: string; responseCount: number }[]> { + const url = `${this.serverUrl}admin/workspace/${workspaceId}/coding/incomplete-variables`; + let params = new HttpParams(); + if (unitName) { + params = params.set('unitName', unitName); + } + // Add cache-busting parameter to ensure fresh data after job definition changes + params = params.set('_t', Date.now().toString()); + return this.http.get<{ unitName: string; variableId: string; responseCount: number }[]>(url, { params }); + } + + getAppliedResultsCount( + workspaceId: number, + incompleteVariables: { unitName: string; variableId: string }[] + ): Observable { + const url = `${this.serverUrl}admin/workspace/${workspaceId}/coding/applied-results-count`; + return this.http.post(url, { incompleteVariables }); + } + + createCoderTrainingJobs( + workspaceId: number, + selectedCoders: { id: number; name: string }[], + variableConfigs: { variableId: string; unitId: string; sampleCount: number }[], + trainingLabel: string, + missingsProfileId?: number + ): Observable<{ success: boolean; jobsCreated: number; message: string; jobs: { coderId: number; coderName: string; jobId: number; jobName: string }[]; trainingId?: number }> { + const url = `${this.serverUrl}admin/workspace/${workspaceId}/coding/coder-training-jobs`; + return this.http.post<{ success: boolean; jobsCreated: number; message: string; jobs: { coderId: number; coderName: string; jobId: number; jobName: string }[]; trainingId?: number }>(url, { + trainingLabel, + selectedCoders, + variableConfigs, + missingsProfileId + }); + } + + getCoderTrainings(workspaceId: number): Observable<{ + id: number; + workspace_id: number; + label: string; + created_at: Date; + updated_at: Date; + jobsCount: number; + }[]> { + const url = `${this.serverUrl}admin/workspace/${workspaceId}/coding/coder-trainings`; + return this.http.get<{ + id: number; + workspace_id: number; + label: string; + created_at: Date; + updated_at: Date; + jobsCount: number; + }[]>(url); + } + + updateCoderTrainingLabel(workspaceId: number, trainingId: number, newLabel: string): Observable<{ success: boolean; message: string }> { + const url = `${this.serverUrl}admin/workspace/${workspaceId}/coding/coder-trainings/${trainingId}`; + return this.http.put<{ success: boolean; message: string }>(url, { label: newLabel }); + } + + deleteCoderTraining(workspaceId: number, trainingId: number): Observable<{ success: boolean; message: string }> { + const url = `${this.serverUrl}admin/workspace/${workspaceId}/coding/coder-trainings/${trainingId}`; + return this.http.delete<{ success: boolean; message: string }>(url); + } + + compareTrainingCodingResults( + workspaceId: number, + trainingIds: string + ): Observable; + }>> { + const url = `${this.serverUrl}admin/workspace/${workspaceId}/coding/compare-training-results?trainingIds=${encodeURIComponent(trainingIds)}`; + return this.http.get; + }>>(url); + } + + compareWithinTrainingCodingResults( + workspaceId: number, + trainingId: number + ): Observable; + }>> { + const url = `${this.serverUrl}admin/workspace/${workspaceId}/coding/compare-within-training?trainingId=${trainingId}`; + return this.http.get; + }>>(url); + } + + getCodingJobsForTraining( + workspaceId: number, + trainingId: number + ): Observable> { + const url = `${this.serverUrl}admin/workspace/${workspaceId}/coding/coder-trainings/${trainingId}/jobs`; + return this.http.get>(url); + } + + downloadWorkspaceFilesAsZip(workspaceId: number): Observable { + const url = `${this.serverUrl}admin/workspace/${workspaceId}/files/download-zip`; + return this.http.get(url, { + responseType: 'blob', + headers: { + Authorization: `Bearer ${localStorage.getItem('auth_token')}` + } + }); + } + + saveCodingProgress( + workspaceId: number, + codingJobId: number, + progressData: { + testPerson: string; + unitId: string; + variableId: string; + selectedCode: { + id: number; + code: string; + label: string; + [key: string]: unknown; + }; + isOpen?: boolean; + notes?: string; + } + ): Observable { + const url = `${this.serverUrl}wsg-admin/workspace/${workspaceId}/coding-job/${codingJobId}/progress`; + return this.http.post(url, progressData); + } + + restartCodingJobWithOpenUnits(workspaceId: number, codingJobId: number): Observable { + const url = `${this.serverUrl}wsg-admin/workspace/${workspaceId}/coding-job/${codingJobId}/restart-open-units`; + return this.http.post(url, {}); + } + + getCodingProgress(workspaceId: number, codingJobId: number): Observable> { + const url = `${this.serverUrl}wsg-admin/workspace/${workspaceId}/coding-job/${codingJobId}/progress`; + return this.http.get>(url, { headers: this.authHeader }); + } + + getBulkCodingProgress(workspaceId: number, jobIds: number[]): Observable>> { + const jobIdsParam = jobIds.join(','); + const url = `${this.serverUrl}wsg-admin/workspace/${workspaceId}/coding-job/progress/bulk?jobIds=${jobIdsParam}`; + return this.http.get>>(url, { headers: this.authHeader }); + } + + getCodingNotes(workspaceId: number, codingJobId: number): Observable | null> { + const url = `${this.serverUrl}admin/workspace/${workspaceId}/coding-job/${codingJobId}/notes`; + return this.http.get | null>(url, { headers: this.authHeader }); + } + + getCodingJobUnits( + workspaceId: number, + codingJobId: number + ): Observable> { + const url = `${this.serverUrl}wsg-admin/workspace/${workspaceId}/coding-job/${codingJobId}/units`; + return this.http.get>(url); + } + + applyCodingResults( + workspaceId: number, + codingJobId: number + ): Observable<{ + success: boolean; + updatedResponsesCount: number; + skippedReviewCount: number; + messageKey: string; + messageParams?: Record; + }> { + const url = `${this.serverUrl}admin/workspace/${workspaceId}/coding/jobs/${codingJobId}/apply-results`; + return this.http.post<{ + success: boolean; + updatedResponsesCount: number; + skippedReviewCount: number; + messageKey: string; + messageParams?: Record; + }>(url, {}); + } + + bulkApplyCodingResults( + workspaceId: number + ): Observable<{ + success: boolean; + jobsProcessed: number; + totalUpdatedResponses: number; + totalSkippedReview: number; + message: string; + results: Array<{ + jobId: number; + jobName: string; + hasIssues: boolean; + skipped: boolean; + result?: { + success: boolean; + updatedResponsesCount: number; + skippedReviewCount: number; + message: string; + }; + }>; + }> { + const url = `${this.serverUrl}admin/workspace/${workspaceId}/coding/jobs/bulk-apply-results`; + return this.http.post<{ + success: boolean; + jobsProcessed: number; + totalUpdatedResponses: number; + totalSkippedReview: number; + message: string; + results: Array<{ + jobId: number; + jobName: string; + hasIssues: boolean; + skipped: boolean; + result?: { + success: boolean; + updatedResponsesCount: number; + skippedReviewCount: number; + message: string; + }; + }>; + }>(url, {}); + } + + getUnitVariables(workspaceId: number): Observable { + const url = `${this.serverUrl}admin/workspace/${workspaceId}/files/unit-variables`; + return this.http.get(url); + } + + createJobDefinition(workspaceId: number, jobDefinition: Omit): Observable { + const url = `${this.serverUrl}admin/workspace/${workspaceId}/coding/job-definitions`; + return this.http.post(url, jobDefinition); + } + + updateJobDefinition(workspaceId: number, jobDefinitionId: number, jobDefinition: Partial): Observable { + const url = `${this.serverUrl}admin/workspace/${workspaceId}/coding/job-definitions/${jobDefinitionId}`; + return this.http.put(url, jobDefinition); + } + + approveJobDefinition(workspaceId: number, jobDefinitionId: number, status: 'pending_review' | 'approved'): Observable { + const url = `${this.serverUrl}admin/workspace/${workspaceId}/coding/job-definitions/${jobDefinitionId}/approve`; + return this.http.put(url, { status }); + } + + getJobDefinitions(workspaceId: number): Observable { + const url = `${this.serverUrl}admin/workspace/${workspaceId}/coding/job-definitions`; + return this.http.get(url).pipe( + map((definitions: JobDefinitionApiResponse[]) => definitions.map(def => ({ + id: def.id, + status: def.status, + assignedVariables: def.assigned_variables, + assignedVariableBundles: def.assigned_variable_bundles, + assignedCoders: def.assigned_coders, + durationSeconds: def.duration_seconds, + maxCodingCases: def.max_coding_cases, + doubleCodingAbsolute: def.double_coding_absolute, + doubleCodingPercentage: def.double_coding_percentage, + caseOrderingMode: def.case_ordering_mode, + createdAt: def.created_at, + updatedAt: def.updated_at + }))) + ); + } + + deleteJobDefinition(workspaceId: number, jobDefinitionId: number): Observable<{ success: boolean; message: string }> { + const url = `${this.serverUrl}admin/workspace/${workspaceId}/coding/job-definitions/${jobDefinitionId}`; + return this.http.delete<{ success: boolean; message: string }>(url); + } + + startExportJob(workspaceId: number, exportConfig: { + exportType: 'aggregated' | 'by-coder' | 'by-variable' | 'detailed' | 'coding-times'; + userId: number; + outputCommentsInsteadOfCodes?: boolean; + includeReplayUrl?: boolean; + anonymizeCoders?: boolean; + usePseudoCoders?: boolean; + doubleCodingMethod?: 'new-row-per-variable' | 'new-column-per-coder' | 'most-frequent'; + includeComments?: boolean; + includeModalValue?: boolean; + includeDoubleCoded?: boolean; + excludeAutoCoded?: boolean; + authToken?: string; + }): Observable<{ jobId: string; message: string }> { + const url = `${this.serverUrl}admin/workspace/${workspaceId}/coding/export/start`; + return this.http.post<{ jobId: string; message: string }>(url, exportConfig, { + headers: this.authHeader + }); + } + + getExportJobStatus(workspaceId: number, jobId: string): Observable<{ + status: string; + progress: number; + result?: { + fileId: string; + fileName: string; + filePath: string; + fileSize: number; + workspaceId: number; + userId: number; + exportType: string; + createdAt: number; + }; + error?: string; + }> { + const url = `${this.serverUrl}admin/workspace/${workspaceId}/coding/export/job/${jobId}`; + return this.http.get<{ + status: string; + progress: number; + result?: { + fileId: string; + fileName: string; + filePath: string; + fileSize: number; + workspaceId: number; + userId: number; + exportType: string; + createdAt: number; + }; + error?: string; + }>(url, { + headers: this.authHeader + }); + } + + downloadExportFile(workspaceId: number, jobId: string): Observable { + const url = `${this.serverUrl}admin/workspace/${workspaceId}/coding/export/job/${jobId}/download`; + return this.http.get(url, { + responseType: 'blob', + headers: this.authHeader + }); + } + + cancelExportJob(workspaceId: number, jobId: string): Observable<{ success: boolean; message: string }> { + const url = `${this.serverUrl}admin/workspace/${workspaceId}/coding/export/job/${jobId}/cancel`; + return this.http.post<{ success: boolean; message: string }>(url, {}, { + headers: this.authHeader + }); + } +} diff --git a/apps/frontend/src/app/services/facades/workspace-facade.service.ts b/apps/frontend/src/app/services/facades/workspace-facade.service.ts index ed10ac8d1..6cfdb6fb3 100644 --- a/apps/frontend/src/app/services/facades/workspace-facade.service.ts +++ b/apps/frontend/src/app/services/facades/workspace-facade.service.ts @@ -102,7 +102,7 @@ export class WorkspaceFacadeService { return this.workspaceBackendService.getWorkspaceUsers(workspaceId); } - addWorkspace(workspaceData: CreateWorkspaceDto): Observable { + addWorkspace(workspaceData: CreateWorkspaceDto): Observable { return this.workspaceBackendService.addWorkspace(workspaceData); } diff --git a/apps/frontend/src/app/shared/services/file/file.service.ts b/apps/frontend/src/app/shared/services/file/file.service.ts index dee78a743..a2ab42328 100644 --- a/apps/frontend/src/app/shared/services/file/file.service.ts +++ b/apps/frontend/src/app/shared/services/file/file.service.ts @@ -71,7 +71,7 @@ export class FileService { private validationTaskStateService = inject(ValidationTaskStateService); get authHeader() { - return {}; + return { Authorization: `Bearer ${localStorage.getItem('auth_token')}` }; } getDirectDownloadLink(): string { diff --git a/apps/frontend/src/app/shared/services/response/resource-package.service.ts b/apps/frontend/src/app/shared/services/response/resource-package.service.ts index daa1c4eae..ed67756fc 100644 --- a/apps/frontend/src/app/shared/services/response/resource-package.service.ts +++ b/apps/frontend/src/app/shared/services/response/resource-package.service.ts @@ -17,7 +17,7 @@ export class ResourcePackageService { private http = inject(HttpClient); get authHeader() { - return {}; + return { Authorization: `Bearer ${localStorage.getItem('auth_token')}` }; } getResourcePackages(workspaceId: number): Observable { diff --git a/apps/frontend/src/app/shared/services/response/response.service.ts b/apps/frontend/src/app/shared/services/response/response.service.ts index e9cbdc51e..d5223c294 100644 --- a/apps/frontend/src/app/shared/services/response/response.service.ts +++ b/apps/frontend/src/app/shared/services/response/response.service.ts @@ -28,7 +28,7 @@ export class ResponseService { private validationTaskStateService = inject(ValidationTaskStateService); get authHeader() { - return {}; + return { Authorization: `Bearer ${localStorage.getItem('auth_token')}` }; } getResponses(workspaceId: number, testPerson: string, unitId: string, authToken?: string): Observable { diff --git a/apps/frontend/src/app/shared/services/response/variable-analysis.service.spec.ts b/apps/frontend/src/app/shared/services/response/variable-analysis.service.spec.ts index ad6348202..6941cb1fe 100644 --- a/apps/frontend/src/app/shared/services/response/variable-analysis.service.spec.ts +++ b/apps/frontend/src/app/shared/services/response/variable-analysis.service.spec.ts @@ -11,6 +11,7 @@ import { VariableAnalysisService } from './variable-analysis.service'; import { SERVER_URL } from '../../../injection-tokens'; import { CodingBackgroundJobsService } from '../../../coding/services/coding-background-jobs.service'; import { TestPersonCodingService } from '../../../coding/services/test-person-coding.service'; +import { AuthService } from '../../../core/services/auth.service'; describe('VariableAnalysisService', () => { let service: VariableAnalysisService; @@ -24,7 +25,10 @@ describe('VariableAnalysisService', () => { beforeEach(() => { Object.defineProperty(window, 'localStorage', { value: { - getItem: jest.fn().mockReturnValue('mock-token') + getItem: jest.fn().mockReturnValue('mock-token'), + setItem: jest.fn(), + removeItem: jest.fn(), + clear: jest.fn() }, writable: true }); @@ -34,7 +38,13 @@ describe('VariableAnalysisService', () => { VariableAnalysisService, provideHttpClient(withInterceptorsFromDi()), provideHttpClientTesting(), - { provide: SERVER_URL, useValue: mockServerUrl } + { provide: SERVER_URL, useValue: mockServerUrl }, + { + provide: AuthService, + useValue: { + getValidToken: jest.fn().mockResolvedValue('mock-token') + } + } ] }); diff --git a/apps/frontend/src/app/shared/services/response/variable-analysis.service.ts b/apps/frontend/src/app/shared/services/response/variable-analysis.service.ts index 6e2cc91cb..896e41b85 100644 --- a/apps/frontend/src/app/shared/services/response/variable-analysis.service.ts +++ b/apps/frontend/src/app/shared/services/response/variable-analysis.service.ts @@ -152,7 +152,7 @@ export class VariableAnalysisService { ]); get authHeader() { - return {}; + return { Authorization: `Bearer ${localStorage.getItem('auth_token')}` }; } createAnalysisJob( diff --git a/apps/frontend/src/app/shared/services/test-result/test-result-cache.service.ts b/apps/frontend/src/app/shared/services/test-result/test-result-cache.service.ts index 67d0f5b20..f2537843a 100644 --- a/apps/frontend/src/app/shared/services/test-result/test-result-cache.service.ts +++ b/apps/frontend/src/app/shared/services/test-result/test-result-cache.service.ts @@ -39,7 +39,7 @@ export class TestResultCacheService { private readonly CACHE_EXPIRATION = 5 * 60 * 1000; get authHeader() { - return {}; + return { Authorization: `Bearer ${localStorage.getItem('auth_token')}` }; } /** diff --git a/apps/frontend/src/app/shared/services/unit/unit-note.service.ts b/apps/frontend/src/app/shared/services/unit/unit-note.service.ts index a0f9e2d5b..fa393ca7c 100644 --- a/apps/frontend/src/app/shared/services/unit/unit-note.service.ts +++ b/apps/frontend/src/app/shared/services/unit/unit-note.service.ts @@ -16,7 +16,7 @@ export class UnitNoteService { private http = inject(HttpClient); get authHeader() { - return {}; + return { Authorization: `Bearer ${localStorage.getItem('auth_token')}` }; } createUnitNote(workspaceId: number, createUnitNoteDto: CreateUnitNoteDto): Observable { diff --git a/apps/frontend/src/app/sys-admin/components/workspaces/workspaces.component.ts b/apps/frontend/src/app/sys-admin/components/workspaces/workspaces.component.ts index 308b11142..6ac1afc39 100755 --- a/apps/frontend/src/app/sys-admin/components/workspaces/workspaces.component.ts +++ b/apps/frontend/src/app/sys-admin/components/workspaces/workspaces.component.ts @@ -44,8 +44,8 @@ export class WorkspacesComponent { name: (result).get('name')?.value, settings: {} }).subscribe( - respOk => { - if (respOk) { + workspaceId => { + if (workspaceId) { this.snackBar.open( this.translateService.instant('admin.workspace-created'), '', diff --git a/apps/frontend/src/app/workspace/components/user-workspaces/user-workspaces.component.spec.ts b/apps/frontend/src/app/workspace/components/user-workspaces/user-workspaces.component.spec.ts old mode 100755 new mode 100644 index 00e29b3a4..1d58eefc8 --- a/apps/frontend/src/app/workspace/components/user-workspaces/user-workspaces.component.spec.ts +++ b/apps/frontend/src/app/workspace/components/user-workspaces/user-workspaces.component.spec.ts @@ -5,17 +5,6 @@ import { UserWorkspacesComponent } from './user-workspaces.component'; import { AuthService } from '../../../core/services/auth.service'; import { AppService } from '../../../core/services/app.service'; -const mockKeycloak = { - idTokenParsed: { sub: 'test-user-id', preferred_username: 'test-user' }, - token: 'mock-token', - authenticated: true, - loadUserProfile: jest.fn().mockResolvedValue({ username: 'test-user' }), - login: jest.fn(), - logout: jest.fn(), - accountManagement: jest.fn(), - realmAccess: { roles: ['user'] } -}; - const mockAuthService = { isLoggedIn: jest.fn().mockReturnValue(true), login: jest.fn() @@ -30,6 +19,7 @@ const mockAppService = { describe('UserWorkspacesComponent', () => { let component: UserWorkspacesComponent; let fixture: ComponentFixture; + beforeEach(async () => { jest.clearAllMocks(); mockAuthService.isLoggedIn.mockReturnValue(true); @@ -39,8 +29,7 @@ describe('UserWorkspacesComponent', () => { await TestBed.configureTestingModule({ providers: [ { provide: AuthService, useValue: mockAuthService }, - { provide: AppService, useValue: mockAppService }, - { provide: 'Keycloak', useValue: mockKeycloak } + { provide: AppService, useValue: mockAppService } ], imports: [TranslateModule.forRoot()] }).compileComponents(); @@ -104,7 +93,7 @@ describe('UserWorkspacesComponent', () => { expect(mockAuthService.login).toHaveBeenCalledWith('/coding'); }); - it('should show reauthentication instead of loading when the session expires while Keycloak is still authenticated', () => { + it('should show reauthentication instead of loading when the session expires while authenticated', () => { component.authBootstrapStatus = 'session-expired'; component.authDataLoaded = false; diff --git a/apps/frontend/src/app/workspace/services/workspace-backend.service.spec.ts b/apps/frontend/src/app/workspace/services/workspace-backend.service.spec.ts index 693036f46..cd93555ea 100644 --- a/apps/frontend/src/app/workspace/services/workspace-backend.service.spec.ts +++ b/apps/frontend/src/app/workspace/services/workspace-backend.service.spec.ts @@ -146,16 +146,16 @@ describe('WorkspaceBackendService', () => { }); describe('addWorkspace', () => { - it('should populate headers and body', () => { + it('should return the new workspace id', () => { const mockDto = { name: 'New' }; service.addWorkspace(mockDto as CreateWorkspaceDto).subscribe(res => { - expect(res).toBe(true); + expect(res).toBe(17); }); const req = httpMock.expectOne(`${mockServerUrl}admin/workspace`); expect(req.request.method).toBe('POST'); expect(req.request.body).toEqual(mockDto); - req.flush(true); + req.flush(17); }); }); }); diff --git a/apps/frontend/src/app/workspace/services/workspace-backend.service.ts b/apps/frontend/src/app/workspace/services/workspace-backend.service.ts index c4dafa2ac..e8b95b636 100644 --- a/apps/frontend/src/app/workspace/services/workspace-backend.service.ts +++ b/apps/frontend/src/app/workspace/services/workspace-backend.service.ts @@ -104,11 +104,11 @@ export class WorkspaceBackendService { ); } - addWorkspace(workspaceData: CreateWorkspaceDto): Observable { + addWorkspace(workspaceData: CreateWorkspaceDto): Observable { return this.http - .post(`${this.serverUrl}admin/workspace`, workspaceData, {}) + .post(`${this.serverUrl}admin/workspace`, workspaceData, {}) .pipe( - catchError(() => of(false)) + catchError(() => of(null)) ); } diff --git a/apps/frontend/src/app/workspace/services/workspace.service.ts b/apps/frontend/src/app/workspace/services/workspace.service.ts index ce4b97ab3..cfb69f7aa 100644 --- a/apps/frontend/src/app/workspace/services/workspace.service.ts +++ b/apps/frontend/src/app/workspace/services/workspace.service.ts @@ -25,7 +25,7 @@ export class WorkspaceService { private accessRightsMatrixCache$ = new BehaviorSubject(null); get authHeader() { - return {}; + return { Authorization: `Bearer ${localStorage.getItem('auth_token')}` }; } markTestTakersAsExcluded(workspaceId: number, logins: string[]): Observable { @@ -89,11 +89,11 @@ export class WorkspaceService { ); } - addWorkspace(workspaceData: CreateWorkspaceDto): Observable { + addWorkspace(workspaceData: CreateWorkspaceDto): Observable { return this.http - .post(`${this.serverUrl}admin/workspace`, workspaceData, { headers: this.authHeader }) + .post(`${this.serverUrl}admin/workspace`, workspaceData, { headers: this.authHeader }) .pipe( - catchError(() => of(false)) + catchError(() => of(null)) ); } diff --git a/apps/frontend/src/app/ws-admin/services/content-pool-integration.service.ts b/apps/frontend/src/app/ws-admin/services/content-pool-integration.service.ts index b48597013..fcd94f31f 100644 --- a/apps/frontend/src/app/ws-admin/services/content-pool-integration.service.ts +++ b/apps/frontend/src/app/ws-admin/services/content-pool-integration.service.ts @@ -24,7 +24,7 @@ export class ContentPoolIntegrationService { private readonly serverUrl = inject(SERVER_URL); private get authHeader() { - return {}; + return { Authorization: `Bearer ${localStorage.getItem('auth_token')}` }; } getWorkspaceConfig(workspaceId: number): Observable { diff --git a/apps/frontend/src/assets/config/runtime-config.js b/apps/frontend/src/assets/config/runtime-config.js index 9a1b8016f..efee66656 100644 --- a/apps/frontend/src/assets/config/runtime-config.js +++ b/apps/frontend/src/assets/config/runtime-config.js @@ -1,8 +1,3 @@ window.RUNTIME_CONFIG = { - keycloak: { - url: 'https://keycloak.kodierbox.iqb.hu-berlin.de/', - realm: 'coding-box', - clientId: 'coding-box' - }, backendUrl: 'api/' }; diff --git a/apps/frontend/src/environments/environment.prod.ts b/apps/frontend/src/environments/environment.prod.ts index 44bfe2a55..a1a9ac804 100755 --- a/apps/frontend/src/environments/environment.prod.ts +++ b/apps/frontend/src/environments/environment.prod.ts @@ -1,34 +1,10 @@ -declare global { - interface Window { - RUNTIME_CONFIG?: { - keycloak?: { - url: string; - realm: string; - clientId: string; - }; - backendUrl?: string; - }; - } -} - // Standardkonfiguration, die durch Laufzeitkonfiguration überschrieben werden kann const defaultConfig = { production: true, - backendUrl: 'api/', - keycloak: { - url: 'https://keycloak.kodierbox.iqb.hu-berlin.de/', - realm: 'coding-box', - clientId: 'coding-box' - } + backendUrl: '/api/' }; // Überschreiben der Standardkonfiguration mit Laufzeitkonfiguration, falls vorhanden export const environment = { - ...defaultConfig, - backendUrl: window.RUNTIME_CONFIG?.backendUrl || defaultConfig.backendUrl, - keycloak: { - url: window.RUNTIME_CONFIG?.keycloak?.url || defaultConfig.keycloak.url, - realm: window.RUNTIME_CONFIG?.keycloak?.realm || defaultConfig.keycloak.realm, - clientId: window.RUNTIME_CONFIG?.keycloak?.clientId || defaultConfig.keycloak.clientId - } + ...defaultConfig }; diff --git a/apps/frontend/src/environments/environment.ts b/apps/frontend/src/environments/environment.ts index 15217519d..712afb5b4 100755 --- a/apps/frontend/src/environments/environment.ts +++ b/apps/frontend/src/environments/environment.ts @@ -1,34 +1,10 @@ -declare global { - interface Window { - RUNTIME_CONFIG?: { - keycloak?: { - url: string; - realm: string; - clientId: string; - }; - backendUrl?: string; - }; - } -} - // Standardkonfiguration, die durch Laufzeitkonfiguration überschrieben werden kann const defaultConfig = { production: false, - backendUrl: 'api/', - keycloak: { - url: 'https://keycloak.kodierbox.iqb.hu-berlin.de/', - realm: 'coding-box', - clientId: 'coding-box' - } + backendUrl: '/api/' }; // Überschreiben der Standardkonfiguration mit Laufzeitkonfiguration, falls vorhanden export const environment = { - ...defaultConfig, - backendUrl: window.RUNTIME_CONFIG?.backendUrl || defaultConfig.backendUrl, - keycloak: { - url: window.RUNTIME_CONFIG?.keycloak?.url || defaultConfig.keycloak.url, - realm: window.RUNTIME_CONFIG?.keycloak?.realm || defaultConfig.keycloak.realm, - clientId: window.RUNTIME_CONFIG?.keycloak?.clientId || defaultConfig.keycloak.clientId - } + ...defaultConfig }; diff --git a/apps/frontend/src/index.html b/apps/frontend/src/index.html index 970be8591..154bc8a6f 100755 --- a/apps/frontend/src/index.html +++ b/apps/frontend/src/index.html @@ -8,8 +8,6 @@ - - diff --git a/config/frontend/runtime-config.sh b/config/frontend/runtime-config.sh index 7313f197a..2a45df131 100644 --- a/config/frontend/runtime-config.sh +++ b/config/frontend/runtime-config.sh @@ -7,11 +7,11 @@ mkdir -p /usr/share/nginx/html/assets/config cat > /usr/share/nginx/html/assets/config/runtime-config.js <=6" + } + }, + "node_modules/bootstrap-datepicker": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/bootstrap-datepicker/-/bootstrap-datepicker-1.9.0.tgz", + "integrity": "sha512-9rYYbaVOheGYxjOr/+bJCmRPihfy+LkLSg4fIFMT9Od8WwWB/MB50w0JO1eBgKUMbb7PFHQD5uAfI3ArAxZRXA==", + "optional": true, + "dependencies": { + "jquery": ">=1.7.1 <4.0.0" + } + }, + "node_modules/bootstrap-sass": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/bootstrap-sass/-/bootstrap-sass-3.4.1.tgz", + "integrity": "sha512-p5rxsK/IyEDQm2CwiHxxUi0MZZtvVFbhWmyMOt4lLkA4bujDA1TGoKT0i1FKIWiugAdP+kK8T5KMDFIKQCLYIA==", + "optional": true + }, + "node_modules/bootstrap-select": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/bootstrap-select/-/bootstrap-select-1.12.2.tgz", + "integrity": "sha1-WNCVs/1YSzFEOGb745tv3U5OEqQ=", + "optional": true, + "dependencies": { + "jquery": ">=1.8" + } + }, + "node_modules/bootstrap-slider": { + "version": "9.10.0", + "resolved": "https://registry.npmjs.org/bootstrap-slider/-/bootstrap-slider-9.10.0.tgz", + "integrity": "sha1-EQPWvADPv6jPyaJZmrUYxVZD2j8=", + "optional": true + }, + "node_modules/bootstrap-switch": { + "version": "3.3.4", + "resolved": "https://registry.npmjs.org/bootstrap-switch/-/bootstrap-switch-3.3.4.tgz", + "integrity": "sha1-cOCusqh3wNx2aZHeEI4hcPwpov8=", + "optional": true, + "peerDependencies": { + "bootstrap": "^3.1.1", + "jquery": ">=1.9.0" + } + }, + "node_modules/bootstrap-touchspin": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/bootstrap-touchspin/-/bootstrap-touchspin-3.1.1.tgz", + "integrity": "sha1-l3nerHKq9Xfl52K4USx0fIcdlZc=", + "optional": true + }, + "node_modules/c3": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/c3/-/c3-0.4.24.tgz", + "integrity": "sha512-mVCFtN5ZWUT5UE7ilFQ7KBQ7TUCdKIq6KsDt1hH/1m6gC1tBjvzFTO7fqhaiWHfhNOjjM7makschdhg6DkWQMA==", + "optional": true, + "dependencies": { + "d3": "~3.5.0" + } + }, + "node_modules/d3": { + "version": "3.5.17", + "resolved": "https://registry.npmjs.org/d3/-/d3-3.5.17.tgz", + "integrity": "sha1-vEZ0gAQ3iyGjYMn8fPUjF5B2L7g=", + "optional": true + }, + "node_modules/datatables.net": { + "version": "1.11.4", + "resolved": "https://registry.npmjs.org/datatables.net/-/datatables.net-1.11.4.tgz", + "integrity": "sha512-z9LG4O0VYOYzp+rnArLExvnUWV8ikyWBcHYZEKDfVuz7BKxQdEq4a/tpO0Trbm+FL1+RY7UEIh+UcYNY/hwGxA==", + "optional": true, + "dependencies": { + "jquery": ">=1.7" + } + }, + "node_modules/datatables.net-bs": { + "version": "1.11.4", + "resolved": "https://registry.npmjs.org/datatables.net-bs/-/datatables.net-bs-1.11.4.tgz", + "integrity": "sha512-lQaytqSOcSv51jFoT7RyDbaoziCStSDl5Ym1yOBP+ZXIOsS9fd4zOFZyDQlmGFyUpa8JAy84C4r8jM1GQ3/olA==", + "optional": true, + "dependencies": { + "datatables.net": ">=1.11.3", + "jquery": ">=1.7" + } + }, + "node_modules/datatables.net-colreorder": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/datatables.net-colreorder/-/datatables.net-colreorder-1.5.5.tgz", + "integrity": "sha512-AUwv5A/87I4hg7GY/WbhRrDhqng9b019jLvvKutHibSPCEtMDWqyNtuP0q8zYoquqU9UQ1/nqXLW/ld8TzIDYQ==", + "optional": true, + "dependencies": { + "datatables.net": ">=1.11.3", + "jquery": ">=1.7" + } + }, + "node_modules/datatables.net-colreorder-bs": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/datatables.net-colreorder-bs/-/datatables.net-colreorder-bs-1.3.3.tgz", + "integrity": "sha1-Op3LCN7r612FQHlZHgbkk615OlM=", + "optional": true, + "dependencies": { + "datatables.net-bs": ">=1.10.9", + "datatables.net-colreorder": ">=1.2.0", + "jquery": ">=1.7" + } + }, + "node_modules/datatables.net-select": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/datatables.net-select/-/datatables.net-select-1.2.7.tgz", + "integrity": "sha512-C3XDi7wpruGjDXV36dc9hN/FrAX9GOFvBZ7+KfKJTBNkGFbbhdzHS91SMeGiwRXPYivAyxfPTcVVndVaO83uBQ==", + "optional": true, + "dependencies": { + "datatables.net": "^1.10.15", + "jquery": ">=1.7" + } + }, + "node_modules/drmonty-datatables-colvis": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/drmonty-datatables-colvis/-/drmonty-datatables-colvis-1.1.2.tgz", + "integrity": "sha1-lque37SGQ8wu3aP4e4iTPN7oEnw=", + "optional": true, + "dependencies": { + "jquery": ">=1.7.0" + } + }, + "node_modules/eonasdan-bootstrap-datetimepicker": { + "version": "4.17.49", + "resolved": "https://registry.npmjs.org/eonasdan-bootstrap-datetimepicker/-/eonasdan-bootstrap-datetimepicker-4.17.49.tgz", + "integrity": "sha512-7KZeDpkj+A6AtPR3XjX8gAnRPUkPSfW0OmMANG1dkUOPMtLSzbyoCjDIdEcfRtQPU5X0D9Gob7wWKn0h4QWy7A==", + "optional": true, + "dependencies": { + "bootstrap": "^3.3", + "jquery": "^3.5.1", + "moment": "^2.10", + "moment-timezone": "^0.4.0" + }, + "peerDependencies": { + "bootstrap": "^3.3", + "jquery": "^1.8.3 || ^2.0 || ^3.0", + "moment": "^2.10", + "moment-timezone": "^0.4.0 || ^0.5.0" + } + }, + "node_modules/font-awesome": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/font-awesome/-/font-awesome-4.7.0.tgz", + "integrity": "sha1-j6jPBBGhoxr9B7BtKQK7n8gVoTM=", + "engines": { + "node": ">=0.10.3" + } + }, + "node_modules/font-awesome-sass": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/font-awesome-sass/-/font-awesome-sass-4.7.0.tgz", + "integrity": "sha1-TtppPpFQCc4Asijglk3F7Km8NOE=", + "optional": true + }, + "node_modules/google-code-prettify": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/google-code-prettify/-/google-code-prettify-1.0.5.tgz", + "integrity": "sha1-n0d/Ik2/piNy5e+AOn4VdBBAAIQ=", + "optional": true + }, + "node_modules/jquery": { + "version": "3.7.1", + "resolved": "https://registry.npmjs.org/jquery/-/jquery-3.7.1.tgz", + "integrity": "sha512-m4avr8yL8kmFN8psrbFFFmB/If14iN5o9nw/NgnnM+kybDJpRsAynV2BsfpTYrTRysYUdADVD7CkUUizgkpLfg==" + }, + "node_modules/jquery-match-height": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/jquery-match-height/-/jquery-match-height-0.7.2.tgz", + "integrity": "sha1-+NnzulMU2qsQnPB0CGdL4gS+Xw4=", + "optional": true + }, + "node_modules/moment": { + "version": "2.29.1", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.29.1.tgz", + "integrity": "sha512-kHmoybcPV8Sqy59DwNDY3Jefr64lK/by/da0ViFcuA4DH0vQg5Q6Ze5VimxkfQNSC+Mls/Kx53s7TjP1RhFEDQ==", + "optional": true, + "engines": { + "node": "*" + } + }, + "node_modules/moment-timezone": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/moment-timezone/-/moment-timezone-0.4.1.tgz", + "integrity": "sha1-gfWYw61eIs2teWtn7NjYjQ9bqgY=", + "optional": true, + "dependencies": { + "moment": ">= 2.6.0" + }, + "engines": { + "node": "*" + } + }, + "node_modules/patternfly": { + "version": "3.59.5", + "resolved": "https://registry.npmjs.org/patternfly/-/patternfly-3.59.5.tgz", + "integrity": "sha512-SMQynv9eFrWWG0Ujta5+jPjxHdQB3xkTLiDW5VP8XXc0nGUxXb4EnZh21qiMeGGJYaKpu9CzaPEpCvuBxgYWHQ==", + "dependencies": { + "bootstrap": "~3.4.1", + "font-awesome": "^4.7.0", + "jquery": "~3.4.1" + }, + "optionalDependencies": { + "@types/c3": "^0.6.0", + "bootstrap-datepicker": "^1.7.1", + "bootstrap-sass": "^3.4.0", + "bootstrap-select": "1.12.2", + "bootstrap-slider": "^9.9.0", + "bootstrap-switch": "3.3.4", + "bootstrap-touchspin": "~3.1.1", + "c3": "~0.4.11", + "d3": "~3.5.17", + "datatables.net": "^1.10.15", + "datatables.net-colreorder": "^1.4.1", + "datatables.net-colreorder-bs": "~1.3.2", + "datatables.net-select": "~1.2.0", + "drmonty-datatables-colvis": "~1.1.2", + "eonasdan-bootstrap-datetimepicker": "^4.17.47", + "font-awesome-sass": "^4.7.0", + "google-code-prettify": "~1.0.5", + "jquery-match-height": "^0.7.2", + "moment": "^2.19.1", + "moment-timezone": "^0.4.1", + "patternfly-bootstrap-combobox": "~1.1.7", + "patternfly-bootstrap-treeview": "~2.1.10" + } + }, + "node_modules/patternfly-bootstrap-combobox": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/patternfly-bootstrap-combobox/-/patternfly-bootstrap-combobox-1.1.7.tgz", + "integrity": "sha1-al48zRFwwhs8S0qhaKdBPh3btuE=", + "optional": true + }, + "node_modules/patternfly-bootstrap-treeview": { + "version": "2.1.10", + "resolved": "https://registry.npmjs.org/patternfly-bootstrap-treeview/-/patternfly-bootstrap-treeview-2.1.10.tgz", + "integrity": "sha512-P9+iFu34CwX+R5Fd7/EWbxTug0q9mDj53PnZIIh5ie54KX2kD0+54lCWtpD9SVylDwDtDv3n3A6gbFVkx7HsuA==", + "optional": true, + "dependencies": { + "bootstrap": "^3.4.1", + "jquery": "^3.4.1" + }, + "engines": { + "node": ">= 0.10.0" + } + }, + "node_modules/patternfly/node_modules/jquery": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/jquery/-/jquery-3.4.1.tgz", + "integrity": "sha512-36+AdBzCL+y6qjw5Tx7HgzeGCzC81MDDgaUP8ld2zhx58HdqXGoBd+tHdrBMiyjGQs0Hxs/MLZTu/eHNJJuWPw==" + } + }, + "dependencies": { + "@types/c3": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/@types/c3/-/c3-0.6.4.tgz", + "integrity": "sha512-W7i7oSmHsXYhseZJsIYexelv9HitGsWdQhx3mcy4NWso+GedpCYr02ghpkNvnZ4oTIjNeISdrOnM70s7HiuV+g==", + "optional": true, + "requires": { + "@types/d3": "^4" + } + }, + "@types/d3": { + "version": "4.13.12", + "resolved": "https://registry.npmjs.org/@types/d3/-/d3-4.13.12.tgz", + "integrity": "sha512-/bbFtkOBc04gGGN8N9rMG5ps3T0eIj5I8bnYe9iIyeM5qoOrydPCbFYlEPUnj2h9ibc2i+QZfDam9jY5XTrTxQ==", + "optional": true, + "requires": { + "@types/d3-array": "^1", + "@types/d3-axis": "^1", + "@types/d3-brush": "^1", + "@types/d3-chord": "^1", + "@types/d3-collection": "*", + "@types/d3-color": "^1", + "@types/d3-dispatch": "^1", + "@types/d3-drag": "^1", + "@types/d3-dsv": "^1", + "@types/d3-ease": "^1", + "@types/d3-force": "^1", + "@types/d3-format": "^1", + "@types/d3-geo": "^1", + "@types/d3-hierarchy": "^1", + "@types/d3-interpolate": "^1", + "@types/d3-path": "^1", + "@types/d3-polygon": "^1", + "@types/d3-quadtree": "^1", + "@types/d3-queue": "*", + "@types/d3-random": "^1", + "@types/d3-request": "*", + "@types/d3-scale": "^1", + "@types/d3-selection": "^1", + "@types/d3-shape": "^1", + "@types/d3-time": "^1", + "@types/d3-time-format": "^2", + "@types/d3-timer": "^1", + "@types/d3-transition": "^1", + "@types/d3-voronoi": "*", + "@types/d3-zoom": "^1" + } + }, + "@types/d3-array": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-1.2.9.tgz", + "integrity": "sha512-E/7RgPr2ylT5dWG0CswMi9NpFcjIEDqLcUSBgNHe/EMahfqYaTx4zhcggG3khqoEB/leY4Vl6nTSbwLUPjXceA==", + "optional": true + }, + "@types/d3-axis": { + "version": "1.0.16", + "resolved": "https://registry.npmjs.org/@types/d3-axis/-/d3-axis-1.0.16.tgz", + "integrity": "sha512-p7085weOmo4W+DzlRRVC/7OI/jugaKbVa6WMQGCQscaMylcbuaVEGk7abJLNyGVFLeCBNrHTdDiqRGnzvL0nXQ==", + "optional": true, + "requires": { + "@types/d3-selection": "^1" + } + }, + "@types/d3-brush": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@types/d3-brush/-/d3-brush-1.1.5.tgz", + "integrity": "sha512-4zGkBafJf5zCsBtLtvDj/pNMo5X9+Ii/1hUz0GvQ+wEwelUBm2AbIDAzJnp2hLDFF307o0fhxmmocHclhXC+tw==", + "optional": true, + "requires": { + "@types/d3-selection": "^1" + } + }, + "@types/d3-chord": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@types/d3-chord/-/d3-chord-1.0.11.tgz", + "integrity": "sha512-0DdfJ//bxyW3G9Nefwq/LDgazSKNN8NU0lBT3Cza6uVuInC2awMNsAcv1oKyRFLn9z7kXClH5XjwpveZjuz2eg==", + "optional": true + }, + "@types/d3-collection": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/@types/d3-collection/-/d3-collection-1.0.10.tgz", + "integrity": "sha512-54Fdv8u5JbuXymtmXm2SYzi1x/Svt+jfWBU5junkhrCewL92VjqtCBDn97coBRVwVFmYNnVTNDyV8gQyPYfm+A==", + "optional": true + }, + "@types/d3-color": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-1.4.2.tgz", + "integrity": "sha512-fYtiVLBYy7VQX+Kx7wU/uOIkGQn8aAEY8oWMoyja3N4dLd8Yf6XgSIR/4yWvMuveNOH5VShnqCgRqqh/UNanBA==", + "optional": true + }, + "@types/d3-dispatch": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-dispatch/-/d3-dispatch-1.0.9.tgz", + "integrity": "sha512-zJ44YgjqALmyps+II7b1mZLhrtfV/FOxw9owT87mrweGWcg+WK5oiJX2M3SYJ0XUAExBduarysfgbR11YxzojQ==", + "optional": true + }, + "@types/d3-drag": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-1.2.5.tgz", + "integrity": "sha512-7NeTnfolst1Js3Vs7myctBkmJWu6DMI3k597AaHUX98saHjHWJ6vouT83UrpE+xfbSceHV+8A0JgxuwgqgmqWw==", + "optional": true, + "requires": { + "@types/d3-selection": "^1" + } + }, + "@types/d3-dsv": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@types/d3-dsv/-/d3-dsv-1.2.1.tgz", + "integrity": "sha512-LLmJmjiqp/fTNEdij5bIwUJ6P6TVNk5hKM9/uk5RPO2YNgEu9XvKO0dJ7Iqd3psEdmZN1m7gB1bOsjr4HmO2BA==", + "optional": true + }, + "@types/d3-ease": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-1.0.11.tgz", + "integrity": "sha512-wUigPL0kleGZ9u3RhzBP07lxxkMcUjL5IODP42mN/05UNL+JJCDnpEPpFbJiPvLcTeRKGIRpBBJyP/1BNwYsVA==", + "optional": true + }, + "@types/d3-force": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@types/d3-force/-/d3-force-1.2.4.tgz", + "integrity": "sha512-fkorLTKvt6AQbFBQwn4aq7h9rJ4c7ZVcPMGB8X6eFFveAyMZcv7t7m6wgF4Eg93rkPgPORU7sAho1QSHNcZu6w==", + "optional": true + }, + "@types/d3-format": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/@types/d3-format/-/d3-format-1.4.2.tgz", + "integrity": "sha512-WeGCHAs7PHdZYq6lwl/+jsl+Nfc1J2W1kNcMeIMYzQsT6mtBDBgtJ/rcdjZ0k0rVIvqEZqhhuD5TK/v3P2gFHQ==", + "optional": true + }, + "@types/d3-geo": { + "version": "1.12.3", + "resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-1.12.3.tgz", + "integrity": "sha512-yZbPb7/5DyL/pXkeOmZ7L5ySpuGr4H48t1cuALjnJy5sXQqmSSAYBiwa6Ya/XpWKX2rJqGDDubmh3nOaopOpeA==", + "optional": true, + "requires": { + "@types/geojson": "*" + } + }, + "@types/d3-hierarchy": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/@types/d3-hierarchy/-/d3-hierarchy-1.1.8.tgz", + "integrity": "sha512-AbStKxNyWiMDQPGDguG2Kuhlq1Sv539pZSxYbx4UZeYkutpPwXCcgyiRrlV4YH64nIOsKx7XVnOMy9O7rJsXkg==", + "optional": true + }, + "@types/d3-interpolate": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-1.4.2.tgz", + "integrity": "sha512-ylycts6llFf8yAEs1tXzx2loxxzDZHseuhPokrqKprTQSTcD3JbJI1omZP1rphsELZO3Q+of3ff0ZS7+O6yVzg==", + "optional": true, + "requires": { + "@types/d3-color": "^1" + } + }, + "@types/d3-path": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-1.0.9.tgz", + "integrity": "sha512-NaIeSIBiFgSC6IGUBjZWcscUJEq7vpVu7KthHN8eieTV9d9MqkSOZLH4chq1PmcKy06PNe3axLeKmRIyxJ+PZQ==", + "optional": true + }, + "@types/d3-polygon": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/d3-polygon/-/d3-polygon-1.0.8.tgz", + "integrity": "sha512-1TOJPXCBJC9V3+K3tGbTqD/CsqLyv/YkTXAcwdsZzxqw5cvpdnCuDl42M4Dvi8XzMxZNCT9pL4ibrK2n4VmAcw==", + "optional": true + }, + "@types/d3-quadtree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-quadtree/-/d3-quadtree-1.0.9.tgz", + "integrity": "sha512-5E0OJJn2QVavITFEc1AQlI8gLcIoDZcTKOD3feKFckQVmFV4CXhqRFt83tYNVNIN4ZzRkjlAMavJa1ldMhf5rA==", + "optional": true + }, + "@types/d3-queue": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@types/d3-queue/-/d3-queue-3.0.8.tgz", + "integrity": "sha512-1FWOiI/MYwS5Z1Sa9EvS1Xet3isiVIIX5ozD6iGnwHonGcqL+RcC1eThXN5VfDmAiYt9Me9EWNEv/9J9k9RIKQ==", + "optional": true + }, + "@types/d3-random": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-random/-/d3-random-1.1.3.tgz", + "integrity": "sha512-XXR+ZbFCoOd4peXSMYJzwk0/elP37WWAzS/DG+90eilzVbUSsgKhBcWqylGWe+lA2ubgr7afWAOBaBxRgMUrBQ==", + "optional": true + }, + "@types/d3-request": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-request/-/d3-request-1.0.6.tgz", + "integrity": "sha512-4nRKDUBg3EBx8VowpMvM3NAVMiMMI1qFUOYv3OJsclGjHX6xjtu09nsWhRQ0fvSUla3MEjb5Ch4IeaYarMEi1w==", + "optional": true, + "requires": { + "@types/d3-dsv": "^1" + } + }, + "@types/d3-scale": { + "version": "1.0.17", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-1.0.17.tgz", + "integrity": "sha512-baIP5/gw+PS8Axs1lfZCeIjcOXen/jxQmgFEjbYThwaj2drvivOIrJMh2Ig4MeenrogCH6zkhiOxCPRkvN1scA==", + "optional": true, + "requires": { + "@types/d3-time": "^1" + } + }, + "@types/d3-selection": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-1.4.3.tgz", + "integrity": "sha512-GjKQWVZO6Sa96HiKO6R93VBE8DUW+DDkFpIMf9vpY5S78qZTlRRSNUsHr/afDpF7TvLDV7VxrUFOWW7vdIlYkA==", + "optional": true + }, + "@types/d3-shape": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-1.3.8.tgz", + "integrity": "sha512-gqfnMz6Fd5H6GOLYixOZP/xlrMtJms9BaS+6oWxTKHNqPGZ93BkWWupQSCYm6YHqx6h9wjRupuJb90bun6ZaYg==", + "optional": true, + "requires": { + "@types/d3-path": "^1" + } + }, + "@types/d3-time": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-1.1.1.tgz", + "integrity": "sha512-ULX7LoqXTCYtM+tLYOaeAJK7IwCT+4Gxlm2MaH0ErKLi07R5lh8NHCAyWcDkCCmx1AfRcBEV6H9QE9R25uP7jw==", + "optional": true + }, + "@types/d3-time-format": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@types/d3-time-format/-/d3-time-format-2.3.1.tgz", + "integrity": "sha512-fck0Z9RGfIQn3GJIEKVrp15h9m6Vlg0d5XXeiE/6+CQiBmMDZxfR21XtjEPuDeg7gC3bBM0SdieA5XF3GW1wKA==", + "optional": true + }, + "@types/d3-timer": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-1.0.10.tgz", + "integrity": "sha512-ZnAbquVqy+4ZjdW0cY6URp+qF/AzTVNda2jYyOzpR2cPT35FTXl78s15Bomph9+ckOiI1TtkljnWkwbIGAb6rg==", + "optional": true + }, + "@types/d3-transition": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-1.3.2.tgz", + "integrity": "sha512-J+a3SuF/E7wXbOSN19p8ZieQSFIm5hU2Egqtndbc54LXaAEOpLfDx4sBu/PKAKzHOdgKK1wkMhINKqNh4aoZAg==", + "optional": true, + "requires": { + "@types/d3-selection": "^1" + } + }, + "@types/d3-voronoi": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/@types/d3-voronoi/-/d3-voronoi-1.1.9.tgz", + "integrity": "sha512-DExNQkaHd1F3dFPvGA/Aw2NGyjMln6E9QzsiqOcBgnE+VInYnFBHBBySbZQts6z6xD+5jTfKCP7M4OqMyVjdwQ==", + "optional": true + }, + "@types/d3-zoom": { + "version": "1.8.3", + "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-1.8.3.tgz", + "integrity": "sha512-3kHkL6sPiDdbfGhzlp5gIHyu3kULhtnHTTAl3UBZVtWB1PzcLL8vdmz5mTx7plLiUqOA2Y+yT2GKjt/TdA2p7Q==", + "optional": true, + "requires": { + "@types/d3-interpolate": "^1", + "@types/d3-selection": "^1" + } + }, + "@types/geojson": { + "version": "7946.0.8", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.8.tgz", + "integrity": "sha512-1rkryxURpr6aWP7R786/UQOkJ3PcpQiWkAXBmdWc7ryFWqN6a4xfK7BtjXvFBKO9LjQ+MWQSWxYeZX1OApnArA==", + "optional": true + }, + "bootstrap": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-3.4.1.tgz", + "integrity": "sha512-yN5oZVmRCwe5aKwzRj6736nSmKDX7pLYwsXiCj/EYmo16hODaBiT4En5btW/jhBF/seV+XMx3aYwukYC3A49DA==" + }, + "bootstrap-datepicker": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/bootstrap-datepicker/-/bootstrap-datepicker-1.9.0.tgz", + "integrity": "sha512-9rYYbaVOheGYxjOr/+bJCmRPihfy+LkLSg4fIFMT9Od8WwWB/MB50w0JO1eBgKUMbb7PFHQD5uAfI3ArAxZRXA==", + "optional": true, + "requires": { + "jquery": ">=1.7.1 <4.0.0" + } + }, + "bootstrap-sass": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/bootstrap-sass/-/bootstrap-sass-3.4.1.tgz", + "integrity": "sha512-p5rxsK/IyEDQm2CwiHxxUi0MZZtvVFbhWmyMOt4lLkA4bujDA1TGoKT0i1FKIWiugAdP+kK8T5KMDFIKQCLYIA==", + "optional": true + }, + "bootstrap-select": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/bootstrap-select/-/bootstrap-select-1.12.2.tgz", + "integrity": "sha1-WNCVs/1YSzFEOGb745tv3U5OEqQ=", + "optional": true, + "requires": { + "jquery": ">=1.8" + } + }, + "bootstrap-slider": { + "version": "9.10.0", + "resolved": "https://registry.npmjs.org/bootstrap-slider/-/bootstrap-slider-9.10.0.tgz", + "integrity": "sha1-EQPWvADPv6jPyaJZmrUYxVZD2j8=", + "optional": true + }, + "bootstrap-switch": { + "version": "3.3.4", + "resolved": "https://registry.npmjs.org/bootstrap-switch/-/bootstrap-switch-3.3.4.tgz", + "integrity": "sha1-cOCusqh3wNx2aZHeEI4hcPwpov8=", + "optional": true, + "requires": {} + }, + "bootstrap-touchspin": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/bootstrap-touchspin/-/bootstrap-touchspin-3.1.1.tgz", + "integrity": "sha1-l3nerHKq9Xfl52K4USx0fIcdlZc=", + "optional": true + }, + "c3": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/c3/-/c3-0.4.24.tgz", + "integrity": "sha512-mVCFtN5ZWUT5UE7ilFQ7KBQ7TUCdKIq6KsDt1hH/1m6gC1tBjvzFTO7fqhaiWHfhNOjjM7makschdhg6DkWQMA==", + "optional": true, + "requires": { + "d3": "~3.5.0" + } + }, + "d3": { + "version": "3.5.17", + "resolved": "https://registry.npmjs.org/d3/-/d3-3.5.17.tgz", + "integrity": "sha1-vEZ0gAQ3iyGjYMn8fPUjF5B2L7g=", + "optional": true + }, + "datatables.net": { + "version": "1.11.4", + "resolved": "https://registry.npmjs.org/datatables.net/-/datatables.net-1.11.4.tgz", + "integrity": "sha512-z9LG4O0VYOYzp+rnArLExvnUWV8ikyWBcHYZEKDfVuz7BKxQdEq4a/tpO0Trbm+FL1+RY7UEIh+UcYNY/hwGxA==", + "optional": true, + "requires": { + "jquery": ">=1.7" + } + }, + "datatables.net-bs": { + "version": "1.11.4", + "resolved": "https://registry.npmjs.org/datatables.net-bs/-/datatables.net-bs-1.11.4.tgz", + "integrity": "sha512-lQaytqSOcSv51jFoT7RyDbaoziCStSDl5Ym1yOBP+ZXIOsS9fd4zOFZyDQlmGFyUpa8JAy84C4r8jM1GQ3/olA==", + "optional": true, + "requires": { + "datatables.net": ">=1.11.3", + "jquery": ">=1.7" + } + }, + "datatables.net-colreorder": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/datatables.net-colreorder/-/datatables.net-colreorder-1.5.5.tgz", + "integrity": "sha512-AUwv5A/87I4hg7GY/WbhRrDhqng9b019jLvvKutHibSPCEtMDWqyNtuP0q8zYoquqU9UQ1/nqXLW/ld8TzIDYQ==", + "optional": true, + "requires": { + "datatables.net": ">=1.11.3", + "jquery": ">=1.7" + } + }, + "datatables.net-colreorder-bs": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/datatables.net-colreorder-bs/-/datatables.net-colreorder-bs-1.3.3.tgz", + "integrity": "sha1-Op3LCN7r612FQHlZHgbkk615OlM=", + "optional": true, + "requires": { + "datatables.net-bs": ">=1.10.9", + "datatables.net-colreorder": ">=1.2.0", + "jquery": ">=1.7" + } + }, + "datatables.net-select": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/datatables.net-select/-/datatables.net-select-1.2.7.tgz", + "integrity": "sha512-C3XDi7wpruGjDXV36dc9hN/FrAX9GOFvBZ7+KfKJTBNkGFbbhdzHS91SMeGiwRXPYivAyxfPTcVVndVaO83uBQ==", + "optional": true, + "requires": { + "datatables.net": "^1.10.15", + "jquery": ">=1.7" + } + }, + "drmonty-datatables-colvis": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/drmonty-datatables-colvis/-/drmonty-datatables-colvis-1.1.2.tgz", + "integrity": "sha1-lque37SGQ8wu3aP4e4iTPN7oEnw=", + "optional": true, + "requires": { + "jquery": ">=1.7.0" + } + }, + "eonasdan-bootstrap-datetimepicker": { + "version": "4.17.49", + "resolved": "https://registry.npmjs.org/eonasdan-bootstrap-datetimepicker/-/eonasdan-bootstrap-datetimepicker-4.17.49.tgz", + "integrity": "sha512-7KZeDpkj+A6AtPR3XjX8gAnRPUkPSfW0OmMANG1dkUOPMtLSzbyoCjDIdEcfRtQPU5X0D9Gob7wWKn0h4QWy7A==", + "optional": true, + "requires": { + "bootstrap": "^3.3", + "jquery": "^3.5.1", + "moment": "^2.10", + "moment-timezone": "^0.4.0" + } + }, + "font-awesome": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/font-awesome/-/font-awesome-4.7.0.tgz", + "integrity": "sha1-j6jPBBGhoxr9B7BtKQK7n8gVoTM=" + }, + "font-awesome-sass": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/font-awesome-sass/-/font-awesome-sass-4.7.0.tgz", + "integrity": "sha1-TtppPpFQCc4Asijglk3F7Km8NOE=", + "optional": true + }, + "google-code-prettify": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/google-code-prettify/-/google-code-prettify-1.0.5.tgz", + "integrity": "sha1-n0d/Ik2/piNy5e+AOn4VdBBAAIQ=", + "optional": true + }, + "jquery": { + "version": "3.7.1", + "resolved": "https://registry.npmjs.org/jquery/-/jquery-3.7.1.tgz", + "integrity": "sha512-m4avr8yL8kmFN8psrbFFFmB/If14iN5o9nw/NgnnM+kybDJpRsAynV2BsfpTYrTRysYUdADVD7CkUUizgkpLfg==" + }, + "jquery-match-height": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/jquery-match-height/-/jquery-match-height-0.7.2.tgz", + "integrity": "sha1-+NnzulMU2qsQnPB0CGdL4gS+Xw4=", + "optional": true + }, + "moment": { + "version": "2.29.1", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.29.1.tgz", + "integrity": "sha512-kHmoybcPV8Sqy59DwNDY3Jefr64lK/by/da0ViFcuA4DH0vQg5Q6Ze5VimxkfQNSC+Mls/Kx53s7TjP1RhFEDQ==", + "optional": true + }, + "moment-timezone": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/moment-timezone/-/moment-timezone-0.4.1.tgz", + "integrity": "sha1-gfWYw61eIs2teWtn7NjYjQ9bqgY=", + "optional": true, + "requires": { + "moment": ">= 2.6.0" + } + }, + "patternfly": { + "version": "3.59.5", + "resolved": "https://registry.npmjs.org/patternfly/-/patternfly-3.59.5.tgz", + "integrity": "sha512-SMQynv9eFrWWG0Ujta5+jPjxHdQB3xkTLiDW5VP8XXc0nGUxXb4EnZh21qiMeGGJYaKpu9CzaPEpCvuBxgYWHQ==", + "requires": { + "@types/c3": "^0.6.0", + "bootstrap": "~3.4.1", + "bootstrap-datepicker": "^1.7.1", + "bootstrap-sass": "^3.4.0", + "bootstrap-select": "1.12.2", + "bootstrap-slider": "^9.9.0", + "bootstrap-switch": "3.3.4", + "bootstrap-touchspin": "~3.1.1", + "c3": "~0.4.11", + "d3": "~3.5.17", + "datatables.net": "^1.10.15", + "datatables.net-colreorder": "^1.4.1", + "datatables.net-colreorder-bs": "~1.3.2", + "datatables.net-select": "~1.2.0", + "drmonty-datatables-colvis": "~1.1.2", + "eonasdan-bootstrap-datetimepicker": "^4.17.47", + "font-awesome": "^4.7.0", + "font-awesome-sass": "^4.7.0", + "google-code-prettify": "~1.0.5", + "jquery": "~3.4.1", + "jquery-match-height": "^0.7.2", + "moment": "^2.19.1", + "moment-timezone": "^0.4.1", + "patternfly-bootstrap-combobox": "~1.1.7", + "patternfly-bootstrap-treeview": "~2.1.10" + }, + "dependencies": { + "jquery": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/jquery/-/jquery-3.4.1.tgz", + "integrity": "sha512-36+AdBzCL+y6qjw5Tx7HgzeGCzC81MDDgaUP8ld2zhx58HdqXGoBd+tHdrBMiyjGQs0Hxs/MLZTu/eHNJJuWPw==" + } + } + }, + "patternfly-bootstrap-combobox": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/patternfly-bootstrap-combobox/-/patternfly-bootstrap-combobox-1.1.7.tgz", + "integrity": "sha1-al48zRFwwhs8S0qhaKdBPh3btuE=", + "optional": true + }, + "patternfly-bootstrap-treeview": { + "version": "2.1.10", + "resolved": "https://registry.npmjs.org/patternfly-bootstrap-treeview/-/patternfly-bootstrap-treeview-2.1.10.tgz", + "integrity": "sha512-P9+iFu34CwX+R5Fd7/EWbxTug0q9mDj53PnZIIh5ie54KX2kD0+54lCWtpD9SVylDwDtDv3n3A6gbFVkx7HsuA==", + "optional": true, + "requires": { + "bootstrap": "^3.4.1", + "jquery": "^3.4.1" + } + } + } +} diff --git a/config/keycloak/themes/iqb/common/resources/package.json b/config/keycloak/themes/iqb/common/resources/package.json new file mode 100644 index 000000000..ef62dadfe --- /dev/null +++ b/config/keycloak/themes/iqb/common/resources/package.json @@ -0,0 +1,11 @@ +{ + "name": "keycloak-npm-dependencies", + "version": "1.0.0", + "description": "Keycloak NPM Dependencies", + "license": "Apache-2.0", + "repository": "https://github.com/keycloak/keycloak", + "dependencies": { + "jquery": "3.7.1", + "patternfly": "3.59.5" + } +} diff --git a/config/keycloak/themes/iqb/email/theme.properties b/config/keycloak/themes/iqb/email/theme.properties new file mode 100644 index 000000000..8f83cc023 --- /dev/null +++ b/config/keycloak/themes/iqb/email/theme.properties @@ -0,0 +1 @@ +parent=base diff --git a/config/keycloak/themes/iqb/login/resources/css/login.css b/config/keycloak/themes/iqb/login/resources/css/login.css new file mode 100644 index 000000000..ed313c89c --- /dev/null +++ b/config/keycloak/themes/iqb/login/resources/css/login.css @@ -0,0 +1,605 @@ +/* Patternfly CSS places a "bg-login.jpg" as the background on this ".login-pf" class. + This clashes with the "keycloak-bg.png' background defined on the body below. + Therefore the Patternfly background must be set to none. */ +.login-pf { + background: none; +} + +.login-pf body { + background: url("../img/keycloak-bg.png") no-repeat center center fixed; + background-size: cover; + height: 100%; +} + +textarea.pf-c-form-control { + height: auto; +} + +.pf-c-alert__title { + font-size: var(--pf-global--FontSize--xs); +} + +p.instruction { + margin: 5px 0; +} + +.pf-c-button.pf-m-control { + border-color: rgba(230, 230, 230, 0.5); +} + +h1#kc-page-title { + margin-top: 10px; +} + +#kc-locale ul { + background-color: var(--pf-global--BackgroundColor--100); + display: none; + top: 20px; + min-width: 100px; + padding: 0; +} + +#kc-locale-dropdown{ + display: inline-block; +} + +#kc-locale-dropdown:hover ul { + display:block; +} + +#kc-locale-dropdown a { + color: var(--pf-global--Color--200); + text-align: right; + font-size: var(--pf-global--FontSize--sm); +} + +a#kc-current-locale-link::after { + content: "\2c5"; + margin-left: var(--pf-global--spacer--xs) +} + +.login-pf .container { + padding-top: 40px; +} + +.login-pf a:hover { + color: #0099d3; +} + +#kc-logo { + width: 100%; +} + +div.kc-logo-text { + background-image: url(../img/keycloak-logo-text.png); + background-repeat: no-repeat; + height: 63px; + width: 300px; + margin: 0 auto; +} + +div.kc-logo-text span { + display: none; +} + +#kc-header { + color: #ededed; + overflow: visible; + white-space: nowrap; +} + +#kc-header-wrapper { + font-size: 29px; + text-transform: uppercase; + letter-spacing: 3px; + line-height: 1.2em; + padding: 62px 10px 20px; + white-space: normal; +} + +#kc-content { + width: 100%; +} + +#kc-attempted-username { + font-size: 20px; + font-family: inherit; + font-weight: normal; + padding-right: 10px; +} + +#kc-username { + text-align: center; + margin-bottom:-10px; +} + +#kc-webauthn-settings-form { + padding-top: 8px; +} + +#kc-form-webauthn .select-auth-box-parent { + pointer-events: none; +} + +#kc-form-webauthn .select-auth-box-desc { + color: var(--pf-global--palette--black-600); +} + +#kc-form-webauthn .select-auth-box-headline { + color: var(--pf-global--Color--300); +} + +#kc-form-webauthn .select-auth-box-icon { + flex: 0 0 3em; +} + +#kc-form-webauthn .select-auth-box-icon-properties { + margin-top: 10px; + font-size: 1.8em; +} + +#kc-form-webauthn .select-auth-box-icon-properties.unknown-transport-class { + margin-top: 3px; +} + +#kc-form-webauthn .pf-l-stack__item { + margin: -1px 0; +} + +#kc-content-wrapper { + margin-top: 20px; +} + +#kc-form-wrapper { + margin-top: 10px; +} + +#kc-info { + margin: 20px -40px -30px; +} + +#kc-info-wrapper { + font-size: 13px; + padding: 15px 35px; + background-color: #F0F0F0; +} + +#kc-form-options span { + display: block; +} + +#kc-form-options .checkbox { + margin-top: 0; + color: #72767b; +} + +#kc-terms-text { + margin-bottom: 20px; +} + +#kc-registration-terms-text { + max-height: 100px; + overflow-y: auto; + overflow-x: hidden; + margin: 5px; +} + +#kc-registration { + margin-bottom: 0; +} + +/* TOTP */ + +.subtitle { + text-align: right; + margin-top: 30px; + color: #909090; +} + +.required { + color: var(--pf-global--danger-color--200); +} + +ol#kc-totp-settings { + margin: 0; + padding-left: 20px; +} + +ul#kc-totp-supported-apps { + margin-bottom: 10px; +} + +#kc-totp-secret-qr-code { + max-width:150px; + max-height:150px; +} + +#kc-totp-secret-key { + background-color: #fff; + color: #333333; + font-size: 16px; + padding: 10px 0; +} + +/* OAuth */ + +#kc-oauth h3 { + margin-top: 0; +} + +#kc-oauth ul { + list-style: none; + padding: 0; + margin: 0; +} + +#kc-oauth ul li { + border-top: 1px solid rgba(255, 255, 255, 0.1); + font-size: 12px; + padding: 10px 0; +} + +#kc-oauth ul li:first-of-type { + border-top: 0; +} + +#kc-oauth .kc-role { + display: inline-block; + width: 50%; +} + +/* Code */ +#kc-code textarea { + width: 100%; + height: 8em; +} + +/* Social */ +.kc-social-links { + margin-top: 20px; +} + +.kc-social-links li { + width: 100%; +} + +.kc-social-provider-logo { + font-size: 23px; + width: 30px; + height: 25px; + float: left; +} + +.kc-social-gray { + color: var(--pf-global--Color--200); +} + +.kc-social-item { + margin-bottom: var(--pf-global--spacer--sm); + font-size: 15px; + text-align: center; +} + +.kc-social-provider-name { + position: relative; +} + +.kc-social-icon-text { + left: -15px; +} + +.kc-social-grid { + display:grid; + grid-column-gap: 10px; + grid-row-gap: 5px; + grid-column-end: span 6; + --pf-l-grid__item--GridColumnEnd: span 6; +} + +.kc-social-grid .kc-social-icon-text { + left: -10px; +} + +.kc-login-tooltip { + position: relative; + display: inline-block; +} + +.kc-social-section { + text-align: center; +} + +.kc-social-section hr{ + margin-bottom: 10px +} + +.kc-login-tooltip .kc-tooltip-text{ + top:-3px; + left:160%; + background-color: black; + visibility: hidden; + color: #fff; + + min-width:130px; + text-align: center; + border-radius: 2px; + box-shadow:0 1px 8px rgba(0,0,0,0.6); + padding: 5px; + + position: absolute; + opacity:0; + transition:opacity 0.5s; +} + +/* Show tooltip */ +.kc-login-tooltip:hover .kc-tooltip-text { + visibility: visible; + opacity:0.7; +} + +/* Arrow for tooltip */ +.kc-login-tooltip .kc-tooltip-text::after { + content: " "; + position: absolute; + top: 15px; + right: 100%; + margin-top: -5px; + border-width: 5px; + border-style: solid; + border-color: transparent black transparent transparent; +} + +@media (min-width: 768px) { + #kc-container-wrapper { + position: absolute; + width: 100%; + } + + .login-pf .container { + padding-right: 80px; + } + + #kc-locale { + position: relative; + text-align: right; + z-index: 9999; + } +} + +@media (max-width: 767px) { + + .login-pf body { + background: white; + } + + #kc-header { + padding-left: 15px; + padding-right: 15px; + float: none; + text-align: left; + } + + #kc-header-wrapper { + font-size: 16px; + font-weight: bold; + padding: 20px 60px 0 0; + color: #72767b; + letter-spacing: 0; + } + + div.kc-logo-text { + margin: 0; + width: 150px; + height: 32px; + background-size: 100%; + } + + #kc-form { + float: none; + } + + #kc-info-wrapper { + border-top: 1px solid rgba(255, 255, 255, 0.1); + background-color: transparent; + } + + .login-pf .container { + padding-top: 15px; + padding-bottom: 15px; + } + + #kc-locale { + position: absolute; + width: 200px; + top: 20px; + right: 20px; + text-align: right; + z-index: 9999; + } +} + +@media (min-height: 646px) { + #kc-container-wrapper { + bottom: 12%; + } +} + +@media (max-height: 645px) { + #kc-container-wrapper { + padding-top: 50px; + top: 20%; + } +} + +.card-pf form.form-actions .btn { + float: right; + margin-left: 10px; +} + +#kc-form-buttons { + margin-top: 20px; +} + +.login-pf-page .login-pf-brand { + margin-top: 20px; + max-width: 360px; + width: 40%; +} + +.select-auth-box-arrow{ + display: flex; + align-items: center; + margin-right: 2rem; +} + +.select-auth-box-icon{ + display: flex; + flex: 0 0 2em; + justify-content: center; + margin-right: 1rem; + margin-left: 3rem; +} + +.select-auth-box-parent{ + border-top: 1px solid var(--pf-global--palette--black-200); + padding-top: 1rem; + padding-bottom: 1rem; + cursor: pointer; +} + +.select-auth-box-parent:hover{ + background-color: #f7f8f8; +} + +.select-auth-container { + padding-bottom: 0px !important; +} + +.select-auth-box-headline { + font-size: var(--pf-global--FontSize--md); + color: var(--pf-global--primary-color--100); + font-weight: bold; +} + +.select-auth-box-desc { + font-size: var(--pf-global--FontSize--sm); +} + +.select-auth-box-paragraph { + text-align: center; + font-size: var(--pf-global--FontSize--md); + margin-bottom: 5px; +} + +.card-pf { + margin: 0 auto; + box-shadow: var(--pf-global--BoxShadow--lg); + padding: 0 20px; + max-width: 500px; + border-top: 4px solid; + border-color: var(--pf-global--primary-color--100); +} + +/*phone*/ +@media (max-width: 767px) { + .login-pf-page .card-pf { + max-width: none; + margin-left: 0; + margin-right: 0; + padding-top: 0; + border-top: 0; + box-shadow: 0 0; + } + + .kc-social-grid { + grid-column-end: 12; + --pf-l-grid__item--GridColumnEnd: span 12; + } + + .kc-social-grid .kc-social-icon-text { + left: -15px; + } +} + +.login-pf-page .login-pf-signup { + font-size: 15px; + color: #72767b; +} +#kc-content-wrapper .row { + margin-left: 0; + margin-right: 0; +} + +.login-pf-page.login-pf-page-accounts { + margin-left: auto; + margin-right: auto; +} + +.login-pf-page .btn-primary { + margin-top: 0; +} + +.login-pf-page .list-view-pf .list-group-item { + border-bottom: 1px solid #ededed; +} + +.login-pf-page .list-view-pf-description { + width: 100%; +} + +#kc-form-login div.form-group:last-of-type, +#kc-register-form div.form-group:last-of-type, +#kc-update-profile-form div.form-group:last-of-type, +#kc-update-email-form div.form-group:last-of-type{ + margin-bottom: 0px; +} + +.no-bottom-margin { + margin-bottom: 0; +} + +#kc-back { + margin-top: 5px; +} + +/* Recovery codes */ +.kc-recovery-codes-warning { + margin-bottom: 32px; +} +.kc-recovery-codes-warning .pf-c-alert__description p { + font-size: 0.875rem; +} +.kc-recovery-codes-list { + list-style: none; + columns: 2; + margin: 16px 0; + padding: 16px 16px 8px 16px; + border: 1px solid #D2D2D2; +} +.kc-recovery-codes-list li { + margin-bottom: 8px; + font-size: 11px; +} +.kc-recovery-codes-list li span { + color: #6A6E73; + width: 16px; + text-align: right; + display: inline-block; + margin-right: 1px; +} + +.kc-recovery-codes-actions { + margin-bottom: 24px; +} +.kc-recovery-codes-actions button { + padding-left: 0; +} +.kc-recovery-codes-actions button i { + margin-right: 8px; +} + +.kc-recovery-codes-confirmation { + align-items: baseline; + margin-bottom: 16px; +} +/* End Recovery codes */ diff --git a/config/keycloak/themes/iqb/login/resources/img/feedback-error-arrow-down.png b/config/keycloak/themes/iqb/login/resources/img/feedback-error-arrow-down.png new file mode 100644 index 000000000..6f2d9d2ae Binary files /dev/null and b/config/keycloak/themes/iqb/login/resources/img/feedback-error-arrow-down.png differ diff --git a/config/keycloak/themes/iqb/login/resources/img/feedback-error-sign.png b/config/keycloak/themes/iqb/login/resources/img/feedback-error-sign.png new file mode 100644 index 000000000..0dd500445 Binary files /dev/null and b/config/keycloak/themes/iqb/login/resources/img/feedback-error-sign.png differ diff --git a/config/keycloak/themes/iqb/login/resources/img/feedback-success-arrow-down.png b/config/keycloak/themes/iqb/login/resources/img/feedback-success-arrow-down.png new file mode 100644 index 000000000..03cc0c45d Binary files /dev/null and b/config/keycloak/themes/iqb/login/resources/img/feedback-success-arrow-down.png differ diff --git a/config/keycloak/themes/iqb/login/resources/img/feedback-success-sign.png b/config/keycloak/themes/iqb/login/resources/img/feedback-success-sign.png new file mode 100644 index 000000000..640bd71ca Binary files /dev/null and b/config/keycloak/themes/iqb/login/resources/img/feedback-success-sign.png differ diff --git a/config/keycloak/themes/iqb/login/resources/img/feedback-warning-arrow-down.png b/config/keycloak/themes/iqb/login/resources/img/feedback-warning-arrow-down.png new file mode 100644 index 000000000..6f2d9d2ae Binary files /dev/null and b/config/keycloak/themes/iqb/login/resources/img/feedback-warning-arrow-down.png differ diff --git a/config/keycloak/themes/iqb/login/resources/img/feedback-warning-sign.png b/config/keycloak/themes/iqb/login/resources/img/feedback-warning-sign.png new file mode 100644 index 000000000..f9392a356 Binary files /dev/null and b/config/keycloak/themes/iqb/login/resources/img/feedback-warning-sign.png differ diff --git a/config/keycloak/themes/iqb/login/resources/img/keycloak-bg.png b/config/keycloak/themes/iqb/login/resources/img/keycloak-bg.png new file mode 100644 index 000000000..31d790786 Binary files /dev/null and b/config/keycloak/themes/iqb/login/resources/img/keycloak-bg.png differ diff --git a/config/keycloak/themes/iqb/login/resources/img/keycloak-logo-text.png b/config/keycloak/themes/iqb/login/resources/img/keycloak-logo-text.png new file mode 100644 index 000000000..63f3b9f87 Binary files /dev/null and b/config/keycloak/themes/iqb/login/resources/img/keycloak-logo-text.png differ diff --git a/config/keycloak/themes/iqb/login/theme.properties b/config/keycloak/themes/iqb/login/theme.properties new file mode 100644 index 000000000..27edf80c5 --- /dev/null +++ b/config/keycloak/themes/iqb/login/theme.properties @@ -0,0 +1,162 @@ +parent=base +import=common/keycloak + +styles=css/login.css +stylesCommon=web_modules/@patternfly/react-core/dist/styles/base.css web_modules/@patternfly/react-core/dist/styles/app.css node_modules/patternfly/dist/css/patternfly.min.css node_modules/patternfly/dist/css/patternfly-additions.min.css lib/pficon/pficon.css + +meta=viewport==width=device-width,initial-scale=1 + +kcHtmlClass=login-pf +kcLoginClass=login-pf-page + +kcLogoLink=http://www.keycloak.org + +kcLogoClass=login-pf-brand + +kcContainerClass=container-fluid +kcContentClass=col-sm-8 col-sm-offset-2 col-md-6 col-md-offset-3 col-lg-6 col-lg-offset-3 + +kcHeaderClass=login-pf-page-header +kcFeedbackAreaClass=col-md-12 +kcLocaleClass=col-xs-12 col-sm-1 + +## Locale +kcLocaleMainClass=pf-c-dropdown +kcLocaleListClass=pf-c-dropdown__menu pf-m-align-right +kcLocaleItemClass=pf-c-dropdown__menu-item + +## Alert +kcAlertClass=pf-c-alert pf-m-inline +kcAlertTitleClass=pf-c-alert__title kc-feedback-text + +kcFormAreaClass=col-sm-10 col-sm-offset-1 col-md-8 col-md-offset-2 col-lg-8 col-lg-offset-2 +kcFormCardClass=card-pf + +### Social providers +kcFormSocialAccountListClass=pf-c-login__main-footer-links kc-social-links +kcFormSocialAccountListGridClass=pf-l-grid kc-social-grid +kcFormSocialAccountListButtonClass=pf-c-button pf-m-control pf-m-block kc-social-item kc-social-gray +kcFormSocialAccountGridItem=pf-l-grid__item + +kcFormSocialAccountNameClass=kc-social-provider-name +kcFormSocialAccountLinkClass=pf-c-login__main-footer-links-item-link +kcFormSocialAccountSectionClass=kc-social-section kc-social-gray +kcFormHeaderClass=login-pf-header + +kcFeedbackErrorIcon=fa fa-fw fa-exclamation-circle +kcFeedbackWarningIcon=fa fa-fw fa-exclamation-triangle +kcFeedbackSuccessIcon=fa fa-fw fa-check-circle +kcFeedbackInfoIcon=fa fa-fw fa-info-circle + +kcResetFlowIcon=pficon pficon-arrow fa + +# WebAuthn icons +kcWebAuthnKeyIcon=pficon pficon-key +kcWebAuthnDefaultIcon=pficon pficon-key +kcWebAuthnUnknownIcon=pficon pficon-key unknown-transport-class +kcWebAuthnUSB=fa fa-usb +kcWebAuthnNFC=fa fa-wifi +kcWebAuthnBLE=fa fa-bluetooth-b +kcWebAuthnInternal=pficon pficon-key + +kcFormClass=form-horizontal +kcFormGroupClass=form-group +kcFormGroupErrorClass=has-error +kcLabelClass=pf-c-form__label pf-c-form__label-text +kcLabelWrapperClass=col-xs-12 col-sm-12 col-md-12 col-lg-12 +kcInputClass=pf-c-form-control +kcInputHelperTextBeforeClass=pf-c-form__helper-text pf-c-form__helper-text-before +kcInputHelperTextAfterClass=pf-c-form__helper-text pf-c-form__helper-text-after +kcInputClassRadio=pf-c-radio +kcInputClassRadioInput=pf-c-radio__input +kcInputClassRadioLabel=pf-c-radio__label +kcInputClassCheckbox=pf-c-check +kcInputClassCheckboxInput=pf-c-check__input +kcInputClassCheckboxLabel=pf-c-check__label +kcInputClassRadioCheckboxLabelDisabled=pf-m-disabled +kcInputErrorMessageClass=pf-c-form__helper-text pf-m-error required kc-feedback-text +kcInputGroup=pf-c-input-group +kcInputWrapperClass=col-xs-12 col-sm-12 col-md-12 col-lg-12 +kcFormOptionsClass=col-xs-12 col-sm-12 col-md-12 col-lg-12 +kcFormButtonsClass=col-xs-12 col-sm-12 col-md-12 col-lg-12 +kcFormSettingClass=login-pf-settings +kcTextareaClass=form-control +kcSignUpClass=login-pf-signup + + +kcInfoAreaClass=col-xs-12 col-sm-4 col-md-4 col-lg-5 details + +### user-profile grouping +kcFormGroupHeader=pf-c-form__group + +##### css classes for form buttons +# main class used for all buttons +kcButtonClass=pf-c-button +# classes defining priority of the button - primary or default (there is typically only one priority button for the form) +kcButtonPrimaryClass=pf-m-primary +kcButtonDefaultClass=btn-default +# classes defining size of the button +kcButtonLargeClass=btn-lg +kcButtonBlockClass=pf-m-block + +##### css classes for input +kcInputLargeClass=input-lg + +##### css classes for form accessability +kcSrOnlyClass=sr-only + +##### css classes for select-authenticator form +kcSelectAuthListClass=pf-l-stack select-auth-container +kcSelectAuthListItemClass=pf-l-stack__item select-auth-box-parent pf-l-split +kcSelectAuthListItemIconClass=pf-l-split__item select-auth-box-icon +kcSelectAuthListItemIconPropertyClass=fa-2x select-auth-box-icon-properties +kcSelectAuthListItemBodyClass=pf-l-split__item pf-l-stack +kcSelectAuthListItemHeadingClass=pf-l-stack__item select-auth-box-headline pf-c-title +kcSelectAuthListItemDescriptionClass=pf-l-stack__item select-auth-box-desc +kcSelectAuthListItemFillClass=pf-l-split__item pf-m-fill +kcSelectAuthListItemArrowClass=pf-l-split__item select-auth-box-arrow +kcSelectAuthListItemArrowIconClass=fa fa-angle-right fa-lg +kcSelectAuthListItemTitle=select-auth-box-paragraph + +##### css classes for the authenticators +kcAuthenticatorDefaultClass=fa fa-list list-view-pf-icon-lg +kcAuthenticatorPasswordClass=fa fa-unlock list-view-pf-icon-lg +kcAuthenticatorOTPClass=fa fa-mobile list-view-pf-icon-lg +kcAuthenticatorWebAuthnClass=fa fa-key list-view-pf-icon-lg +kcAuthenticatorWebAuthnPasswordlessClass=fa fa-key list-view-pf-icon-lg + +##### css classes for the OTP Login Form +kcLoginOTPListClass=pf-c-tile +kcLoginOTPListInputClass=pf-c-tile__input +kcLoginOTPListItemHeaderClass=pf-c-tile__header +kcLoginOTPListItemIconBodyClass=pf-c-tile__icon +kcLoginOTPListItemIconClass=fa fa-mobile +kcLoginOTPListItemTitleClass=pf-c-tile__title + +##### css classes for identity providers logos +kcCommonLogoIdP=kc-social-provider-logo kc-social-gray + +## Social +kcLogoIdP-facebook=fa fa-facebook +kcLogoIdP-google=fa fa-google +kcLogoIdP-github=fa fa-github +kcLogoIdP-linkedin=fa fa-linkedin +kcLogoIdP-instagram=fa fa-instagram +## windows instead of microsoft - not included in PF4 +kcLogoIdP-microsoft=fa fa-windows +kcLogoIdP-bitbucket=fa fa-bitbucket +kcLogoIdP-gitlab=fa fa-gitlab +kcLogoIdP-paypal=fa fa-paypal +kcLogoIdP-stackoverflow=fa fa-stack-overflow +kcLogoIdP-twitter=fa fa-twitter +kcLogoIdP-openshift-v4=pf-icon pf-icon-openshift +kcLogoIdP-openshift-v3=pf-icon pf-icon-openshift + +## Recovery codes +kcRecoveryCodesWarning=kc-recovery-codes-warning +kcRecoveryCodesList=kc-recovery-codes-list +kcRecoveryCodesActions=kc-recovery-codes-actions +kcRecoveryCodesConfirmation=kc-recovery-codes-confirmation +kcCheckClass=pf-c-check +kcCheckInputClass=pf-c-check__input +kcCheckLabelClass=pf-c-check__label diff --git a/config/keycloak/themes/iqb/welcome/index.ftl b/config/keycloak/themes/iqb/welcome/index.ftl new file mode 100644 index 000000000..85b100cf0 --- /dev/null +++ b/config/keycloak/themes/iqb/welcome/index.ftl @@ -0,0 +1,13 @@ + + + + + + + + + If you are not redirected automatically, follow this link. + + diff --git a/config/keycloak/themes/iqb/welcome/index.ftl.old b/config/keycloak/themes/iqb/welcome/index.ftl.old new file mode 100644 index 000000000..a8ea7f91e --- /dev/null +++ b/config/keycloak/themes/iqb/welcome/index.ftl.old @@ -0,0 +1,117 @@ + + + + + + Welcome to ${productName} + + + + + + + + <#if properties.stylesCommon?has_content> + <#list properties.stylesCommon?split(' ') as style> + + + + <#if properties.styles?has_content> + <#list properties.styles?split(' ') as style> + + + + + + +
+
+
+
+ ${productName} +

Welcome to ${productName}

+
+
+ <#if adminConsoleEnabled> +
+
+ <#if successMessage?has_content> +

${successMessage}

+ <#elseif errorMessage?has_content> +

${errorMessage}

+

Administration Console

+ <#elseif bootstrap> + <#if localUser> +

Administration Console

+

Please create an initial admin user to get started.

+ <#else> +

+ You need local access to create the initial admin user.

Open ${localAdminUrl} +
${adminUserCreationMessage}. +

+ + + + <#if bootstrap && localUser> +
+

+ + +

+ +

+ + +

+ +

+ + +

+ + + + +
+ + + +
+
+ <#-- adminConsoleEnabled --> +
+
+

Documentation

+
+ + User Guide, Admin REST API and Javadocs + +
+
+
+
+
+
+
+ + diff --git a/config/keycloak/themes/iqb/welcome/resources/admin-console.png b/config/keycloak/themes/iqb/welcome/resources/admin-console.png new file mode 100644 index 000000000..ac734972f Binary files /dev/null and b/config/keycloak/themes/iqb/welcome/resources/admin-console.png differ diff --git a/config/keycloak/themes/iqb/welcome/resources/alert.png b/config/keycloak/themes/iqb/welcome/resources/alert.png new file mode 100644 index 000000000..74b4bc79f Binary files /dev/null and b/config/keycloak/themes/iqb/welcome/resources/alert.png differ diff --git a/config/keycloak/themes/iqb/welcome/resources/bg.png b/config/keycloak/themes/iqb/welcome/resources/bg.png new file mode 100644 index 000000000..b722a001b Binary files /dev/null and b/config/keycloak/themes/iqb/welcome/resources/bg.png differ diff --git a/config/keycloak/themes/iqb/welcome/resources/bug.png b/config/keycloak/themes/iqb/welcome/resources/bug.png new file mode 100644 index 000000000..4f00775ca Binary files /dev/null and b/config/keycloak/themes/iqb/welcome/resources/bug.png differ diff --git a/config/keycloak/themes/iqb/welcome/resources/css/welcome.css b/config/keycloak/themes/iqb/welcome/resources/css/welcome.css new file mode 100644 index 000000000..c6a679a28 --- /dev/null +++ b/config/keycloak/themes/iqb/welcome/resources/css/welcome.css @@ -0,0 +1,140 @@ +body { + background: #fff url(../bg.png) no-repeat center bottom fixed; + background-size: cover; +} +.welcome-header { + margin-top: 10px; + margin-bottom: 50px; + margin-left: -10px; +} +.welcome-header img { + width: 150px; + margin-bottom: 40px; +} +.welcome-message { + margin-top: 20px; +} +.h-l { + min-height: 370px; + padding: 10px 20px 10px; + overflow: hidden; +} +.h-l h3 { + margin-bottom: 10px; +} +.h-m { + height: 110px; + padding-top: 23px; +} +.card-pf img { + width: 22px; + margin-right: 10px; + vertical-align: bottom; +} +img.doc-img { + width: auto; + height: 22px; +} +.link { + font-size: 16px; + vertical-align: baseline; + margin-left: 5px; +} +h3 { + font-weight: 550; +} +h3 a:link, +h3 a:visited { + color: #333; + font-weight: 550; +} +h3 a:hover, +h3 a:hover .link { + text-decoration: none; + color: #00659c; +} +.h-l h3 a img { + height: 30px; + width: auto; +} + +.description { + margin-top: 30px; +} + +.card-pf { + border-top: 1px solid rgba(3, 3, 3, 0.1); + box-shadow: 0 1px 1px rgba(3, 3, 3, 0.275); +} + +.welcome-form label, +.welcome-form input { + display: block; + width: 100%; +} + +.welcome-form label { + color: #828486; + font-weight: normal; + margin-top: 18px; +} +.welcome-form input { + border: 0; + border-bottom: solid 1px #cbcbcb; +} +.welcome-form input:focus { + border-bottom: solid 1px #5e99c6; + outline-width: 0; +} +.welcome-form button { + margin-top: 10px; +} +.error { + color: #c00; + border-color: #c00; + padding: 5px 10px; +} +.success { + color: #3f9c35; + border-color: #3f9c35; + padding: 5px 10px; +} +.welcome-form + .welcome-primary-link, +.welcome-message + .welcome-primary-link { + display: none; +} + +.footer img { + float: right; + width: 150px; + margin-top: 30px; +} + +@media (max-width: 768px) { + .welcome-header { + margin-top: 10px; + margin-bottom: 20px; + } + .welcome-header img { + margin-bottom: 20px; + } + h3 { + margin-top: 10px; + } + .h-l, + .h-m { + height: auto; + min-height: auto; + padding: 5px 10px; + } + .h-l img { + display: inline; + margin-bottom: auto; + } + .description { + display: none; + } + .footer img { + margin-top: 10px; + } +} diff --git a/config/keycloak/themes/iqb/welcome/resources/keycloak-project.png b/config/keycloak/themes/iqb/welcome/resources/keycloak-project.png new file mode 100644 index 000000000..cd63e5ab3 Binary files /dev/null and b/config/keycloak/themes/iqb/welcome/resources/keycloak-project.png differ diff --git a/config/keycloak/themes/iqb/welcome/resources/keycloak_logo.png b/config/keycloak/themes/iqb/welcome/resources/keycloak_logo.png new file mode 100644 index 000000000..134440b16 Binary files /dev/null and b/config/keycloak/themes/iqb/welcome/resources/keycloak_logo.png differ diff --git a/config/keycloak/themes/iqb/welcome/resources/logo.png b/config/keycloak/themes/iqb/welcome/resources/logo.png new file mode 100644 index 000000000..134440b16 Binary files /dev/null and b/config/keycloak/themes/iqb/welcome/resources/logo.png differ diff --git a/config/keycloak/themes/iqb/welcome/resources/mail.png b/config/keycloak/themes/iqb/welcome/resources/mail.png new file mode 100644 index 000000000..3a63e7b85 Binary files /dev/null and b/config/keycloak/themes/iqb/welcome/resources/mail.png differ diff --git a/config/keycloak/themes/iqb/welcome/resources/user.png b/config/keycloak/themes/iqb/welcome/resources/user.png new file mode 100644 index 000000000..0d61bb470 Binary files /dev/null and b/config/keycloak/themes/iqb/welcome/resources/user.png differ diff --git a/config/keycloak/themes/iqb/welcome/theme.properties b/config/keycloak/themes/iqb/welcome/theme.properties new file mode 100644 index 000000000..4506bd228 --- /dev/null +++ b/config/keycloak/themes/iqb/welcome/theme.properties @@ -0,0 +1,7 @@ +styles=css/welcome.css +import=common/keycloak + +stylesCommon=node_modules/patternfly/dist/css/patternfly.css node_modules/patternfly/dist/css/patternfly-additions.css + +documentationUrl=https://www.keycloak.org/documentation.html +displayCommunityLinks=true diff --git a/docker-compose.coding-box.prod.yaml b/docker-compose.coding-box.prod.yaml index 81bed2c43..34191887f 100644 --- a/docker-compose.coding-box.prod.yaml +++ b/docker-compose.coding-box.prod.yaml @@ -82,11 +82,6 @@ services: - "traefik.http.services.frontend.loadbalancer.server.port=8080" - "traefik.docker.network=app-net" image: ${REGISTRY_PATH}iqbberlin/coding-box-frontend:${TAG} - environment: - - KEYCLOAK_URL=${KEYCLOAK_URL:-https://keycloak.kodierbox.iqb.hu-berlin.de/} - - KEYCLOAK_REALM=${KEYCLOAK_REALM:-coding-box} - - KEYCLOAK_CLIENT_ID=${KEYCLOAK_CLIENT_ID:-coding-box} - - BACKEND_URL=${BACKEND_URL:-api/} volumes: - "./config/frontend/default.conf.template:/etc/nginx/templates/default.conf.template:ro" restart: always diff --git a/docker-compose.override.yaml b/docker-compose.override.yaml index 2018792d7..3e7b0b86e 100644 --- a/docker-compose.override.yaml +++ b/docker-compose.override.yaml @@ -1,4 +1,53 @@ services: + keycloak-db: + image: postgres:14 + restart: always + healthcheck: + test: [ "CMD", "pg_isready", "-q", "-d", "${KEYCLOAK_DB_NAME}", "-U", "${KEYCLOAK_DB_USER}" ] + interval: 10s + timeout: 3s + start_period: 60s + start_interval: 1s + retries: 5 + environment: + POSTGRES_HOST: keycloak-db + POSTGRES_PORT: 5432 + POSTGRES_USER: ${KEYCLOAK_DB_USER} + POSTGRES_PASSWORD: ${KEYCLOAK_DB_PASSWORD} + POSTGRES_DB: ${KEYCLOAK_DB_NAME} + volumes: + - "keycloak_db_vol:/var/lib/postgresql/data" + networks: + - application-network + + keycloak: + image: quay.io/keycloak/keycloak:22.0 + restart: always + depends_on: + keycloak-db: + condition: service_healthy + command: [ 'start', '--import-realm' ] + ports: + - "8080:8080" + environment: + KC_HOSTNAME: ${SERVER_NAME} + KC_HTTP_ENABLED: 'true' + KC_HOSTNAME_STRICT_HTTPS: 'false' + KC_DB: postgres + KC_DB_URL: jdbc:postgresql://keycloak-db/${KEYCLOAK_DB_NAME} + KC_DB_USERNAME: ${KEYCLOAK_DB_USER} + KC_DB_PASSWORD: ${KEYCLOAK_DB_PASSWORD} + KEYCLOAK_ADMIN: ${ADMIN_NAME} + KEYCLOAK_ADMIN_PASSWORD: ${ADMIN_PASSWORD} + KEYCLOAK_LOGLEVEL: DEBUG + env_file: + - ./config/keycloak/realm/coding-box-realm.config + volumes: + - "./config/keycloak/realm/coding-box-realm.json:/opt/keycloak/data/import/coding-box-realm.json" + - "./config/keycloak/themes/iqb:/opt/keycloak/themes/iqb" + networks: + - application-network + redis: ports: - "${REDIS_PORT:-6379}:6379" @@ -12,7 +61,6 @@ services: ports: - "${POSTGRES_PORT}:5432" - liquibase: build: context: . @@ -39,6 +87,8 @@ services: args: REGISTRY_PATH: ${REGISTRY_PATH} PROJECT: backend + depends_on: + - keycloak ports: - "${API_PORT}:3333" # backend - "9229:9229" # default debug port @@ -95,3 +145,6 @@ services: - "frontend" - "--host=frontend" - "--port=8080" + +volumes: + keycloak_db_vol: diff --git a/docker-compose.yaml b/docker-compose.yaml index 80876546f..ae01ca633 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -10,13 +10,18 @@ x-env-redis: &env-redis REDIS_PORT: 6379 REDIS_PREFIX: coding-box -x-env-keycloak: &env-keycloak - KEYCLOAK_URL: ${KEYCLOAK_URL} - KEYCLOAK_REALM: ${KEYCLOAK_REALM} - KEYCLOAK_CLIENT_ID: ${KEYCLOAK_CLIENT_ID} - KEYCLOAK_CLIENT_SECRET: ${KEYCLOAK_CLIENT_SECRET} +x-env-oidc: &env-oidc + OIDC_PROVIDER_URL: ${OIDC_PROVIDER_URL} OIDC_ISSUER: ${OIDC_ISSUER} + OIDC_ACCOUNT_ENDPOINT: ${OIDC_ACCOUNT_ENDPOINT} + OIDC_AUTHORIZATION_ENDPOINT: ${OIDC_AUTHORIZATION_ENDPOINT} + OIDC_TOKEN_ENDPOINT: ${OIDC_TOKEN_ENDPOINT} + OIDC_USERINFO_ENDPOINT: ${OIDC_USERINFO_ENDPOINT} + OIDC_END_SESSION_ENDPOINT: ${OIDC_END_SESSION_ENDPOINT} OIDC_JWKS_URI: ${OIDC_JWKS_URI} + OAUTH2_CLIENT_ID: ${OAUTH2_CLIENT_ID} + OAUTH2_CLIENT_SECRET: ${OAUTH2_CLIENT_SECRET} + OAUTH2_REDIRECT_URL: ${OAUTH2_REDIRECT_URL} x-env-auth-policy: &env-auth-policy WORKSPACE_TOKEN_REPLAY_READ_MAX_DURATION_DAYS: ${WORKSPACE_TOKEN_REPLAY_READ_MAX_DURATION_DAYS:-90} @@ -72,13 +77,10 @@ services: environment: APP_ROLE: api API_HOST: backend + HTTP_PORT: ${HTTP_PORT:-4200} JWT_SECRET: ${JWT_SECRET} GEOGEBRA_BUNDLE_DOWNLOAD_URL: ${GEOGEBRA_BUNDLE_DOWNLOAD_URL:-} - EXPORT_CODING_LIST_BATCH_SIZE: ${EXPORT_CODING_LIST_BATCH_SIZE:-500} - EXPORT_VERSIONED_BATCH_SIZE: ${EXPORT_VERSIONED_BATCH_SIZE:-250} - POSTGRES_POOL_MAX: ${POSTGRES_POOL_MAX:-10} - DISABLED_JOB_QUEUE_PROCESSORS: data-export - <<: [ *env-redis, *env-postgres, *env-keycloak, *env-auth-policy ] + <<: [ *env-redis, *env-postgres, *env-oidc ] healthcheck: test: [ @@ -110,7 +112,7 @@ services: POSTGRES_POOL_MAX: ${EXPORT_WORKER_POSTGRES_POOL_MAX:-4} JOB_QUEUE_PROCESSORS: data-export PGOPTIONS: ${EXPORT_WORKER_PGOPTIONS:--c jit=off} - <<: [ *env-redis, *env-postgres, *env-keycloak, *env-auth-policy ] + <<: [ *env-redis, *env-postgres, *env-oidc, *env-auth-policy ] networks: - application-network diff --git a/docs/oidc-authentication-de.md b/docs/oidc-authentication-de.md new file mode 100644 index 000000000..b5f2096d7 --- /dev/null +++ b/docs/oidc-authentication-de.md @@ -0,0 +1,948 @@ +# OIDC-Authentifizierung + +Dieses Dokument beschreibt die OpenID Connect (OIDC) Authentifizierung in Kodierbox, einschließlich der generischen OIDC-Implementierung und der Keycloak-spezifischen Integration. + +## 1. Einführung + +### Was ist OIDC/OpenID Connect? + +OpenID Connect (OIDC) ist eine Authentifizierungsschicht, die auf OAuth 2.0 aufbaut. Es ermöglicht Clients, die Identität eines Benutzers basierend auf der Authentifizierung durch einen Autorisierungsserver zu verifizieren. + +### Warum verwendet Kodierbox OIDC? + +Kodierbox verwendet OIDC für: +- Zentralisierte Benutzerverwaltung +- Single Sign-On (SSO) über mehrere Anwendungen +- Sichere Authentifizierung mit PKCE (Proof Key for Code Exchange) +- Unterstützung von Standard-Identity-Providern wie Keycloak +- Flexibilität für verschiedene OIDC-Provider + +### Überblick über den Authentifizierungsablauf + +Der Authentifizierungsablauf in Kodierbox verwendet den Authorization Code Flow mit PKCE: + +1. Benutzer initiiert Login +2. Backend generiert PKCE Code Verifier und Challenge +3. Redirect zum OIDC-Provider (z.B. Keycloak) +4. Benutzer authentifiziert sich beim Provider +5. Provider leitet mit Authorization Code zurück +6. Backend tauscht Code gegen Access Token +7. Backend ruft Benutzerinformationen ab +8. Benutzer wird in Kodierbox-Datenbank gespeichert +9. Access Token wird an Frontend zurückgegeben + +## 2. Für Systemadministratoren + +### 2.1 Umgebungskonfiguration + +#### Erforderliche Umgebungsvariablen (generischer OIDC) + +Die folgenden Variablen müssen für die generische OIDC-Implementierung konfiguriert werden: + +```bash +# OIDC Provider-Endpunkte +OIDC_PROVIDER_URL=https://keycloak.example.com +OIDC_ISSUER=https://keycloak.example.com/auth/realms/coding-box +OIDC_ACCOUNT_ENDPOINT=https://keycloak.example.com/auth/realms/iqb/account +OIDC_AUTHORIZATION_ENDPOINT=https://keycloak.example.com/auth/realms/iqb/protocol/openid-connect/auth +OIDC_TOKEN_ENDPOINT=https://keycloak.example.com/auth/realms/iqb/protocol/openid-connect/token +OIDC_USERINFO_ENDPOINT=https://keycloak.example.com/auth/realms/iqb/protocol/openid-connect/userinfo +OIDC_END_SESSION_ENDPOINT=https://keycloak.example.com/auth/realms/iqb/protocol/openid-connect/logout +OIDC_JWKS_URI=https://keycloak.example.com/auth/realms/iqb/protocol/openid-connect/certs + +# OAuth2 Client-Konfiguration +OAUTH2_CLIENT_ID=coding-box +OAUTH2_CLIENT_SECRET=your_secret_here +OAUTH2_REDIRECT_URL=//example.com/auth/callback +``` + +#### Keycloak-spezifische Variablen + +Für die Keycloak-spezifische Implementierung können diese Variablen verwendet werden: + +```bash +KEYCLOAK_URL=https://keycloak.example.com/auth/ +KEYCLOAK_REALM=coding-box +KEYCLOAK_CLIENT_ID=coding-box +KEYCLOAK_CLIENT_SECRET=your_secret_here +``` + +#### Konfigurationsvorlagen + +Die Hauptkonfigurationsvorlage befindet sich in `.env.coding-box.template`. Kopieren Sie diese Datei und passen Sie die Werte an Ihre Umgebung an: + +```bash +cp .env.coding-box.template .env.coding-box +# Bearbeiten Sie .env.coding-box mit Ihren Werten +``` + +#### Docker Compose Setup + +Die OIDC-Umgebungsvariablen sind in `docker-compose.yaml` definiert und werden über den `x-env-oidc` Anchor an Backend und Frontend weitergegeben: + +```yaml +x-env-oidc: &env-oidc + OIDC_PROVIDER_URL: ${OIDC_PROVIDER_URL} + OIDC_ISSUER: ${OIDC_ISSUER} + OIDC_ACCOUNT_ENDPOINT: ${OIDC_ACCOUNT_ENDPOINT} + OIDC_AUTHORIZATION_ENDPOINT: ${OIDC_AUTHORIZATION_ENDPOINT} + OIDC_TOKEN_ENDPOINT: ${OIDC_TOKEN_ENDPOINT} + OIDC_USERINFO_ENDPOINT: ${OIDC_USERINFO_ENDPOINT} + OIDC_END_SESSION_ENDPOINT: ${OIDC_END_SESSION_ENDPOINT} + OIDC_JWKS_URI: ${OIDC_JWKS_URI} + OAUTH2_CLIENT_ID: ${OAUTH2_CLIENT_ID} + OAUTH2_CLIENT_SECRET: ${OAUTH2_CLIENT_SECRET} + OAUTH2_REDIRECT_URL: ${OAUTH2_REDIRECT_URL} +``` + +### 2.2 Keycloak Setup + +#### Realm-Konfiguration + +Die Keycloak Realm-Konfiguration befindet sich in `config/keycloak/realm/coding-box-realm.json`. Wichtige Einstellungen: + +- **Realm Name**: `coding-box` +- **Access Token Lifespan**: 300 Sekunden (5 Minuten) +- **SSO Session Idle Timeout**: 1800 Sekunden (30 Minuten) +- **SSO Session Max Lifespan**: 36000 Sekunden (10 Stunden) +- **Registration Allowed**: `true` (kann deaktiviert werden) +- **Reset Password Allowed**: `false` + +#### Client-Setup + +Die Client-Konfiguration befindet sich in `config/keycloak/clients/coding-box.json`: + +- **Client ID**: `coding-box` +- **Name**: IQB Kodierbox +- **Standard Flow Enabled**: `true` (Authorization Code Flow) +- **Implicit Flow Enabled**: `false` +- **Public Client**: `true` +- **Redirect URIs**: `*` (in Produktion einschränken) +- **Web Origins**: `*` (in Produktion einschränken) +- **Access Token Lifespan**: 4579200 Sekunden (53 Tage) + +#### Benutzerrollen und Berechtigungen + +Kodierbox verwendet die folgende Rollenstruktur in Keycloak: + +- **admin**: Systemadministrator mit vollen Rechten +- **default-roles-coding-box**: Standardrolle für alle Benutzer + +Der Admin-Status wird aus dem `realm_access.roles` Array des Benutzers gelesen. Benutzer mit der Rolle `admin` erhalten Administrator-Rechte in Kodierbox. + +#### Admin-Account Setup + +Der Admin-Account wird über Umgebungsvariablen konfiguriert: + +```bash +CODING_BOX_ADMIN_NAME=coding-box-admin +CODING_BOX_ADMIN_EMAIL=coding-box-admin@localhost +CODING_BOX_ADMIN_PASSWORD=change_me +CODING_BOX_ADMIN_CREATED_TIMESTAMP=1234567890 +``` + +Diese Variablen werden in der Realm-Konfiguration verwendet, um den initialen Admin-Benutzer zu erstellen. + +**Standard-Anmeldedaten für die lokale Entwicklung:** + +- **Keycloak Admin Console** (http://localhost:8080/admin): + - Benutzername: `admin` + - Passwort: `change_me` + +- **Kodierbox Realm** (http://localhost:8080/realms/coding-box): + - Benutzername: `coding-box-admin` + - Passwort: `change_me` + +**Wichtig:** Ändern Sie diese Passwörter nach dem ersten Login aus Sicherheitsgründen. + +#### Theme-Anpassung + +Kodierbox verwendet ein benutzerdefiniertes IQB-Theme für Keycloak. Das Theme befindet sich in `config/keycloak/themes/iqb/`. Das Theme wird über den Client-Attribut `login_theme: iqb` aktiviert. + +### 2.3 Deployment + +#### Docker Compose Deployment + +Starten Sie die komplette Umgebung mit: + +```bash +make dev-up +``` + +Dies startet: +- PostgreSQL Datenbank +- Redis für PKCE Verifier Storage +- Backend mit OIDC-Konfiguration +- Frontend +- Keycloak (falls im Compose-File konfiguriert) + +#### Traefik Integration + +Traefik wird als Reverse Proxy verwendet und konfiguriert SSL/TLS. Stellen Sie sicher, dass: + +- `SERVER_NAME` korrekt gesetzt ist +- `TLS_CERTIFICATE_RESOLVER` konfiguriert ist (oder leer für benutzerdefinierte Zertifikate) +- Die OIDC-Endpunkte über HTTPS erreichbar sind + +#### SSL/TLS-Konfiguration + +Für Produktionsumgebungen: +- Verwenden Sie HTTPS für alle OIDC-Endpunkte +- Konfigurieren Sie gültige SSL-Zertifikate +- Setzen Sie `sslRequired: external` in der Keycloak Realm-Konfiguration +- Aktualisieren Sie alle Redirect URIs auf HTTPS + +#### Netzwerkkonfiguration + +Kodierbox verwendet ein Docker-Netzwerk `app-net` für die Kommunikation zwischen Services. Stellen Sie sicher, dass: + +- Backend kann OIDC-Provider erreichen +- Frontend kann Backend erreichen +- OIDC-Provider kann Callback-URL erreichen + +## 3. Für Entwickler + +### 3.1 Backend-Implementierung + +#### OidcAuthService - Generischer OIDC-Service + +Der `OidcAuthService` (`apps/backend/src/app/auth/service/oidc-auth.service.ts`) implementiert eine generische OIDC-Lösung, die mit jedem OIDC-konformen Provider funktioniert. + +**Hauptmethoden:** + +- `getAuthorizationUrl(state, redirectUri, codeChallenge)`: Generiert die Authorization URL für den OIDC-Provider +- `exchangeCodeForToken(code, redirectUri, codeVerifier)`: Tauscht Authorization Code gegen Access Token +- `getUserInfo(accessToken)`: Ruft Benutzerinformationen vom Provider ab +- `getLogoutUrl(idToken, redirectUri)`: Generiert Logout-URL +- `logoutWithRefreshToken(refreshToken)`: Führt POST Logout durch +- `getProfileUrl(redirectUri)`: Generiert Profilmanagement-URL +- `generatePkcePair()`: Generiert PKCE Code Verifier und Challenge +- `storePkceVerifier(state, codeVerifier)`: Speichert PKCE Verifier (5 Minuten TTL) +- `consumePkceVerifier(state)`: Konsumiert und löscht PKCE Verifier + +**Schnittstellen:** + +```typescript +export interface OidcConfiguration { + issuer: string; + account_endpoint: string; + authorization_endpoint: string; + token_endpoint: string; + userinfo_endpoint: string; + end_session_endpoint: string; + jwks_uri: string; +} + +export interface OidcTokenResponse { + access_token: string; + token_type: string; + expires_in: number; + refresh_token: string; + scope?: string; + id_token?: string; +} + +export interface OidcUserInfo { + sub: string; + preferred_username: string; + given_name?: string; + family_name?: string; + email?: string; + realm_access?: { + roles: string[]; + }; +} +``` + +#### KeycloakAuthService - Keycloak-spezifischer Service + +Der `KeycloakAuthService` (`apps/backend/src/app/auth/service/keycloak-auth.service.ts`) bietet eine Keycloak-spezifische Implementierung mit vereinfachter Konfiguration. + +**Unterschiede zum generischen Service:** +- Benötigt nur `KEYCLOAK_URL`, `KEYCLOAK_REALM`, `KEYCLOAK_CLIENT_ID` +- Konstruiert Endpunkte automatisch aus Basis-URL +- Identische Methodensignaturen wie `OidcAuthService` + +#### AuthController - REST API Endpunkte + +Der `AuthController` (`apps/backend/src/app/auth/auth.controller.ts`) stellt folgende Endpunkte bereit: + +**GET /auth/login** +- Initiiert OIDC Login +- Generiert PKCE Pair +- Speichert Code Verifier +- Redirect zum OIDC-Provider +- Query Parameter: `redirect_uri` (optional) + +**GET /auth/callback** +- Verarbeitet OIDC Callback +- Validiert State Parameter +- Konsumiert PKCE Verifier +- Tauscht Code gegen Token +- Ruft User Info ab +- Speichert Benutzer in Datenbank +- Redirect mit Token oder JSON Response +- Query Parameter: `code`, `state` + +**POST /auth/logout** +- Führt SSO Logout durch +- Invalidiert Refresh Token +- Body: `{ refresh_token: string }` + +**GET /auth/profile** +- Redirect zum Profilmanagement des OIDC-Providers +- Query Parameter: `redirect_uri` (optional) + +**POST /auth/token** +- OAuth2 Client Credentials Flow +- Body: `{ client_id, client_secret, scope? }` +- Gibt Access Token zurück + +**POST /auth/validate** +- Validiert Access Token gegen OIDC-Provider +- Body: `{ access_token: string }` +- Gibt User Info zurück + +#### AuthService - Benutzermanagement + +Der `AuthService` (`apps/backend/src/app/auth/service/auth.service.ts`) verwaltet Benutzer in der Kodierbox-Datenbank: + +**Hauptmethoden:** + +- `storeOidcProviderUser(user)`: Speichert OIDC-Benutzer in Datenbank +- `loginOidcProviderUser(user)`: Loggt OIDC-Benutzer ein und erstellt JWT +- `createToken(identity, workspaceId, duration)`: Erstellt Workspace-spezifisches Token +- `isAdminUser(userId)`: Prüft Admin-Status +- `canAccessWorkSpace(userId, workspaceId)`: Prüft Workspace-Zugriff + +### 3.2 Frontend-Implementierung + +#### Authentication Service + +Der `AuthenticationService` (`apps/frontend/src/app/core/services/authentication.service.ts`) verwaltet die Authentifizierung im Frontend. + +**Hauptfunktionen:** +- Token-Speicherung (localStorage/sessionStorage) +- Token-Refresh +- Benutzer-Session-Management + +#### Route Guards + +Kodierbox verwendet mehrere Guards für Routenschutz: + +- **AuthGuard** (`apps/frontend/src/app/core/guards/auth.guard.ts`): Schützt authentifizierte Routen +- **AdminGuard** (`apps/frontend/src/app/core/guards/admin.guard.ts`): Schützt Admin-Routen +- **TokenGuard** (`apps/frontend/src/app/core/guards/token.guard.ts`): Validiert Token +- **AccessLevelGuard** (`apps/frontend/src/app/core/guards/access-level.guard.ts`): Prüft Zugriffsebene + +#### Token-Speicherung und Refresh + +Tokens werden im Frontend gespeichert und automatisch refreshet: +- Access Token wird für API-Calls verwendet +- Refresh Token wird für Token-Refresh verwendet +- Token-Storage folgt Sicherheitsbest Practices + +#### Interceptors für API-Calls + +Der `AuthInterceptor` (`apps/frontend/src/app/core/interceptors/auth.interceptor.ts`) fügt automatisch Authorization Headers zu API-Calls hinzu. + +#### User Session Management + +Die Session-Verwaltung umfasst: +- Login-Status-Tracking +- Benutzer-Informationen-Caching +- Automatisches Logout bei Token-Ablauf +- Session-Timeout-Handling + +### 3.3 Authentifizierungsablauf + +#### PKCE (Proof Key for Code Exchange) Flow + +Der PKCE Flow wird für Public Clients verwendet und erhöht die Sicherheit: + +**Schritt 1: Code Verifier und Challenge generieren** +```typescript +const { codeVerifier, codeChallenge } = this.oidcAuthService.generatePkcePair(); +``` +- Code Verifier: 32 Bytes Random Data, Base64URL-encoded +- Code Challenge: SHA256 Hash des Verifiers, Base64URL-encoded + +**Schritt 2: Verifier speichern** +```typescript +await this.oidcAuthService.storePkceVerifier(state, codeVerifier); +``` +- Speicherung mit State als Key +- TTL: 5 Minuten +- Speicherort: In-Memory Map (produktiv: Redis) + +**Schritt 3: Redirect zum OIDC-Provider** +```typescript +const authUrl = this.oidcAuthService.getAuthorizationUrl(state, redirectUri, codeChallenge); +res.redirect(authUrl); +``` +- Parameter: `response_type=code`, `client_id`, `redirect_uri`, `state`, `scope`, `code_challenge`, `code_challenge_method=S256` + +**Schritt 4: Code gegen Token tauschen** +```typescript +const tokenResponse = await this.oidcAuthService.exchangeCodeForToken(code, redirectUri, codeVerifier); +``` +- POST Request zum Token Endpoint +- Parameter: `grant_type=authorization_code`, `client_id`, `code`, `redirect_uri`, `code_verifier`, optional `client_secret` + +**Schritt 5: User Info abrufen** +```typescript +const userInfo = await this.oidcAuthService.getUserInfo(tokenResponse.access_token); +``` +- GET Request zum Userinfo Endpoint +- Header: `Authorization: Bearer {access_token}` + +**Schritt 6: Benutzer speichern** +```typescript +const userData: CreateUserDto = { + identity: userInfo.sub, + username: userInfo.preferred_username, + firstName: userInfo.given_name || '', + lastName: userInfo.family_name || '', + email: userInfo.email || '', + issuer: 'coding-box', + isAdmin: userInfo.realm_access?.roles?.includes('admin') || false +}; +await this.authService.storeOidcProviderUser(userData); +``` + +#### Token-Lebenszyklus + +1. **Access Token**: Kurzlebig (Standard 5 Minuten), für API-Calls +2. **Refresh Token**: Langlebig (Standard 53 Tage), für Token-Refresh +3. **ID Token**: Enthält Benutzer-Claims, für Identitätsprüfung +4. **Token Refresh**: Automatisch durch Frontend bei Ablauf +5. **Token Invalidierung**: Bei Logout oder SSO-Logout + +#### Refresh Token Handling + +- Refresh Tokens werden sicher gespeichert +- Automatischer Refresh bei abgelaufenem Access Token +- Refresh Token wird bei Logout invalidiert +- SSO-Logout invalidiert Refresh Token beim Provider + +#### Session-Management + +- Session-Timeout basierend auf SSO Session (30 Minuten Idle, 10 Stunden Max) +- Aktivitäts-Tracking für Idle-Timeout +- Automatischer Redirect bei Session-Ablauf +- Manuelle Logout-Möglichkeit + +### 3.4 Integration mit anderen Services + +#### Datenbank-Integration + +Benutzer werden in der PostgreSQL-Datenbank gespeichert: + +- Tabelle: `users` +- Felder: `id`, `identity`, `username`, `email`, `first_name`, `last_name`, `issuer`, `is_admin` +- Index auf `identity` und `issuer` für schnellen Lookup +- Upsert-Logik: Benutzer wird aktualisiert, wenn bereits vorhanden + +#### Redis-Integration + +Redis wird für PKCE Verifier Storage verwendet: + +- Key-Format: `oidc:pkce:{sha256(state)}` +- TTL: 5 Minuten +- Speicherort: In-Memory Map in Entwicklung, Redis in Produktion +- Automatische Bereinigung abgelaufener Einträge + +#### Workspace-Zugriffskontrolle + +Zugriff auf Workspaces wird über `AuthService.canAccessWorkSpace()` geprüft: + +- Benutzer muss Workspace-Zugriff haben +- Admin-Benutzer haben Zugriff auf alle Workspaces +- Workspace-Admins haben Zugriff auf ihren Workspace + +#### Rollenbasierte Autorisierung + +Rollen werden aus Keycloak `realm_access.roles` gelesen: + +- `admin`: Vollzugriff auf alle Funktionen +- Andere Rollen können nach Bedarf hinzugefügt werden +- Rollen werden in Kodierbox-Datenbank synchronisiert + +## 4. Für Endbenutzer + +### 4.1 Login-Prozess + +**So loggen Sie sich ein:** + +1. Klicken Sie auf der Login-Seite auf "Anmelden" +2. Sie werden zum Keycloak Login-Redirect weitergeleitet +3. Geben Sie Ihren Benutzernamen und Passwort ein +4. Nach erfolgreicher Authentifizierung werden Sie zurück zu Kodierbox geleitet +5. Sie sind jetzt angemeldet + +**Passwort-Management:** +- Passwörter werden in Keycloak verwaltet +- Passwort-Reset ist abhängig von Keycloak-Konfiguration +- In der Standardkonfiguration ist Passwort-Reset deaktiviert + +**Account-Erstellung:** +- Wenn Registration in Keycloak aktiviert ist, können neue Benutzer sich registrieren +- Klicken Sie auf "Registrieren" auf der Login-Seite +- Füllen Sie das Registrierungsformular aus +- Nach Bestätigung können Sie sich einloggen + +### 4.2 Profil-Management + +**Zugriff auf Profileinstellungen:** + +1. Klicken Sie auf Ihren Benutzernamen im Header +2. Wählen Sie "Profil" aus dem Menü +3. Sie werden zum Keycloak Profilmanagement weitergeleitet + +**Passwort ändern:** +- Gehen Sie zu Profilmanagement +- Navigieren Sie zu "Passwort" +- Geben Sie Ihr aktuelles und neues Passwort ein +- Bestätigen Sie das neue Passwort +- Speichern Sie die Änderungen + +**Persönliche Informationen verwalten:** +- Profilmanagement ermöglicht Änderung von: + - Vorname + - Nachname + - E-Mail-Adresse + - Weitere Attribute (je nach Konfiguration) + +**E-Mail-Verifizierung:** +- Abhängig von Keycloak-Konfiguration +- In der Standardkonfiguration ist E-Mail-Verifizierung deaktiviert +- Wenn aktiviert, müssen Sie Ihre E-Mail nach Registration verifizieren + +### 4.3 Logout + +**Single Logout (nur Anwendung):** +- Klicken Sie auf "Abmelden" im Benutzermenü +- Sie werden aus Kodierbox ausgeloggt +- Andere Anwendungen bleiben angemeldet + +**SSO Logout (alle Sessions):** +- Kodierbox führt automatisch SSO Logout durch +- Refresh Token wird beim Provider invalidiert +- Sie werden aus allen Anwendungen ausgeloggt, die SSO verwenden +- Session wird bei Keycloak beendet + +**Session-Timeout:** +- Idle-Timeout: 30 Minuten Inaktivität +- Max Session: 10 Stunden +- Nach Timeout werden Sie automatisch ausgeloggt +- Sie müssen sich erneut anmelden + +### 4.4 Fehlerbehebung für Benutzer + +**Login-Fehler:** + +**Falsche Anmeldedaten:** +- Überprüfen Sie Benutzernamen und Passwort +- Achten Sie auf Groß-/Kleinschreibung +- Versuchen Sie, Ihr Passwort zurückzusetzen (falls aktiviert) + +**Account gesperrt:** +- Wenden Sie sich an Ihren Administrator +- Administrator kann Account in Keycloak entsperren + +**Browser-Probleme:** +- Löschen Sie Browser-Cookies und Cache +- Deaktivieren Sie Browser-Extensions +- Versuchen Sie einen anderen Browser +- Überprüfen Sie, ob JavaScript aktiviert ist + +**Verbindungsprobleme:** +- Überprüfen Sie Ihre Internetverbindung +- Überprüfen Sie, ob der Server erreichbar ist +- Versuchen Sie es später erneut + +## 5. Fehlerbehebung (Häufige Probleme) + +### 5.1 Konfigurationsprobleme + +**Fehlende Umgebungsvariablen** + +Symptom: Backend startet nicht mit Fehler "OpenID Connect configuration is missing" + +Lösung: +- Überprüfen Sie `.env.coding-box` Datei +- Stellen Sie sicher, alle OIDC_* Variablen gesetzt sind +- Überprüfen Sie Docker Compose Umgebungsvariablen +- Starten Sie Backend neu + +**Falsche Endpoint-URLs** + +Symptom: "Failed to exchange authorization code for token" oder "Failed to get user information" + +Lösung: +- Überprüfen Sie OIDC_ENDPOINT_* Variablen +- Stellen Sie sicher, URLs korrekt und erreichbar sind +- Testen Sie Endpunkte mit curl oder Postman +- Überprüfen Sie TLS/SSL-Konfiguration + +**Client Secret Mismatch** + +Symptom: "Invalid client credentials" beim Token-Exchange + +Lösung: +- Überprüfen Sie OAUTH2_CLIENT_SECRET in Umgebungsvariablen +- Stellen Sie sicher, Secret mit Keycloak Client-Konfiguration übereinstimmt +- Regenerieren Sie Secret in Keycloak wenn nötig +- Starten Sie Backend neu + +**Redirect URI Probleme** + +Symptom: "Invalid redirect_uri" oder Redirect funktioniert nicht + +Lösung: +- Überprüfen Sie OAUTH2_REDIRECT_URL +- Stellen Sie sicher, Redirect URI in Keycloak Client-Konfiguration enthalten ist +- Verwenden Sie in Produktion HTTPS +- Überprüfen Sie CORS-Konfiguration + +### 5.2 Authentifizierungsfehler + +**PKCE Verifier abgelaufen** + +Symptom: "PKCE verifier missing or expired" + +Lösung: +- PKCE Verifier hat 5 Minuten TTL +- Starten Sie Login-Prozess neu +- Überprüfen Sie Systemzeit auf Server und Client +- Stellen Sie sicher, Redis läuft (in Produktion) + +**Ungültiger State Parameter** + +Symptom: "State parameter is required for PKCE flow" + +Lösung: +- State wird automatisch generiert +- Überprüfen Sie, ob State im Callback korrekt zurückgegeben wird +- Stellen Sie sicher, keine State-Manipulation durch Middleware +- Prüfen Sie Browser-Console auf Fehler + +**Token-Exchange-Fehler** + +Symptom: "Failed to exchange authorization code for token" + +Lösung: +- Überprüfen Sie Authorization Code ist nicht abgelaufen (60 Sekunden) +- Stellen Sie sicher, PKCE Verifier korrekt ist +- Überprüfen Sie Client ID und Secret +- Prüfen Sie Keycloak Logs auf Fehler + +**User Info Retrieval Fehler** + +Symptom: "Failed to get user information" + +Lösung: +- Überprüfen Sie Access Token ist gültig +- Stellen Sie sicher, Userinfo Endpoint erreichbar ist +- Überprüfen Sie Token-Scopes enthalten `profile` und `email` +- Prüfen Sie Keycloak Benutzer-Konfiguration + +### 5.3 Keycloak-Probleme + +**Realm nicht gefunden** + +Symptom: "Realm not found" in Keycloak Logs + +Lösung: +- Überprüfen Sie KEYCLOAK_REALM Variable +- Stellen Sie sicher, Realm in Keycloak existiert +- Importieren Sie Realm-Konfiguration falls nötig +- Überprüfen Sie Keycloak Admin Console + +**Client nicht gefunden** + +Symptom: "Client not found" oder "Invalid client" + +Lösung: +- Überprüfen Sie OAUTH2_CLIENT_ID Variable +- Stellen Sie sicher, Client in Realm existiert +- Überprüfen Sie Client ist enabled +- Importieren Sie Client-Konfiguration falls nötig + +**Ungültige Anmeldedaten** + +Symptom: "Invalid credentials" beim Login + +Lösung: +- Überprüfen Sie Benutzer-Login in Keycloak Admin Console +- Stellen Sie sicher, Benutzer enabled ist +- Setzen Sie Passwort zurück falls nötig +- Überprüfen Sie Benutzer-Rollen + +**Rollen-Zuweisungsprobleme** + +Symptom: Benutzer hat keine Admin-Rechte trotz Rolle + +Lösung: +- Überprüfen Sie Benutzer hat `admin` Rolle in Keycloak +- Stellen Sie sicher, Rolle in `realm_access.roles` enthalten ist +- Synchronisieren Sie Benutzer in Kodierbox-Datenbank +- Überprüfen Sie Backend Logs für Role-Mapping + +### 5.4 Netzwerkprobleme + +**CORS-Fehler** + +Symptom: CORS-Fehler im Browser + +Lösung: +- Überprüfen Sie CORS-Konfiguration im Backend +- Stellen Sie sicher, Origin erlaubt ist +- Konfigurieren Sie CORS in Keycloak wenn nötig +- Verwenden Sie CORS-Plugin für Browser-Tests + +**Proxy-Konfiguration** + +Symptom: Verbindungsfehler durch Proxy + +Lösung: +- Überprüfen Sie Traefik-Konfiguration +- Stellen Sie sicher, OIDC-Endpunkte korrekt geroutet werden +- Konfigurieren Sie Proxy-Header (X-Forwarded-*, etc.) +- Testen Sie Endpunkte ohne Proxy + +**SSL-Zertifikatprobleme** + +Symptom: SSL-Fehler oder Zertifikat-Warnungen + +Lösung: +- Verwenden Sie gültige SSL-Zertifikate in Produktion +- Stellen Sie sicher, Zertifikat für alle Endpunkte gültig ist +- Überprüfen Sie Zertifikatskette +- Konfigurieren Sie `sslRequired: external` in Keycloak + +**Timeout-Probleme** + +Symptom: Request-Timeouts + +Lösung: +- Überprüfen Sie Netzwerkverbindung +- Erhöhen Sie Timeout-Werte in HTTP-Client +- Überprüfen Sie Firewall-Konfiguration +- Prüfen Sie Load Balancer-Settings + +### 5.5 Debugging + +**Backend Logs** + +```bash +# Docker Logs +docker-compose logs backend | grep oidc + +# Filter für Authentifizierung +docker-compose logs backend | grep -i "auth\|oidc\|keycloak" +``` + +Wichtige Log-Meldungen: +- "Initiating OpenID Connect Provider login" +- "Processing OpenID Connect Provider callback" +- "Successfully obtained access token" +- "OIDC Provider User with id 'X' stored in database" + +**Frontend Console** + +Öffnen Sie Browser Developer Tools (F12) und prüfen Sie: +- Console für JavaScript-Fehler +- Network Tab für Failed Requests +- Application Tab für Token-Storage +- LocalStorage/SessionStorage für Session-Daten + +**Keycloak Logs** + +```bash +# Keycloak Container Logs +docker-compose logs keycloak + +# Admin Console für detaillierte Logs +# Navigieren Sie zu: Keycloak Admin > Realm > Events +``` + +**Network Inspection** + +Verwenden Sie Browser DevTools oder Tools wie: +- curl für API-Testing +- Postman für komplexere Requests +- Wireshark für Network-Analysis + +Beispiel curl: +```bash +curl -X GET "https://keycloak.example.com/auth/realms/coding-box/.well-known/openid-configuration" +``` + +## 6. Sicherheitsüberlegungen + +### PKCE für Public Clients + +PKCE (Proof Key for Code Exchange) wird verwendet, um Authorization Code Interception Angriffe zu verhindern: + +- Code Verifier wird zufällig generiert (32 Bytes) +- Code Challenge ist SHA256 Hash des Verifiers +- Verifier wird nicht über das Netzwerk übertragen +- Server kann Challenge validieren +- Schützt vor Code Interception und Replay Angriffe + +### State Parameter Validierung + +State Parameter wird für CSRF-Schutz verwendet: + +- Zufälliger String wird generiert +- State wird im Authorization Request und Callback validiert +- Optional kann Redirect URI im State encodiert werden +- Verhindert CSRF-Angriffe auf Callback-Endpoint + +### Redirect URI Validierung + +Redirect URIs werden validiert, um Open Redirect Angriffe zu verhindern: + +- Nur erlaubte URIs werden akzeptiert +- Relative URIs sind erlaubt +- Same-Origin URIs sind erlaubt +- OIDC Provider Origin ist explizit blockiert +- Validierung in `isAllowedRedirect()` Methode + +### Token Storage Best Practices + +**Backend:** +- Access Tokens werden nicht persistent gespeichert +- Refresh Tokens werden sicher in Datenbank gespeichert +- PKCE Verifier haben kurze TTL (5 Minuten) +- Tokens werden über HTTPS übertragen + +**Frontend:** +- Tokens werden in sessionStorage oder localStorage gespeichert +- Verwenden Sie HttpOnly Cookies wenn möglich +- Implementieren Sie Token-Refresh-Mechanismus +- Löschen Sie Tokens bei Logout + +### Secret Management + +**Umgebungsvariablen:** +- Speichern Sie Secrets niemals im Code +- Verwenden Sie `.env` Dateien für Entwicklung +- Verwenden Sie Secret Management in Produktion (Vault, Kubernetes Secrets) +- Rotieren Sie Secrets regelmäßig + +**Keycloak:** +- Verwenden Sie starke Client Secrets +- Rotieren Sie Secrets regelmäßig +- Verwenden Sie separate Secrets für Development/Production +- Aktivieren Sie Client Secret Rotation wenn verfügbar + +### HTTPS-Anforderungen + +**Produktion:** +- Alle OIDC-Endpunkte müssen HTTPS verwenden +- SSL/TLS muss korrekt konfiguriert sein +- Verwenden Sie aktuelle TLS-Versionen (TLS 1.2+) +- Deaktivieren Sie veraltete Cipher Suites + +**Entwicklung:** +- HTTP ist für lokale Entwicklung akzeptabel +- Verwenden Sie HTTPS für Tests mit Produktion-Konfiguration +- Beachten Sie Browser-Sicherheitswarnungen + +## 7. Migrationsleitfaden + +### Von Legacy-Authentifizierung + +Wenn Sie von einer Legacy-Authentifizierung migrieren: + +1. **Backup erstellen:** + - Sichern Sie Benutzerdatenbank + - Sichern Sie Konfigurationsdateien + - Dokumentieren Sie bestehende Authentifizierungsabläufe + +2. **OIDC konfigurieren:** + - Richten Sie Keycloak oder anderen OIDC-Provider ein + - Konfigurieren Sie Umgebungsvariablen + - Importieren Sie Realm und Client-Konfiguration + +3. **Benutzer migrieren:** + - Exportieren Sie bestehende Benutzer + - Importieren Sie Benutzer in Keycloak + - Setzen Sie initiale Passwörter + - Weisen Sie Rollen zu + +4. **Testing:** + - Testen Sie Login-Flow + - Testen Sie Token-Refresh + - Testen Sie Logout + - Testen Sie Rollen und Berechtigungen + +5. **Deployment:** + - Deployen Sie neue Version + - Überwachen Sie Logs + - Seien Sie bereit für Rollback + +### Von Keycloak-only zu generischem OIDC + +Wenn Sie von Keycloak-spezifischer zu generischer OIDC-Implementierung wechseln: + +1. **Umgebungsvariablen ändern:** + - Entfernen Sie KEYCLOAK_* Variablen + - Fügen Sie OIDC_* Variablen hinzu + - Konfigurieren Sie alle Endpunkte explizit + +2. **Code anpassen:** + - Ersetzen Sie `KeycloakAuthService` durch `OidcAuthService` + - Aktualisieren Sie Imports + - Testen Sie alle Authentifizierungsabläufe + +3. **Konfiguration testen:** + - Verifizieren Sie alle Endpunkte + - Testen Sie mit verschiedenen OIDC-Providern + - Überprüfen Sie Kompatibilität + +### Umgebungsvariablen-Änderungen + +Bei Änderungen an Umgebungsvariablen: + +1. **Dokumentieren Sie Änderungen:** + - Notieren Sie alte und neue Werte + - Dokumentieren Sie Grund für Änderung + +2. **Testen Sie Changes:** + - Testen Sie in Entwicklungsumgebung + - Verifizieren Sie alle Flows + - Überprüfen Sie Kompatibilität + +3. **Deployen Sie Changes:** + - Aktualisieren Sie `.env` Dateien + - Starten Sie Services neu + - Überwachen Sie Logs + +### Datenbank-Migration + +Bei Änderungen am Benutzer-Schema: + +1. **Migration-Skript erstellen:** + - Verwenden Sie Liquibase für Schema-Änderungen + - Erstellen Sie Rollback-Skript + - Testen Sie Migration in Testumgebung + +2. **Daten migrieren:** + - Sichern Sie bestehende Daten + - Führen Sie Migration durch + - Verifizieren Sie Datenintegrität + +3. **Deployment:** + - Führen Sie Migration während Deployment + - Überwachen Sie auf Fehler + - Seien Sie bereit für Rollback + +## Zusätzliche Ressourcen + +- [OpenID Connect Specification](https://openid.net/connect/) +- [Keycloak Documentation](https://www.keycloak.org/documentation) +- [OAuth 2.0 Security Best Current Practice](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-security-topics) +- [PKCE RFC 7636](https://datatracker.ietf.org/doc/html/rfc7636) diff --git a/docs/oidc-authentication.md b/docs/oidc-authentication.md new file mode 100644 index 000000000..7d24ffc86 --- /dev/null +++ b/docs/oidc-authentication.md @@ -0,0 +1,946 @@ +# OIDC Authentication + +This document describes the OpenID Connect (OIDC) authentication in Kodierbox, including the generic OIDC implementation and Keycloak-specific integration. + +## 1. Introduction + +### What is OIDC/OpenID Connect? + +OpenID Connect (OIDC) is an authentication layer built on top of OAuth 2.0. It allows clients to verify the identity of a user based on the authentication performed by an authorization server. + +### Why does Kodierbox use OIDC? + +Kodierbox uses OIDC for: +- Centralized user management +- Single Sign-On (SSO) across multiple applications +- Secure authentication with PKCE (Proof Key for Code Exchange) +- Support for standard identity providers like Keycloak +- Flexibility for different OIDC providers + +### Authentication Flow Overview + +The authentication flow in Kodierbox uses the Authorization Code Flow with PKCE: + +1. User initiates login +2. Backend generates PKCE code verifier and challenge +3. Redirect to OIDC provider (e.g., Keycloak) +4. User authenticates with provider +5. Provider redirects with authorization code +6. Backend exchanges code for access token +7. Backend retrieves user information +8. User is stored in Kodierbox database +9. Access token is returned to frontend + +## 2. For System Administrators + +### 2.1 Environment Configuration + +#### Required Environment Variables (Generic OIDC) + +The following variables must be configured for the generic OIDC implementation: + +```bash +# OIDC Provider Endpoints +OIDC_PROVIDER_URL=https://keycloak.example.com +OIDC_ISSUER=https://keycloak.example.com/auth/realms/coding-box +OIDC_ACCOUNT_ENDPOINT=https://keycloak.example.com/auth/realms/iqb/account +OIDC_AUTHORIZATION_ENDPOINT=https://keycloak.example.com/auth/realms/iqb/protocol/openid-connect/auth +OIDC_TOKEN_ENDPOINT=https://keycloak.example.com/auth/realms/iqb/protocol/openid-connect/token +OIDC_USERINFO_ENDPOINT=https://keycloak.example.com/auth/realms/iqb/protocol/openid-connect/userinfo +OIDC_END_SESSION_ENDPOINT=https://keycloak.example.com/auth/realms/iqb/protocol/openid-connect/logout +OIDC_JWKS_URI=https://keycloak.example.com/auth/realms/iqb/protocol/openid-connect/certs + +# OAuth2 Client Configuration +OAUTH2_CLIENT_ID=coding-box +OAUTH2_CLIENT_SECRET=your_secret_here +OAUTH2_REDIRECT_URL=//example.com/auth/callback +``` + +#### Keycloak-Specific Variables + +For the Keycloak-specific implementation, these variables can be used: + +```bash +KEYCLOAK_URL=https://keycloak.example.com/auth/ +KEYCLOAK_REALM=coding-box +KEYCLOAK_CLIENT_ID=coding-box +KEYCLOAK_CLIENT_SECRET=your_secret_here +``` + +#### Configuration Templates + +The main configuration template is in `.env.coding-box.template`. Copy this file and adjust values for your environment: + +```bash +cp .env.coding-box.template .env.coding-box +# Edit .env.coding-box with your values +``` + +#### Docker Compose Setup + +The OIDC environment variables are defined in `docker-compose.yaml` and passed to backend and frontend via the `x-env-oidc` anchor: + +```yaml +x-env-oidc: &env-oidc + OIDC_PROVIDER_URL: ${OIDC_PROVIDER_URL} + OIDC_ISSUER: ${OIDC_ISSUER} + OIDC_ACCOUNT_ENDPOINT: ${OIDC_ACCOUNT_ENDPOINT} + OIDC_AUTHORIZATION_ENDPOINT: ${OIDC_AUTHORIZATION_ENDPOINT} + OIDC_TOKEN_ENDPOINT: ${OIDC_TOKEN_ENDPOINT} + OIDC_USERINFO_ENDPOINT: ${OIDC_USERINFO_ENDPOINT} + OIDC_END_SESSION_ENDPOINT: ${OIDC_END_SESSION_ENDPOINT} + OIDC_JWKS_URI: ${OIDC_JWKS_URI} + OAUTH2_CLIENT_ID: ${OAUTH2_CLIENT_ID} + OAUTH2_CLIENT_SECRET: ${OAUTH2_CLIENT_SECRET} + OAUTH2_REDIRECT_URL: ${OAUTH2_REDIRECT_URL} +``` + +### 2.2 Keycloak Setup + +#### Realm Configuration + +The Keycloak realm configuration is in `config/keycloak/realm/coding-box-realm.json`. Important settings: + +- **Realm Name**: `coding-box` +- **Access Token Lifespan**: 300 seconds (5 minutes) +- **SSO Session Idle Timeout**: 1800 seconds (30 minutes) +- **SSO Session Max Lifespan**: 36000 seconds (10 hours) +- **Registration Allowed**: `true` (can be disabled) +- **Reset Password Allowed**: `false` + +#### Client Setup + +The client configuration is in `config/keycloak/clients/coding-box.json`: + +- **Client ID**: `coding-box` +- **Name**: IQB Kodierbox +- **Standard Flow Enabled**: `true` (Authorization Code Flow) +- **Implicit Flow Enabled**: `false` +- **Public Client**: `true` +- **Redirect URIs**: `*` (restrict in production) +- **Web Origins**: `*` (restrict in production) +- **Access Token Lifespan**: 4579200 seconds (53 days) + +#### User Roles and Permissions + +Kodierbox uses the following role structure in Keycloak: + +- **admin**: System administrator with full permissions +- **default-roles-coding-box**: Default role for all users + +Admin status is read from the user's `realm_access.roles` array. Users with the `admin` role receive administrator privileges in Kodierbox. + +#### Admin Account Setup + +The admin account is configured via environment variables: + +```bash +CODING_BOX_ADMIN_NAME=coding-box-admin +CODING_BOX_ADMIN_EMAIL=coding-box-admin@localhost +CODING_BOX_ADMIN_PASSWORD=change_me +CODING_BOX_ADMIN_CREATED_TIMESTAMP=1234567890 +``` + +These variables are used in the realm configuration to create the initial admin user. + +**Default Credentials for Local Development:** + +- **Keycloak Admin Console** (http://localhost:8080/admin): + - Username: `admin` + - Password: `change_me` + +- **Kodierbox Realm User** (http://localhost:8080/realms/coding-box): + - Username: `coding-box-admin` + - Password: `change_me` + +**Important:** Change these passwords after the first login for security. + +#### Theme Customization + +Kodierbox uses a custom IQB theme for Keycloak. The theme is located in `config/keycloak/themes/iqb/`. The theme is activated via the client attribute `login_theme: iqb`. + +### 2.3 Deployment + +#### Docker Compose Deployment + +Start the complete environment with: + +```bash +make dev-up +``` + +This starts: +- PostgreSQL database +- Redis for PKCE verifier storage +- Backend with OIDC configuration +- Frontend +- Keycloak (if configured in compose file) + +#### Traefik Integration + +Traefik is used as a reverse proxy and configures SSL/TLS. Ensure that: +- `SERVER_NAME` is set correctly +- `TLS_CERTIFICATE_RESOLVER` is configured (or empty for custom certificates) +- OIDC endpoints are accessible via HTTPS + +#### SSL/TLS Configuration + +For production environments: +- Use HTTPS for all OIDC endpoints +- Configure valid SSL certificates +- Set `sslRequired: external` in Keycloak realm configuration +- Update all redirect URIs to HTTPS + +#### Network Configuration + +Kodierbox uses a Docker network `app-net` for communication between services. Ensure that: +- Backend can reach OIDC provider +- Frontend can reach backend +- OIDC provider can reach callback URL + +## 3. For Developers + +### 3.1 Backend Implementation + +#### OidcAuthService - Generic OIDC Service + +The `OidcAuthService` (`apps/backend/src/app/auth/service/oidc-auth.service.ts`) implements a generic OIDC solution that works with any OIDC-compliant provider. + +**Main Methods:** + +- `getAuthorizationUrl(state, redirectUri, codeChallenge)`: Generates authorization URL for OIDC provider +- `exchangeCodeForToken(code, redirectUri, codeVerifier)`: Exchanges authorization code for access token +- `getUserInfo(accessToken)`: Retrieves user information from provider +- `getLogoutUrl(idToken, redirectUri)`: Generates logout URL +- `logoutWithRefreshToken(refreshToken)`: Performs POST logout +- `getProfileUrl(redirectUri)`: Generates profile management URL +- `generatePkcePair()`: Generates PKCE code verifier and challenge +- `storePkceVerifier(state, codeVerifier)`: Stores PKCE verifier (5 minute TTL) +- `consumePkceVerifier(state)`: Consumes and deletes PKCE verifier + +**Interfaces:** + +```typescript +export interface OidcConfiguration { + issuer: string; + account_endpoint: string; + authorization_endpoint: string; + token_endpoint: string; + userinfo_endpoint: string; + end_session_endpoint: string; + jwks_uri: string; +} + +export interface OidcTokenResponse { + access_token: string; + token_type: string; + expires_in: number; + refresh_token: string; + scope?: string; + id_token?: string; +} + +export interface OidcUserInfo { + sub: string; + preferred_username: string; + given_name?: string; + family_name?: string; + email?: string; + realm_access?: { + roles: string[]; + }; +} +``` + +#### KeycloakAuthService - Keycloak-Specific Service + +The `KeycloakAuthService` (`apps/backend/src/app/auth/service/keycloak-auth.service.ts`) provides a Keycloak-specific implementation with simplified configuration. + +**Differences from generic service:** +- Only requires `KEYCLOAK_URL`, `KEYCLOAK_REALM`, `KEYCLOAK_CLIENT_ID` +- Constructs endpoints automatically from base URL +- Identical method signatures to `OidcAuthService` + +#### AuthController - REST API Endpoints + +The `AuthController` (`apps/backend/src/app/auth/auth.controller.ts`) provides the following endpoints: + +**GET /auth/login** +- Initiates OIDC login +- Generates PKCE pair +- Stores code verifier +- Redirects to OIDC provider +- Query parameter: `redirect_uri` (optional) + +**GET /auth/callback** +- Processes OIDC callback +- Validates state parameter +- Consumes PKCE verifier +- Exchanges code for token +- Retrieves user info +- Stores user in database +- Redirects with token or JSON response +- Query parameters: `code`, `state` + +**POST /auth/logout** +- Performs SSO logout +- Invalidates refresh token +- Body: `{ refresh_token: string }` + +**GET /auth/profile** +- Redirects to OIDC provider profile management +- Query parameter: `redirect_uri` (optional) + +**POST /auth/token** +- OAuth2 Client Credentials Flow +- Body: `{ client_id, client_secret, scope? }` +- Returns access token + +**POST /auth/validate** +- Validates access token against OIDC provider +- Body: `{ access_token: string }` +- Returns user info + +#### AuthService - User Management + +The `AuthService` (`apps/backend/src/app/auth/service/auth.service.ts`) manages users in the Kodierbox database: + +**Main Methods:** + +- `storeOidcProviderUser(user)`: Stores OIDC user in database +- `loginOidcProviderUser(user)`: Logs in OIDC user and creates JWT +- `createToken(identity, workspaceId, duration)`: Creates workspace-specific token +- `isAdminUser(userId)`: Checks admin status +- `canAccessWorkSpace(userId, workspaceId)`: Checks workspace access + +### 3.2 Frontend Implementation + +#### Authentication Service + +The `AuthenticationService` (`apps/frontend/src/app/core/services/authentication.service.ts`) manages authentication in the frontend. + +**Main functions:** +- Token storage (localStorage/sessionStorage) +- Token refresh +- User session management + +#### Route Guards + +Kodierbox uses multiple guards for route protection: + +- **AuthGuard** (`apps/frontend/src/app/core/guards/auth.guard.ts`): Protects authenticated routes +- **AdminGuard** (`apps/frontend/src/app/core/guards/admin.guard.ts`): Protects admin routes +- **TokenGuard** (`apps/frontend/src/app/core/guards/token.guard.ts`): Validates tokens +- **AccessLevelGuard** (`apps/frontend/src/app/core/guards/access-level.guard.ts`): Checks access level + +#### Token Storage and Refresh + +Tokens are stored in the frontend and automatically refreshed: +- Access token is used for API calls +- Refresh token is used for token refresh +- Token storage follows security best practices + +#### Interceptors for API Calls + +The `AuthInterceptor` (`apps/frontend/src/app/core/interceptors/auth.interceptor.ts`) automatically adds authorization headers to API calls. + +#### User Session Management + +Session management includes: +- Login status tracking +- User information caching +- Automatic logout on token expiration +- Session timeout handling + +### 3.3 Authentication Flow + +#### PKCE (Proof Key for Code Exchange) Flow + +The PKCE flow is used for public clients and increases security: + +**Step 1: Generate Code Verifier and Challenge** +```typescript +const { codeVerifier, codeChallenge } = this.oidcAuthService.generatePkcePair(); +``` +- Code verifier: 32 bytes random data, base64url-encoded +- Code challenge: SHA256 hash of verifier, base64url-encoded + +**Step 2: Store Verifier** +```typescript +await this.oidcAuthService.storePkceVerifier(state, codeVerifier); +``` +- Storage with state as key +- TTL: 5 minutes +- Storage location: In-memory map (development), Redis (production) + +**Step 3: Redirect to OIDC Provider** +```typescript +const authUrl = this.oidcAuthService.getAuthorizationUrl(state, redirectUri, codeChallenge); +res.redirect(authUrl); +``` +- Parameters: `response_type=code`, `client_id`, `redirect_uri`, `state`, `scope`, `code_challenge`, `code_challenge_method=S256` + +**Step 4: Exchange Code for Token** +```typescript +const tokenResponse = await this.oidcAuthService.exchangeCodeForToken(code, redirectUri, codeVerifier); +``` +- POST request to token endpoint +- Parameters: `grant_type=authorization_code`, `client_id`, `code`, `redirect_uri`, `code_verifier`, optional `client_secret` + +**Step 5: Retrieve User Info** +```typescript +const userInfo = await this.oidcAuthService.getUserInfo(tokenResponse.access_token); +``` +- GET request to userinfo endpoint +- Header: `Authorization: Bearer {access_token}` + +**Step 6: Store User** +```typescript +const userData: CreateUserDto = { + identity: userInfo.sub, + username: userInfo.preferred_username, + firstName: userInfo.given_name || '', + lastName: userInfo.family_name || '', + email: userInfo.email || '', + issuer: 'coding-box', + isAdmin: userInfo.realm_access?.roles?.includes('admin') || false +}; +await this.authService.storeOidcProviderUser(userData); +``` + +#### Token Lifecycle + +1. **Access Token**: Short-lived (default 5 minutes), used for API calls +2. **Refresh Token**: Long-lived (default 53 days), used for token refresh +3. **ID Token**: Contains user claims, used for identity verification +4. **Token Refresh**: Automatic by frontend on expiration +5. **Token Invalidation**: On logout or SSO logout + +#### Refresh Token Handling + +- Refresh tokens are stored securely +- Automatic refresh on expired access token +- Refresh token is invalidated on logout +- SSO logout invalidates refresh token at provider + +#### Session Management + +- Session timeout based on SSO session (30 minutes idle, 10 hours max) +- Activity tracking for idle timeout +- Automatic redirect on session expiration +- Manual logout option + +### 3.4 Integration with Other Services + +#### Database Integration + +Users are stored in the PostgreSQL database: + +- Table: `users` +- Fields: `id`, `identity`, `username`, `email`, `first_name`, `last_name`, `issuer`, `is_admin` +- Index on `identity` and `issuer` for fast lookup +- Upsert logic: User is updated if already exists + +#### Redis Integration + +Redis is used for PKCE verifier storage: + +- Key format: `oidc:pkce:{sha256(state)}` +- TTL: 5 minutes +- Storage location: In-memory map in development, Redis in production +- Automatic cleanup of expired entries + +#### Workspace Access Control + +Access to workspaces is checked via `AuthService.canAccessWorkSpace()`: + +- User must have workspace access +- Admin users have access to all workspaces +- Workspace admins have access to their workspace + +#### Role-Based Authorization + +Roles are read from Keycloak `realm_access.roles`: + +- `admin`: Full access to all features +- Other roles can be added as needed +- Roles are synchronized in Kodierbox database + +## 4. For End Users + +### 4.1 Login Process + +**How to log in:** + +1. Click "Login" on the login page +2. You will be redirected to Keycloak login +3. Enter your username and password +4. After successful authentication, you will be redirected back to Kodierbox +5. You are now logged in + +**Password Management:** +- Passwords are managed in Keycloak +- Password reset depends on Keycloak configuration +- In default configuration, password reset is disabled + +**Account Creation:** +- If registration is enabled in Keycloak, new users can register +- Click "Register" on the login page +- Fill out the registration form +- After confirmation, you can log in + +### 4.2 Profile Management + +**Accessing Profile Settings:** + +1. Click on your username in the header +2. Select "Profile" from the menu +3. You will be redirected to Keycloak profile management + +**Changing Password:** +- Go to profile management +- Navigate to "Password" +- Enter your current and new password +- Confirm the new password +- Save changes + +**Managing Personal Information:** +- Profile management allows changes to: + - First name + - Last name + - Email address + - Other attributes (depending on configuration) + +**Email Verification:** +- Depends on Keycloak configuration +- In default configuration, email verification is disabled +- If enabled, you must verify your email after registration + +### 4.3 Logout + +**Single Logout (application only):** +- Click "Logout" in the user menu +- You will be logged out of Kodierbox +- Other applications remain logged in + +**SSO Logout (all sessions):** +- Kodierbox automatically performs SSO logout +- Refresh token is invalidated at provider +- You will be logged out of all applications using SSO +- Session is terminated at Keycloak + +**Session Timeout:** +- Idle timeout: 30 minutes of inactivity +- Max session: 10 hours +- After timeout, you are automatically logged out +- You must log in again + +### 4.4 Troubleshooting for Users + +**Login Errors:** + +**Incorrect credentials:** +- Verify username and password +- Check for case sensitivity +- Try resetting your password (if enabled) + +**Account locked:** +- Contact your administrator +- Administrator can unlock account in Keycloak + +**Browser issues:** +- Clear browser cookies and cache +- Disable browser extensions +- Try a different browser +- Ensure JavaScript is enabled + +**Connection issues:** +- Check your internet connection +- Verify server is reachable +- Try again later + +## 5. Troubleshooting (Common Issues) + +### 5.1 Configuration Issues + +**Missing Environment Variables** + +Symptom: Backend fails to start with error "OpenID Connect configuration is missing" + +Solution: +- Check `.env.coding-box` file +- Ensure all OIDC_* variables are set +- Check Docker Compose environment variables +- Restart backend + +**Incorrect Endpoint URLs** + +Symptom: "Failed to exchange authorization code for token" or "Failed to get user information" + +Solution: +- Verify OIDC_ENDPOINT_* variables +- Ensure URLs are correct and reachable +- Test endpoints with curl or Postman +- Check TLS/SSL configuration + +**Client Secret Mismatch** + +Symptom: "Invalid client credentials" on token exchange + +Solution: +- Verify OAUTH2_CLIENT_SECRET in environment variables +- Ensure secret matches Keycloak client configuration +- Regenerate secret in Keycloak if needed +- Restart backend + +**Redirect URI Problems** + +Symptom: "Invalid redirect_uri" or redirect not working + +Solution: +- Verify OAUTH2_REDIRECT_URL +- Ensure redirect URI is included in Keycloak client configuration +- Use HTTPS in production +- Check CORS configuration + +### 5.2 Authentication Failures + +**PKCE Verifier Expired** + +Symptom: "PKCE verifier missing or expired" + +Solution: +- PKCE verifier has 5 minute TTL +- Restart login process +- Check system time on server and client +- Ensure Redis is running (in production) + +**Invalid State Parameter** + +Symptom: "State parameter is required for PKCE flow" + +Solution: +- State is automatically generated +- Verify state is returned correctly in callback +- Ensure no state manipulation by middleware +- Check browser console for errors + +**Token Exchange Failure** + +Symptom: "Failed to exchange authorization code for token" + +Solution: +- Verify authorization code hasn't expired (60 seconds) +- Ensure PKCE verifier is correct +- Check client ID and secret +- Check Keycloak logs for errors + +**User Info Retrieval Error** + +Symptom: "Failed to get user information" + +Solution: +- Verify access token is valid +- Ensure userinfo endpoint is reachable +- Check token scopes include `profile` and `email` +- Check Keycloak user configuration + +### 5.3 Keycloak Issues + +**Realm Not Found** + +Symptom: "Realm not found" in Keycloak logs + +Solution: +- Verify KEYCLOAK_REALM variable +- Ensure realm exists in Keycloak +- Import realm configuration if needed +- Check Keycloak Admin Console + +**Client Not Found** + +Symptom: "Client not found" or "Invalid client" + +Solution: +- Verify OAUTH2_CLIENT_ID variable +- Ensure client exists in realm +- Check client is enabled +- Import client configuration if needed + +**Invalid Credentials** + +Symptom: "Invalid credentials" on login + +Solution: +- Check user login in Keycloak Admin Console +- Ensure user is enabled +- Reset password if needed +- Check user roles + +**Role Assignment Problems** + +Symptom: User doesn't have admin rights despite role + +Solution: +- Verify user has `admin` role in Keycloak +- Ensure role is in `realm_access.roles` +- Sync user in Kodierbox database +- Check backend logs for role mapping + +### 5.4 Network Issues + +**CORS Errors** + +Symptom: CORS errors in browser + +Solution: +- Check CORS configuration in backend +- Ensure origin is allowed +- Configure CORS in Keycloak if needed +- Use CORS plugin for browser testing + +**Proxy Configuration** + +Symptom: Connection errors through proxy + +Solution: +- Check Traefik configuration +- Ensure OIDC endpoints are routed correctly +- Configure proxy headers (X-Forwarded-*, etc.) +- Test endpoints without proxy + +**SSL Certificate Problems** + +Symptom: SSL errors or certificate warnings + +Solution: +- Use valid SSL certificates in production +- Ensure certificate is valid for all endpoints +- Check certificate chain +- Configure `sslRequired: external` in Keycloak + +**Timeout Issues** + +Symptom: Request timeouts + +Solution: +- Check network connection +- Increase timeout values in HTTP client +- Check firewall configuration +- Check load balancer settings + +### 5.5 Debugging + +**Backend Logs** + +```bash +# Docker logs +docker-compose logs backend | grep oidc + +# Filter for authentication +docker-compose logs backend | grep -i "auth\|oidc\|keycloak" +``` + +Important log messages: +- "Initiating OpenID Connect Provider login" +- "Processing OpenID Connect Provider callback" +- "Successfully obtained access token" +- "OIDC Provider User with id 'X' stored in database" + +**Frontend Console** + +Open browser Developer Tools (F12) and check: +- Console for JavaScript errors +- Network tab for failed requests +- Application tab for token storage +- LocalStorage/SessionStorage for session data + +**Keycloak Logs** + +```bash +# Keycloak container logs +docker-compose logs keycloak + +# Admin console for detailed logs +# Navigate to: Keycloak Admin > Realm > Events +``` + +**Network Inspection** + +Use browser DevTools or tools like: +- curl for API testing +- Postman for more complex requests +- Wireshark for network analysis + +Example curl: +```bash +curl -X GET "https://keycloak.example.com/auth/realms/coding-box/.well-known/openid-configuration" +``` + +## 6. Security Considerations + +### PKCE for Public Clients + +PKCE (Proof Key for Code Exchange) is used to prevent authorization code interception attacks: + +- Code verifier is randomly generated (32 bytes) +- Code challenge is SHA256 hash of verifier +- Verifier is not transmitted over the network +- Server can validate challenge +- Protects against code interception and replay attacks + +### State Parameter Validation + +State parameter is used for CSRF protection: + +- Random string is generated +- State is validated in authorization request and callback +- Redirect URI can optionally be encoded in state +- Prevents CSRF attacks on callback endpoint + +### Redirect URI Validation + +Redirect URIs are validated to prevent open redirect attacks: + +- Only allowed URIs are accepted +- Relative URIs are allowed +- Same-origin URIs are allowed +- OIDC provider origin is explicitly blocked +- Validation in `isAllowedRedirect()` method + +### Token Storage Best Practices + +**Backend:** +- Access tokens are not persistently stored +- Refresh tokens are stored securely in database +- PKCE verifiers have short TTL (5 minutes) +- Tokens are transmitted over HTTPS + +**Frontend:** +- Tokens are stored in sessionStorage or localStorage +- Use HttpOnly cookies when possible +- Implement token refresh mechanism +- Clear tokens on logout + +### Secret Management + +**Environment Variables:** +- Never store secrets in code +- Use `.env` files for development +- Use secret management in production (Vault, Kubernetes Secrets) +- Rotate secrets regularly + +**Keycloak:** +- Use strong client secrets +- Rotate secrets regularly +- Use separate secrets for development/production +- Enable client secret rotation if available + +### HTTPS Requirements + +**Production:** +- All OIDC endpoints must use HTTPS +- SSL/TLS must be properly configured +- Use current TLS versions (TLS 1.2+) +- Disable deprecated cipher suites + +**Development:** +- HTTP is acceptable for local development +- Use HTTPS for testing with production configuration +- Note browser security warnings + +## 7. Migration Guide + +### From Legacy Authentication + +If migrating from legacy authentication: + +1. **Create backup:** + - Backup user database + - Backup configuration files + - Document existing authentication flows + +2. **Configure OIDC:** + - Set up Keycloak or other OIDC provider + - Configure environment variables + - Import realm and client configuration + +3. **Migrate users:** + - Export existing users + - Import users to Keycloak + - Set initial passwords + - Assign roles + +4. **Testing:** + - Test login flow + - Test token refresh + - Test logout + - Test roles and permissions + +5. **Deployment:** + - Deploy new version + - Monitor logs + - Be ready for rollback + +### From Keycloak-Only to Generic OIDC + +If switching from Keycloak-specific to generic OIDC implementation: + +1. **Change environment variables:** + - Remove KEYCLOAK_* variables + - Add OIDC_* variables + - Configure all endpoints explicitly + +2. **Adapt code:** + - Replace `KeycloakAuthService` with `OidcAuthService` + - Update imports + - Test all authentication flows + +3. **Test configuration:** + - Verify all endpoints + - Test with different OIDC providers + - Check compatibility + +### Environment Variable Changes + +When changing environment variables: + +1. **Document changes:** + - Note old and new values + - Document reason for change + +2. **Test changes:** + - Test in development environment + - Verify all flows + - Check compatibility + +3. **Deploy changes:** + - Update `.env` files + - Restart services + - Monitor logs + +### Database Migration + +When changing user schema: + +1. **Create migration script:** + - Use Liquibase for schema changes + - Create rollback script + - Test migration in test environment + +2. **Migrate data:** + - Backup existing data + - Run migration + - Verify data integrity + +3. **Deployment:** + - Run migration during deployment + - Monitor for errors + - Be ready for rollback + +## Additional Resources + +- [OpenID Connect Specification](https://openid.net/connect/) +- [Keycloak Documentation](https://www.keycloak.org/documentation) +- [OAuth 2.0 Security Best Current Practice](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-security-topics) +- [PKCE RFC 7636](https://datatracker.ietf.org/doc/html/rfc7636) diff --git a/package-lock.json b/package-lock.json index fd34bdedf..8b1d99f4a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -65,10 +65,9 @@ "fast-csv": "^5.0.1", "file-saver-es": "^2.0.5", "ioredis": "^5.7.0", + "jwks-rsa": "^3.2.0", "jwt-decode": "^4.0.0", "katex": "^0.16.11", - "keycloak-angular": "20.0.0", - "keycloak-js": "^23.0.6", "libxmljs2": "^0.37.0", "mathml2omml": "^0.5.0", "multer": "^2.0.1", @@ -9444,6 +9443,16 @@ } } }, + "node_modules/@nestjs/swagger/node_modules/path-to-regexp": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz", + "integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/@nestjs/terminus": { "version": "11.0.0", "resolved": "https://registry.npmjs.org/@nestjs/terminus/-/terminus-11.0.0.tgz", @@ -27493,12 +27502,6 @@ "url": "https://github.com/sponsors/panva" } }, - "node_modules/js-sha256": { - "version": "0.10.1", - "resolved": "https://registry.npmjs.org/js-sha256/-/js-sha256-0.10.1.tgz", - "integrity": "sha512-5obBtsz9301ULlsgggLg542s/jqtddfOpV5KJc4hajc9JV8GeY2gZHSVpYBn4nWqAUTJ9v+xwtbJ1mIBgIH5Vw==", - "license": "MIT" - }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -27904,6 +27907,31 @@ "safe-buffer": "^5.0.1" } }, + "node_modules/jwks-rsa": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/jwks-rsa/-/jwks-rsa-3.2.2.tgz", + "integrity": "sha512-BqTyEDV+lS8F2trk3A+qJnxV5Q9EqKCBJOPti3W97r7qTympCZjb7h2X6f2kc+0K3rsSTY1/6YG2eaXKoj497w==", + "license": "MIT", + "dependencies": { + "@types/jsonwebtoken": "^9.0.4", + "debug": "^4.3.4", + "jose": "^4.15.4", + "limiter": "^1.1.5", + "lru-memoizer": "^2.2.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/jwks-rsa/node_modules/jose": { + "version": "4.15.9", + "resolved": "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz", + "integrity": "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/jws": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", @@ -27958,33 +27986,6 @@ "node": ">= 12" } }, - "node_modules/keycloak-angular": { - "version": "20.0.0", - "resolved": "https://registry.npmjs.org/keycloak-angular/-/keycloak-angular-20.0.0.tgz", - "integrity": "sha512-p9ThVUN8TNz15M2dd11VRDdHzgEDRSSxvyRGtK4N45lTbfs52DeNK+YXcpgt8ZX0/YN27GjU9GjiB4odI4/A2Q==", - "license": "MIT", - "dependencies": { - "tslib": "^2.3.1" - }, - "peerDependencies": { - "@angular/common": "^20", - "@angular/core": "^20", - "@angular/router": "^20", - "keycloak-js": "^18 || ^19 || ^20 || ^21 || ^22 || ^23 || ^24 || ^25 || ^26", - "rxjs": "^7" - } - }, - "node_modules/keycloak-js": { - "version": "23.0.7", - "resolved": "https://registry.npmjs.org/keycloak-js/-/keycloak-js-23.0.7.tgz", - "integrity": "sha512-OmszsKzBhhm5yP4W1q/tMd+nNnKpOAdeVYcoGhphlv8Fj1bNk4wRTYzp7pn5BkvueLz7fhvKHz7uOc33524YrA==", - "license": "Apache-2.0", - "dependencies": { - "base64-js": "^1.5.1", - "js-sha256": "^0.10.1", - "jwt-decode": "^4.0.0" - } - }, "node_modules/keygrip": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/keygrip/-/keygrip-1.1.0.tgz", @@ -28392,6 +28393,11 @@ "url": "https://github.com/sponsors/antonk52" } }, + "node_modules/limiter": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/limiter/-/limiter-1.1.5.tgz", + "integrity": "sha512-FWWMIEOxz3GwUI4Ts/IvgVy6LPvoMPgjMdQ185nN6psJyBJ4yOpzqm695/h5umdLJg2vW3GR5iG11MAkR2AzJA==" + }, "node_modules/lines-and-columns": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-2.0.3.tgz", @@ -28611,6 +28617,12 @@ "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", "license": "MIT" }, + "node_modules/lodash.clonedeep": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz", + "integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==", + "license": "MIT" + }, "node_modules/lodash.clonedeepwith": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/lodash.clonedeepwith/-/lodash.clonedeepwith-4.5.0.tgz", @@ -29008,6 +29020,34 @@ "yallist": "^3.0.2" } }, + "node_modules/lru-memoizer": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/lru-memoizer/-/lru-memoizer-2.3.0.tgz", + "integrity": "sha512-GXn7gyHAMhO13WSKrIiNfztwxodVsP8IoZ3XfrJV4yH2x0/OeTO/FIaAHTY5YekdGgW94njfuKmyyt1E0mR6Ug==", + "license": "MIT", + "dependencies": { + "lodash.clonedeep": "^4.5.0", + "lru-cache": "6.0.0" + } + }, + "node_modules/lru-memoizer/node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/lru-memoizer/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "license": "ISC" + }, "node_modules/luxon": { "version": "3.7.2", "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz", diff --git a/package.json b/package.json index 0d9fa66c7..c0a0280fb 100644 --- a/package.json +++ b/package.json @@ -84,10 +84,9 @@ "fast-csv": "^5.0.1", "file-saver-es": "^2.0.5", "ioredis": "^5.7.0", + "jwks-rsa": "^3.2.0", "jwt-decode": "^4.0.0", "katex": "^0.16.11", - "keycloak-angular": "20.0.0", - "keycloak-js": "^23.0.6", "libxmljs2": "^0.37.0", "mathml2omml": "^0.5.0", "multer": "^2.0.1", @@ -161,9 +160,7 @@ }, "serialize-javascript": "7.0.5", "picomatch": "4.0.4", - "vite": "7.3.2", - "path-to-regexp": "8.4.2", - "shell-quote": "1.8.4" + "vite": "7.3.2" }, "eslintConfig": { "extends": "@iqb/eslint-config", diff --git a/scripts/install.sh b/scripts/install.sh index 061be7a15..eb44da053 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -12,24 +12,18 @@ declare MAKE_BASE_DIR_NAME='CODING_BOX_BASE_DIR' declare REQUIRED_PACKAGES=("docker -v" "docker compose version") declare OPTIONAL_PACKAGES=("make -v") -declare -A ENV_VARS -ENV_VARS[POSTGRES_USER]=root -ENV_VARS[POSTGRES_PASSWORD]=$(tr -dc 'a-zA-Z0-9' >.env.${APP_NAME} + + printf " Docker environment file '%s' successfully upgraded.\n" .env.${APP_NAME} +} + +import_keycloak_realm() { + declare are_keycloak_services_up=false + + # Copy Coding Box realm + declare realm_file="${TRAEFIK_DIR}/config/keycloak/${APP_NAME}-realm.json" + if test -e "${realm_file}"; then + declare is_realm_override + read -p " Keycloak realm (${realm_file}) already exists! Do you want to replace it? [Y/n] " -er -n 1 is_realm_override + if [[ ! ${is_realm_override} =~ ^[nN]$ ]]; then + printf " - " && rm -v "${realm_file}" + printf " - " && cp -v config/keycloak/${APP_NAME}-realm.json "${realm_file}" + fi + else + cp config/keycloak/${APP_NAME}-realm.json "${realm_file}" + fi + + # Copy Coding Box realm configuration + declare realm_config="${TRAEFIK_DIR}/config/keycloak/${APP_NAME}-realm.config" + if test -e "${realm_config}"; then + declare is_config_override + read -p " Keycloak realm configuration (${realm_config}) already exists! Do you want to replace it? [Y/n] " -er -n 1 is_config_override + if [[ ! ${is_config_override} =~ ^[nN]$ ]]; then + printf " - " && rm -v "${realm_config}" + printf " - " && cp -v config/keycloak/${APP_NAME}-realm.config "${realm_config}" + fi + else + cp config/keycloak/${APP_NAME}-realm.config "${realm_config}" + fi + + # Start/Stop Keycloak Services + if [ "$(docker compose \ + --env-file "${TRAEFIK_DIR}/.env.traefik" \ + --file "${TRAEFIK_DIR}/docker-compose.traefik.yaml" \ + --file "${TRAEFIK_DIR}/docker-compose.traefik.prod.yaml" \ + ps -q keycloak keycloak-db | wc -l)" != 2 ]; then + printf "\n One or more Keycloak services are down ...\n" + + printf " - Starting Keycloak DB\n" + docker compose \ + --progress quiet \ + --env-file "${TRAEFIK_DIR}/.env.traefik" \ + --file "${TRAEFIK_DIR}/docker-compose.traefik.yaml" \ + --file "${TRAEFIK_DIR}/docker-compose.traefik.prod.yaml" \ + up --detach keycloak-db + sleep 15 # waiting keycloak started completely + printf " Keycloak DB is up.\n\n" + else + printf " Keycloak services are up ...\n" + are_keycloak_services_up=true + + # Stop Keycloak + printf " - Shutting down all Keycloak services except Keycloak DB\n" + docker compose \ + --progress quiet \ + --env-file "${TRAEFIK_DIR}/.env.traefik" \ + --file "${TRAEFIK_DIR}/docker-compose.traefik.yaml" \ + --file "${TRAEFIK_DIR}/docker-compose.traefik.prod.yaml" \ + down keycloak + printf " Only Keycloak DB is up.\n\n" + fi + + # Import Coding Box Realm + printf " Import Keycloak realm ...\n\n" + declare keycloak_version keycloak_admin_name keycloak_admin_password keycloak_db_name keycloak_db_root \ + keycloak_db_root_password keycloak_container_realm_file + + keycloak_version=$(grep -oP 'quay.io/keycloak/keycloak:\K[^*]*' "${TRAEFIK_DIR}/docker-compose.traefik.yaml") + keycloak_admin_name=$(grep -oP 'ADMIN_NAME=\K[^*]*' "${TRAEFIK_DIR}/.env.traefik") + keycloak_admin_password=$(grep -oP 'ADMIN_PASSWORD=\K[^*]*' "${TRAEFIK_DIR}/.env.traefik") + keycloak_db_name=$(grep -oP 'POSTGRES_DB=\K[^*]*' "${TRAEFIK_DIR}/.env.traefik") + keycloak_db_root=$(grep -oP 'POSTGRES_USER=\K[^*]*' "${TRAEFIK_DIR}/.env.traefik") + keycloak_db_root_password=$(grep -oP 'POSTGRES_PASSWORD=\K[^*]*' "${TRAEFIK_DIR}/.env.traefik") + keycloak_container_realm_file="/opt/keycloak/data/import/${APP_NAME}-realm.json" + + docker run \ + --rm \ + --name keycloak-coding-box-realm-import \ + --env KC_DB=postgres \ + --env KC_DB_URL="jdbc:postgresql://keycloak-db/${keycloak_db_name}" \ + --env KC_DB_USERNAME="${keycloak_db_root}" \ + --env KC_DB_PASSWORD="${keycloak_db_root_password}" \ + --env KC_HOSTNAME="keycloak.${SERVER_NAME}" \ + --env KEYCLOAK_ADMIN_USERNAME="${keycloak_admin_name}" \ + --env KEYCLOAK_ADMIN_PASSWORD="${keycloak_admin_password}" \ + --env JAVA_OPTS_APPEND="-Dkeycloak.migration.replace-placeholders=true" \ + --env-file "${realm_config}" \ + --volume "${realm_file}:${keycloak_container_realm_file}" \ + --network app-net \ + "quay.io/keycloak/keycloak:${keycloak_version}" import --file "${keycloak_container_realm_file}" + + printf "\n Keycloak realm imported.\n\n" + + # Start/Stop Keycloak Services + if ! ${are_keycloak_services_up}; then + printf " Shutting down Keycloak DB ...\n" + docker compose \ + --progress quiet \ + --env-file "${TRAEFIK_DIR}/.env.traefik" \ + --file "${TRAEFIK_DIR}/docker-compose.traefik.yaml" \ + --file "${TRAEFIK_DIR}/docker-compose.traefik.prod.yaml" \ + down keycloak-db + printf " Keycloak DB is down again.\n\n" + else + printf " Starting Keycloak Services ...\n" + docker compose \ + --progress quiet \ + --env-file "${TRAEFIK_DIR}/.env.traefik" \ + --file "${TRAEFIK_DIR}/docker-compose.traefik.yaml" \ + --file "${TRAEFIK_DIR}/docker-compose.traefik.prod.yaml" \ + up --detach keycloak + printf " All Keycloak Services are up again.\n\n" + fi +} + +download_file() { + declare local_file="${1}" + declare remote_file="${REPO_URL}/${TARGET_VERSION}/${2}" + + if curl --silent --fail --output "${local_file}" "${remote_file}"; then + printf " - File '%s' successfully downloaded.\n" "${1}" + else + printf " - File '%s' download failed.\n\n" "${1}" + printf " '%s' installation script finished with error.\n\n" "${APP_NAME}" + + exit 1 + fi +} + +configure_oidc() { + printf "\n Configure OpenID Connect with OAuth2 Authentication ...\n" + declare -A env_vars_oidc + declare env_vars_order_oidc=(oidc_provider_url oidc_issuer oidc_account_endpoint oidc_authorization_endpoint + oidc_token_endpoint oidc_userinfo_endpoint oidc_end_session_endpoint oidc_jwks_uri oauth2_client_id + oauth2_client_secret oauth2_redirect_url) + + env_vars_oidc[oauth2_client_id]=coding-box + env_vars_oidc[oauth2_client_secret]=$(tr -dc 'a-zA-Z0-9'