diff --git a/apps/server-nestjs/.env-example b/apps/server-nestjs/.env-example index 0c706ecb8..f9f3a143f 100644 --- a/apps/server-nestjs/.env-example +++ b/apps/server-nestjs/.env-example @@ -21,6 +21,8 @@ KEYCLOAK_CLIENT_SECRET=client-secret-backend KEYCLOAK_ADMIN=admin # Mot de passe de l'administrateur Keycloak sur le royaume KEYCLOAK_REALM KEYCLOAK_ADMIN_PASSWORD=admin +# Client utilisé par le client admin pour s'authentifier sur le royaume master (requis) +KEYCLOAK_ADMIN_CLIENT_ID=admin-cli # URL de redirection après authentification Keycloak KEYCLOAK_REDIRECT_URI=http://localhost:8080 # JWKS cache TTL in milliseconds (default 300000 = 5 min) diff --git a/apps/server-nestjs/.env.docker-example b/apps/server-nestjs/.env.docker-example index 54609cef2..0096e0736 100644 --- a/apps/server-nestjs/.env.docker-example +++ b/apps/server-nestjs/.env.docker-example @@ -22,6 +22,8 @@ KEYCLOAK_CLIENT_SECRET=client-secret-backend KEYCLOAK_ADMIN=admin # Mot de passe de l'administrateur Keycloak KEYCLOAK_ADMIN_PASSWORD=admin +# Client utilisé par le client admin pour s'authentifier sur le royaume master (requis) +KEYCLOAK_ADMIN_CLIENT_ID=admin-cli # URL de redirection après authentification Keycloak KEYCLOAK_REDIRECT_URI=http://localhost:8080 # JWKS cache TTL in milliseconds (default 300000 = 5 min) diff --git a/apps/server-nestjs/.env.integ-example b/apps/server-nestjs/.env.integ-example index c095681be..9b1a5cb45 100644 --- a/apps/server-nestjs/.env.integ-example +++ b/apps/server-nestjs/.env.integ-example @@ -54,6 +54,8 @@ HARBOR_INTERNAL_URL= KEYCLOAK_ADMIN= # Mot de passe admin Keycloak KEYCLOAK_ADMIN_PASSWORD= +# Client utilisé par le client admin pour s'authentifier sur le royaume master (requis) +KEYCLOAK_ADMIN_CLIENT_ID=admin-cli # URL complète de Keycloak (peut différer de KEYCLOAK_DOMAIN) KEYCLOAK_URL= diff --git a/apps/server-nestjs/src/modules/infrastructure/configuration/configuration.service.ts b/apps/server-nestjs/src/modules/infrastructure/configuration/configuration.service.ts index 608209b95..4fd75bd88 100644 --- a/apps/server-nestjs/src/modules/infrastructure/configuration/configuration.service.ts +++ b/apps/server-nestjs/src/modules/infrastructure/configuration/configuration.service.ts @@ -31,6 +31,7 @@ export class ConfigurationService { keycloakClientSecret = process.env.KEYCLOAK_CLIENT_SECRET keycloakAdmin = process.env.KEYCLOAK_ADMIN keycloakAdminPassword = process.env.KEYCLOAK_ADMIN_PASSWORD + keycloakAdminClientId = process.env.KEYCLOAK_ADMIN_CLIENT_ID keycloakRedirectUri = process.env.KEYCLOAK_REDIRECT_URI // JWKS cache TTL in ms (default 5 min); Keycloak rotates keys periodically diff --git a/apps/server-nestjs/src/modules/keycloak/keycloak-client.service.spec.ts b/apps/server-nestjs/src/modules/keycloak/keycloak-client.service.spec.ts index 977285da9..b6e13aeeb 100644 --- a/apps/server-nestjs/src/modules/keycloak/keycloak-client.service.spec.ts +++ b/apps/server-nestjs/src/modules/keycloak/keycloak-client.service.spec.ts @@ -1,28 +1,45 @@ +import type { TestingModule } from '@nestjs/testing' import KcAdminClient from '@keycloak/keycloak-admin-client' +import { ScheduleModule } from '@nestjs/schedule' import { Test } from '@nestjs/testing' import { http, HttpResponse } from 'msw' import { setupServer } from 'msw/node' -import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest' +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' import { mockDeep } from 'vitest-mock-extended' import { ConfigurationService } from '../infrastructure/configuration/configuration.service' import { KEYCLOAK_ADMIN_CLIENT, KeycloakClientService } from './keycloak-client.service' +import { ADMIN_TOKEN_REFRESH_INTERVAL_MS } from './keycloak.constants' const keycloakUrl = 'https://keycloak.internal' const projectRealm = 'project-realm' +// The only handled token endpoint is the master realm's: a token request +// against any other realm is unhandled and fails the test, which enforces +// the realm pinning of authenticate() const tokenUrl = `${keycloakUrl}/realms/master/protocol/openid-connect/token` const childrenUrl = `${keycloakUrl}/admin/realms/${projectRealm}/groups/:parentId/children` const server = setupServer() -function useTokenEndpoint() { +function useTokenEndpoint({ rejectGrant = () => false }: { rejectGrant?: (grantType: string | null) => boolean } = {}) { + const tokenRequests: URLSearchParams[] = [] + let issued = 0 server.use( - http.post(tokenUrl, () => - HttpResponse.json({ access_token: 'access-token', refresh_token: 'refresh-token', expires_in: 300 })), + http.post(tokenUrl, async ({ request }) => { + const body = new URLSearchParams(await request.text()) + tokenRequests.push(body) + if (rejectGrant(body.get('grant_type'))) { + return HttpResponse.json({ error: 'invalid_grant' }, { status: 401 }) + } + issued++ + return HttpResponse.json({ access_token: `access-token-${issued}`, refresh_token: `refresh-token-${issued}`, expires_in: 60 }) + }), ) + return tokenRequests } function createKeycloakClientServiceTestingModule(config: Partial = {}) { return Test.createTestingModule({ + imports: [ScheduleModule.forRoot()], providers: [ KeycloakClientService, { provide: KEYCLOAK_ADMIN_CLIENT, useValue: new KcAdminClient({ baseUrl: keycloakUrl }) }, @@ -32,6 +49,7 @@ function createKeycloakClientServiceTestingModule(config: Partial { - let service: KeycloakClientService + let module: TestingModule let client: KcAdminClient beforeAll(() => server.listen({ onUnhandledRequest: 'error' })) beforeEach(async () => { - const module = await createKeycloakClientServiceTestingModule().compile() - service = module.get(KeycloakClientService) + vi.useFakeTimers() + module = await createKeycloakClientServiceTestingModule().compile() client = module.get(KEYCLOAK_ADMIN_CLIENT) }) - afterEach(() => server.resetHandlers()) + afterEach(async () => { + // close() re-runs init() on a module whose init() already failed; swallow + // that rethrow so the "initial authentication fails" test can clean up + await module.close().catch(() => {}) + server.resetHandlers() + vi.useRealTimers() + }) afterAll(() => server.close()) it('should authenticate with the password grant then switch to the project realm', async () => { - server.use( - http.post(tokenUrl, async ({ request }) => { - const body = new URLSearchParams(await request.text()) - expect(body.get('grant_type')).toBe('password') - expect(body.get('client_id')).toBe('admin-cli') - expect(body.get('username')).toBe('admin') - expect(body.get('password')).toBe('admin-password') - return HttpResponse.json({ access_token: 'access-token', refresh_token: 'refresh-token', expires_in: 300 }) - }), - ) + const tokenRequests = useTokenEndpoint() + + await module.init() + + expect(tokenRequests).toHaveLength(1) + expect(Object.fromEntries(tokenRequests[0])).toMatchObject({ + grant_type: 'password', + client_id: 'admin-cli', + username: 'admin', + password: 'admin-password', + }) + expect(client.accessToken).toBe('access-token-1') + expect(client.realmName).toBe(projectRealm) + }) + + it('should refresh the token periodically with the rotated refresh token so it never expires', async () => { + const tokenRequests = useTokenEndpoint() + await module.init() + + await vi.advanceTimersByTimeAsync(ADMIN_TOKEN_REFRESH_INTERVAL_MS) + expect(tokenRequests).toHaveLength(2) + expect(Object.fromEntries(tokenRequests[1])).toMatchObject({ + grant_type: 'refresh_token', + client_id: 'admin-cli', + refresh_token: 'refresh-token-1', + }) + + await vi.advanceTimersByTimeAsync(ADMIN_TOKEN_REFRESH_INTERVAL_MS) + expect(tokenRequests).toHaveLength(3) + expect(tokenRequests[2].get('refresh_token')).toBe('refresh-token-2') + expect(client.accessToken).toBe('access-token-3') + expect(client.realmName).toBe(projectRealm) + }) + + it('should fall back to a full re-authentication when the refresh grant fails', async () => { + const tokenRequests = useTokenEndpoint({ rejectGrant: grantType => grantType === 'refresh_token' }) + await module.init() - await service.onModuleInit() + await vi.advanceTimersByTimeAsync(ADMIN_TOKEN_REFRESH_INTERVAL_MS) - expect(client.accessToken).toBe('access-token') + expect(tokenRequests.map(body => body.get('grant_type'))).toEqual(['password', 'refresh_token', 'password']) + expect(client.accessToken).toBe('access-token-2') expect(client.realmName).toBe(projectRealm) }) + it('should keep refreshing and restore the project realm when both grants fail', async () => { + let keycloakIsDown = false + const tokenRequests = useTokenEndpoint({ rejectGrant: () => keycloakIsDown }) + await module.init() + + keycloakIsDown = true + await vi.advanceTimersByTimeAsync(ADMIN_TOKEN_REFRESH_INTERVAL_MS) + expect(tokenRequests.map(body => body.get('grant_type'))).toEqual(['password', 'refresh_token', 'password']) + expect(client.realmName).toBe(projectRealm) + + keycloakIsDown = false + await vi.advanceTimersByTimeAsync(ADMIN_TOKEN_REFRESH_INTERVAL_MS) + expect(tokenRequests).toHaveLength(4) + expect(tokenRequests[3].get('grant_type')).toBe('refresh_token') + expect(client.accessToken).toBe('access-token-2') + }) + + it('should stop refreshing the token on module destroy', async () => { + const tokenRequests = useTokenEndpoint() + await module.init() + expect(tokenRequests).toHaveLength(1) + + await module.close() + + await vi.advanceTimersByTimeAsync(ADMIN_TOKEN_REFRESH_INTERVAL_MS * 3) + expect(tokenRequests).toHaveLength(1) + }) + it('should rethrow when the initial authentication fails', async () => { - server.use( - http.post(tokenUrl, () => - HttpResponse.json({ error: 'invalid_grant' }, { status: 401 })), - ) + const tokenRequests = useTokenEndpoint({ rejectGrant: () => true }) - await expect(service.onModuleInit()).rejects.toThrow('Network response was not OK.') + await expect(module.init()).rejects.toThrow('Network response was not OK.') expect(client.realmName).toBe('master') + + await vi.advanceTimersByTimeAsync(ADMIN_TOKEN_REFRESH_INTERVAL_MS * 2) + expect(tokenRequests).toHaveLength(1) }) - it('should not authenticate when the Keycloak realm is not configured', async () => { - const module = await createKeycloakClientServiceTestingModule({ keycloakRealm: undefined }).compile() - const serviceWithoutRealm = module.get(KeycloakClientService) + it('should not authenticate nor refresh the token when the Keycloak realm is not configured', async () => { + const tokenRequests = useTokenEndpoint() + await module.close() + module = await createKeycloakClientServiceTestingModule({ keycloakRealm: undefined }).compile() - // No token endpoint handler: any authentication attempt would fail the test - await expect(serviceWithoutRealm.onModuleInit()).resolves.toBeUndefined() + await module.init() + + await vi.advanceTimersByTimeAsync(ADMIN_TOKEN_REFRESH_INTERVAL_MS * 2) + expect(tokenRequests).toHaveLength(0) }) - it('should not authenticate when the admin credentials are not configured', async () => { - const module = await createKeycloakClientServiceTestingModule({ keycloakAdminPassword: undefined }).compile() - const serviceWithoutCredentials = module.get(KeycloakClientService) + it('should not authenticate nor refresh the token when the admin credentials are not configured', async () => { + const tokenRequests = useTokenEndpoint() + await module.close() + module = await createKeycloakClientServiceTestingModule({ keycloakAdminPassword: undefined }).compile() + + await module.init() - await expect(serviceWithoutCredentials.onModuleInit()).resolves.toBeUndefined() + await vi.advanceTimersByTimeAsync(ADMIN_TOKEN_REFRESH_INTERVAL_MS * 2) + expect(tokenRequests).toHaveLength(0) }) }) describe('getOrCreateSubGroupByName', () => { + let module: TestingModule let service: KeycloakClientService beforeAll(() => server.listen({ onUnhandledRequest: 'error' })) beforeEach(async () => { - const module = await createKeycloakClientServiceTestingModule().compile() + module = await createKeycloakClientServiceTestingModule().compile() service = module.get(KeycloakClientService) useTokenEndpoint() - await service.onModuleInit() + await module.init() + }) + afterEach(async () => { + await module.close() + server.resetHandlers() }) - afterEach(() => server.resetHandlers()) afterAll(() => server.close()) it('should return the existing subgroup without creating it', async () => { diff --git a/apps/server-nestjs/src/modules/keycloak/keycloak-client.service.ts b/apps/server-nestjs/src/modules/keycloak/keycloak-client.service.ts index c7397d8e2..a2fe7ac15 100644 --- a/apps/server-nestjs/src/modules/keycloak/keycloak-client.service.ts +++ b/apps/server-nestjs/src/modules/keycloak/keycloak-client.service.ts @@ -1,16 +1,18 @@ import type KcAdminClient from '@keycloak/keycloak-admin-client' import type GroupRepresentation from '@keycloak/keycloak-admin-client/lib/defs/groupRepresentation' import type UserRepresentation from '@keycloak/keycloak-admin-client/lib/defs/userRepresentation' +import type { Credentials } from '@keycloak/keycloak-admin-client/lib/utils/auth' import type { OnModuleInit } from '@nestjs/common' import type { ProjectWithDetails } from './keycloak-datastore.service' import type { GroupRepresentationWith } from './keycloak.utils' import { Inject, Injectable, Logger } from '@nestjs/common' +import { Interval } from '@nestjs/schedule' import { trace } from '@opentelemetry/api' import z from 'zod' import { getErrorResponseStatus } from '../../utils/http-error' import { ConfigurationService } from '../infrastructure/configuration/configuration.service' import { StartActiveSpan } from '../infrastructure/telemetry/telemetry.decorator' -import { CONSOLE_GROUP_NAME, SUBGROUPS_PAGINATE_QUERY_MAX } from './keycloak.constants' +import { ADMIN_AUTH_REALM, ADMIN_TOKEN_REFRESH_INTERVAL_MS, CONSOLE_GROUP_NAME, PASSWORD_GRANT_TYPE, REFRESH_TOKEN_GRANT_TYPE, SUBGROUPS_PAGINATE_QUERY_MAX } from './keycloak.constants' export const KEYCLOAK_ADMIN_CLIENT = Symbol('KEYCLOAK_ADMIN_CLIENT') @@ -18,6 +20,8 @@ export const KEYCLOAK_ADMIN_CLIENT = Symbol('KEYCLOAK_ADMIN_CLIENT') export class KeycloakClientService implements OnModuleInit { private readonly logger = new Logger(KeycloakClientService.name) + private authenticated = false + constructor( @Inject(ConfigurationService) private readonly config: ConfigurationService, @Inject(KEYCLOAK_ADMIN_CLIENT) private readonly client: KcAdminClient, @@ -278,14 +282,13 @@ export class KeycloakClientService implements OnModuleInit { this.logger.fatal('Keycloak admin username or password is not configured') return } + if (!this.config.keycloakAdminClientId) { + this.logger.fatal('Keycloak admin client id is not configured') + return + } try { this.logger.log(`Authenticating Keycloak admin client (realm=${this.config.keycloakRealm})`) - await this.client.auth({ - clientId: 'admin-cli', - grantType: 'password', - username: this.config.keycloakAdmin, - password: this.config.keycloakAdminPassword, - }) + await this.authenticate(this.passwordCredentials()) } catch (err) { if (err instanceof Error) { this.logger.error(`Keycloak Admin Client authentication failed: ${err.message}`, err.stack) @@ -295,6 +298,66 @@ export class KeycloakClientService implements OnModuleInit { throw err } this.client.setConfig({ realmName: this.config.keycloakRealm }) + this.authenticated = true this.logger.log(`Keycloak Admin Client authenticated (realm=${this.config.keycloakRealm})`) } + + // The admin client never refreshes its token on its own; without this the + // access token expires (~60s) and every admin call fails with a 401 + @Interval(ADMIN_TOKEN_REFRESH_INTERVAL_MS) + async refreshAdminToken() { + if (!this.authenticated) return + try { + await this.authenticate(this.refreshTokenCredentials()) + } catch (refreshErr) { + // The refresh token itself can expire or be revoked (e.g. Keycloak + // restart); fall back to a full re-authentication to recover + this.logger.warn(`Keycloak Admin Client token refresh failed, re-authenticating: ${refreshErr instanceof Error ? refreshErr.message : String(refreshErr)}`) + try { + await this.authenticate(this.passwordCredentials()) + } catch (err) { + this.logger.error(`Keycloak Admin Client re-authentication failed: ${err instanceof Error ? err.message : String(err)}`) + } + } + } + + // Checked by onModuleInit before any authentication; the getter narrows the + // config value so Credentials.clientId stays a plain string + private get adminClientId(): string { + if (!this.config.keycloakAdminClientId) { + throw new Error('KEYCLOAK_ADMIN_CLIENT_ID is not configured') + } + return this.config.keycloakAdminClientId + } + + private passwordCredentials(): Credentials { + return { + clientId: this.adminClientId, + grantType: PASSWORD_GRANT_TYPE, + username: this.config.keycloakAdmin, + password: this.config.keycloakAdminPassword, + } + } + + private refreshTokenCredentials(): Credentials { + return { + clientId: this.adminClientId, + grantType: REFRESH_TOKEN_GRANT_TYPE, + refreshToken: this.client.refreshToken, + } + } + + // auth() resolves the token endpoint from client.realmName, which onModuleInit + // switches to the project realm — the admin user lives in the master realm. + // Restore the realm even when auth fails: the still-valid previous token keeps + // serving admin calls, and those must target the project realm, not master + private async authenticate(credentials: Credentials) { + const realmName = this.client.realmName + this.client.setConfig({ realmName: ADMIN_AUTH_REALM }) + try { + await this.client.auth(credentials) + } finally { + this.client.setConfig({ realmName }) + } + } } diff --git a/apps/server-nestjs/src/modules/keycloak/keycloak.constants.ts b/apps/server-nestjs/src/modules/keycloak/keycloak.constants.ts index d0e79d982..62f3bbc4f 100644 --- a/apps/server-nestjs/src/modules/keycloak/keycloak.constants.ts +++ b/apps/server-nestjs/src/modules/keycloak/keycloak.constants.ts @@ -1,6 +1,17 @@ // Name for the console group for admin and project roles export const CONSOLE_GROUP_NAME = 'console' +// Realm hosting the admin user used by the admin client (password grant on admin-cli) +export const ADMIN_AUTH_REALM = 'master' + +// OAuth2 grant types (RFC 6749) used to authenticate the admin client +export const PASSWORD_GRANT_TYPE = 'password' +export const REFRESH_TOKEN_GRANT_TYPE = 'refresh_token' + +// Keycloak's default access token lifespan is 60s; refresh well before it +// expires so a slow or delayed tick still lands within the lifespan +export const ADMIN_TOKEN_REFRESH_INTERVAL_MS = 45_000 + // Maximum number of entities returned in a paginated query export const GROUPS_PAGINATE_QUERY_MAX = 20 export const SUBGROUPS_PAGINATE_QUERY_MAX = 20