refactor(auth): land Session -> Principal, add MedewerkerAdapter (RB-13)
ADR-0002 SS3 models Zorgverlener/Medewerker as different Principal variants with different login flows. Actor #2 (apps/behandelportal) landed in WP-61/67 and the union never followed: grep -rn "Principal" returned one hit, a comment. Both apps' auth/domain/session.ts stayed byte-identical (`{ bsn, naam }`), so the backoffice's Behandelaar carried a BSN and logged into the backoffice as a citizen, by DigiD, under a fabricated citizen's name (login.page.ts). The divergence ADR-0002 predicted took an orthogonal side door instead (medewerker.interceptor.ts's X-Medewerker/X-Rollen stamp, which never touches SessionStore) -- which is why ssp/auth and bhp/auth still measured as 100%/84% duplicated after ADR-C-006 shared the route guards. RB-09 (landed the day before) made the backend's IIdentityProvider able to say "no identity" and fail closed; this ticket is its named FE half. Each app's auth/domain/session.ts becomes principal.ts, holding the one Principal variant that app actually has an actor for: ssp keeps `{ kind: 'zorgverlener', bsn, naam }` (G1 still strips the BSN before persisting); behandelportal gets `{ kind: 'medewerker', medewerkerId, naam, rollen }` (no BSN to strip -- G2 shape validation only). A new MedewerkerAdapter replaces DigidAdapter in behandelportal, resolving the existing MEDEWERKER_ID/currentRollen() dev stand-in into a Principal; because there is no credential to check, it returns Principal directly rather than a Result whose error variant could never occur. login.page.ts stops being a BSN/wachtwoord form -- one explainer line and an "Inloggen met SSO" button -- and its dead error-handling branch goes with the Result wrapper that justified it. Measured with tools/baseline-scan.mjs --dup: auth duplication drops from 168/168 (ssp) and 168/200 (bhp) to 32/179 and 32/259 -- under the backlog's <40 target. What remains is the ADR-C-006 route-guard re-export (deliberately identical), generic test/story-file boilerplate, and one shared fragment of the root-singleton-store idiom -- not re-converged identity or login-flow logic. SS3's prediction that the two actors would authenticate differently enough to justify not sharing auth has now actually been tested, not just asserted, and held. Also: renamed Session.bsn to Principal.bsn in two doc comments (libs/shared/src/infrastructure/subject.ts, subject.interceptor.ts) that cited the old type name; regenerated libs/shared/docs/behaviour-spec.mdx (generated file, per its own banner); recorded the resolution in ADR-0002 as a new amendment, replacing its "Known debt" section. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,51 +1,50 @@
|
||||
import { Injectable, computed, effect, inject, signal } from '@angular/core';
|
||||
import { Result } from '@shared/kernel/fp';
|
||||
import { Session, parseStoredSession } from '../domain/session';
|
||||
import { DigidAdapter } from '../infrastructure/digid.adapter';
|
||||
import { Principal, parseStoredPrincipal } from '../domain/principal';
|
||||
import { MedewerkerAdapter } from '../infrastructure/medewerker.adapter';
|
||||
|
||||
const STORAGE_KEY = 'session-v1';
|
||||
|
||||
/** Restore a persisted session (best-effort; corrupt entry → logged out).
|
||||
The parse + shape validation (G1/G2) lives in `parseStoredSession`
|
||||
(`../domain/session`) — pure, spec'd, and testable without stubbing
|
||||
`localStorage`; this just supplies the raw value. */
|
||||
function restore(): Session | null {
|
||||
return parseStoredSession(localStorage.getItem(STORAGE_KEY));
|
||||
/** Restore a persisted principal (best-effort; corrupt entry → logged out).
|
||||
The shape validation (G2 — there is no BSN here, so no G1 to enforce) lives in
|
||||
`parseStoredPrincipal` (`../domain/principal`) — pure, spec'd, and testable
|
||||
without stubbing `localStorage`; this just supplies the raw value. */
|
||||
function restore(): Principal | null {
|
||||
return parseStoredPrincipal(localStorage.getItem(STORAGE_KEY));
|
||||
}
|
||||
|
||||
/**
|
||||
* Holds the current session for the whole app. Because it is providedIn:'root'
|
||||
* there is exactly one instance — every component that injects it sees the same
|
||||
* session signal, so logging in is instantly visible everywhere (the guard, the
|
||||
* header, etc.). The session is mirrored to localStorage so a refresh, a deep-link,
|
||||
* or the full-page navigation the language switch performs (nl at `/` ⇄ en at `/en/`,
|
||||
* separate bundles) keeps you logged in. ponytail: localStorage, not sessionStorage —
|
||||
* sessionStorage's per-tab clearing dropped the login on the cross-bundle language
|
||||
* switch. Trade-off: the demo session now survives tab close; a real portal keeps auth
|
||||
* in an httpOnly cookie/token, not web storage.
|
||||
* Holds the current medewerker principal for the whole backoffice app. One
|
||||
* `providedIn: 'root'` instance, so logging in is instantly visible everywhere
|
||||
* (the guard, the header). Persisted to localStorage — a refresh or the
|
||||
* cross-bundle language switch (nl at `/` ⇄ en at `/en/`) keeps you logged in —
|
||||
* which is safe to do verbatim here: a medewerker principal carries no BSN or
|
||||
* other national identifier, unlike the SSP's `SessionStore`, whose equivalent
|
||||
* comment explains why *that* app strips a field before writing. A real
|
||||
* deployment keeps auth in an httpOnly cookie/token, not web storage, regardless.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class SessionStore {
|
||||
private digid = inject(DigidAdapter);
|
||||
private _session = signal<Session | null>(restore());
|
||||
private medewerker = inject(MedewerkerAdapter);
|
||||
private _session = signal<Principal | null>(restore());
|
||||
|
||||
readonly session = this._session.asReadonly();
|
||||
readonly isAuthenticated = computed(() => this._session() !== null);
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
const s = this._session();
|
||||
// G1: persist only `naam` — never write the BSN (national ID) to storage.
|
||||
if (s) localStorage.setItem(STORAGE_KEY, JSON.stringify({ naam: s.naam }));
|
||||
const p = this._session();
|
||||
if (p) localStorage.setItem(STORAGE_KEY, JSON.stringify(p));
|
||||
else localStorage.removeItem(STORAGE_KEY);
|
||||
});
|
||||
}
|
||||
|
||||
/** Effectful command: authenticate, then store the session on success. */
|
||||
async login(bsn: string): Promise<Result<string, Session>> {
|
||||
const r = await this.digid.authenticate(bsn);
|
||||
if (r.ok) this._session.set(r.value);
|
||||
return r;
|
||||
/** Effectful command: authenticate via the SSO stand-in, then store the
|
||||
resulting principal. No credential to pass in, and nothing that can fail
|
||||
today — see `MedewerkerAdapter`. */
|
||||
async login(): Promise<Principal> {
|
||||
const p = await this.medewerker.authenticate();
|
||||
this._session.set(p);
|
||||
return p;
|
||||
}
|
||||
|
||||
logout() {
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { isAuthenticated, parseRollen, parseStoredPrincipal, Principal } from './principal';
|
||||
|
||||
const principal: Principal = {
|
||||
kind: 'medewerker',
|
||||
medewerkerId: 'medewerker-1',
|
||||
naam: 'Test',
|
||||
rollen: ['behandelaar'],
|
||||
};
|
||||
|
||||
describe('isAuthenticated', () => {
|
||||
it('narrows a present principal to Principal', () => {
|
||||
expect(isAuthenticated(principal)).toBe(true);
|
||||
});
|
||||
|
||||
it('reports no principal as not authenticated', () => {
|
||||
expect(isAuthenticated(null)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseStoredPrincipal', () => {
|
||||
it('returns null when nothing is stored', () => {
|
||||
expect(parseStoredPrincipal(null)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for a non-JSON string', () => {
|
||||
expect(parseStoredPrincipal('not json')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when the stored shape is wrong (no naam)', () => {
|
||||
expect(
|
||||
parseStoredPrincipal(JSON.stringify({ kind: 'medewerker', medewerkerId: 'medewerker-1' })),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when kind is not medewerker', () => {
|
||||
expect(
|
||||
parseStoredPrincipal(
|
||||
JSON.stringify({
|
||||
kind: 'zorgverlener',
|
||||
medewerkerId: 'medewerker-1',
|
||||
naam: 'Test',
|
||||
rollen: [],
|
||||
}),
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when rollen holds an unrecognized token', () => {
|
||||
expect(
|
||||
parseStoredPrincipal(
|
||||
JSON.stringify({
|
||||
kind: 'medewerker',
|
||||
medewerkerId: 'medewerker-1',
|
||||
naam: 'Test',
|
||||
rollen: ['geen'],
|
||||
}),
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('restores a well-shaped stored principal as-is (no BSN to strip)', () => {
|
||||
const restored = parseStoredPrincipal(JSON.stringify(principal));
|
||||
expect(restored).toEqual(principal);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseRollen', () => {
|
||||
it('parses a single recognized rol', () => {
|
||||
expect(parseRollen('behandelaar')).toEqual(['behandelaar']);
|
||||
});
|
||||
|
||||
it('is case-insensitive and trims whitespace', () => {
|
||||
expect(parseRollen(' Behandelaar , behandelaar ')).toEqual(['behandelaar', 'behandelaar']);
|
||||
});
|
||||
|
||||
it('drops unrecognized tokens (the deny-path toggle, e.g. ?rollen=geen)', () => {
|
||||
expect(parseRollen('geen')).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns an empty list for an empty string', () => {
|
||||
expect(parseRollen('')).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Who is logged in. Framework-free domain type.
|
||||
*
|
||||
* The `medewerker` variant of ADR-0002 §3's `Principal` union — the backoffice has
|
||||
* exactly one actor kind (an employee, authenticated via SSO), so this app's own copy
|
||||
* of the union only ever holds this one member. Unlike the SSP's `zorgverlener`
|
||||
* variant, there is no BSN: a Behandelaar is not a citizen, and §3 names this
|
||||
* unrepresentable-by-construction distinction as the whole point of the union.
|
||||
* `rollen` is the FE-visible echo of the same dev stand-in `medewerker.interceptor.ts`
|
||||
* already stamps onto every backend request — it does not itself grant anything;
|
||||
* `AccessStore`/`GET /me` (server-resolved capabilities) is still the sole authority
|
||||
* on what this principal may do (ADR-0001, ADR-0002 §3).
|
||||
*/
|
||||
export type Rol = 'behandelaar';
|
||||
|
||||
const ROLLEN: readonly Rol[] = ['behandelaar'];
|
||||
export const isRol = (v: unknown): v is Rol => typeof v === 'string' && ROLLEN.includes(v as Rol);
|
||||
|
||||
export interface Principal {
|
||||
readonly kind: 'medewerker';
|
||||
readonly medewerkerId: string;
|
||||
readonly naam: string;
|
||||
readonly rollen: readonly Rol[];
|
||||
}
|
||||
|
||||
export function isAuthenticated(p: Principal | null): p is Principal {
|
||||
return p !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn the raw `X-Rollen` stand-in value (`medewerker.ts`'s `currentRollen()`) into
|
||||
* typed `Rol[]`, mirroring the backend's own `StubIdentityProvider.ParseRollen`:
|
||||
* comma-separated, case-insensitive, unrecognized tokens dropped — so
|
||||
* `?rollen=geen` (the deny-path toggle) yields an empty list here too, rather than
|
||||
* a fabricated recognized role. Pure so `MedewerkerAdapter` (infrastructure) can
|
||||
* stay a thin wire-up instead of holding logic of its own.
|
||||
*/
|
||||
export function parseRollen(raw: string): Rol[] {
|
||||
return raw
|
||||
.split(',')
|
||||
.map((t) => t.trim().toLowerCase())
|
||||
.filter(isRol);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a persisted principal out of a raw `localStorage` string (best-effort;
|
||||
* anything that isn't a well-shaped record → logged out). G2: validate the shape
|
||||
* before trusting it. Unlike the zorgverlener variant there is no G1 field to strip
|
||||
* — a medewerker carries no national identifier — so a well-shaped record is
|
||||
* restored as-is rather than reconstructed field-by-field.
|
||||
*/
|
||||
export function parseStoredPrincipal(raw: string | null): Principal | null {
|
||||
try {
|
||||
if (!raw) return null;
|
||||
const parsed = JSON.parse(raw) as Partial<Principal>;
|
||||
return parsed?.kind === 'medewerker' &&
|
||||
typeof parsed.medewerkerId === 'string' &&
|
||||
typeof parsed.naam === 'string' &&
|
||||
Array.isArray(parsed.rollen) &&
|
||||
parsed.rollen.every(isRol)
|
||||
? {
|
||||
kind: 'medewerker',
|
||||
medewerkerId: parsed.medewerkerId,
|
||||
naam: parsed.naam,
|
||||
rollen: parsed.rollen,
|
||||
}
|
||||
: null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { isAuthenticated, parseStoredSession, Session } from './session';
|
||||
|
||||
const session: Session = { bsn: '19012345601', naam: 'Test' };
|
||||
|
||||
describe('isAuthenticated', () => {
|
||||
it('narrows a present session to Session', () => {
|
||||
expect(isAuthenticated(session)).toBe(true);
|
||||
});
|
||||
|
||||
it('reports no session as not authenticated', () => {
|
||||
expect(isAuthenticated(null)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseStoredSession', () => {
|
||||
it('returns null when nothing is stored', () => {
|
||||
expect(parseStoredSession(null)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for a non-JSON string', () => {
|
||||
expect(parseStoredSession('not json')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when the stored shape is wrong (no naam)', () => {
|
||||
expect(parseStoredSession(JSON.stringify({ bsn: '19012345601' }))).toBeNull();
|
||||
});
|
||||
|
||||
it('G1: a stored bsn is never restored, even if present in the raw value', () => {
|
||||
const restored = parseStoredSession(JSON.stringify({ bsn: '19012345601', naam: 'Test' }));
|
||||
expect(restored).toEqual({ bsn: '', naam: 'Test' });
|
||||
});
|
||||
});
|
||||
@@ -1,27 +0,0 @@
|
||||
/** Who is logged in. Framework-free domain type. */
|
||||
export interface Session {
|
||||
readonly bsn: string;
|
||||
readonly naam: string;
|
||||
}
|
||||
|
||||
export function isAuthenticated(s: Session | null): s is Session {
|
||||
return s !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a persisted session out of a raw `localStorage` string (best-effort;
|
||||
* anything that isn't a well-shaped record → logged out). G2: validate the
|
||||
* shape before trusting it. G1: even if a stored entry carries a `bsn`, the
|
||||
* restored session's `bsn` is always `''` — the BSN is never persisted (see
|
||||
* the `SessionStore` effect that writes it), so a legacy or tampered entry
|
||||
* cannot resurrect one.
|
||||
*/
|
||||
export function parseStoredSession(raw: string | null): Session | null {
|
||||
try {
|
||||
if (!raw) return null;
|
||||
const parsed = JSON.parse(raw) as Partial<Session>;
|
||||
return typeof parsed?.naam === 'string' ? { bsn: '', naam: parsed.naam } : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { Result, ok } from '@shared/kernel/fp';
|
||||
import { parseBsn } from '@shared/kernel/bsn';
|
||||
import { Session } from '../domain/session';
|
||||
|
||||
/** Infrastructure: talks to the (mock) DigiD identity provider. */
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class DigidAdapter {
|
||||
// ponytail: fake DigiD — any elfproef-valid BSN authenticates to a fixed identity.
|
||||
// Real BSN validation (parseBsn, WP-40) is the trust boundary; swap the fixed identity
|
||||
// for a real OIDC redirect flow when there's an IdP.
|
||||
async authenticate(bsn: string): Promise<Result<string, Session>> {
|
||||
const r = parseBsn(bsn);
|
||||
return r.ok ? ok({ bsn: r.value, naam: 'Dr. A. (Anna) de Vries' }) : r;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { Principal, parseRollen } from '../domain/principal';
|
||||
import { MEDEWERKER_ID, currentRollen } from './medewerker';
|
||||
|
||||
/**
|
||||
* Infrastructure: resolves the current medewerker identity into a `Principal`
|
||||
* (ADR-C-004/RB-13). Stands in for a real employee-SSO redirect flow (ADR-0002 §3,
|
||||
* "out of scope here") — there is no credential to enter and, unlike `DigidAdapter`'s
|
||||
* BSN check, no format to reject, so `authenticate()` takes no input and returns the
|
||||
* `Principal` directly rather than a `Result` with an error variant that can never
|
||||
* actually occur. A real SSO callback (which *can* fail — session expired, access
|
||||
* denied) swaps in behind this same method; that is the point where this return
|
||||
* type would gain a `Result`, not before.
|
||||
*
|
||||
* Resolves the same `MEDEWERKER_ID` + `currentRollen()` the dev-only
|
||||
* `medewerkerInterceptor` already stamps onto every backend request as
|
||||
* `X-Medewerker`/`X-Rollen` — this only makes that identity visible on the
|
||||
* frontend (the guard, the header, `SessionStore`'s persisted principal), it does
|
||||
* not change what the backend resolves or authorizes.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class MedewerkerAdapter {
|
||||
// ponytail: fake employee SSO — a fixed medewerker, no credential exchange.
|
||||
async authenticate(): Promise<Principal> {
|
||||
return {
|
||||
kind: 'medewerker',
|
||||
medewerkerId: MEDEWERKER_ID,
|
||||
naam: 'H. (Hassan) Bakker',
|
||||
rollen: parseRollen(currentRollen()),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,50 +1,27 @@
|
||||
import { Component, output } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { FormFieldComponent } from '@shared/ui/form-field/form-field.component';
|
||||
import { TextInputComponent } from '@shared/ui/text-input/text-input.component';
|
||||
import { ButtonComponent } from '@shared/ui/button/button.component';
|
||||
|
||||
/** Organism: DigiD-style mock login. No real auth — just composes atoms/molecules. */
|
||||
/**
|
||||
* Organism: employee-SSO-style mock login (ADR-C-004/RB-13). No real auth — and,
|
||||
* unlike the SSP's DigiD form, no credential to enter at all: a Behandelaar has no
|
||||
* BSN, and this app has no password of its own to check either way. There is
|
||||
* nothing to compose beyond one button, which is itself evidence for the ADR — the
|
||||
* two apps' login flows are meant to look this different.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-login-form',
|
||||
imports: [FormsModule, FormFieldComponent, TextInputComponent, ButtonComponent],
|
||||
imports: [ButtonComponent],
|
||||
template: `
|
||||
<form (ngSubmit)="submitted.emit(bsn)" class="form-horizontal">
|
||||
<div class="form-header">
|
||||
<div class="form-action">
|
||||
<span class="meta" i18n="@@form.verplichteVelden">* verplichte velden</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<app-form-field
|
||||
i18n-label="@@login.bsnLabel"
|
||||
label="BSN"
|
||||
fieldId="bsn"
|
||||
required
|
||||
i18n-description="@@login.bsnDescription"
|
||||
description="9-cijferig BSN, elfproef-geldig (demo: 123456782)"
|
||||
>
|
||||
<app-text-input
|
||||
inputId="bsn"
|
||||
hasDescription
|
||||
[(ngModel)]="bsn"
|
||||
name="bsn"
|
||||
placeholder="123456782"
|
||||
/>
|
||||
</app-form-field>
|
||||
|
||||
<app-form-field i18n-label="@@login.wachtwoordLabel" label="Wachtwoord" fieldId="pw" required>
|
||||
<app-text-input inputId="pw" type="password" [(ngModel)]="password" name="pw" />
|
||||
</app-form-field>
|
||||
|
||||
<app-button type="submit" variant="primary" i18n="@@login.submit"
|
||||
>Inloggen met DigiD</app-button
|
||||
>
|
||||
</form>
|
||||
<div class="form-horizontal">
|
||||
<p i18n="@@login.ssoExplainer">
|
||||
U meldt zich aan via de SSO van uw organisatie — er is geen wachtwoord nodig.
|
||||
</p>
|
||||
<app-button type="button" variant="primary" (click)="submitted.emit()" i18n="@@login.submit">
|
||||
Inloggen met SSO
|
||||
</app-button>
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
export class LoginFormComponent {
|
||||
bsn = '';
|
||||
password = '';
|
||||
submitted = output<string>();
|
||||
submitted = output<void>();
|
||||
}
|
||||
|
||||
@@ -1,36 +1,35 @@
|
||||
import { Component, inject, signal } from '@angular/core';
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component';
|
||||
import { AlertComponent } from '@shared/ui/alert/alert.component';
|
||||
import { LoginFormComponent } from '@auth/ui/login-form/login-form.component';
|
||||
import { SessionStore } from '@auth/application/session.store';
|
||||
|
||||
/**
|
||||
* No error alert here — unlike the SSP's DigiD form, `SessionStore.login()` has
|
||||
* nothing to fail on (see `MedewerkerAdapter`). A real SSO integration is where
|
||||
* this page would grow one back.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-login-page',
|
||||
imports: [PageShellComponent, AlertComponent, LoginFormComponent],
|
||||
imports: [PageShellComponent, LoginFormComponent],
|
||||
template: `
|
||||
<app-page-shell
|
||||
i18n-heading="@@login.heading"
|
||||
heading="Inloggen"
|
||||
heading="Inloggen bij het behandelportal"
|
||||
width="narrow"
|
||||
i18n-intro="@@login.intro"
|
||||
intro="Log in op uw persoonlijke BIG-register omgeving."
|
||||
intro="Voor medewerkers die aanvragen beoordelen."
|
||||
>
|
||||
@if (error()) {
|
||||
<app-alert type="error">{{ error() }}</app-alert>
|
||||
}
|
||||
<app-login-form (submitted)="login($event)" />
|
||||
<app-login-form (submitted)="login()" />
|
||||
</app-page-shell>
|
||||
`,
|
||||
})
|
||||
export class LoginPage {
|
||||
private store = inject(SessionStore);
|
||||
private router = inject(Router);
|
||||
error = signal('');
|
||||
|
||||
async login(bsn: string) {
|
||||
const r = await this.store.login(bsn);
|
||||
if (r.ok) this.router.navigate(['/dashboard']);
|
||||
else this.error.set(r.error);
|
||||
async login() {
|
||||
await this.store.login();
|
||||
this.router.navigate(['/dashboard']);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,65 +26,33 @@
|
||||
<context context-type="linenumber">27</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="form.verplichteVelden" datatype="html">
|
||||
<source>* verplichte velden</source>
|
||||
<target datatype="html">* required fields</target>
|
||||
<trans-unit id="login.ssoExplainer" datatype="html">
|
||||
<source>U meldt zich aan via de SSO van uw organisatie — er is geen wachtwoord nodig.</source>
|
||||
<target datatype="html">You sign in through your organization's SSO — no password is needed.</target>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/auth/ui/login-form/login-form.component.ts</context>
|
||||
<context context-type="linenumber">15,18</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/registratie/ui/change-request-form/change-request-form.component.ts</context>
|
||||
<context context-type="linenumber">44,46</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/shared/layout/wizard-shell/wizard-shell.component.ts</context>
|
||||
<context context-type="linenumber">90,92</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="login.bsnLabel" datatype="html">
|
||||
<source>BSN</source>
|
||||
<target datatype="html">BSN</target>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/auth/ui/login-form/login-form.component.ts</context>
|
||||
<context context-type="linenumber">22,23</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="login.bsnDescription" datatype="html">
|
||||
<source>9-cijferig BSN, elfproef-geldig (demo: 123456782)</source>
|
||||
<target datatype="html">9-digit BSN, valid eleven-test checksum (demo: 123456782)</target>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/auth/ui/login-form/login-form.component.ts</context>
|
||||
<context context-type="linenumber">25,28</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="login.wachtwoordLabel" datatype="html">
|
||||
<source>Wachtwoord</source>
|
||||
<target datatype="html">Password</target>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/auth/ui/login-form/login-form.component.ts</context>
|
||||
<context context-type="linenumber">36,37</context>
|
||||
<context context-type="linenumber">17,19</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="login.submit" datatype="html">
|
||||
<source>Inloggen met DigiD</source>
|
||||
<target datatype="html">Log in with DigiD</target>
|
||||
<source>Inloggen met SSO</source>
|
||||
<target datatype="html">Log in with SSO</target>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/auth/ui/login-form/login-form.component.ts</context>
|
||||
<context context-type="linenumber">41,43</context>
|
||||
<context context-type="linenumber">20,21</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="login.heading" datatype="html">
|
||||
<source>Inloggen</source>
|
||||
<target datatype="html">Log in</target>
|
||||
<source>Inloggen bij het behandelportal</source>
|
||||
<target datatype="html">Log in to the treatment portal</target>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/auth/ui/login.page.ts</context>
|
||||
<context context-type="linenumber">14,16</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="login.intro" datatype="html">
|
||||
<source>Log in op uw persoonlijke BIG-register omgeving.</source>
|
||||
<target datatype="html">Log in to your personal BIG register environment.</target>
|
||||
<source>Voor medewerkers die aanvragen beoordelen.</source>
|
||||
<target datatype="html">For staff who assess applications.</target>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/auth/ui/login.page.ts</context>
|
||||
<context context-type="linenumber">17,19</context>
|
||||
|
||||
@@ -1,48 +1,50 @@
|
||||
import { Injectable, computed, effect, inject, signal } from '@angular/core';
|
||||
import { Result } from '@shared/kernel/fp';
|
||||
import { Session, parseStoredSession } from '../domain/session';
|
||||
import { Principal, parseStoredPrincipal } from '../domain/principal';
|
||||
import { DigidAdapter } from '../infrastructure/digid.adapter';
|
||||
|
||||
const STORAGE_KEY = 'session-v1';
|
||||
|
||||
/** Restore a persisted session (best-effort; corrupt entry → logged out).
|
||||
The parse + shape validation (G1/G2) lives in `parseStoredSession`
|
||||
(`../domain/session`) — pure, spec'd, and testable without stubbing
|
||||
/** Restore a persisted principal (best-effort; corrupt entry → logged out).
|
||||
The parse + shape validation (G1/G2) lives in `parseStoredPrincipal`
|
||||
(`../domain/principal`) — pure, spec'd, and testable without stubbing
|
||||
`localStorage`; this just supplies the raw value. */
|
||||
function restore(): Session | null {
|
||||
return parseStoredSession(localStorage.getItem(STORAGE_KEY));
|
||||
function restore(): Principal | null {
|
||||
return parseStoredPrincipal(localStorage.getItem(STORAGE_KEY));
|
||||
}
|
||||
|
||||
/**
|
||||
* Holds the current session for the whole app. Because it is providedIn:'root'
|
||||
* there is exactly one instance — every component that injects it sees the same
|
||||
* session signal, so logging in is instantly visible everywhere (the guard, the
|
||||
* header, etc.). The session is mirrored to localStorage so a refresh, a deep-link,
|
||||
* or the full-page navigation the language switch performs (nl at `/` ⇄ en at `/en/`,
|
||||
* separate bundles) keeps you logged in. ponytail: localStorage, not sessionStorage —
|
||||
* sessionStorage's per-tab clearing dropped the login on the cross-bundle language
|
||||
* switch. Trade-off: the demo session now survives tab close; a real portal keeps auth
|
||||
* in an httpOnly cookie/token, not web storage.
|
||||
* Holds the current zorgverlener principal for the whole SSP. One
|
||||
* `providedIn: 'root'` instance, so logging in is instantly visible everywhere
|
||||
* (the guard, the header). Persisted to localStorage — a refresh or the
|
||||
* cross-bundle language switch (nl at `/` ⇄ en at `/en/`) keeps you logged in —
|
||||
* but never the BSN itself (G1 in the `effect` below): this principal carries a
|
||||
* citizen's national identifier, which the behandelportal's equivalent store does
|
||||
* not have to guard against, because its `medewerker` principal has no BSN.
|
||||
* ponytail: localStorage, not sessionStorage — sessionStorage's per-tab clearing
|
||||
* dropped the login on the cross-bundle language switch. Trade-off: the demo
|
||||
* session now survives tab close; a real portal keeps auth in an httpOnly
|
||||
* cookie/token, not web storage.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class SessionStore {
|
||||
private digid = inject(DigidAdapter);
|
||||
private _session = signal<Session | null>(restore());
|
||||
private _session = signal<Principal | null>(restore());
|
||||
|
||||
readonly session = this._session.asReadonly();
|
||||
readonly isAuthenticated = computed(() => this._session() !== null);
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
const s = this._session();
|
||||
const p = this._session();
|
||||
// G1: persist only `naam` — never write the BSN (national ID) to storage.
|
||||
if (s) localStorage.setItem(STORAGE_KEY, JSON.stringify({ naam: s.naam }));
|
||||
if (p) localStorage.setItem(STORAGE_KEY, JSON.stringify({ naam: p.naam }));
|
||||
else localStorage.removeItem(STORAGE_KEY);
|
||||
});
|
||||
}
|
||||
|
||||
/** Effectful command: authenticate, then store the session on success. */
|
||||
async login(bsn: string): Promise<Result<string, Session>> {
|
||||
/** Effectful command: authenticate, then store the principal on success. */
|
||||
async login(bsn: string): Promise<Result<string, Principal>> {
|
||||
const r = await this.digid.authenticate(bsn);
|
||||
if (r.ok) this._session.set(r.value);
|
||||
return r;
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { isAuthenticated, parseStoredPrincipal, Principal } from './principal';
|
||||
|
||||
const principal: Principal = { kind: 'zorgverlener', bsn: '19012345601', naam: 'Test' };
|
||||
|
||||
describe('isAuthenticated', () => {
|
||||
it('narrows a present principal to Principal', () => {
|
||||
expect(isAuthenticated(principal)).toBe(true);
|
||||
});
|
||||
|
||||
it('reports no principal as not authenticated', () => {
|
||||
expect(isAuthenticated(null)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseStoredPrincipal', () => {
|
||||
it('returns null when nothing is stored', () => {
|
||||
expect(parseStoredPrincipal(null)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for a non-JSON string', () => {
|
||||
expect(parseStoredPrincipal('not json')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when the stored shape is wrong (no naam)', () => {
|
||||
expect(parseStoredPrincipal(JSON.stringify({ bsn: '19012345601' }))).toBeNull();
|
||||
});
|
||||
|
||||
it('G1: a stored bsn is never restored, even if present in the raw value', () => {
|
||||
const restored = parseStoredPrincipal(JSON.stringify({ bsn: '19012345601', naam: 'Test' }));
|
||||
expect(restored).toEqual({ kind: 'zorgverlener', bsn: '', naam: 'Test' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Who is logged in. Framework-free domain type.
|
||||
*
|
||||
* The `zorgverlener` variant of ADR-0002 §3's `Principal` union — the SSP has exactly
|
||||
* one actor kind (a citizen, authenticated via DigiD/BSN), so this app's own copy of
|
||||
* the union only ever holds this one member. `kind` is still a discriminant, not
|
||||
* decoration: it is what makes `apps/behandelportal`'s `medewerker` variant a
|
||||
* genuinely different type rather than a same-shaped coincidence, and what a future
|
||||
* third actor (§4 — admin/auditor/institution-rep) would add a member to.
|
||||
*/
|
||||
export interface Principal {
|
||||
readonly kind: 'zorgverlener';
|
||||
readonly bsn: string;
|
||||
readonly naam: string;
|
||||
}
|
||||
|
||||
export function isAuthenticated(p: Principal | null): p is Principal {
|
||||
return p !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a persisted principal out of a raw `localStorage` string (best-effort;
|
||||
* anything that isn't a well-shaped record → logged out). G2: validate the
|
||||
* shape before trusting it. G1: even if a stored entry carries a `bsn`, the
|
||||
* restored principal's `bsn` is always `''` — the BSN is never persisted (see
|
||||
* the `SessionStore` effect that writes it), so a legacy or tampered entry
|
||||
* cannot resurrect one.
|
||||
*/
|
||||
export function parseStoredPrincipal(raw: string | null): Principal | null {
|
||||
try {
|
||||
if (!raw) return null;
|
||||
const parsed = JSON.parse(raw) as Partial<Principal>;
|
||||
return typeof parsed?.naam === 'string'
|
||||
? { kind: 'zorgverlener', bsn: '', naam: parsed.naam }
|
||||
: null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { isAuthenticated, parseStoredSession, Session } from './session';
|
||||
|
||||
const session: Session = { bsn: '19012345601', naam: 'Test' };
|
||||
|
||||
describe('isAuthenticated', () => {
|
||||
it('narrows a present session to Session', () => {
|
||||
expect(isAuthenticated(session)).toBe(true);
|
||||
});
|
||||
|
||||
it('reports no session as not authenticated', () => {
|
||||
expect(isAuthenticated(null)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseStoredSession', () => {
|
||||
it('returns null when nothing is stored', () => {
|
||||
expect(parseStoredSession(null)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for a non-JSON string', () => {
|
||||
expect(parseStoredSession('not json')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when the stored shape is wrong (no naam)', () => {
|
||||
expect(parseStoredSession(JSON.stringify({ bsn: '19012345601' }))).toBeNull();
|
||||
});
|
||||
|
||||
it('G1: a stored bsn is never restored, even if present in the raw value', () => {
|
||||
const restored = parseStoredSession(JSON.stringify({ bsn: '19012345601', naam: 'Test' }));
|
||||
expect(restored).toEqual({ bsn: '', naam: 'Test' });
|
||||
});
|
||||
});
|
||||
@@ -1,27 +0,0 @@
|
||||
/** Who is logged in. Framework-free domain type. */
|
||||
export interface Session {
|
||||
readonly bsn: string;
|
||||
readonly naam: string;
|
||||
}
|
||||
|
||||
export function isAuthenticated(s: Session | null): s is Session {
|
||||
return s !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a persisted session out of a raw `localStorage` string (best-effort;
|
||||
* anything that isn't a well-shaped record → logged out). G2: validate the
|
||||
* shape before trusting it. G1: even if a stored entry carries a `bsn`, the
|
||||
* restored session's `bsn` is always `''` — the BSN is never persisted (see
|
||||
* the `SessionStore` effect that writes it), so a legacy or tampered entry
|
||||
* cannot resurrect one.
|
||||
*/
|
||||
export function parseStoredSession(raw: string | null): Session | null {
|
||||
try {
|
||||
if (!raw) return null;
|
||||
const parsed = JSON.parse(raw) as Partial<Session>;
|
||||
return typeof parsed?.naam === 'string' ? { bsn: '', naam: parsed.naam } : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { Result, ok } from '@shared/kernel/fp';
|
||||
import { parseBsn } from '@shared/kernel/bsn';
|
||||
import { Session } from '../domain/session';
|
||||
import { Principal } from '../domain/principal';
|
||||
|
||||
/** Infrastructure: talks to the (mock) DigiD identity provider. */
|
||||
@Injectable({ providedIn: 'root' })
|
||||
@@ -9,8 +9,8 @@ export class DigidAdapter {
|
||||
// ponytail: fake DigiD — any elfproef-valid BSN authenticates to a fixed identity.
|
||||
// Real BSN validation (parseBsn, WP-40) is the trust boundary; swap the fixed identity
|
||||
// for a real OIDC redirect flow when there's an IdP.
|
||||
async authenticate(bsn: string): Promise<Result<string, Session>> {
|
||||
async authenticate(bsn: string): Promise<Result<string, Principal>> {
|
||||
const r = parseBsn(bsn);
|
||||
return r.ok ? ok({ bsn: r.value, naam: 'Dr. A. (Anna) de Vries' }) : r;
|
||||
return r.ok ? ok({ kind: 'zorgverlener', bsn: r.value, naam: 'Dr. A. (Anna) de Vries' }) : r;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Component, Injector, computed, inject, isDevMode, signal } from '@angular/core';
|
||||
import { JsonPipe } from '@angular/common';
|
||||
import { SessionStore } from '@auth/application/session.store';
|
||||
import { Session } from '@auth/domain/session';
|
||||
import { Principal } from '@auth/domain/principal';
|
||||
import { BigProfileStore } from '@registratie/application/big-profile.store';
|
||||
import { map } from '@shared/application/remote-data';
|
||||
import { Role } from '@shared/domain/role';
|
||||
@@ -172,6 +172,6 @@ export class DebugStateComponent {
|
||||
}
|
||||
}
|
||||
|
||||
function maskSession(s: Session | null): Session | null {
|
||||
return s ? { ...s, bsn: maskBsn(s.bsn) } : null;
|
||||
function maskSession(p: Principal | null): Principal | null {
|
||||
return p ? { ...p, bsn: maskBsn(p.bsn) } : null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
# RB-13 — land `Session → Principal`; `MedewerkerAdapter`; the backoffice login stops being a DigiD/BSN form
|
||||
|
||||
Status: **implemented** · 2026-08-27 · Source findings: `06-adr-conformance.md` ADR-C-004 · `00-baseline.md` BL-002 · `docs/reference/architecture/0002-user-groups-and-bounded-contexts.md` §3, "Known debt" · `99-backlog.md` RB-13
|
||||
|
||||
## What was wrong
|
||||
|
||||
ADR-0002 §3 ("Separate identity from authorization") specifies a discriminated
|
||||
`Principal` union — `{ kind: 'zorgverlener'; bsn; naam } | { kind: 'medewerker';
|
||||
medewerkerId; naam; rollen }` — as "the one concrete FE change when actor #2 lands."
|
||||
Actor #2 (`apps/behandelportal`) landed in WP-61/67; the union did not follow.
|
||||
|
||||
Verified before this ticket:
|
||||
|
||||
- `grep -rn "Principal" apps libs` returned exactly one hit — a comment in
|
||||
`libs/shared/src/infrastructure/role.ts:8`. No such type existed.
|
||||
- `apps/ssp/src/app/auth/domain/session.ts` and
|
||||
`apps/behandelportal/src/app/auth/domain/session.ts` were byte-identical:
|
||||
`interface Session { readonly bsn: string; readonly naam: string }` — a Behandelaar
|
||||
carrying a `bsn`, which §3 names as precisely the state the union exists to make
|
||||
unrepresentable.
|
||||
- `apps/behandelportal/src/app/auth/ui/login.page.ts` rendered `intro="Log in op uw
|
||||
persoonlijke BIG-register omgeving."` and called `SessionStore.login(bsn)` →
|
||||
`DigidAdapter.authenticate(bsn)`, resolving `{ bsn: r.value, naam: 'Dr. A. (Anna) de
|
||||
Vries' }` — a backoffice employee logging into the backoffice as a citizen, by DigiD,
|
||||
under a citizen's name.
|
||||
- `apps/behandelportal/src/app/auth/infrastructure/medewerker.interceptor.ts` already
|
||||
stamps every backend request with `X-Medewerker`/`X-Rollen`, independently of
|
||||
`SessionStore` — the divergence ADR-0002 predicted took this orthogonal side door
|
||||
instead of the `Principal` union, which is why the two `auth` contexts still measured
|
||||
as identical.
|
||||
- `tools/baseline-scan.mjs --dup`, measured immediately before this ticket (after
|
||||
ADR-C-006 shared the route guards): `ssp/auth` 168/168 dup lines (100.0%),
|
||||
`bhp/auth` 168/200 (84.0%) — down from the original 211/211, but the WP-67 amendment's
|
||||
"auth stays duplicated because it's expected to diverge" claim had never actually been
|
||||
tested, only asserted.
|
||||
|
||||
RB-09 (a prerequisite, landed the day before) made the backend's `IIdentityProvider`
|
||||
able to say "no identity" and fail closed; this ticket is its stated FE half — without
|
||||
it, a production behandelportal falls through to the seeded zorgverlener by default,
|
||||
open on every citizen-scoped endpoint and holding `CanRevealBigNummer`. This ticket
|
||||
does not touch that backend behaviour — it makes the FE identity model honest about
|
||||
who is actually authenticating.
|
||||
|
||||
## What changed
|
||||
|
||||
| File | Change |
|
||||
| -------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `apps/ssp/src/app/auth/domain/session.ts` → `principal.ts` | `Session` → `Principal`, `{ kind: 'zorgverlener'; bsn; naam }`; `parseStoredSession` → `parseStoredPrincipal` (G1/G2 unchanged) |
|
||||
| `apps/ssp/src/app/auth/domain/session.spec.ts` → `principal.spec.ts` | renamed, updated to the `Principal`/`kind` shape |
|
||||
| `apps/ssp/src/app/auth/application/session.store.ts` | `Session` → `Principal`; header doc rewritten to state _why_ G1 applies here and not in behandelportal (cross-reference, not shared prose) |
|
||||
| `apps/ssp/src/app/auth/infrastructure/digid.adapter.ts` | resolves `{ kind: 'zorgverlener', bsn, naam }` |
|
||||
| `apps/ssp/src/app/shell/debug-state/debug-state.component.ts` | `Session` → `Principal` (the one other consumer of the domain type) |
|
||||
| `apps/behandelportal/src/app/auth/domain/session.ts` → `principal.ts` | new `medewerker` variant: `{ kind: 'medewerker'; medewerkerId; naam; rollen: readonly Rol[] }`; `parseStoredPrincipal` validates the full shape (no BSN to strip — G2 only); new `parseRollen(raw): Rol[]`, mirroring the backend's `StubIdentityProvider.ParseRollen` (comma-separated, case-insensitive, unrecognized tokens dropped) |
|
||||
| `apps/behandelportal/src/app/auth/domain/session.spec.ts` → `principal.spec.ts` | rewritten: `isAuthenticated`, `parseStoredPrincipal` (5 cases including "kind is not medewerker" and "unrecognized rol"), `parseRollen` (4 cases) |
|
||||
| `apps/behandelportal/src/app/auth/infrastructure/digid.adapter.ts` → `medewerker.adapter.ts` | **new `MedewerkerAdapter`** — resolves `MEDEWERKER_ID` + `currentRollen()` (`medewerker.ts`, unchanged) into a `Principal`; no input, returns the `Principal` directly (no `Result` — there is nothing for this stand-in to fail on) |
|
||||
| `apps/behandelportal/src/app/auth/application/session.store.ts` | `MedewerkerAdapter` replaces `DigidAdapter`; `login()` takes no argument; the whole principal round-trips through `localStorage` (no G1 field to strip); header doc rewritten, cross-referencing the SSP's instead of repeating it |
|
||||
| `apps/behandelportal/src/app/auth/ui/login-form/login-form.component.ts` | rewritten: no BSN/wachtwoord fields — one explainer line + one "Inloggen met SSO" button, `submitted = output<void>()` |
|
||||
| `apps/behandelportal/src/app/auth/ui/login.page.ts` | new heading/intro copy ("Inloggen bij het behandelportal" / "Voor medewerkers die aanvragen beoordelen."); `login()` takes no argument; the error-alert branch is gone (nothing can fail) |
|
||||
| `apps/behandelportal/src/locale/messages.en.xlf` | new id `login.ssoExplainer`; `login.submit`/`login.heading`/`login.intro` updated to the new source text + English target; `login.bsnLabel`/`bsnDescription`/`wachtwoordLabel`/`form.verplichteVelden` removed (no longer reachable from this app — confirmed by grep and by a trial `extract-i18n:behandelportal` run) |
|
||||
| `libs/shared/src/infrastructure/subject.ts`, `subject.interceptor.ts` | doc comments: `` `Session.bsn` `` → `` `Principal.bsn` `` (the type these comments cite renamed; the design they describe — `libs/shared` can't reach an app-local `auth` context, so `?subject=` exists instead — is unchanged) |
|
||||
| `docs/reference/architecture/0002-user-groups-and-bounded-contexts.md` | new "Amendment (RB-13, 2026-08-27)" replacing the "Known debt" section it closes out; records what landed and the re-measured duplication figure |
|
||||
| `libs/shared/docs/behaviour-spec.mdx` | regenerated (`npm run gen:behaviour-spec`) — reflects the renamed spec titles and the new `parseRollen`/medewerker `parseStoredPrincipal` cases |
|
||||
|
||||
## Judgement calls
|
||||
|
||||
- **Each app's `Principal` holds only the one variant it has an actor for**, not the
|
||||
full two-member union ADR-0002 §3 writes as a single illustrative type. The ADR's own
|
||||
proposed resolution under ADR-C-004 says this explicitly ("In `apps/behandelportal`:
|
||||
replace `Session` with the `medewerker` variant … In `apps/ssp`: the `zorgverlener`
|
||||
variant"), and it matches how the codebase already splits `auth` per app. `kind` stays
|
||||
on both single-member types anyway — it is what makes the two types genuinely
|
||||
different rather than a same-shaped coincidence, and it is where a third actor (§4 —
|
||||
admin/auditor/institution-rep) would add a member.
|
||||
- **`MedewerkerAdapter.authenticate()` returns `Promise<Principal>`, not
|
||||
`Promise<Result<string, Principal>>`.** The first draft mirrored `DigidAdapter`'s
|
||||
`Result`-returning shape for symmetry, but that `Result`'s error variant could never
|
||||
actually be produced — there is no credential to check, so wrapping the return in a
|
||||
type that claims to have a failure mode was itself a small instance of the thing
|
||||
CLAUDE.md §3 warns against (representing a state that can't happen). Reverted to a
|
||||
direct `Promise<Principal>` and dropped the now-dead error-handling branch from
|
||||
`login.page.ts` (`error` signal, the `<app-alert type="error">`, the `AlertComponent`
|
||||
import) — a real SSO integration is where that branch would come back, not before.
|
||||
This was also the change that did the most to bring the duplication figure down (see
|
||||
below): `login.page.ts`'s 7-window overlap with the SSP's disappeared once the two
|
||||
pages' control flow, not just their copy, actually differed.
|
||||
- **`rollen` is typed `readonly Rol[]` with `Rol = 'behandelaar'`, and `parseRollen`
|
||||
lives in `domain/`, not the adapter.** The raw `currentRollen()` stand-in returns an
|
||||
unvalidated string (`medewerker.ts`, untouched by this ticket); turning it into typed
|
||||
`Rol[]` is pure string logic with no Angular dependency, so it belongs in
|
||||
`domain/principal.ts` per CLAUDE.md §1's layer table — the adapter (`infrastructure/`)
|
||||
stays a thin wire-up that only reaches for `MEDEWERKER_ID`/`currentRollen()` and
|
||||
hands them to a pure function. `parseRollen` deliberately mirrors the backend's own
|
||||
`StubIdentityProvider.ParseRollen` (comma-separated, unrecognized tokens dropped, so
|
||||
`?rollen=geen` yields `[]`) — this is not the FE recomputing a business rule
|
||||
(ADR-0001's boundary is about _authorization decisions_, which still come only from
|
||||
`GET /me`/`AccessStore`); it is the FE's own dev-only identity stand-in echoing the
|
||||
same header value it is about to send, for display, the same way `DigidAdapter`
|
||||
already fabricates its own fake identity.
|
||||
- **`SessionStore` (bhp) persists the whole `Principal` to `localStorage`, not a
|
||||
stripped-down `{ naam }` copy.** The SSP's G1 guarantee ("never persist the BSN")
|
||||
doesn't apply here — a `medewerker` principal has no national identifier — so there is
|
||||
nothing to strip. `parseStoredPrincipal` validates the full shape (G2 only) and
|
||||
restores it as-is. This was a deliberate choice against an alternative: reconstructing
|
||||
`medewerkerId`/`rollen` from the live `MEDEWERKER_ID`/`currentRollen()` on every
|
||||
restore, which would have made `domain/principal.ts` depend on
|
||||
`infrastructure/medewerker.ts` — backwards per CLAUDE.md §1's inward-only dependency
|
||||
rule, and it would have made `parseStoredPrincipal` impure. Consequence: changing
|
||||
`?rollen=` mid-session does not retroactively change an already-restored `Principal`
|
||||
until the next `login()`/`logout()` — the same way changing the DigiD demo BSN
|
||||
requires a fresh login in the SSP. The backend's own authorization is unaffected
|
||||
either way, since `medewerkerInterceptor` reads `currentRollen()` fresh on every HTTP
|
||||
request regardless of what `SessionStore` holds.
|
||||
- **Session/store class names (`SessionStore`, `SESSION_PORT`, `SessionPort`) were left
|
||||
unchanged.** ADR-0002 §3's own Consequences section names `SessionStore` — alongside
|
||||
`auth.guard.ts` — as one of the _seams that localise_ the `Session → Principal` change,
|
||||
not as something the change renames. `libs/shared/src/application/session.port.ts`'s
|
||||
`SessionPort` (ADR-C-006) is unaffected: it only ever exposed `{ naam }` and
|
||||
`isAuthenticated`, neither of which is `kind`-dependent.
|
||||
- **`libs/shared/src/infrastructure/subject.ts`/`subject.interceptor.ts` doc comments
|
||||
updated, code untouched.** Both cite `` `Session.bsn` `` by name to explain why
|
||||
`?subject=` exists instead of reading the store directly; renaming the type these
|
||||
comments describe without updating the comment would have left them citing a type
|
||||
that no longer exists.
|
||||
- **`auth.guard.ts`'s verbatim re-export in both apps was left alone.** ADR-C-006 is
|
||||
explicit that a route guard is actor-agnostic and out of ADR-0002 §3's scope — it
|
||||
reads only `SESSION_PORT`/`AccessStore`, never `Principal`, so there was nothing for
|
||||
this ticket to change there.
|
||||
- **No backend change.** RB-09 already made `IIdentityProvider` nullable and
|
||||
Production-fail-fast; this ticket is purely the frontend counterpart it named. The
|
||||
residual RB-09 flagged (`GET /uploads/{documentId}/content`'s plain-navigation
|
||||
callers carrying no identity header once a real, non-stub `IIdentityProvider` exists)
|
||||
is unaffected by anything here — it is about a _future_ real provider replacing the
|
||||
Development-only stub, which this ticket does not touch.
|
||||
|
||||
## Duplication, measured (`tools/baseline-scan.mjs --dup`)
|
||||
|
||||
| When | `ssp/auth` dup lines | `bhp/auth` dup lines |
|
||||
| ----------------------------------- | -------------------: | -------------------: |
|
||||
| Before ADR-C-006 (baseline, BL-002) | 211/211 (100%) | — |
|
||||
| After ADR-C-006, before this ticket | 168/168 (100.0%) | 168/200 (84.0%) |
|
||||
| **After this ticket** | **32/179 (17.9%)** | **32/259 (12.4%)** |
|
||||
|
||||
Expected by the backlog: "<40 after this." Measured: **32 lines each side** — under
|
||||
target. The full clone-pair listing (the script's own output truncates to the top 15
|
||||
pairs repo-wide; re-run with the pair filter widened to confirm nothing auth-related was
|
||||
hiding below that cut) resolves to exactly four remaining pairs:
|
||||
|
||||
- `principal.spec.ts` (6 windows) — both files test the same G2 "validate before
|
||||
trusting a stored shape" concept with a parallel `describe`/`it` structure (including
|
||||
the shared `import { describe, it, expect } from 'vitest';` line); the assertions
|
||||
themselves differ (BSN-stripping vs. kind/rollen validation).
|
||||
- `login-form.stories.ts` (3 windows) — the generic Storybook `Meta`/`StoryObj`/`Default`
|
||||
scaffold, unavoidable for any two co-located `.stories.ts` files regardless of subject.
|
||||
- `auth.guard.ts` (2 windows) — the intentional verbatim re-export (ADR-C-006); this is
|
||||
meant to stay identical.
|
||||
- `session.store.ts` (1 window) — down from 33 windows before this ticket to one small
|
||||
shared fragment (the `@Injectable`/signal/`asReadonly`/`computed` wiring any root
|
||||
singleton store in this codebase shares).
|
||||
|
||||
None of what remains is re-converged identity or login-flow logic — the domain type,
|
||||
the adapter, and the login UI all now differ in kind, not just in copy. §3's prediction
|
||||
("the two groups authenticate differently") has been tested for the first time by this
|
||||
ticket, not just asserted, and it held.
|
||||
|
||||
## Verification
|
||||
|
||||
Confirmed each non-trivial change is red without its fix (edited in place, verified red,
|
||||
edited back — never `git checkout`):
|
||||
|
||||
- **ssp `parseStoredPrincipal` (G1):** changed `bsn: ''` to `bsn: parsed.bsn ?? ''` →
|
||||
`G1: a stored bsn is never restored…` failed with `expected { bsn: '19012345601', …}
|
||||
to deeply equal { bsn: '', … }`. Reverted; all other tests unaffected.
|
||||
- **bhp `parseStoredPrincipal` (kind guard):** dropped the `parsed?.kind === 'medewerker'`
|
||||
clause → `returns null when kind is not medewerker` failed, returning the parsed
|
||||
zorgverlener-shaped object instead of `null`. Reverted.
|
||||
- **bhp `parseRollen`:** dropped `.filter(isRol)` → `drops unrecognized tokens` and
|
||||
`returns an empty list for an empty string` both failed (`['geen']`/`['']` returned
|
||||
instead of `[]`). Reverted.
|
||||
|
||||
`npm test` (both apps + both libraries): all green, 258 (ssp) + 37 (behandelportal) +
|
||||
133 (shared) + 23 (beheer) tests passing, including the new/renamed auth specs.
|
||||
`npm run lint`: clean. `npm run dep:check`: 0 violations, both apps. `ng build ssp
|
||||
--localize` and `ng build behandelportal --localize`: both succeed (the new
|
||||
`login.ssoExplainer` id and the updated `login.submit`/`login.heading`/`login.intro`
|
||||
sources all resolve to an English `<target>`). `npm run ci`: green (see the commit this
|
||||
doc ships with).
|
||||
@@ -1,6 +1,6 @@
|
||||
# ADR 0002 — User groups as actors, not bounded contexts
|
||||
|
||||
Status: Accepted · Date: 2026-07-01 · Amended 2026-08-01 (WP-67)
|
||||
Status: Accepted · Date: 2026-07-01 · Amended 2026-08-01 (WP-67), 2026-08-27 (RB-13)
|
||||
|
||||
## Problem
|
||||
|
||||
@@ -167,28 +167,34 @@ status lifecycle + authorization endpoints/DTOs — **shipped** (WP-61…WP-67):
|
||||
`AanvraagStatusTag` (`Domain/Applications/AanvraagStatus.cs`), `GET /me` (`Program.cs:578`),
|
||||
`Domain/Authorization/Authz.cs`.
|
||||
|
||||
## Known debt: `Session → Principal` was never built
|
||||
A third bullet stood here too — `Session → Principal` — from 2026-08-26 until it was paid
|
||||
off by RB-13 the next day. See the amendment below for the historical record and what
|
||||
landed.
|
||||
|
||||
§3's `Principal` union is the one decision here that has **not** been executed, and it is now
|
||||
debt rather than a deferral. Actor #2 arrived — `apps/behandelportal` shipped — and the union
|
||||
did not follow. `grep -rn "Principal" apps libs` returns a single hit: a comment in
|
||||
`libs/shared/src/infrastructure/role.ts`. There is no such type.
|
||||
## Amendment (RB-13, 2026-08-27): `Session → Principal` landed
|
||||
|
||||
What that omission actually costs, measured 2026-08-26:
|
||||
§3's `Principal` union was accepted on 2026-07-01 and not executed until now — see the
|
||||
"Known debt" record this replaces, added 2026-08-26 by the refactor-backlog audit
|
||||
(`ADR-C-004`) that found it. `apps/ssp/src/app/auth/domain/principal.ts` now exports the
|
||||
`zorgverlener` variant (`{ kind: 'zorgverlener'; bsn; naam }`);
|
||||
`apps/behandelportal/src/app/auth/domain/principal.ts` exports the `medewerker` variant
|
||||
(`{ kind: 'medewerker'; medewerkerId; naam; rollen }`) — each app holds only the one
|
||||
member of the union it actually has an actor for, per this ADR's own proposed resolution.
|
||||
`apps/behandelportal`'s `DigidAdapter` is gone; a `MedewerkerAdapter` resolves the
|
||||
dev-stand-in medewerker identity (`medewerker.ts`'s `MEDEWERKER_ID`/`currentRollen()` —
|
||||
unchanged, still the mechanism `medewerkerInterceptor` uses for the backend headers) into
|
||||
a `Principal` instead, and `login.page.ts` is an SSO-stand-in entry (one button, no BSN
|
||||
field) rather than the citizen DigiD form it used to share with the SSP verbatim.
|
||||
|
||||
- `apps/ssp/src/app/auth` and `apps/behandelportal/src/app/auth` are byte-identical —
|
||||
`diff -rq` reports **zero** content differences across 9 of 11 files, the only delta being
|
||||
two extra files in behandelportal.
|
||||
- `behandelportal`'s Behandelaar still carries a `bsn` and logs in through `DigidAdapter`.
|
||||
A backoffice user authenticates as a citizen, which is precisely what §3 was written to prevent.
|
||||
- The divergence that _did_ occur took an orthogonal side door — `medewerker.interceptor.ts`,
|
||||
a dev-only `X-Medewerker` header stamp that never touches `Session`.
|
||||
|
||||
The WP-67 amendment above justifies keeping `auth` duplicated on the grounds that it is
|
||||
"expected to diverge". That reasoning still holds — but it has never been **tested**, because
|
||||
the change that would test it is this one. Read the two identical copies as evidence that
|
||||
§3 is unexecuted, not as evidence that §3 was wrong.
|
||||
|
||||
ponytail: this ADR draws the boundaries so nothing has to be undone later. The original
|
||||
"YAGNI until the backoffice work starts" call was right when written and has now expired —
|
||||
the backoffice started. `Principal` is owed.
|
||||
The two `auth` contexts, measured 2026-08-27 after the change
|
||||
(`tools/baseline-scan.mjs --dup`): **32 duplicated lines each** (from 168 at the
|
||||
2026-08-26 measurement above; from 211 before ADR-C-006 shared the route guards). What
|
||||
remains is not re-converged identity/login-flow code — it is `auth.guard.ts`'s intentional
|
||||
verbatim re-export (ADR-C-006: a route guard is actor-agnostic, not in this ADR's scope)
|
||||
plus ordinary test/story-file boilerplate (`describe`/`it` shape, a `Meta`/`StoryObj`
|
||||
scaffold) that any two spec or story files share regardless of subject. The prediction in
|
||||
§3 — that Zorgverlener and Medewerker, modelled as distinct `Principal` variants, would
|
||||
turn out to authenticate differently enough that sharing `auth` would have been the wrong
|
||||
call — has now actually been tested, not just asserted, and held: the two contexts diverge
|
||||
in domain type, adapter, and login UI as soon as the union exists to make that
|
||||
divergence possible.
|
||||
|
||||
@@ -20,7 +20,7 @@ tested where._
|
||||
|
||||
Every bullet below is a real test name from the suite — an `it()` title (frontend) or a test
|
||||
method name (backend), read as a sentence. Nothing here is hand-written prose: this page
|
||||
**is** the suite, reshaped for a business reader. 440 frontend behaviours across
|
||||
**is** the suite, reshaped for a business reader. 446 frontend behaviours across
|
||||
9 contexts; 231 backend behaviours across 39 test
|
||||
classes.
|
||||
|
||||
@@ -30,17 +30,26 @@ classes.
|
||||
|
||||
#### isAuthenticated
|
||||
|
||||
- narrows a present session to Session
|
||||
- reports no session as not authenticated
|
||||
- narrows a present session to Session
|
||||
- reports no session as not authenticated
|
||||
- narrows a present principal to Principal
|
||||
- reports no principal as not authenticated
|
||||
- narrows a present principal to Principal
|
||||
- reports no principal as not authenticated
|
||||
|
||||
#### parseStoredSession
|
||||
#### parseRollen
|
||||
|
||||
- parses a single recognized rol
|
||||
- is case-insensitive and trims whitespace
|
||||
- drops unrecognized tokens (the deny-path toggle, e.g. ?rollen=geen)
|
||||
- returns an empty list for an empty string
|
||||
|
||||
#### parseStoredPrincipal
|
||||
|
||||
- returns null when nothing is stored
|
||||
- returns null for a non-JSON string
|
||||
- returns null when the stored shape is wrong (no naam)
|
||||
- G1: a stored bsn is never restored, even if present in the raw value
|
||||
- returns null when kind is not medewerker
|
||||
- returns null when rollen holds an unrecognized token
|
||||
- restores a well-shaped stored principal as-is (no BSN to strip)
|
||||
- returns null when nothing is stored
|
||||
- returns null for a non-JSON string
|
||||
- returns null when the stored shape is wrong (no naam)
|
||||
|
||||
@@ -12,7 +12,7 @@ import { currentSubject } from './subject';
|
||||
* middleware resolves a `CallerIdentity` for every request, not just some endpoints.
|
||||
*
|
||||
* **BSN source — a deliberate compromise, read before changing:** the "obvious"
|
||||
* source would be the authenticated `Session.bsn` held by each app's own
|
||||
* source would be the authenticated `Principal.bsn` held by each app's own
|
||||
* `SessionStore`, but `libs/shared` may not depend on an app-local `auth` context
|
||||
* (the import-direction rule), and the one sanctioned cross-context seam —
|
||||
* `SessionPort` (`@shared/application/session.port`) — deliberately exposes only
|
||||
|
||||
@@ -3,7 +3,7 @@ import { isDevMode } from '@angular/core';
|
||||
/**
|
||||
* Dev-only role stand-in's sibling (the reading MECHANISM for `X-Subject`; see
|
||||
* `role.ts`'s own doc comment for the twin `X-Role` mechanism this mirrors). This
|
||||
* POC has no real DigiD identity — `Session.bsn` lives only in each app's own
|
||||
* POC has no real DigiD identity — `Principal.bsn` lives only in each app's own
|
||||
* in-memory `SessionStore` and is deliberately never persisted (see that store's G1
|
||||
* comment) — so `subject.interceptor.ts` can't reach it without a layering
|
||||
* violation (`libs/shared` may not depend on an app-local `auth` context). Instead a
|
||||
|
||||
Reference in New Issue
Block a user