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:
eho
2026-08-27 16:54:26 +02:00
co-authored by Claude Opus 5
parent 988612cd7e
commit f19185ed81
22 changed files with 588 additions and 319 deletions
@@ -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']);
}
}
+11 -43
View File
@@ -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' });
});
});
+39
View File
@@ -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' });
});
});
-27
View File
@@ -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.
+16 -7
View File
@@ -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
+1 -1
View File
@@ -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