refactor(auth): share the actor-agnostic route guards (ADR-C-006)

authGuard and capabilityGuard were duplicated byte-for-byte across both
apps, along with their specs — 57 of the 211 duplicated lines BL-002
measured in the two auth contexts, the largest block after session.store.ts.

They are not actor-specific. They ask "is anyone logged in" and "may they do
X", never "who are you or how did you get here". ADR-0002 §3's non-sharing
decision scopes to identity and login flow — Principal, DigiD vs employee
SSO — and a route guard is neither; §Consequences names auth.guard.ts only
as a seam that localises the change, not as something that must be
duplicated.

Moves both to libs/shared/src/application/auth.guard.ts, reading SESSION_PORT
instead of an app-local SessionStore. The port gains one member,
isAuthenticated: Signal<boolean> — free, because both SessionStores already
expose exactly that (session.store.ts:40) and both apps already register
{ provide: SESSION_PORT, useExisting: SessionStore }. The seam existed; it
was just narrower than what it already carried.

Each app keeps a re-export at @auth/auth.guard so app.routes.ts is untouched
— routing asks the auth context for its guards, which is the direction the
boundary should read. The two identical specs collapse into one, plus a case
asserting the guard resolves through the port.

Deliberately NOT merged: session.store.ts, session.ts, digid.adapter.ts,
login-form.component.ts, login.page.ts. Those are identical only because
ADR-C-004 (Session -> Principal) was never executed. Merging them would make
a citizen DigiD/BSN login the backoffice's shared login.

Measured with tools/baseline-scan.mjs: ssp/auth duplicated lines 211 -> 151,
bhp/auth 86.8% -> 82.5%, repo-wide 7.1% -> 6.6%. Both guard clone pairs drop
out of the top-clones list. What remains is exactly the three files
ADR-C-004 should differentiate.

behaviour-spec.mdx regenerated (the spec moved libraries).

Verified: lint, typecheck, dep:check (0 violations, 224 modules), prettier,
ng build --localize for both apps, and 407 tests passing across all four
projects.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
eho
2026-08-26 17:49:04 +02:00
co-authored by Claude Opus 5
parent 4debf6614f
commit f2d4c900b4
7 changed files with 86 additions and 143 deletions
+7 -31
View File
@@ -1,34 +1,10 @@
import { inject } from '@angular/core';
import { CanActivateFn, Router } from '@angular/router';
import { AccessStore } from '@shared/application/access.store';
import { Capability } from '@shared/domain/capability';
import { SessionStore } from './application/session.store';
/** Route guard: only let authenticated users in; otherwise redirect to /login. */
export const authGuard: CanActivateFn = () => {
const store = inject(SessionStore);
const router = inject(Router);
return store.isAuthenticated() ? true : router.createUrlTree(['/login']);
};
/**
* Route guard factory (PRD-0002 §6): authenticated AND holding `capability`, else
* redirect. Used by the admin pages (`/brief/huisstijl`, `/beheer/stamdata`).
* The route guards live in `libs/shared` (ADR-C-006) — they are actor-agnostic, reading
* only `SESSION_PORT` and `AccessStore`, so both apps share one copy and one spec.
* Re-exported here so `app.routes.ts` keeps importing them from `@auth/auth.guard`:
* routing asks the auth context for its guards, which is the right direction to read.
*
* **Async on purpose:** `can()` is deny-by-default, so it must not be read while `/me`
* is still loading — it would deny an entitled admin and bounce them. We await
* `AccessStore.whenReady()` (caps resolved) before deciding. An unauthenticated user
* goes to `/login`; an authenticated-but-unentitled user goes to `/dashboard` (they're
* logged in, just not allowed here — no re-login loop). The backend re-enforces
* regardless (403); this guard is the UX pre-gate.
* ADR-0002 §3's "auth stays duplicated" still holds for what it actually scopes —
* `Principal`, the login flow, `SessionStore`. A guard is neither.
*/
export function capabilityGuard(capability: Capability): CanActivateFn {
return async () => {
const session = inject(SessionStore);
const access = inject(AccessStore);
const router = inject(Router);
if (!session.isAuthenticated()) return router.createUrlTree(['/login']);
await access.whenReady();
return access.can(capability) ? true : router.createUrlTree(['/dashboard']);
};
}
export { authGuard, capabilityGuard } from '@shared/application/auth.guard';