feat(behandel): behandel-portal — werkbak + beoordeling (closes #13) #87
@@ -16,7 +16,22 @@
|
|||||||
"standardFlowEnabled": true,
|
"standardFlowEnabled": true,
|
||||||
"directAccessGrantsEnabled": true,
|
"directAccessGrantsEnabled": true,
|
||||||
"redirectUris": ["*"],
|
"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": [
|
"users": [
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
export * from './lib/auth.service';
|
export * from './lib/auth.service';
|
||||||
export * from './lib/digid-auth.service';
|
export * from './lib/digid-auth.service';
|
||||||
export * from './lib/digid-auth.providers';
|
export * from './lib/digid-auth.providers';
|
||||||
|
export * from './lib/medewerker-auth.service';
|
||||||
|
export * from './lib/medewerker-auth.providers';
|
||||||
export * from './lib/authenticated.guard';
|
export * from './lib/authenticated.guard';
|
||||||
|
|||||||
@@ -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
|
* 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 {
|
export abstract class AuthService {
|
||||||
/** Whether a DigiD session is active. */
|
/** Whether a session is active. */
|
||||||
abstract readonly isAuthenticated: Signal<boolean>;
|
abstract readonly isAuthenticated: Signal<boolean>;
|
||||||
/** 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<string | undefined>;
|
abstract readonly bsn: Signal<string | undefined>;
|
||||||
/** 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<readonly string[]> = signal<readonly string[]>([]);
|
||||||
|
/** Start login (redirects to Keycloak). */
|
||||||
abstract login(): void;
|
abstract login(): void;
|
||||||
/** End the session. */
|
/** End the session. */
|
||||||
abstract logout(): void;
|
abstract logout(): void;
|
||||||
|
/** Whether the signed-in user holds the given realm role. */
|
||||||
|
hasRole(role: string): boolean {
|
||||||
|
return this.roles().includes(role);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
49
libs/auth/src/lib/medewerker-auth.providers.ts
Normal file
49
libs/auth/src/lib/medewerker-auth.providers.ts
Normal file
@@ -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 },
|
||||||
|
]);
|
||||||
|
}
|
||||||
41
libs/auth/src/lib/medewerker-auth.service.ts
Normal file
41
libs/auth/src/lib/medewerker-auth.service.ts
Normal file
@@ -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<boolean> = 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<string | undefined> = toSignal(this.oidc.userData$.pipe(map(() => undefined)), {
|
||||||
|
initialValue: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
override readonly roles: Signal<readonly string[]> = 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user