Merge RB-13 — land Session -> Principal, add MedewerkerAdapter
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> # Conflicts: # libs/shared/docs/behaviour-spec.mdx
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user