From a88584514c8bb4a012bbe47096f66c5b99840d41 Mon Sep 17 00:00:00 2001 From: Niek Otten Date: Thu, 16 Jul 2026 09:05:45 +0200 Subject: [PATCH] feat(auth): medewerker-realm AuthService + provider, roles surface, realm-roles mapper (refs #13) The behandel-portal authenticates staff against the Keycloak `medewerker` realm. Add MedewerkerAuthService (reads nested realm_access.roles), provideMedewerkerAuth, a roles/hasRole surface on the shared AuthService, and a realm-roles protocol mapper so the frontend can read the behandelaar/teamlead roles from the token. Per ADR-0013 the BFF remains the security boundary. Co-Authored-By: Claude Opus 4.8 (1M context) --- infra/keycloak/realms/medewerker-realm.json | 17 ++++++- libs/auth/src/index.ts | 2 + libs/auth/src/lib/auth.service.ts | 20 ++++++-- .../auth/src/lib/medewerker-auth.providers.ts | 49 +++++++++++++++++++ libs/auth/src/lib/medewerker-auth.service.ts | 41 ++++++++++++++++ 5 files changed, 123 insertions(+), 6 deletions(-) create mode 100644 libs/auth/src/lib/medewerker-auth.providers.ts create mode 100644 libs/auth/src/lib/medewerker-auth.service.ts diff --git a/infra/keycloak/realms/medewerker-realm.json b/infra/keycloak/realms/medewerker-realm.json index 40ab79a..d56b6d7 100644 --- a/infra/keycloak/realms/medewerker-realm.json +++ b/infra/keycloak/realms/medewerker-realm.json @@ -16,7 +16,22 @@ "standardFlowEnabled": true, "directAccessGrantsEnabled": true, "redirectUris": ["*"], - "webOrigins": ["*"] + "webOrigins": ["*"], + "protocolMappers": [ + { + "name": "realm roles", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-realm-role-mapper", + "config": { + "multivalued": "true", + "claim.name": "realm_access.roles", + "jsonType.label": "String", + "id.token.claim": "true", + "access.token.claim": "true", + "userinfo.token.claim": "true" + } + } + ] } ], "users": [ diff --git a/libs/auth/src/index.ts b/libs/auth/src/index.ts index 81eef46..a821e0f 100644 --- a/libs/auth/src/index.ts +++ b/libs/auth/src/index.ts @@ -1,4 +1,6 @@ export * from './lib/auth.service'; export * from './lib/digid-auth.service'; export * from './lib/digid-auth.providers'; +export * from './lib/medewerker-auth.service'; +export * from './lib/medewerker-auth.providers'; export * from './lib/authenticated.guard'; diff --git a/libs/auth/src/lib/auth.service.ts b/libs/auth/src/lib/auth.service.ts index 605608c..e3023ec 100644 --- a/libs/auth/src/lib/auth.service.ts +++ b/libs/auth/src/lib/auth.service.ts @@ -1,16 +1,26 @@ -import { Signal } from '@angular/core'; +import { signal, Signal } from '@angular/core'; /** * The portal's view of the signed-in user. An abstraction over the OIDC library so components and - * guards depend on a small, mockable surface (the real implementation is DigiadAuthService). + * guards depend on a small, mockable surface (the real implementations are DigiadAuthService for + * citizens and MedewerkerAuthService for staff). */ export abstract class AuthService { - /** Whether a DigiD session is active. */ + /** Whether a session is active. */ abstract readonly isAuthenticated: Signal; - /** The citizen-service number from the DigiD token, once authenticated. */ + /** The citizen-service number from the DigiD token, once authenticated (staff have none). */ abstract readonly bsn: Signal; - /** Start the DigiD login (redirects to Keycloak). */ + /** + * The realm roles carried in the token. Empty for realms that don't grant roles (e.g. `digid`); + * the `medewerker` realm carries `behandelaar`/`teamlead`. + */ + readonly roles: Signal = signal([]); + /** Start login (redirects to Keycloak). */ abstract login(): void; /** End the session. */ abstract logout(): void; + /** Whether the signed-in user holds the given realm role. */ + hasRole(role: string): boolean { + return this.roles().includes(role); + } } diff --git a/libs/auth/src/lib/medewerker-auth.providers.ts b/libs/auth/src/lib/medewerker-auth.providers.ts new file mode 100644 index 0000000..ccd0d9c --- /dev/null +++ b/libs/auth/src/lib/medewerker-auth.providers.ts @@ -0,0 +1,49 @@ +import { EnvironmentProviders, makeEnvironmentProviders } from '@angular/core'; +import { LogLevel, provideAuth, withAppInitializerAuthCheck } from 'angular-auth-oidc-client'; +import { AuthService } from './auth.service'; +import { MedewerkerAuthService } from './medewerker-auth.service'; + +export interface MedewerkerAuthOptions { + /** The Keycloak `medewerker` realm issuer, as reachable from the browser. */ + authority: string; + /** Where Keycloak redirects back to after login (usually the app origin). */ + redirectUrl: string; + /** + * Route prefixes whose requests get the bearer token attached. The api-client calls the BFF with + * **relative** URLs (same-origin via the nginx proxy), so these must be relative path prefixes + * (e.g. `/behandel/`) — angular-auth-oidc-client matches `req.url.startsWith(route)`, and a + * relative `req.url` never starts with an absolute origin. + */ + secureRoutes: string[]; +} + +/** + * Configure medewerker login (Keycloak `medewerker` realm, public client `big-portal`, auth-code + + * PKCE) and bind {@link AuthService} to the medewerker-backed implementation. Register + * {@link authInterceptor} (re-exported from digid-auth.providers) in the app's HttpClient so BFF + * calls carry the token. + */ +export function provideMedewerkerAuth(options: MedewerkerAuthOptions): EnvironmentProviders { + return makeEnvironmentProviders([ + provideAuth( + { + config: { + authority: options.authority, + redirectUrl: options.redirectUrl, + postLogoutRedirectUri: options.redirectUrl, + clientId: 'big-portal', + scope: 'openid profile', + responseType: 'code', + silentRenew: true, + useRefreshToken: true, + secureRoutes: options.secureRoutes, + logLevel: LogLevel.Warn, + }, + }, + // Run checkAuth() at startup so the login callback (?code=…) is processed before the router + // and guard run — without it the guard sees "not authenticated" and re-triggers login (loop). + withAppInitializerAuthCheck(), + ), + { provide: AuthService, useClass: MedewerkerAuthService }, + ]); +} diff --git a/libs/auth/src/lib/medewerker-auth.service.ts b/libs/auth/src/lib/medewerker-auth.service.ts new file mode 100644 index 0000000..5ffab98 --- /dev/null +++ b/libs/auth/src/lib/medewerker-auth.service.ts @@ -0,0 +1,41 @@ +import { inject, Injectable, Signal } from '@angular/core'; +import { toSignal } from '@angular/core/rxjs-interop'; +import { OidcSecurityService } from 'angular-auth-oidc-client'; +import { map } from 'rxjs'; +import { AuthService } from './auth.service'; + +/** The subset of the medewerker token the portal reads: Keycloak nests realm roles here. */ +interface MedewerkerClaims { + realm_access?: { roles?: string[] }; +} + +/** Medewerker-backed AuthService over angular-auth-oidc-client (Keycloak `medewerker` realm). */ +@Injectable() +export class MedewerkerAuthService extends AuthService { + private readonly oidc = inject(OidcSecurityService); + + readonly isAuthenticated: Signal = toSignal( + this.oidc.isAuthenticated$.pipe(map((result) => result.isAuthenticated)), + { initialValue: false }, + ); + + // Staff have no BSN; the abstract surface keeps this present for the shared guard/interceptor. + readonly bsn: Signal = toSignal(this.oidc.userData$.pipe(map(() => undefined)), { + initialValue: undefined, + }); + + override readonly roles: Signal = toSignal( + this.oidc.userData$.pipe( + map((data) => (data.userData as MedewerkerClaims | null)?.realm_access?.roles ?? []), + ), + { initialValue: [] }, + ); + + override login(): void { + this.oidc.authorize(); + } + + override logout(): void { + this.oidc.logoff().subscribe(); + } +}