diff --git a/apps/behandelportal/src/app/auth/auth.guard.ts b/apps/behandelportal/src/app/auth/auth.guard.ts index 3eadd8f..e3def66 100644 --- a/apps/behandelportal/src/app/auth/auth.guard.ts +++ b/apps/behandelportal/src/app/auth/auth.guard.ts @@ -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'; diff --git a/apps/ssp/src/app/auth/auth.guard.spec.ts b/apps/ssp/src/app/auth/auth.guard.spec.ts deleted file mode 100644 index dfc3fb5..0000000 --- a/apps/ssp/src/app/auth/auth.guard.spec.ts +++ /dev/null @@ -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; -}; - -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 = (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>(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>(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>(guard())).resolves.toEqual({ tree: ['/login'] }); - expect(readySpy).not.toHaveBeenCalled(); - }); -}); diff --git a/apps/ssp/src/app/auth/auth.guard.ts b/apps/ssp/src/app/auth/auth.guard.ts index 3eadd8f..e3def66 100644 --- a/apps/ssp/src/app/auth/auth.guard.ts +++ b/apps/ssp/src/app/auth/auth.guard.ts @@ -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'; diff --git a/libs/shared/docs/behaviour-spec.mdx b/libs/shared/docs/behaviour-spec.mdx index 34118ac..1fce825 100644 --- a/libs/shared/docs/behaviour-spec.mdx +++ b/libs/shared/docs/behaviour-spec.mdx @@ -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. 406 frontend behaviours across +**is** the suite, reshaped for a business reader. 402 frontend behaviours across 8 contexts; 217 backend behaviours across 36 test classes. @@ -28,22 +28,6 @@ classes. ### 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 - narrows a present session to Session @@ -614,6 +598,18 @@ classes. - map only touches 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 - flushes after the delay when canSave is true diff --git a/apps/behandelportal/src/app/auth/auth.guard.spec.ts b/libs/shared/src/application/auth.guard.spec.ts similarity index 82% rename from apps/behandelportal/src/app/auth/auth.guard.spec.ts rename to libs/shared/src/application/auth.guard.spec.ts index dfc3fb5..0487283 100644 --- a/apps/behandelportal/src/app/auth/auth.guard.spec.ts +++ b/libs/shared/src/application/auth.guard.spec.ts @@ -2,7 +2,7 @@ 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 { SESSION_PORT } from '@shared/application/session.port'; import { authGuard, capabilityGuard } from './auth.guard'; type Opts = { @@ -16,7 +16,7 @@ function setup({ authed, can = () => false, whenReady = () => Promise.resolve() const readySpy = vi.fn(whenReady); TestBed.configureTestingModule({ providers: [ - { provide: SessionStore, useValue: { isAuthenticated: () => authed } }, + { provide: SESSION_PORT, useValue: { isAuthenticated: () => authed } }, { provide: AccessStore, useValue: { whenReady: readySpy, can } }, { provide: Router, useValue: { createUrlTree } }, ], @@ -59,4 +59,11 @@ describe('capabilityGuard', () => { await expect(call>(guard())).resolves.toEqual({ tree: ['/login'] }); 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); + }); }); diff --git a/libs/shared/src/application/auth.guard.ts b/libs/shared/src/application/auth.guard.ts new file mode 100644 index 0000000..a252a39 --- /dev/null +++ b/libs/shared/src/application/auth.guard.ts @@ -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']); + }; +} diff --git a/libs/shared/src/application/session.port.ts b/libs/shared/src/application/session.port.ts index da78cad..c94db44 100644 --- a/libs/shared/src/application/session.port.ts +++ b/libs/shared/src/application/session.port.ts @@ -8,6 +8,8 @@ import { InjectionToken, Signal } from '@angular/core'; */ export interface SessionPort { readonly session: Signal<{ naam: string } | null>; + /** Whether anyone is logged in. The shared route guards read only this — never who. */ + readonly isAuthenticated: Signal; logout(): void; }