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 2cde6a37f..8e9c469bc 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 @@ -20,3 +20,16 @@ JWT_SECRET=random_string ## 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 bb5f622fe..fda2b4871 100644 --- a/.env.dev.template +++ b/.env.dev.template @@ -19,6 +19,7 @@ POSTGRES_DB=coding-box ## Backend API_PORT=3333 JWT_SECRET=random_string +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 @@ -27,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..4eddc3512 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,69 @@ 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 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 +100,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 +112,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 +124,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 +136,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 +178,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 +227,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 +242,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 +254,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 +265,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..b202cce26 100644 --- a/apps/backend/src/app/admin/admin.guard.ts +++ b/apps/backend/src/app/admin/admin.guard.ts @@ -2,20 +2,37 @@ import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from '@nestjs/common'; import { AuthService } from '../auth/service/auth.service'; +import { UsersService } from '../database/services/users'; @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 isAdmin = await this.authService.isAdminUser(userId); + // Prefer the role claim from the validated JWT. + // This avoids blocking admin actions when the DB flag is stale. + if (req.user?.isAdmin === true) { + return true; + } + + const userIdentity = req.user?.id; + if (!userIdentity) { + throw new UnauthorizedException('User not found'); + } + + const user = await this.usersService.findUserByIdentity(userIdentity); + if (!user) { + throw new UnauthorizedException('User not found'); + } + + const isAdmin = await this.authService.isAdminUser(user.id); if (!isAdmin) { throw new UnauthorizedException('Admin privileges required'); } 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/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 b0d90a31c..099157762 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 @@ -18,7 +18,8 @@ describe('AccessLevelGuard (Backend)', () => { const mockUsersService = { getUserIsAdmin: jest.fn(), - getUserAccessLevel: jest.fn() + getUserAccessLevel: jest.fn(), + findUserByIdentity: jest.fn() }; const module: TestingModule = await Test.createTestingModule({ @@ -41,14 +42,15 @@ describe('AccessLevelGuard (Backend)', () => { }); const createMockExecutionContext = ( - userId: number, + userId: number | string, workspaceId: string, - requiredLevel?: number + requiredLevel?: number, + isAdmin = false ): ExecutionContext => { const context = { switchToHttp: () => ({ getRequest: () => ({ - user: { id: userId }, + user: { id: userId, isAdmin }, params: { workspace_id: workspaceId } }) }), @@ -86,6 +88,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); @@ -114,6 +128,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); @@ -253,6 +281,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 64addc829..1f01ada0f 100644 --- a/apps/backend/src/app/admin/workspace/access-level.guard.ts +++ b/apps/backend/src/app/admin/workspace/access-level.guard.ts @@ -38,12 +38,33 @@ export class AccessLevelGuard implements CanActivate { } const request = context.switchToHttp().getRequest(); - const userId = request.user?.id; + const requestUserId = request.user?.id; - 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 = parseInt(request.params.workspace_id, 10); 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 83ea16fda..47214cf01 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 @@ -18,7 +18,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 { WorkspaceGuard } from './workspace.guard'; import { WorkspaceId } from './workspace.decorator'; import { CodingReplayService } from '../../database/services/coding'; @@ -72,7 +72,7 @@ export class WorkspaceCodingReplayController { } @Get(':workspace_id/coding/responses/:responseId/replay-url') - @UseGuards(JwtAuthGuard, WorkspaceGuard) + @UseGuards(JwtOrWorkspaceTokenAuthGuard, WorkspaceGuard) @ApiTags('coding') @ApiParam({ name: 'workspace_id', type: Number }) @ApiParam({ @@ -114,7 +114,7 @@ export class WorkspaceCodingReplayController { } @Get(':workspace_id/replay-payload/:testPerson/:unitId') - @UseGuards(JwtAuthGuard, WorkspaceGuard) + @UseGuards(JwtOrWorkspaceTokenAuthGuard, WorkspaceGuard) @ApiTags('coding') @ApiParam({ name: 'workspace_id', type: Number }) @ApiParam({ @@ -173,7 +173,7 @@ export class WorkspaceCodingReplayController { } @Get(':workspace_id/replay-assets/:unitId') - @UseGuards(JwtAuthGuard, WorkspaceGuard) + @UseGuards(JwtOrWorkspaceTokenAuthGuard, WorkspaceGuard) @ApiTags('coding') @ApiParam({ name: 'workspace_id', type: Number }) @ApiParam({ @@ -214,7 +214,7 @@ export class WorkspaceCodingReplayController { } @Get(':workspace_id/replay-response/:testPerson/:unitId') - @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 457a96315..aede5b681 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 { WorkspaceGuard } from './workspace.guard'; import { WorkspaceId } from './workspace.decorator'; import { @@ -214,7 +215,7 @@ export class WorkspaceCodingController { } @Get(':workspace_id/coding-job/:codingJobId/notes') - @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-users.controller.spec.ts b/apps/backend/src/app/admin/workspace/workspace-users.controller.spec.ts index f29d81150..ee1ecc5fd 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,5 +1,5 @@ import { BadRequestException } from '@nestjs/common'; -import { GUARDS_METADATA } from '@nestjs/common/constants'; +import { GUARDS_METADATA, PATH_METADATA } from '@nestjs/common/constants'; import { WorkspaceUsersController } from './workspace-users.controller'; import { WorkspaceUsersService } from '../../database/services/workspace/workspace-users.service'; import { AuthService } from '../../auth/service/auth.service'; @@ -89,4 +89,17 @@ describe('WorkspaceUsersController', () => { } ); }); + + describe('setWorkspaceUsers', () => { + it('requires workspace admin access level metadata and uses workspace_id route param', () => { + const guards = Reflect.getMetadata( + GUARDS_METADATA, + WorkspaceUsersController.prototype.setWorkspaceUsers + ); + + 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 263b3a916..071fe4ae0 100644 --- a/apps/backend/src/app/admin/workspace/workspace-users.controller.ts +++ b/apps/backend/src/app/admin/workspace/workspace-users.controller.ts @@ -179,11 +179,12 @@ export class WorkspaceUsersController { } } - @Post(':workspaceId/users') + @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: 'workspaceId', type: Number, description: 'ID of the workspace' }) + @ApiParam({ name: 'workspace_id', type: Number, description: 'ID of the workspace' }) @ApiBody({ schema: { type: 'array', @@ -200,7 +201,7 @@ export class WorkspaceUsersController { @ApiBadRequestResponse({ description: 'Invalid user IDs or workspace ID' }) @ApiTags('admin users') async setWorkspaceUsers(@Body() userIds: number[], - @Param('workspaceId') workspaceId: number) { + @Param('workspace_id', ParseIntPipe) workspaceId: number) { return this.workspaceUsersService.setWorkspaceUsers(workspaceId, userIds); } 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 40355ce33..f1036a888 100755 --- a/apps/backend/src/app/admin/workspace/workspace.controller.spec.ts +++ b/apps/backend/src/app/admin/workspace/workspace.controller.spec.ts @@ -4,7 +4,7 @@ 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', () => { @@ -34,6 +34,10 @@ describe('WorkspaceController', () => { provide: WorkspaceCoreService, useValue: createMock() }, + { + provide: WorkspaceUsersService, + useValue: createMock() + }, { provide: AccessRightsMatrixService, useValue: createMock() diff --git a/apps/backend/src/app/admin/workspace/workspace.controller.ts b/apps/backend/src/app/admin/workspace/workspace.controller.ts index 435d83d08..142f81257 100755 --- a/apps/backend/src/app/admin/workspace/workspace.controller.ts +++ b/apps/backend/src/app/admin/workspace/workspace.controller.ts @@ -9,6 +9,7 @@ import { Patch, Post, Query, + Request, UseGuards } from '@nestjs/common'; import { @@ -25,13 +26,14 @@ import { import { WorkspaceInListDto } from '../../../../../../api-dto/workspaces/workspace-in-list-dto'; import { WorkspaceFullDto } from '../../../../../../api-dto/workspaces/workspace-full-dto'; import { CreateWorkspaceDto } from '../../../../../../api-dto/workspaces/create-workspace-dto'; -import { WorkspaceCoreService } from '../../database/services/workspace'; +import { WorkspaceCoreService, WorkspaceUsersService } from '../../database/services/workspace'; import { WorkspaceId } from './workspace.decorator'; import { JwtAuthGuard } from '../../auth/jwt-auth.guard'; 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'; @ApiTags('Admin Workspace') @Controller('admin/workspace') @@ -40,7 +42,9 @@ export class WorkspaceController { constructor( private workspaceCoreService: WorkspaceCoreService, - private accessRightsMatrixService: AccessRightsMatrixService + private accessRightsMatrixService: AccessRightsMatrixService, + private usersService: UsersService, + private workspaceUsersService: WorkspaceUsersService ) {} @Get() @@ -190,7 +194,20 @@ export class WorkspaceController { }) @ApiBadRequestResponse({ description: 'Invalid workspace data' }) @ApiTags('admin workspaces') - async create(@Body() createWorkspaceDto: CreateWorkspaceDto) { - return this.workspaceCoreService.create(createWorkspaceDto); + async create(@Body() createWorkspaceDto: CreateWorkspaceDto, @Request() req): Promise { + const workspaceId = await this.workspaceCoreService.create(createWorkspaceDto); + const userIdentity = req.user?.id; + + if (!userIdentity) { + throw new BadRequestException('Missing user identity'); + } + + const user = await this.usersService.findUserByIdentity(userIdentity); + if (!user) { + throw new BadRequestException('Creating user not found'); + } + + await this.workspaceUsersService.setWorkspaceUsers(workspaceId, [user.id]); + return workspaceId; } } 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 e0a6d0dd2..a1a6b5b2c 100644 --- a/apps/backend/src/app/admin/workspace/workspace.guard.spec.ts +++ b/apps/backend/src/app/admin/workspace/workspace.guard.spec.ts @@ -4,322 +4,115 @@ import { import { ExecutionContext, UnauthorizedException } from '@nestjs/common'; import { WorkspaceGuard } from './workspace.guard'; import { AuthService } from '../../auth/service/auth.service'; +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, { 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): ExecutionContext => ({ - switchToHttp: () => ({ - getRequest: () => ({ - user: { id: userId }, - 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 }) - }) - } 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'); - }); - }); - - 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'); + } 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 - 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 handle string workspace ID', async () => { - authService.canAccessWorkSpace.mockResolvedValue(true); - const context = createMockExecutionContext(1, 'workspace-abc'); - - await guard.canActivate(context); - - expect(authService.canAccessWorkSpace).toHaveBeenCalledWith(1, 'workspace-abc'); - }); - - it('should handle zero workspace ID', async () => { - authService.canAccessWorkSpace.mockResolvedValue(false); - const context = createMockExecutionContext(1, '0'); - - await expect(guard.canActivate(context)).rejects.toThrow(UnauthorizedException); - expect(authService.canAccessWorkSpace).toHaveBeenCalledWith(1, '0'); - }); - - it('should handle negative workspace ID', async () => { - authService.canAccessWorkSpace.mockResolvedValue(false); - const context = createMockExecutionContext(1, '-1'); - - await expect(guard.canActivate(context)).rejects.toThrow(UnauthorizedException); - expect(authService.canAccessWorkSpace).toHaveBeenCalledWith(1, '-1'); - }); - - it('should handle very large workspace ID', async () => { - authService.canAccessWorkSpace.mockResolvedValue(true); - const context = createMockExecutionContext(1, '999999999999'); - - await guard.canActivate(context); + it('denies an OIDC user without workspace access', async () => { + usersService.findUserByIdentity.mockResolvedValue({ id: 1 } as never); + authService.canAccessWorkSpace.mockResolvedValue(false); - expect(authService.canAccessWorkSpace).toHaveBeenCalledWith(1, '999999999999'); - }); - - it('should handle workspace ID with special characters', async () => { - authService.canAccessWorkSpace.mockResolvedValue(false); - const context = createMockExecutionContext(1, 'ws-123-abc'); - - await expect(guard.canActivate(context)).rejects.toThrow(UnauthorizedException); - expect(authService.canAccessWorkSpace).toHaveBeenCalledWith(1, 'ws-123-abc'); - }); + await expect(guard.canActivate(createContext({ id: 'oidc-1' }))).rejects.toThrow(UnauthorizedException); + expect(authService.canAccessWorkSpace).toHaveBeenCalledWith(1, 123); }); - 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('should handle very large user ID', async () => { - authService.canAccessWorkSpace.mockResolvedValue(true); - const context = createMockExecutionContext(Number.MAX_SAFE_INTEGER, '123'); - - await guard.canActivate(context); + it('denies an OIDC user that is unknown locally', async () => { + usersService.findUserByIdentity.mockResolvedValue(null); - expect(authService.canAccessWorkSpace).toHaveBeenCalledWith(Number.MAX_SAFE_INTEGER, '123'); - }); + await expect(guard.canActivate(createContext({ id: 'oidc-1' }))).rejects.toThrow(UnauthorizedException); + expect(authService.canAccessWorkSpace).not.toHaveBeenCalled(); }); - 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('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('allows a workspace token for its own workspace', async () => { + authService.canAccessWorkSpace.mockResolvedValue(true); - it('should handle auth service returning undefined', async () => { - authService.canAccessWorkSpace.mockResolvedValue(undefined as unknown as boolean); - const context = createMockExecutionContext(1, '123'); + const result = await guard.canActivate(createContext({ + id: 12, + workspace: 123, + tokenUse: 'workspace', + isWorkspaceToken: true + })); - await expect(guard.canActivate(context)).rejects.toThrow(UnauthorizedException); - }); + expect(result).toBe(true); + expect(usersService.findUserByIdentity).not.toHaveBeenCalled(); + expect(authService.canAccessWorkSpace).toHaveBeenCalledWith(12, 123); }); - 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); + 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(2); - }); - - it('should always verify workspace access from auth service', async () => { - authService.canAccessWorkSpace.mockResolvedValue(true); - const context = createMockExecutionContext(1, '123'); - - await guard.canActivate(context); - await guard.canActivate(context); - await guard.canActivate(context); - - expect(authService.canAccessWorkSpace).toHaveBeenCalledTimes(3); - }); - - it('should verify access for each unique workspace', async () => { - authService.canAccessWorkSpace.mockResolvedValue(true); - - await guard.canActivate(createMockExecutionContext(1, '123')); - await guard.canActivate(createMockExecutionContext(1, '456')); - await guard.canActivate(createMockExecutionContext(1, '789')); - - 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 7fa913eec..d307e19cd 100644 --- a/apps/backend/src/app/admin/workspace/workspace.guard.ts +++ b/apps/backend/src/app/admin/workspace/workspace.guard.ts @@ -2,20 +2,65 @@ import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from '@nestjs/common'; import { AuthService } from '../../auth/service/auth.service'; +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; + workspace?: number | string; + tokenUse?: string; + isWorkspaceToken?: boolean; +} @Injectable() export class WorkspaceGuard implements CanActivate { constructor( - private authService: AuthService + private authService: AuthService, + 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 canAccess = await this.authService.canAccessWorkSpace(userId, params.workspace_id); + const workspaceId = Number(params?.workspace_id); + + if (!Number.isInteger(workspaceId) || workspaceId <= 0 || !requestUser?.id) { + throw new UnauthorizedException(); + } + + if (requestUser.isWorkspaceToken || requestUser.tokenUse === WORKSPACE_TOKEN_USE) { + const tokenWorkspace = Number(requestUser.workspace); + const userId = Number(requestUser.id ?? requestUser.userId); + 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; + } + + const userIdentity = String(requestUser.id); // This is the OIDC Provider UUID + const user = await this.usersService.findUserByIdentity(userIdentity); + if (!user) { + throw new UnauthorizedException('User not found'); + } + + req.user = { + ...requestUser, + id: user.id, + userId: user.id, + identity: userIdentity + }; + + const canAccess = await this.authService.canAccessWorkSpace(user.id, workspaceId); if (!canAccess) { throw new UnauthorizedException(); } diff --git a/apps/backend/src/app/app.controller.ts b/apps/backend/src/app/app.controller.ts index 4d494d60b..d855e5101 100755 --- a/apps/backend/src/app/app.controller.ts +++ b/apps/backend/src/app/app.controller.ts @@ -4,8 +4,6 @@ import { } from '@nestjs/common'; import { ApiOkResponse, ApiTags } from '@nestjs/swagger'; -import { AuthService } from './auth/service/auth.service'; -import { CreateUserDto } from '../../../../api-dto/user/create-user-dto'; import { AuthDataDto } from '../../../../api-dto/auth-data-dto'; import { UsersService } from './database/services/users'; import { JwtAuthGuard } from './auth/jwt-auth.guard'; @@ -14,8 +12,7 @@ import { WorkspaceUsersService } from './database/services/workspace'; @Controller() export class AppController { - constructor(private authService:AuthService, - private usersService: UsersService, + constructor(private usersService: UsersService, private testCenterService: TestcenterService, private workspaceUsersService: WorkspaceUsersService) {} @@ -34,14 +31,6 @@ export class AppController { }; } - @Post('keycloak-login') - @ApiTags('auth') - @ApiOkResponse({ description: 'Keycloak login successful.' }) - async keycloakLogin(@Body() user: CreateUserDto) { - const token = await this.authService.keycloakLogin(user); - return `"${token}"`; - } - @Post('tc_authentication') async authenticateTestCenter( @Body() credentials: { username: string, password: string, server: string, url: string } 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..e73c13329 --- /dev/null +++ b/apps/backend/src/app/auth/auth.controller.spec.ts @@ -0,0 +1,198 @@ +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' + }), + 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: [] } + }) + }; + + 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('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' }); + }); +}); 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..2feb8f411 --- /dev/null +++ b/apps/backend/src/app/auth/auth.controller.ts @@ -0,0 +1,497 @@ +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('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 profileUrl = this.oidcAuthService.getProfileUrl(redirectUri); + 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 dbbf67f7d..fabbbbc27 100755 --- a/apps/backend/src/app/auth/auth.module.ts +++ b/apps/backend/src/app/auth/auth.module.ts @@ -1,26 +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 { 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: '365d' } - }), - inject: [ConfigService] + inject: [ConfigService], + useFactory: (configService: ConfigService) => ({ + secret: configService.get('JWT_SECRET') + }) }) ], - providers: [AuthService, JwtStrategy], - 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.ts b/apps/backend/src/app/auth/jwt-or-workspace-token-auth.guard.ts new file mode 100644 index 000000000..79dd92d79 --- /dev/null +++ b/apps/backend/src/app/auth/jwt-or-workspace-token-auth.guard.ts @@ -0,0 +1,6 @@ +import { Injectable } from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; +import { WORKSPACE_TOKEN_STRATEGY } from './workspace-token.constants'; + +@Injectable() +export class JwtOrWorkspaceTokenAuthGuard extends AuthGuard(['jwt', WORKSPACE_TOKEN_STRATEGY]) {} diff --git a/apps/backend/src/app/auth/jwt.strategy.spec.ts b/apps/backend/src/app/auth/jwt.strategy.spec.ts new file mode 100644 index 000000000..e9607c183 --- /dev/null +++ b/apps/backend/src/app/auth/jwt.strategy.spec.ts @@ -0,0 +1,51 @@ +import { ConfigService } from '@nestjs/config'; +import { UnauthorizedException } from '@nestjs/common'; +import { JwtStrategy } from './jwt.strategy'; + +describe('JwtStrategy', () => { + const createStrategy = () => 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); + + it('accepts tokens issued for the configured client through azp', async () => { + const strategy = createStrategy(); + + await expect(strategy.validate({ + sub: 'oidc-user-id', + preferred_username: 'tester', + azp: 'coding-box', + realm_access: { roles: ['admin'] } + })).resolves.toEqual(expect.objectContaining({ + id: 'oidc-user-id', + userId: 'oidc-user-id', + isAdmin: true + })); + }); + + it('accepts tokens issued for the configured client through aud', async () => { + const strategy = createStrategy(); + + await expect(strategy.validate({ + sub: 'oidc-user-id', + preferred_username: 'tester', + aud: ['account', 'coding-box'] + })).resolves.toEqual(expect.objectContaining({ + id: 'oidc-user-id' + })); + }); + + it('rejects tokens for another client', async () => { + const strategy = createStrategy(); + + await expect(strategy.validate({ + sub: 'oidc-user-id', + preferred_username: 'tester', + azp: 'other-client', + aud: ['account'] + })).rejects.toThrow(UnauthorizedException); + }); +}); diff --git a/apps/backend/src/app/auth/jwt.strategy.ts b/apps/backend/src/app/auth/jwt.strategy.ts index 4616993c0..e6f9901a8 100755 --- a/apps/backend/src/app/auth/jwt.strategy.ts +++ b/apps/backend/src/app/auth/jwt.strategy.ts @@ -1,24 +1,68 @@ import { ExtractJwt, Strategy } from 'passport-jwt'; import { PassportStrategy } from '@nestjs/passport'; -import { Injectable } from '@nestjs/common'; +import { Injectable, UnauthorizedException } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; +import { passportJwtSecret } from 'jwks-rsa'; + +interface OidcJwtPayload { + sub: string; + preferred_username: string; + given_name?: string; + family_name?: string; + email?: string; + aud?: string | string[]; + azp?: string; + realm_access?: { roles: string[] }; +} @Injectable() export class JwtStrategy extends PassportStrategy(Strategy) { + private readonly oAuth2ClientId?: string; + constructor(configService: ConfigService) { + 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, - secretOrKey: configService.get('JWT_SECRET') + secretOrKeyProvider: passportJwtSecret({ + cache: true, + rateLimit: true, + jwksRequestsPerMinute: 5, + jwksUri: `${oidcJwksUri}` + }), + issuer: `${oidcIssuer}`, + algorithms: ['RS256'] }); + + this.oAuth2ClientId = oAuth2ClientId; } - // eslint-disable-next-line class-methods-use-this - async validate( - payload: { userId:string, sub:string, username: string, workspace: string } - ) { + async validate(payload: OidcJwtPayload) { + if (!this.isTokenForConfiguredClient(payload)) { + throw new UnauthorizedException('JWT client does not match configured OAuth2 client'); + } + return { - userId: payload.userId, id: payload.userId, name: payload.username, workspace: payload.workspace || '' + userId: payload.sub, + id: payload.sub, + name: payload.preferred_username, + firstName: payload.given_name, + lastName: payload.family_name, + email: payload.email, + isAdmin: payload.realm_access?.roles?.includes('admin') || false, + sub: payload.sub }; } + + private isTokenForConfiguredClient(payload: OidcJwtPayload): boolean { + if (!this.oAuth2ClientId) { + return false; + } + + const audiences = Array.isArray(payload.aud) ? payload.aud : [payload.aud].filter(Boolean); + return payload.azp === this.oAuth2ClientId || audiences.includes(this.oAuth2ClientId); + } } 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 165c44761..c7e1b3868 100755 --- a/apps/backend/src/app/auth/service/auth.service.spec.ts +++ b/apps/backend/src/app/auth/service/auth.service.spec.ts @@ -54,16 +54,18 @@ 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 }, - { expiresIn: '30d' } + { + expiresIn: '30d', + issuer: 'coding-box', + audience: 'coding-box-workspace-token', + algorithm: 'HS256' + } ); expect(usersService.getUserIsAdmin).not.toHaveBeenCalled(); expect(usersService.getUserAccessLevel).not.toHaveBeenCalled(); @@ -126,16 +128,18 @@ 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 }, - { 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 6687d313a..48de80797 100755 --- a/apps/backend/src/app/auth/service/auth.service.ts +++ b/apps/backend/src/app/auth/service/auth.service.ts @@ -8,6 +8,11 @@ import { JwtService } from '@nestjs/jwt'; 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 { + WORKSPACE_TOKEN_AUDIENCE, + WORKSPACE_TOKEN_ISSUER, + WORKSPACE_TOKEN_USE +} from '../workspace-token.constants'; @Injectable() export class AuthService { @@ -18,11 +23,11 @@ export class AuthService { ) { } - async keycloakLogin(user: CreateUserDto) { + async storeOidcProviderUser(user: CreateUserDto) { const { username, lastName, firstName, email, identity, issuer, isAdmin } = user; - const userId = await this.usersService.createKeycloakUser({ + const userId = await this.usersService.createOidcProviderUser({ identity: identity, username: username, email: email, @@ -31,11 +36,8 @@ export class AuthService { issuer: issuer, isAdmin: isAdmin }); - this.logger.log(`Keycloak User with id '${userId}' is logging in.`); - const payload = { - userId: userId, username: username, sub: user - }; - return this.jwtService.sign(payload); + this.logger.log(`OIDC Provider User with id '${userId}' stored in database.`); + return userId; } async createToken( @@ -75,9 +77,18 @@ export class AuthService { private signWorkspaceToken(user: UserFullDto, workspaceId: number, duration: number): string { const payload = { - userId: user.id, username: user.username, sub: user, workspace: workspaceId + token_use: WORKSPACE_TOKEN_USE, + userId: user.id, + username: user.username, + sub: String(user.id), + workspace: workspaceId }; - 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..ee0990e91 --- /dev/null +++ b/apps/backend/src/app/auth/service/oidc-auth.service.spec.ts @@ -0,0 +1,50 @@ +import { HttpService } from '@nestjs/axios'; +import { ConfigService } from '@nestjs/config'; +import { OidcAuthService } from './oidc-auth.service'; +import { CacheService } from '../../cache/cache.service'; + +describe('OidcAuthService', () => { + let service: OidcAuthService; + let cacheService: jest.Mocked>; + + beforeEach(() => { + cacheService = { + set: jest.fn().mockResolvedValue(true), + getAndDelete: jest.fn() + }; + + service = new OidcAuthService( + {} as HttpService, + { + get: jest.fn().mockReturnValue('') + } 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(); + }); +}); 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..d923c564c --- /dev/null +++ b/apps/backend/src/app/auth/service/oidc-auth.service.ts @@ -0,0 +1,292 @@ +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'); + } + } + + /** + * 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..853e81cd8 --- /dev/null +++ b/apps/backend/src/app/auth/workspace-token.strategy.spec.ts @@ -0,0 +1,46 @@ +import { ConfigService } from '@nestjs/config'; +import { UnauthorizedException } from '@nestjs/common'; +import { WorkspaceTokenStrategy } from './workspace-token.strategy'; + +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 + })).resolves.toEqual({ + userId: 12, + id: 12, + name: 'coder', + workspace: 7, + tokenUse: 'workspace', + 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..d41040d81 --- /dev/null +++ b/apps/backend/src/app/auth/workspace-token.strategy.ts @@ -0,0 +1,53 @@ +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'; + +interface WorkspaceTokenPayload { + token_use?: string; + userId?: number | string; + username?: string; + workspace?: number | string; +} + +@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, + 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 bc6cf085d..9c26b96ee 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'; @Module({ imports: [ @@ -91,7 +92,8 @@ import { CodingUnitFreshness } from './entities/coding-unit-freshness.entity'; CoderTrainingCoder, CoderTrainingDiscussionResult, CodingUnitFreshness, - MissingsProfile + MissingsProfile, + TestPersonCodingJob ], synchronize: false }), 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 baaf04e8b..2a9f5ca8d 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 @@ -85,7 +85,10 @@ describe('CodingJobService', () => { let cacheService: { delete: jest.Mock }; let codingFreshnessService: { reconcileAppliedManualCodingJobs: jest.Mock }; let codingFileCacheService: { getVariablePageMap: jest.Mock }; - let missingsProfilesService: { resolveMissingsProfileId: jest.Mock }; + let missingsProfilesService: { + resolveMissingsProfileId: jest.Mock; + getMissingByIdForProfileOrDefault: jest.Mock; + }; let coderTrainingDiscussionResultRepository: ReturnType; let workspaceFilesService: { getDerivedVariableMap: jest.Mock; @@ -200,7 +203,8 @@ describe('CodingJobService', () => { getVariablePageMap: jest.fn().mockResolvedValue(new Map()) }; missingsProfilesService = { - resolveMissingsProfileId: jest.fn(async (_workspaceId: number, profileId?: number | null) => profileId || 55) + 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 5ef8e12be..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,7 +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); + 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 a7381c959..50fa1c49d 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 @@ -304,7 +304,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) @@ -315,21 +315,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 f3a672a92..0a2fef357 100755 --- a/apps/backend/src/app/database/services/users/users.service.ts +++ b/apps/backend/src/app/database/services/users/users.service.ts @@ -263,7 +263,6 @@ export class UsersService { const savedEntries = await this.workspaceUserRepository.save(entries); 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) { this.logger.error( @@ -341,10 +340,10 @@ export class UsersService { await this.usersRepository.delete(ids); } - async createKeycloakUser(keycloakUser: CreateUserDto): Promise { + async createOidcProviderUser(oidcPdUser: CreateUserDto): Promise { const { username, identity, issuer, isAdmin - } = keycloakUser; + } = oidcPdUser; const existingUser = await this.usersRepository.findOne({ where: [ { username }, @@ -369,8 +368,8 @@ export class UsersService { return existingUser.id; } - this.logger.log(`Creating new Keycloak user: ${JSON.stringify(keycloakUser)}`); - const newUser = this.usersRepository.create(keycloakUser); + 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; diff --git a/apps/backend/src/app/job-queue/job-queue.module.ts b/apps/backend/src/app/job-queue/job-queue.module.ts index 3ed8dac6e..47bcd8724 100644 --- a/apps/backend/src/app/job-queue/job-queue.module.ts +++ b/apps/backend/src/app/job-queue/job-queue.module.ts @@ -74,7 +74,7 @@ import { ValidationTask } from '../database/entities/validation-task.entity'; }), forwardRef(() => CodingModule), forwardRef(() => WorkspaceModule), - CacheModule + forwardRef(() => CacheModule) ], providers: [ JobQueueService, 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 01517730b..f3fa86535 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 @@ -35,7 +35,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 }), saveCodingProgress: jest.fn().mockResolvedValue({ id: 123 }), saveCodingNotes: jest.fn().mockResolvedValue({ id: 123 }), assertUserCanAccessCodingJob: jest.fn().mockResolvedValue(undefined), @@ -97,6 +97,42 @@ describe('WsgCodingJobController', () => { expect(codingJobService.saveCodingNotes).toHaveBeenCalled(); }); + 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, + 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(codingJobService.updateCodingJob).toHaveBeenCalledWith( + 123, + 47, + { comment: 'review note' } + ); + }); + it('rejects jobDefinitionId on direct coding job creates', async () => { await expect(controller.createCodingJob(47, { name: 'Direct job', 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 6fa731348..6f1e574f6 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 @@ -29,6 +29,7 @@ import { ApiTags } from '@nestjs/swagger'; import { JwtAuthGuard } from '../../auth/jwt-auth.guard'; +import { JwtOrWorkspaceTokenAuthGuard } from '../../auth/jwt-or-workspace-token-auth.guard'; import { WorkspaceGuard } from '../../admin/workspace/workspace.guard'; import { WorkspaceId } from '../../admin/workspace/workspace.decorator'; import { AccessLevelGuard, RequireAccessLevel } from '../../admin/workspace/access-level.guard'; @@ -36,6 +37,8 @@ import { CodingJobService, CodingReplayService } from '../../database/services/c 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'; @@ -192,7 +195,7 @@ export class WsgCodingJobController { } @Get(':id') - @UseGuards(JwtAuthGuard, WorkspaceGuard) + @UseGuards(JwtOrWorkspaceTokenAuthGuard, WorkspaceGuard) @ApiBearerAuth() @ApiOperation({ summary: 'Get a coding job by ID', @@ -318,6 +321,62 @@ export class WsgCodingJobController { ); } + @Put(':id/status') + @UseGuards(JwtOrWorkspaceTokenAuthGuard, WorkspaceGuard) + @ApiBearerAuth() + @ApiOperation({ + summary: 'Update a coding job status from replay', + description: 'Updates only the replay-safe status field of a coding job' + }) + @ApiOkResponse({ + description: 'The coding job status has been successfully updated.', + type: CodingJobDto + }) + @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') + @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 comment has been successfully updated.', + type: CodingJobDto + }) + @ApiBadRequestResponse({ + description: 'Invalid input data.' + }) + 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.assertCodingJobAccess(workspaceId, id, req); + return this.codingJobService.updateCodingJob( + id, + workspaceId, + { comment: updateCodingJobCommentDto.comment } + ); + } + @Post(':id/start') @UseGuards(JwtAuthGuard, WorkspaceGuard) @ApiBearerAuth() @@ -415,7 +474,7 @@ export class WsgCodingJobController { } @Post(':id/progress') - @UseGuards(JwtAuthGuard, WorkspaceGuard) + @UseGuards(JwtOrWorkspaceTokenAuthGuard, WorkspaceGuard) @ApiBearerAuth() @ApiOperation({ summary: 'Save coding progress', @@ -456,7 +515,7 @@ export class WsgCodingJobController { } @Post(':id/notes') - @UseGuards(JwtAuthGuard, WorkspaceGuard) + @UseGuards(JwtOrWorkspaceTokenAuthGuard, WorkspaceGuard) @ApiBearerAuth() @ApiOperation({ summary: 'Save coding notes', @@ -536,7 +595,7 @@ export class WsgCodingJobController { } @Get(':id/progress') - @UseGuards(JwtAuthGuard, WorkspaceGuard) + @UseGuards(JwtOrWorkspaceTokenAuthGuard, WorkspaceGuard) @ApiBearerAuth() @ApiOperation({ summary: 'Get coding progress', @@ -629,7 +688,7 @@ export class WsgCodingJobController { } @Get(':id/units') - @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 ee871a7ce..f917e6a6c 100755 --- a/apps/backend/src/main.ts +++ b/apps/backend/src/main.ts @@ -14,7 +14,7 @@ import { requestIdMiddleware } from './app/http/request-id.middleware'; 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'; diff --git a/apps/frontend/Dockerfile b/apps/frontend/Dockerfile index 0c47a220e..710b29ba2 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..80f6efe16 --- /dev/null +++ b/apps/frontend/src/app/app.component.spec.ts @@ -0,0 +1,116 @@ +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 { AuthDataDto } from '../../../../api-dto/auth-data-dto'; + +describe('AppComponent', () => { + let authService: { + exchangeLoginCode: jest.Mock; + setToken: jest.Mock; + setIdToken: jest.Mock; + setRefreshToken: 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; + }; + + 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(), + 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) + }; + + await TestBed.configureTestingModule({ + imports: [AppComponent], + providers: [ + { provide: AppService, useValue: appService }, + { provide: AuthService, useValue: authService }, + { 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(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('#/#'); + }); +}); 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 b0dfcce5d..e47216fb1 --- 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,34 @@ 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 { CreateUserDto } from '../../../../api-dto/user/create-user-dto'; +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'; @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,22 +45,17 @@ export class AppComponent implements OnInit, OnDestroy { url = inject(LocationStrategy); private router = inject(Router); - private keycloakEvent = inject(KEYCLOAK_EVENT_SIGNAL); - private snackBar = inject(MatSnackBar); 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); - }); - - this.appService.authData$.subscribe(authData => { + this.authDataSubscription = this.appService.authData$.subscribe(authData => { this.authData = authData; this.updateCurrentWorkspaceName(); }); @@ -66,6 +67,29 @@ export class AppComponent implements OnInit, OnDestroy { }); } + async ngOnInit(): Promise { + const postLoginReturnUrl = await this.handleAuthCallback(); + + if (this.authService.isLoggedIn()) { + this.setAuthState(); + this.appService.refreshAuthData(); + 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(); + } + private updateCurrentWorkspaceName(): void { const workspaceId = this.getWorkspaceIdFromUrl(); if (workspaceId > 0 && this.authData.workspaces) { @@ -82,81 +106,67 @@ export class AppComponent implements OnInit, OnDestroy { return match ? parseInt(match[1], 10) : 0; } - ngOnDestroy(): void { - this.routerSubscription?.unsubscribe(); + private setAuthState(): void { + this.isLoggedIn = true; + this.appService.isLoggedIn = true; + this.appService.loggedUser = this.authService.getLoggedUser(); } - async keycloakLogin(user: CreateUserDto): Promise { - this.errorMessage = ''; - this.appService.errorMessagesDisabled = true; - + private async handleAuthCallback(): Promise { try { - const success = await firstValueFrom(this.appService.keycloakLogin(user)); - 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(); - const isAdmin = hasAdminBypass(this.authService.getRoles()); + if (tokenResponse.id_token) { + this.authService.setIdToken(tokenResponse.id_token); + } - if (this.isValidUserProfile(keycloakUserProfile)) { - const keycloakUser = this.createKeycloakUser(keycloakUserProfile, isAdmin); - this.appService.kcUser = keycloakUser; - await this.keycloakLogin(keycloakUser); - } 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; + } - private isValidUserProfile(userProfile: KeycloakProfile): boolean { - return !!userProfile?.id && !!userProfile?.username; + const hashParams = new URLSearchParams(hash.slice(queryStart + 1)); + return this.appService.normalizeInternalRoute(hashParams.get('returnUrl') || undefined); } - private createKeycloakUser(userProfile: KeycloakProfile, isAdmin: boolean): CreateUserDto { - return { - issuer: this.appService.loggedUser?.iss || '', - identity: userProfile.id, - username: userProfile.username || '', - lastName: userProfile.lastName || '', - firstName: userProfile.firstName || '', - email: userProfile.email || '', - isAdmin: isAdmin - }; + 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 632f73dab..511e053de 100755 --- a/apps/frontend/src/app/app.config.ts +++ b/apps/frontend/src/app/app.config.ts @@ -9,58 +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 { 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'; +registerLocaleData(localeDeAt); + 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: 'logout', - sessionTimeout: 300000 - }) - ], - - providers: [ - { - provide: INCLUDE_BEARER_TOKEN_INTERCEPTOR_CONFIG, - useValue: allUrlsCondition - }, - AutoRefreshTokenService, UserActivityService] -}); - -registerLocaleData(localeDeAt); - export const appConfig: ApplicationConfig = { providers: [ provideHttpClient( @@ -74,7 +38,6 @@ export const appConfig: ApplicationConfig = { deps: [HttpClient] } })), - provideKeycloakAngular(), provideRouter(routes), provideAnimationsAsync(), { diff --git a/apps/frontend/src/app/coding/components/test-person-coding/test-person-coding.component.ts b/apps/frontend/src/app/coding/components/test-person-coding/test-person-coding.component.ts index 607839491..3631cc3a1 100644 --- a/apps/frontend/src/app/coding/components/test-person-coding/test-person-coding.component.ts +++ b/apps/frontend/src/app/coding/components/test-person-coding/test-person-coding.component.ts @@ -203,7 +203,7 @@ export class TestPersonCodingComponent implements OnInit { this.isLoading = true; this.currentPage = page; this.pageSize = limit; - const authToken = localStorage.getItem('id_token') || ''; + const authToken = localStorage.getItem('auth_token') || ''; const serverUrl = window.location.origin; this.testPersonCodingService 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 d6582e8ba..797450fab 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 @@ -245,7 +245,7 @@ export class CodingJobBackendService { private validationTaskStateService = inject(ValidationTaskStateService); private getAuthHeader(authToken?: string) { - return { Authorization: `Bearer ${authToken || localStorage.getItem('id_token')}` }; + return { Authorization: `Bearer ${authToken || localStorage.getItem('auth_token')}` }; } private get authHeader() { @@ -397,6 +397,26 @@ export class CodingJobBackendService { return this.http.put(url, codingJob, { headers: this.getAuthHeader(authToken) }); } + 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 @@ -546,10 +566,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, @@ -557,7 +577,7 @@ export class CodingJobBackendService { ...this.getAuthHeader(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 055a642ae..d689a5066 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 @@ -86,7 +86,7 @@ export class CodingTrainingBackendService { private http = inject(HttpClient); private get authHeader() { - return { Authorization: `Bearer ${localStorage.getItem('id_token')}` }; + return { Authorization: `Bearer ${localStorage.getItem('auth_token')}` }; } createCoderTrainingJobs( 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 db66d9701..078efdd27 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 @@ -208,7 +208,7 @@ export class TestPersonCodingService { testResultsChanged$ = this.testResultsChangedSubject.asObservable(); get authHeader() { - return { Authorization: `Bearer ${localStorage.getItem('id_token')}` }; + return { Authorization: `Bearer ${localStorage.getItem('auth_token')}` }; } private hasJobId(jobId: string | null | undefined): jobId is string { diff --git a/apps/frontend/src/app/components/home/home.component.html b/apps/frontend/src/app/components/home/home.component.html index 07b0e1e6c..d1a667637 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.15.1'" [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 a17a768d4..0621f339d 100644 --- a/apps/frontend/src/app/core/interceptors/auth.interceptor.spec.ts +++ b/apps/frontend/src/app/core/interceptors/auth.interceptor.spec.ts @@ -11,11 +11,13 @@ import { MatSnackBar } from '@angular/material/snack-bar'; import { authInterceptor } from './auth.interceptor'; import { SUPPRESS_GLOBAL_HTTP_ERROR } from './http-error-context'; import { AppService } from '../services/app.service'; +import { AuthService } from '../services/auth.service'; describe('authInterceptor', () => { let http: HttpClient; let httpMock: HttpTestingController; let appService: jest.Mocked; + let authService: jest.Mocked; let snackBar: { open: jest.Mock }; beforeEach(() => { @@ -30,6 +32,10 @@ describe('authInterceptor', () => { open: jest.fn() }; + authService = { + getToken: jest.fn().mockReturnValue('backend-token') + } as unknown as jest.Mocked; + Object.defineProperty(window, 'localStorage', { value: { getItem: jest.fn().mockReturnValue('backend-token') @@ -42,6 +48,7 @@ describe('authInterceptor', () => { provideHttpClient(withInterceptors([authInterceptor])), provideHttpClientTesting(), { provide: AppService, useValue: appService }, + { provide: AuthService, useValue: authService }, { provide: MatSnackBar, useValue: snackBar }, { provide: Router, useValue: { url: '/home' } } ] diff --git a/apps/frontend/src/app/core/interceptors/auth.interceptor.ts b/apps/frontend/src/app/core/interceptors/auth.interceptor.ts index b5f1ef76b..810824715 100644 --- a/apps/frontend/src/app/core/interceptors/auth.interceptor.ts +++ b/apps/frontend/src/app/core/interceptors/auth.interceptor.ts @@ -4,27 +4,29 @@ import { HttpHandlerFn, HttpHeaders, HttpInterceptorFn, - HttpRequest + HttpRequest, + HttpErrorResponse } from '@angular/common/http'; import { Router } from '@angular/router'; import { - finalize, Observable, - tap + catchError, + finalize, + tap, + throwError } from 'rxjs'; import { MatSnackBar } from '@angular/material/snack-bar'; import { AppHttpError } from './app-http-error.class'; import { SUPPRESS_GLOBAL_HTTP_ERROR } from './http-error-context'; import { AppService } from '../services/app.service'; +import { AuthService } from '../services/auth.service'; -/** - * Functional interceptor for adding authentication headers and handling errors - */ 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); let httpErrorInfo: AppHttpError | null = null; @@ -33,15 +35,19 @@ export const authInterceptor: HttpInterceptorFn = ( let modifiedReq = req; if (!req.headers.has('Authorization')) { - const idToken = localStorage.getItem('id_token'); - if (idToken) { - const headers = new HttpHeaders({ Authorization: `Bearer ${idToken}` }); + const token = authService.getToken(); + if (token) { + const headers = new HttpHeaders({ Authorization: `Bearer ${token}` }); modifiedReq = req.clone({ headers }); } } return next(modifiedReq) .pipe( + catchError((error: HttpErrorResponse) => { + httpErrorInfo = new AppHttpError(error); + return throwError(() => error); + }), tap({ error: error => { httpErrorInfo = new AppHttpError(error); @@ -53,7 +59,17 @@ export const authInterceptor: HttpInterceptorFn = ( return; } - if (error.status === 401 || error.status === 403) { + 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) { suppressGlobalErrorMessage = true; const errorMessage = error.error?.message || error.message || ''; @@ -79,6 +95,9 @@ export const authInterceptor: HttpInterceptorFn = ( ); } } + if (!httpErrorInfo) { + httpErrorInfo = new AppHttpError(error); + } } }), finalize(() => { 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 5119a943b..f6d6edab0 100644 --- a/apps/frontend/src/app/core/services/app.service.spec.ts +++ b/apps/frontend/src/app/core/services/app.service.spec.ts @@ -1,18 +1,18 @@ 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'; -import { CreateUserDto } from '../../../../../../api-dto/user/create-user-dto'; import { AuthDataDto } from '../../../../../../api-dto/auth-data-dto'; import { AppHttpError, 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; @@ -81,176 +81,78 @@ describe('AppService', () => { }); }); - describe('keycloakLogin', () => { - it('should login and fetch auth data on success', () => { - const mockToken = 'new-token'; - const mockUser = { username: 'user', identity: 'id1' } as unknown as CreateUserDto; - const mockAuthData = { userId: 1, userName: 'user' } as unknown as AuthDataDto; - - service.keycloakLogin(mockUser).subscribe(result => { - expect(result).toBe(true); - expect(localStorage.setItem).toHaveBeenCalledWith('id_token', mockToken); - expect(service.authBootstrapStatus).toBe('ready'); - }); - - expect(service.authBootstrapStatus).toBe('backend-login-running'); - - // 1. Login POST - const reqLogin = httpMock.expectOne(`${mockServerUrl}keycloak-login`); - expect(reqLogin.request.method).toBe('POST'); - expect(reqLogin.request.context.get(SUPPRESS_GLOBAL_HTTP_ERROR)).toBe(true); - reqLogin.flush(mockToken); - - // 2. Auth Data GET - 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); - }); - - it('should retry backend login after transient backend errors', fakeAsync(() => { - const mockToken = 'new-token'; - const mockUser = { username: 'user', identity: 'id1' } as unknown as CreateUserDto; - const mockAuthData = { userId: 1, userName: 'user' } as unknown as AuthDataDto; - let loginResult: boolean | undefined; - - service.keycloakLogin(mockUser).subscribe(result => { - loginResult = result; - }); - - const firstLoginRequest = httpMock.expectOne(`${mockServerUrl}keycloak-login`); - firstLoginRequest.flush('Service unavailable', { status: 503, statusText: 'Service Unavailable' }); - - tick(500); - - const secondLoginRequest = httpMock.expectOne(`${mockServerUrl}keycloak-login`); - expect(secondLoginRequest.request.context.get(SUPPRESS_GLOBAL_HTTP_ERROR)).toBe(true); - secondLoginRequest.flush(mockToken); - - const reqAuth = httpMock.expectOne(`${mockServerUrl}auth-data?identity=id1`); - reqAuth.flush(mockAuthData); - - expect(loginResult).toBe(true); - expect(service.authBootstrapStatus).toBe('ready'); - })); - + describe('auth data loading', () => { it('should retry auth data after transient backend errors', fakeAsync(() => { - const mockToken = 'new-token'; - const mockUser = { username: 'user', identity: 'id1' } as unknown as CreateUserDto; 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.keycloakLogin(mockUser).subscribe(result => { - loginResult = result; + service.retryAuthDataLoad().subscribe(loadResult => { + result = loadResult; }); - const reqLogin = httpMock.expectOne(`${mockServerUrl}keycloak-login`); - reqLogin.flush(mockToken); - - 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 secondAuthRequest = httpMock.expectOne(`${mockServerUrl}auth-data?identity=id1`); + 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(() => { - const mockToken = 'new-token'; - const mockUser = { username: 'user', identity: 'id1' } as unknown as CreateUserDto; - let loginResult: boolean | undefined; + let result: boolean | undefined; + service.loggedUser = { sub: 'user1' } as DecodedToken; - service.keycloakLogin(mockUser).subscribe(result => { - loginResult = result; + service.retryAuthDataLoad().subscribe(loadResult => { + result = loadResult; }); - const reqLogin = httpMock.expectOne(`${mockServerUrl}keycloak-login`); - reqLogin.flush(mockToken); - - 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 login failure', () => { - const mockUser = { username: 'user' } as unknown as CreateUserDto; - - service.keycloakLogin(mockUser).subscribe(result => { - expect(result).toBe(false); - expect(service.authBootstrapStatus).toBe('auth-data-failed'); - }); - - const reqLogin = httpMock.expectOne(`${mockServerUrl}keycloak-login`); - reqLogin.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(); 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(false); + expect(req.request.context.get(SUPPRESS_GLOBAL_HTTP_ERROR)).toBe(true); req.flush(mockAuthData); }); - - 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.kcUser = { username: 'user' } as CreateUserDto; + service.loggedUser = { sub: 'user1' } as DecodedToken; + service.isLoggedIn = true; + service.user = { username: 'user', isAdmin: false } as CreateUserDto; service.needsReAuthentication = true; service.reAuthenticationReturnUrl = '/coding'; service.updateAuthData({ userId: 1, userName: 'user' } as AuthDataDto); 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.kcUser).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.reAuthenticationReturnUrl).toBeUndefined(); @@ -260,7 +162,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.reAuthenticationReturnUrl).toBe('/coding'); expect(service.authBootstrapStatus).toBe('session-expired'); @@ -273,121 +175,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 10288e4b1..f352f2494 --- a/apps/frontend/src/app/core/services/app.service.ts +++ b/apps/frontend/src/app/core/services/app.service.ts @@ -8,11 +8,10 @@ import { map, of, retry, - switchMap, 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 { @@ -52,15 +51,15 @@ export class AppService { workspaces: [] }; - kcUser?: CreateUserDto; - 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; @@ -68,76 +67,54 @@ 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, duration: number): Observable { + createOwnToken(workspaceId: number, duration: number): Observable { return this.http.get( - `${this.serverUrl}admin/workspace/${workspace_id}/token/${duration}` + `${this.serverUrl}admin/workspace/${workspaceId}/token/${duration}` ); } - createTokenForIdentity(workspace_id: number, identity: string, duration: number): Observable { + createTokenForIdentity(workspaceId: number, identity: string, duration: number): 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}` ); } - keycloakLogin(user: CreateUserDto): Observable { - this.setAuthBootstrapStatus('backend-login-running'); - - return this.getKeycloakLoginTokenWithRetry(user) - .pipe( - switchMap(loginToken => { - if (typeof loginToken === 'string') { - localStorage.setItem('id_token', loginToken); - return this.getAuthDataWithRetry(user.identity || '') - .pipe( - map(authData => { - this.updateAuthData(authData); - return true; - }) - ); - } - return of(false); - }), - 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.serverUrl}auth-data?identity=${id}` + `${this.serverUrl}auth-data?identity=${encodeURIComponent(identity)}` ); } retryAuthDataLoad(): Observable { - const identity = this.loggedUser?.sub || this.kcUser?.identity || ''; - if (!identity) { + const identity = this.loggedUser?.sub || ''; + if (!identity || !this.hasStoredAuthToken()) { this.markAuthDataFailed(); return of(false); } - if (!this.hasStoredAuthToken()) { - if (this.kcUser) { - return this.keycloakLogin(this.kcUser); - } + return this.loadAuthData(identity); + } + + refreshAuthData(): void { + const identity = this.loggedUser?.sub; + if (!identity) { this.markAuthDataFailed(); - return of(false); + return; } + this.loadAuthData(identity).subscribe(); + } + + private loadAuthData(identity: string): Observable { this.setAuthBootstrapStatus('backend-login-running'); + return this.getAuthDataWithRetry(identity) .pipe( map(authData => { @@ -152,32 +129,10 @@ export class AppService { ); } - refreshAuthData(): void { - if (this.authBootstrapStatus !== 'ready') { - return; - } - - if (this.loggedUser?.sub) { - this.getAuthData(this.loggedUser.sub).subscribe(authData => { - this.updateAuthData(authData); - }); - } - } - - private getAuthDataWithRetry(id: string): Observable { + private getAuthDataWithRetry(identity: string): Observable { return this.withAuthBootstrapRetry( this.http.get( - `${this.serverUrl}auth-data?identity=${id}`, - { context: suppressGlobalHttpErrorContext() } - ) - ); - } - - private getKeycloakLoginTokenWithRetry(user: CreateUserDto): Observable { - return this.withAuthBootstrapRetry( - this.http.post( - `${this.serverUrl}keycloak-login`, - user, + `${this.serverUrl}auth-data?identity=${encodeURIComponent(identity)}`, { context: suppressGlobalHttpErrorContext() } ) ); @@ -214,8 +169,6 @@ export class AppService { }); } - private authDataSubject = new BehaviorSubject(AppService.defaultAuthData); - get authData$() { return this.authDataSubject.asObservable(); } @@ -289,7 +242,7 @@ export class AppService { } hasStoredAuthToken(): boolean { - return !!localStorage.getItem('id_token'); + return !!localStorage.getItem('auth_token'); } isBackendLoginRunning(): boolean { @@ -322,10 +275,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}`; @@ -342,10 +295,12 @@ export class AppService { } clearAuthState(options: { clearReAuthentication?: boolean; clearReturnUrl?: boolean } = {}): void { + localStorage.removeItem('auth_token'); localStorage.removeItem('id_token'); - this.kcUser = 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.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 a768b6623..d81cd693c 100644 --- a/apps/frontend/src/app/core/services/auth.service.spec.ts +++ b/apps/frontend/src/app/core/services/auth.service.spec.ts @@ -1,83 +1,101 @@ 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; - idTokenParsed?: unknown; - token?: string; - realmAccess?: { roles: string[] }; - login: jest.Mock; - logout: jest.Mock; - loadUserProfile: jest.Mock; - accountManagement: jest.Mock; - }; - let appService: jest.Mocked; + let httpMock: HttpTestingController; + let appService: jest.Mocked>; + let originalLocation: Location; beforeEach(() => { - keycloak = { - authenticated: false, - idTokenParsed: { sub: 'user-1' }, - token: 'keycloak-token', - realmAccess: { roles: ['user'] }, - 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 + }); + + Object.defineProperty(window, 'localStorage', { + value: { + getItem: jest.fn().mockReturnValue(null), + setItem: jest.fn(), + removeItem: jest.fn() + }, + 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 pass a sanitized return URL as Keycloak redirect URI', async () => { - await service.login('/workspace-admin/1'); + it('should redirect to the backend login endpoint with a sanitized return URL', () => { + service.login('/workspace-admin/1'); expect(appService.createLoginRedirectUri).toHaveBeenCalledWith('/workspace-admin/1'); - expect(keycloak.login).toHaveBeenCalledWith({ redirectUri: 'http://localhost/#/coding' }); + expect(window.location.href).toBe( + 'http://localhost:3333/api/auth/login?redirect_uri=http%3A%2F%2Flocalhost%2F%23%2Fcoding' + ); }); - it('should fall back to the stored reauthentication return URL during login', async () => { - await service.login(); + it('should fall back to the stored reauthentication return URL during login', () => { + service.login(); expect(appService.createLoginRedirectUri).toHaveBeenCalledWith('/coding'); }); - it('should login without options when there is no return URL', async () => { - appService.reAuthenticationReturnUrl = undefined; - appService.createLoginRedirectUri.mockReturnValue(undefined); + it('should clear local auth state before logout', () => { + service.logout(); - await service.login(); - - expect(keycloak.login).toHaveBeenCalledWith(undefined); + expect(appService.markExplicitLogoutInProgress).toHaveBeenCalled(); + expect(appService.clearAuthState).toHaveBeenCalledWith({ clearReAuthentication: true }); }); - it('should mark explicit logout and clear local auth state before Keycloak logout', async () => { - await service.logout(); + 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.markExplicitLogoutInProgress).toHaveBeenCalled(); - expect(appService.clearAuthState).toHaveBeenCalledWith({ clearReAuthentication: true }); - expect(keycloak.logout).toHaveBeenCalledWith({ redirectUri: window.location.origin }); + 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 + }); }); }); 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 29843d685..c32a0b660 --- a/apps/frontend/src/app/core/services/auth.service.ts +++ b/apps/frontend/src/app/core/services/auth.service.ts @@ -1,53 +1,144 @@ import { inject, Injectable } from '@angular/core'; -import Keycloak, { KeycloakProfile, KeycloakTokenParsed } from 'keycloak-js'; +import { HttpClient } from '@angular/common/http'; +import { BehaviorSubject, 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.idTokenParsed; - } catch (e) { - return undefined; + private readonly tokenKey = 'auth_token'; + private readonly idTokenKey = 'id_token'; + private readonly refreshTokenKey = 'refresh_token'; + private isAuthenticatedSubject = new BehaviorSubject(this.hasValidToken()); + + constructor() { + this.checkTokenValidity(); + } + + getLoggedUser(): DecodedToken | undefined { + const token = this.getToken(); + if (token) { + try { + return jwtDecode(token); + } catch { + return undefined; + } } + return undefined; + } + + getToken(): string | null { + return localStorage.getItem(this.tokenKey); + } + + getIdToken(): string | null { + return localStorage.getItem(this.idTokenKey); } - getToken() { - const token = this.keycloak.token; - return token; + getRefreshToken(): string | null { + return localStorage.getItem(this.refreshTokenKey); } - isLoggedIn(): boolean | undefined { - return this.keycloak.authenticated; + isLoggedIn(): boolean { + const hasValidToken = this.hasValidToken(); + if (hasValidToken !== this.isAuthenticatedSubject.value) { + this.isAuthenticatedSubject.next(hasValidToken); + } + return hasValidToken; } - 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)}`; } - async logout(): Promise { + exchangeLoginCode(code: string): Observable { + return this.http.post(`${this.appService.serverUrl}auth/exchange`, { code }); + } + + 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; + } + + try { + const decoded = jwtDecode(token); + const now = Date.now() / 1000; + return decoded.exp ? decoded.exp > now : false; + } catch { + return false; + } + } + + private checkTokenValidity(): void { + if (!this.hasValidToken()) { + localStorage.removeItem(this.tokenKey); + localStorage.removeItem(this.idTokenKey); + localStorage.removeItem(this.refreshTokenKey); + this.isAuthenticatedSubject.next(false); } - 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 6f9a3bdde..000000000 --- a/apps/frontend/src/app/core/services/keycloak-session-events.spec.ts +++ /dev/null @@ -1,90 +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(), - 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 events', () => { - handleKeycloakSessionEvent( - { type: KeycloakEventType.AuthRefreshSuccess } as KeycloakEvent, - appService, - router - ); - - expect(appService.setNeedsReAuthentication).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 19e06a102..000000000 --- a/apps/frontend/src/app/core/services/keycloak-session-events.ts +++ /dev/null @@ -1,44 +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: - case KeycloakEventType.AuthRefreshSuccess: - appService.setNeedsReAuthentication(false); - break; - default: - break; - } -} 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 33a13841b..bb56e93c7 100644 --- a/apps/frontend/src/app/core/services/system-settings.service.ts +++ b/apps/frontend/src/app/core/services/system-settings.service.ts @@ -18,7 +18,7 @@ export class SystemSettingsService { private readonly serverUrl = inject(SERVER_URL); private get authHeader() { - return { Authorization: `Bearer ${localStorage.getItem('id_token')}` }; + 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 ad2eb8abc..d45345431 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 @@ -92,6 +92,8 @@ describe('ReplayComponent', () => { let codingJobBackendServiceMock: { getCodingJobUnits: jest.Mock; updateCodingJob: jest.Mock; + updateCodingJobStatus: jest.Mock; + updateCodingJobComment: jest.Mock; getCodingProgress: jest.Mock; getCodingNotes: jest.Mock; getCodingJob: jest.Mock; @@ -115,6 +117,8 @@ describe('ReplayComponent', () => { codingJobBackendServiceMock = { getCodingJobUnits: jest.fn().mockReturnValue(of([])), updateCodingJob: jest.fn().mockReturnValue(of({})), + updateCodingJobStatus: jest.fn().mockReturnValue(of({})), + updateCodingJobComment: jest.fn().mockReturnValue(of({})), getCodingProgress: jest.fn().mockReturnValue(of({})), getCodingNotes: jest.fn().mockReturnValue(of({})), getCodingJob: 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 f8e348657..088cc597e 100755 --- a/apps/frontend/src/app/replay/components/replay/replay.component.ts +++ b/apps/frontend/src/app/replay/components/replay/replay.component.ts @@ -638,7 +638,7 @@ export class ReplayComponent implements OnInit, OnDestroy, OnChanges { } private getWorkspaceIdFromToken(): number | null { - const candidateTokens = [this.authToken, localStorage.getItem('id_token')] + 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.ts b/apps/frontend/src/app/replay/services/replay-backend.service.ts index 926da4131..7ed7e033b 100644 --- a/apps/frontend/src/app/replay/services/replay-backend.service.ts +++ b/apps/frontend/src/app/replay/services/replay-backend.service.ts @@ -63,7 +63,7 @@ export class ReplayBackendService { private http = inject(HttpClient); private get authHeader() { - return { Authorization: `Bearer ${localStorage.getItem('id_token')}` }; + 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 c8684889a..ea9d9a792 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(), getCodingProgress: jest.fn(), getCodingNotes: jest.fn(), getCodingJob: jest.fn(), @@ -49,21 +51,21 @@ describe('ReplayCodingService', () => { describe('updateCodingJobStatus', () => { it('should update status via backend', async () => { - codingJobBackendServiceMock.updateCodingJob.mockReturnValue(of({} as CodingJob)); + codingJobBackendServiceMock.updateCodingJobStatus.mockReturnValue(of({} as CodingJob)); await service.updateCodingJobStatus(1, 100, 'active'); - expect(codingJobBackendServiceMock.updateCodingJob).toHaveBeenCalledWith(1, 100, { status: 'active' }); + expect(codingJobBackendServiceMock.updateCodingJobStatus).toHaveBeenCalledWith(1, 100, 'active'); }); it('should pass the replay auth token to backend status updates', async () => { - codingJobBackendServiceMock.updateCodingJob.mockReturnValue(of({} as CodingJob)); + codingJobBackendServiceMock.updateCodingJobStatus.mockReturnValue(of({} as CodingJob)); service.setAuthToken('replay-token'); await service.updateCodingJobStatus(1, 100, 'active'); - expect(codingJobBackendServiceMock.updateCodingJob).toHaveBeenCalledWith( + expect(codingJobBackendServiceMock.updateCodingJobStatus).toHaveBeenCalledWith( 1, 100, - { status: 'active' }, + 'active', 'replay-token' ); }); @@ -277,7 +279,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', () => { @@ -288,7 +290,7 @@ describe('ReplayCodingService', () => { expect(codingJobBackendServiceMock.updateCodingJobKeepalive).toHaveBeenCalledWith( 1, 100, - { status: 'paused' }, + 'paused', 'replay-token' ); }); 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 3df272400..4b38ce485 100644 --- a/apps/frontend/src/app/replay/services/replay-coding.service.ts +++ b/apps/frontend/src/app/replay/services/replay-coding.service.ts @@ -87,9 +87,9 @@ 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') { return firstValueFrom( - this.codingJobBackendService.updateCodingJob(workspaceId, jobId, { status }, ...this.authTokenArg) + this.codingJobBackendService.updateCodingJobStatus(workspaceId, jobId, status, ...this.authTokenArg) ); } @@ -459,7 +459,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 @@ -486,7 +486,7 @@ export class ReplayCodingService { this.codingJobBackendService.updateCodingJobKeepalive( workspaceId, jobId, - { status: 'paused' }, + 'paused', ...this.authTokenArg ); } 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 3ccdaa412..946571628 100644 --- a/apps/frontend/src/app/services/facades/workspace-facade.service.ts +++ b/apps/frontend/src/app/services/facades/workspace-facade.service.ts @@ -101,7 +101,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 d10105ce9..1f287c122 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 { Authorization: `Bearer ${localStorage.getItem('id_token')}` }; + 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 3c26371fe..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 { Authorization: `Bearer ${localStorage.getItem('id_token')}` }; + 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 6cf0b80ec..72d82e10a 100644 --- a/apps/frontend/src/app/shared/services/response/response.service.ts +++ b/apps/frontend/src/app/shared/services/response/response.service.ts @@ -24,7 +24,7 @@ export class ResponseService { private validationTaskStateService = inject(ValidationTaskStateService); get authHeader() { - return { Authorization: `Bearer ${localStorage.getItem('id_token')}` }; + 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.ts b/apps/frontend/src/app/shared/services/response/variable-analysis.service.ts index 17c17fba7..170fff193 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 @@ -136,7 +136,7 @@ export class VariableAnalysisService { private http = inject(HttpClient); get authHeader() { - return { Authorization: `Bearer ${localStorage.getItem('id_token')}` }; + 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 f06c52072..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 { Authorization: `Bearer ${localStorage.getItem('id_token')}` }; + 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 57710b9e3..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 { Authorization: `Bearer ${localStorage.getItem('id_token')}` }; + return { Authorization: `Bearer ${localStorage.getItem('auth_token')}` }; } createUnitNote(workspaceId: number, createUnitNoteDto: CreateUnitNoteDto): Observable { diff --git a/apps/frontend/src/app/sys-admin/components/sys-admin-settings/sys-admin-settings.component.ts b/apps/frontend/src/app/sys-admin/components/sys-admin-settings/sys-admin-settings.component.ts index 091874d60..c06d65f1a 100755 --- a/apps/frontend/src/app/sys-admin/components/sys-admin-settings/sys-admin-settings.component.ts +++ b/apps/frontend/src/app/sys-admin/components/sys-admin-settings/sys-admin-settings.component.ts @@ -539,7 +539,7 @@ export class SysAdminSettingsComponent implements OnInit, OnDestroy { } private getAuthHeaders(): HttpHeaders | null { - const token = localStorage.getItem('id_token'); + const token = localStorage.getItem('auth_token'); if (!token) { return null; } 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 3d67a7ad1..e7df151f2 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 @@ -54,16 +54,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 7bba243c4..cdd31f2ea 100644 --- a/apps/frontend/src/app/workspace/services/workspace-backend.service.ts +++ b/apps/frontend/src/app/workspace/services/workspace-backend.service.ts @@ -67,11 +67,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 a38c0c1fe..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 { Authorization: `Bearer ${localStorage.getItem('id_token')}` }; + 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/components/ws-settings/ws-settings.component.ts b/apps/frontend/src/app/ws-admin/components/ws-settings/ws-settings.component.ts index 9bcbbe7b1..e2118e14d 100755 --- a/apps/frontend/src/app/ws-admin/components/ws-settings/ws-settings.component.ts +++ b/apps/frontend/src/app/ws-admin/components/ws-settings/ws-settings.component.ts @@ -464,7 +464,7 @@ export class WsSettingsComponent implements OnInit, OnDestroy { } private getAuthHeaders(): HttpHeaders | null { - const token = localStorage.getItem('id_token'); + const token = localStorage.getItem('auth_token'); if (!token) { return 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 9bd04df9c..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 { Authorization: `Bearer ${localStorage.getItem('id_token')}` }; + 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 13c10bbc8..94ee702dd 100644 --- a/docker-compose.coding-box.prod.yaml +++ b/docker-compose.coding-box.prod.yaml @@ -46,11 +46,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 cb478cb3b..f9c1d001d 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 @@ -76,3 +126,6 @@ services: - "frontend" - "--host=frontend" - "--port=8080" + +volumes: + keycloak_db_vol: diff --git a/docker-compose.yaml b/docker-compose.yaml index 78c222ed5..5297f99e5 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -10,11 +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} services: redis: @@ -65,9 +72,10 @@ services: condition: service_healthy environment: API_HOST: backend + HTTP_PORT: ${HTTP_PORT:-4200} JWT_SECRET: ${JWT_SECRET} GEOGEBRA_BUNDLE_DOWNLOAD_URL: ${GEOGEBRA_BUNDLE_DOWNLOAD_URL:-} - <<: [ *env-redis, *env-postgres, *env-keycloak ] + <<: [ *env-redis, *env-postgres, *env-oidc ] healthcheck: test: [ 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 7935b52db..64d0d4c4f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -62,10 +62,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", @@ -9440,6 +9439,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", @@ -27471,12 +27480,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", @@ -27882,6 +27885,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", @@ -27936,33 +27964,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", @@ -28370,6 +28371,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", @@ -28589,6 +28595,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", @@ -28986,6 +28998,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 f5921f9ba..6dc626b77 100644 --- a/package.json +++ b/package.json @@ -81,10 +81,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", @@ -158,8 +157,7 @@ }, "serialize-javascript": "7.0.5", "picomatch": "4.0.4", - "vite": "7.3.2", - "path-to-regexp": "8.4.2" + "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'