Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/server-nestjs/.env-example
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 2 additions & 0 deletions apps/server-nestjs/.env.docker-example
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 2 additions & 0 deletions apps/server-nestjs/.env.integ-example
Original file line number Diff line number Diff line change
Expand Up @@ -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=

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
165 changes: 128 additions & 37 deletions apps/server-nestjs/src/modules/keycloak/keycloak-client.service.spec.ts
Original file line number Diff line number Diff line change
@@ -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<ConfigurationService> = {}) {
return Test.createTestingModule({
imports: [ScheduleModule.forRoot()],
providers: [
KeycloakClientService,
{ provide: KEYCLOAK_ADMIN_CLIENT, useValue: new KcAdminClient({ baseUrl: keycloakUrl }) },
Expand All @@ -32,6 +49,7 @@ function createKeycloakClientServiceTestingModule(config: Partial<ConfigurationS
keycloakRealm: projectRealm,
keycloakAdmin: 'admin',
keycloakAdminPassword: 'admin-password',
keycloakAdminClientId: 'admin-cli',
...config,
}),
},
Expand All @@ -40,73 +58,146 @@ function createKeycloakClientServiceTestingModule(config: Partial<ConfigurationS
}

describe('keycloakClientService authentication lifecycle', () => {
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 () => {
Expand Down
77 changes: 70 additions & 7 deletions apps/server-nestjs/src/modules/keycloak/keycloak-client.service.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,27 @@
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')

@Injectable()
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,
Expand Down Expand Up @@ -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)
Expand All @@ -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() {
Comment thread
KepoParis marked this conversation as resolved.
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
Comment thread
KepoParis marked this conversation as resolved.
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 {
Comment thread
StephaneTrebel marked this conversation as resolved.
this.client.setConfig({ realmName })
}
}
}
Loading