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:
@@ -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
|
* The route guards live in `libs/shared` (ADR-C-006) — they are actor-agnostic, reading
|
||||||
* redirect. Used by the admin pages (`/brief/huisstijl`, `/beheer/stamdata`).
|
* 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`
|
* ADR-0002 §3's "auth stays duplicated" still holds for what it actually scopes —
|
||||||
* is still loading — it would deny an entitled admin and bounce them. We await
|
* `Principal`, the login flow, `SessionStore`. A guard is neither.
|
||||||
* `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.
|
|
||||||
*/
|
*/
|
||||||
export function capabilityGuard(capability: Capability): CanActivateFn {
|
export { authGuard, capabilityGuard } from '@shared/application/auth.guard';
|
||||||
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']);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,62 +0,0 @@
|
|||||||
import { TestBed } from '@angular/core/testing';
|
|
||||||
import { Router } from '@angular/router';
|
|
||||||
import { describe, it, expect, vi } from 'vitest';
|
|
||||||
import { AccessStore } from '@shared/application/access.store';
|
|
||||||
import { SessionStore } from './application/session.store';
|
|
||||||
import { authGuard, capabilityGuard } from './auth.guard';
|
|
||||||
|
|
||||||
type Opts = {
|
|
||||||
authed: boolean;
|
|
||||||
can?: (c: string) => boolean;
|
|
||||||
whenReady?: () => Promise<void>;
|
|
||||||
};
|
|
||||||
|
|
||||||
function setup({ authed, can = () => false, whenReady = () => Promise.resolve() }: Opts) {
|
|
||||||
const createUrlTree = vi.fn((cmds: string[]) => ({ tree: cmds }));
|
|
||||||
const readySpy = vi.fn(whenReady);
|
|
||||||
TestBed.configureTestingModule({
|
|
||||||
providers: [
|
|
||||||
{ provide: SessionStore, useValue: { isAuthenticated: () => authed } },
|
|
||||||
{ provide: AccessStore, useValue: { whenReady: readySpy, can } },
|
|
||||||
{ provide: Router, useValue: { createUrlTree } },
|
|
||||||
],
|
|
||||||
});
|
|
||||||
return { createUrlTree, readySpy };
|
|
||||||
}
|
|
||||||
|
|
||||||
// The guards ignore their (route, state) args; cast to call with none.
|
|
||||||
const call = <T>(fn: unknown) => TestBed.runInInjectionContext(() => (fn as () => T)());
|
|
||||||
|
|
||||||
describe('authGuard', () => {
|
|
||||||
it('allows an authenticated user', () => {
|
|
||||||
setup({ authed: true });
|
|
||||||
expect(call(authGuard)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('redirects an anonymous user to /login', () => {
|
|
||||||
const { createUrlTree } = setup({ authed: false });
|
|
||||||
expect(call(authGuard)).toEqual({ tree: ['/login'] });
|
|
||||||
expect(createUrlTree).toHaveBeenCalledWith(['/login']);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('capabilityGuard', () => {
|
|
||||||
const guard = () => capabilityGuard('stamdata:edit');
|
|
||||||
|
|
||||||
it('waits for /me, then allows an entitled admin', async () => {
|
|
||||||
const { readySpy } = setup({ authed: true, can: (c) => c === 'stamdata:edit' });
|
|
||||||
await expect(call<Promise<unknown>>(guard())).resolves.toBe(true);
|
|
||||||
expect(readySpy).toHaveBeenCalledOnce(); // it awaited caps before deciding
|
|
||||||
});
|
|
||||||
|
|
||||||
it('sends an authenticated-but-unentitled user to /dashboard (not a login loop)', async () => {
|
|
||||||
setup({ authed: true, can: () => false });
|
|
||||||
await expect(call<Promise<unknown>>(guard())).resolves.toEqual({ tree: ['/dashboard'] });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('redirects an anonymous user to /login without waiting for caps', async () => {
|
|
||||||
const { readySpy } = setup({ authed: false, can: () => true });
|
|
||||||
await expect(call<Promise<unknown>>(guard())).resolves.toEqual({ tree: ['/login'] });
|
|
||||||
expect(readySpy).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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
|
* The route guards live in `libs/shared` (ADR-C-006) — they are actor-agnostic, reading
|
||||||
* redirect. Used by the admin pages (`/brief/huisstijl`, `/beheer/stamdata`).
|
* 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`
|
* ADR-0002 §3's "auth stays duplicated" still holds for what it actually scopes —
|
||||||
* is still loading — it would deny an entitled admin and bounce them. We await
|
* `Principal`, the login flow, `SessionStore`. A guard is neither.
|
||||||
* `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.
|
|
||||||
*/
|
*/
|
||||||
export function capabilityGuard(capability: Capability): CanActivateFn {
|
export { authGuard, capabilityGuard } from '@shared/application/auth.guard';
|
||||||
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']);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ tested where._
|
|||||||
|
|
||||||
Every bullet below is a real test name from the suite — an `it()` title (frontend) or a test
|
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
|
method name (backend), read as a sentence. Nothing here is hand-written prose: this page
|
||||||
**is** the suite, reshaped for a business reader. 406 frontend behaviours across
|
**is** the suite, reshaped for a business reader. 402 frontend behaviours across
|
||||||
8 contexts; 217 backend behaviours across 36 test
|
8 contexts; 217 backend behaviours across 36 test
|
||||||
classes.
|
classes.
|
||||||
|
|
||||||
@@ -28,22 +28,6 @@ classes.
|
|||||||
|
|
||||||
### auth
|
### auth
|
||||||
|
|
||||||
#### authGuard
|
|
||||||
|
|
||||||
- allows an authenticated user
|
|
||||||
- redirects an anonymous user to /login
|
|
||||||
- allows an authenticated user
|
|
||||||
- redirects an anonymous user to /login
|
|
||||||
|
|
||||||
#### capabilityGuard
|
|
||||||
|
|
||||||
- waits for /me, then allows an entitled admin
|
|
||||||
- sends an authenticated-but-unentitled user to /dashboard (not a login loop)
|
|
||||||
- redirects an anonymous user to /login without waiting for caps
|
|
||||||
- waits for /me, then allows an entitled admin
|
|
||||||
- sends an authenticated-but-unentitled user to /dashboard (not a login loop)
|
|
||||||
- redirects an anonymous user to /login without waiting for caps
|
|
||||||
|
|
||||||
#### isAuthenticated
|
#### isAuthenticated
|
||||||
|
|
||||||
- narrows a present session to Session
|
- narrows a present session to Session
|
||||||
@@ -614,6 +598,18 @@ classes.
|
|||||||
- map only touches Success
|
- map only touches Success
|
||||||
- map2 precedence: Failure > Loading > Success
|
- map2 precedence: Failure > Loading > Success
|
||||||
|
|
||||||
|
#### authGuard
|
||||||
|
|
||||||
|
- allows an authenticated user
|
||||||
|
- redirects an anonymous user to /login
|
||||||
|
|
||||||
|
#### capabilityGuard
|
||||||
|
|
||||||
|
- waits for /me, then allows an entitled admin
|
||||||
|
- sends an authenticated-but-unentitled user to /dashboard (not a login loop)
|
||||||
|
- redirects an anonymous user to /login without waiting for caps
|
||||||
|
- reads authentication through the port, not an app-local store
|
||||||
|
|
||||||
#### createDebouncedSave
|
#### createDebouncedSave
|
||||||
|
|
||||||
- flushes after the delay when canSave is true
|
- flushes after the delay when canSave is true
|
||||||
|
|||||||
+9
-2
@@ -2,7 +2,7 @@ import { TestBed } from '@angular/core/testing';
|
|||||||
import { Router } from '@angular/router';
|
import { Router } from '@angular/router';
|
||||||
import { describe, it, expect, vi } from 'vitest';
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
import { AccessStore } from '@shared/application/access.store';
|
import { AccessStore } from '@shared/application/access.store';
|
||||||
import { SessionStore } from './application/session.store';
|
import { SESSION_PORT } from '@shared/application/session.port';
|
||||||
import { authGuard, capabilityGuard } from './auth.guard';
|
import { authGuard, capabilityGuard } from './auth.guard';
|
||||||
|
|
||||||
type Opts = {
|
type Opts = {
|
||||||
@@ -16,7 +16,7 @@ function setup({ authed, can = () => false, whenReady = () => Promise.resolve()
|
|||||||
const readySpy = vi.fn(whenReady);
|
const readySpy = vi.fn(whenReady);
|
||||||
TestBed.configureTestingModule({
|
TestBed.configureTestingModule({
|
||||||
providers: [
|
providers: [
|
||||||
{ provide: SessionStore, useValue: { isAuthenticated: () => authed } },
|
{ provide: SESSION_PORT, useValue: { isAuthenticated: () => authed } },
|
||||||
{ provide: AccessStore, useValue: { whenReady: readySpy, can } },
|
{ provide: AccessStore, useValue: { whenReady: readySpy, can } },
|
||||||
{ provide: Router, useValue: { createUrlTree } },
|
{ provide: Router, useValue: { createUrlTree } },
|
||||||
],
|
],
|
||||||
@@ -59,4 +59,11 @@ describe('capabilityGuard', () => {
|
|||||||
await expect(call<Promise<unknown>>(guard())).resolves.toEqual({ tree: ['/login'] });
|
await expect(call<Promise<unknown>>(guard())).resolves.toEqual({ tree: ['/login'] });
|
||||||
expect(readySpy).not.toHaveBeenCalled();
|
expect(readySpy).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// The guard reads SESSION_PORT, never a concrete SessionStore — that is what lets one
|
||||||
|
// copy serve both apps while ADR-0002 §3 keeps their `auth` contexts separate.
|
||||||
|
it('reads authentication through the port, not an app-local store', () => {
|
||||||
|
setup({ authed: true });
|
||||||
|
expect(TestBed.inject(SESSION_PORT).isAuthenticated()).toBe(true);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { inject } from '@angular/core';
|
||||||
|
import { CanActivateFn, Router } from '@angular/router';
|
||||||
|
import { AccessStore } from '@shared/application/access.store';
|
||||||
|
import { SESSION_PORT } from '@shared/application/session.port';
|
||||||
|
import { Capability } from '@shared/domain/capability';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Route guards, shared by both apps (ADR-C-006).
|
||||||
|
*
|
||||||
|
* These are deliberately **not** part of the `auth` context that ADR-0002 §3 keeps
|
||||||
|
* duplicated per app. That decision scopes to *identity and login flow* — `Principal`,
|
||||||
|
* DigiD vs. employee SSO. A route guard is neither: it asks only "is anyone logged in"
|
||||||
|
* and "may they do X", never "who are you or how did you get here". Both questions are
|
||||||
|
* answered through seams that already live here — `SESSION_PORT` and `AccessStore` — so
|
||||||
|
* the guards never see an actor type and have nothing to diverge on.
|
||||||
|
*
|
||||||
|
* Each app re-exports these from its own `auth/auth.guard.ts`, so `app.routes.ts` keeps
|
||||||
|
* importing `@auth/auth.guard` and the context boundary reads unchanged.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Route guard: only let authenticated users in; otherwise redirect to /login. */
|
||||||
|
export const authGuard: CanActivateFn = () => {
|
||||||
|
const session = inject(SESSION_PORT);
|
||||||
|
const router = inject(Router);
|
||||||
|
return session.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`).
|
||||||
|
*
|
||||||
|
* **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.
|
||||||
|
*/
|
||||||
|
export function capabilityGuard(capability: Capability): CanActivateFn {
|
||||||
|
return async () => {
|
||||||
|
const session = inject(SESSION_PORT);
|
||||||
|
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']);
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -8,6 +8,8 @@ import { InjectionToken, Signal } from '@angular/core';
|
|||||||
*/
|
*/
|
||||||
export interface SessionPort {
|
export interface SessionPort {
|
||||||
readonly session: Signal<{ naam: string } | null>;
|
readonly session: Signal<{ naam: string } | null>;
|
||||||
|
/** Whether anyone is logged in. The shared route guards read only this — never who. */
|
||||||
|
readonly isAuthenticated: Signal<boolean>;
|
||||||
logout(): void;
|
logout(): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user