From 9247e5e02e68812eaa359af9f13859d0d8677a90 Mon Sep 17 00:00:00 2001 From: Julien Gautier Date: Wed, 13 May 2026 00:26:04 +0200 Subject: [PATCH] feat(portal-shell): authGuard + BFF http interceptors + /profile demo route MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit brings the spa auth track to the level of polish the bff surface deserves. before this pr the header reflected sign-in state but there were no protected routes and no global handling of session drift; this pr lands one guard, two interceptors, and a demo consumer that exercises the loop end-to-end. authGuard (CanActivateFn): gates routes on AuthService.state. waits out the bootstrap `loading` state (filter+firstValueFrom on toObservable(state)), allows when `authenticated`, redirects via auth.login() (full- page navigation to the bff's /auth/login → entra round-trip) when `anonymous` or `error`. error → same redirect as anonymous: the bff-side login screen surfaces diagnostics more usefully than a generic spa outage page would. bffCredentialsInterceptor: flips withCredentials: true on every request whose url starts with AUTH_BFF_BASE_URL. replaces the per-call flag added in #114 with a single point of truth; future bff calls inherit it automatically. bffUnauthorizedInterceptor: calls AuthService.refresh() when a bff route (other than /auth/me) answers 401. keeps the spa state in sync after server-side session destruction (absolute-timeout, manual revoke, idle-ttl expiry). /me is deliberately excluded — refresh() itself calls /me, a 401 there would loop. notable: the interceptor injects Injector and resolves AuthService lazily inside catchError. injecting it eagerly at the top would re-enter di while AuthService's own constructor is still firing the bootstrap /me through this same interceptor, raising a circular-construction error that swallowed the request before the testing backend could record it. standard angular pattern for "interceptor depends on a service that uses HttpClient". /profile demo route: first real consumer of authGuard. lazy-loaded angular component that renders the curated CurrentUser payload (displayName, username, oid, tid). exercises the full loop guard → bff /me → spa render. removed the per-call withCredentials: true from AuthService.refresh() — the credentials interceptor handles it uniformly now. the spec that pinned the per-call flag moved to bff-credentials.interceptor.spec.ts where it belongs. i18n: 6 new message ids + route.profile.title shipped with fr translations in messages.fr.xlf. 19/19 feature-auth + 34/34 portal-shell + 123/123 portal-bff under the clean-env ci repro (env -u redis_url … etc.). bundle main is 492 kb raw / 131 kb transfer — well under the 300 kb gzip budget per adr-0017. out of scope, landing in follow-ups: - a real user-profile feature (settings, preferences, etc.) - showing the auth-loading state on the route the guard blocks (today the user sees the previous route until /me resolves) --- apps/portal-shell/src/app/app.config.ts | 23 +++- apps/portal-shell/src/app/app.routes.ts | 11 ++ .../src/app/pages/profile/profile.html | 71 ++++++++++ .../src/app/pages/profile/profile.spec.ts | 61 +++++++++ .../src/app/pages/profile/profile.ts | 23 ++++ apps/portal-shell/src/locale/messages.fr.xlf | 30 ++++ libs/feature/auth/src/index.ts | 3 + libs/feature/auth/src/lib/auth.guard.spec.ts | 89 ++++++++++++ libs/feature/auth/src/lib/auth.guard.ts | 49 +++++++ .../feature/auth/src/lib/auth.service.spec.ts | 12 -- libs/feature/auth/src/lib/auth.service.ts | 19 +-- .../lib/bff-credentials.interceptor.spec.ts | 51 +++++++ .../src/lib/bff-credentials.interceptor.ts | 34 +++++ .../lib/bff-unauthorized.interceptor.spec.ts | 128 ++++++++++++++++++ .../src/lib/bff-unauthorized.interceptor.ts | 65 +++++++++ 15 files changed, 642 insertions(+), 27 deletions(-) create mode 100644 apps/portal-shell/src/app/pages/profile/profile.html create mode 100644 apps/portal-shell/src/app/pages/profile/profile.spec.ts create mode 100644 apps/portal-shell/src/app/pages/profile/profile.ts create mode 100644 libs/feature/auth/src/lib/auth.guard.spec.ts create mode 100644 libs/feature/auth/src/lib/auth.guard.ts create mode 100644 libs/feature/auth/src/lib/bff-credentials.interceptor.spec.ts create mode 100644 libs/feature/auth/src/lib/bff-credentials.interceptor.ts create mode 100644 libs/feature/auth/src/lib/bff-unauthorized.interceptor.spec.ts create mode 100644 libs/feature/auth/src/lib/bff-unauthorized.interceptor.ts diff --git a/apps/portal-shell/src/app/app.config.ts b/apps/portal-shell/src/app/app.config.ts index c07fd1e..56fc460 100644 --- a/apps/portal-shell/src/app/app.config.ts +++ b/apps/portal-shell/src/app/app.config.ts @@ -3,9 +3,13 @@ import { provideBrowserGlobalErrorListeners, provideZonelessChangeDetection, } from '@angular/core'; -import { provideHttpClient, withFetch } from '@angular/common/http'; +import { provideHttpClient, withFetch, withInterceptors } from '@angular/common/http'; import { provideRouter } from '@angular/router'; -import { AUTH_BFF_BASE_URL } from 'feature-auth'; +import { + AUTH_BFF_BASE_URL, + bffCredentialsInterceptor, + bffUnauthorizedInterceptor, +} from 'feature-auth'; import { environment } from '../environments/environment'; import { appRoutes } from './app.routes'; @@ -19,7 +23,20 @@ export const appConfig: ApplicationConfig = { // fetch` patches — every HttpClient request gets its own span and // the W3C `traceparent` header propagated to the BFF // automatically. The legacy XHR backend would short-circuit that. - provideHttpClient(withFetch()), + // + // Interceptors: + // - `bffCredentialsInterceptor` flips `withCredentials: true` + // on every request whose URL starts with `AUTH_BFF_BASE_URL` + // so the session cookie crosses the SPA→BFF origin gap in + // dev (different ports = different origins). + // - `bffUnauthorizedInterceptor` calls `AuthService.refresh()` + // when a BFF route (other than `/auth/me` itself) answers + // 401, keeping the SPA's auth state in sync after server- + // side session destruction (absolute-timeout, manual revoke). + provideHttpClient( + withFetch(), + withInterceptors([bffCredentialsInterceptor, bffUnauthorizedInterceptor]), + ), // `feature-auth` is environment-agnostic and reads the BFF base // URL from this token — provided once per app from `environment.ts` // (per ADR-0018). diff --git a/apps/portal-shell/src/app/app.routes.ts b/apps/portal-shell/src/app/app.routes.ts index 5e89e14..90fbd3d 100644 --- a/apps/portal-shell/src/app/app.routes.ts +++ b/apps/portal-shell/src/app/app.routes.ts @@ -1,4 +1,5 @@ import { Route } from '@angular/router'; +import { authGuard } from 'feature-auth'; // Tab titles per route — marked with `$localize` so each locale's // bundle ships its own. The accessibility statement now lives at the @@ -6,6 +7,7 @@ import { Route } from '@angular/router'; // previous `/accessibility` + `/accessibilite` pair). const homeTitle = $localize`:@@route.home.title:APF Portal`; const accessibilityTitle = $localize`:@@route.accessibility.title:Accessibility statement · APF Portal`; +const profileTitle = $localize`:@@route.profile.title:My profile · APF Portal`; export const appRoutes: Route[] = [ { @@ -31,6 +33,15 @@ export const appRoutes: Route[] = [ redirectTo: 'accessibility', pathMatch: 'full', }, + // Authenticated demo route. First real consumer of `authGuard` — + // anonymous visitors get redirected through the BFF's `/auth/login` + // (Entra round-trip) before the page renders. + { + path: 'profile', + canActivate: [authGuard], + loadComponent: () => import('./pages/profile/profile').then((m) => m.Profile), + title: profileTitle, + }, // Catch-all. In production each locale ships with its own // ``, so the router never actually sees // the locale segment — `/fr/foo` is normalised to `foo` before diff --git a/apps/portal-shell/src/app/pages/profile/profile.html b/apps/portal-shell/src/app/pages/profile/profile.html new file mode 100644 index 0000000..9e704a6 --- /dev/null +++ b/apps/portal-shell/src/app/pages/profile/profile.html @@ -0,0 +1,71 @@ +@if (user(); as currentUser) { +
+

+ My profile +

+

+ Identity served by the BFF from the active session. +

+ +
+
+
+ Display name +
+
+ {{ currentUser.displayName }} +
+
+
+
+ Username +
+
+ {{ currentUser.username }} +
+
+
+
+ Entra object id +
+
+ {{ currentUser.oid }} +
+
+
+
+ Tenant id +
+
+ {{ currentUser.tid }} +
+
+
+
+} diff --git a/apps/portal-shell/src/app/pages/profile/profile.spec.ts b/apps/portal-shell/src/app/pages/profile/profile.spec.ts new file mode 100644 index 0000000..3b12af8 --- /dev/null +++ b/apps/portal-shell/src/app/pages/profile/profile.spec.ts @@ -0,0 +1,61 @@ +import { provideHttpClient } from '@angular/common/http'; +import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; +import { TestBed } from '@angular/core/testing'; +import { AUTH_BFF_BASE_URL, type CurrentUser } from 'feature-auth'; +import { Profile } from './profile'; + +const BFF_BASE = 'http://bff.test/api'; +const ME_URL = `${BFF_BASE}/auth/me`; + +const USER: CurrentUser = { + oid: 'user-oid', + tid: 'tenant-id', + username: 'jane.doe@apf.example', + displayName: 'Jane Doe', +}; + +async function setup() { + TestBed.configureTestingModule({ + imports: [Profile], + providers: [ + provideHttpClient(), + provideHttpClientTesting(), + { provide: AUTH_BFF_BASE_URL, useValue: BFF_BASE }, + ], + }); + const fixture = TestBed.createComponent(Profile); + const httpCtrl = TestBed.inject(HttpTestingController); + // AuthService's bootstrap /me fires from its constructor on first + // injection — drain it so the user signal commits before the + // template renders. + httpCtrl.expectOne(ME_URL).flush(USER); + await Promise.resolve(); + fixture.detectChanges(); + await fixture.whenStable(); + return { fixture }; +} + +describe('Profile', () => { + afterEach(() => { + TestBed.resetTestingModule(); + }); + + it('renders the identity served by the BFF /me payload', async () => { + const { fixture } = await setup(); + const root = fixture.nativeElement as HTMLElement; + expect(root.querySelector('[data-testid="profile-displayname"]')?.textContent?.trim()).toBe( + USER.displayName, + ); + expect(root.querySelector('[data-testid="profile-username"]')?.textContent?.trim()).toBe( + USER.username, + ); + expect(root.querySelector('[data-testid="profile-oid"]')?.textContent?.trim()).toBe(USER.oid); + expect(root.querySelector('[data-testid="profile-tid"]')?.textContent?.trim()).toBe(USER.tid); + }); + + it('renders an accessible heading', async () => { + const { fixture } = await setup(); + const heading = (fixture.nativeElement as HTMLElement).querySelector('h1'); + expect(heading?.textContent?.trim()).toBe('My profile'); + }); +}); diff --git a/apps/portal-shell/src/app/pages/profile/profile.ts b/apps/portal-shell/src/app/pages/profile/profile.ts new file mode 100644 index 0000000..a8c62a6 --- /dev/null +++ b/apps/portal-shell/src/app/pages/profile/profile.ts @@ -0,0 +1,23 @@ +import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; +import { AuthService } from 'feature-auth'; + +/** + * Authenticated demo page. First real consumer of `authGuard` — the + * guard upstream guarantees `AuthService.currentUser()` is non-null + * by the time the component renders, so the template can read it + * directly without an "anonymous" branch. + * + * v1 carries just the user identity card. The route is a deliberate + * stub to exercise the full auth loop end-to-end (guard → BFF /me → + * SPA render); the real profile content lands when the user-profile + * feature is in scope. + */ +@Component({ + selector: 'app-profile', + templateUrl: './profile.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class Profile { + private readonly auth = inject(AuthService); + protected readonly user = this.auth.currentUser; +} diff --git a/apps/portal-shell/src/locale/messages.fr.xlf b/apps/portal-shell/src/locale/messages.fr.xlf index a6fbc19..9d81cf3 100644 --- a/apps/portal-shell/src/locale/messages.fr.xlf +++ b/apps/portal-shell/src/locale/messages.fr.xlf @@ -178,6 +178,36 @@ APF Portal Portail APF + + My profile · APF Portal + Mon profil · Portail APF + + + + + My profile + Mon profil + + + Identity served by the BFF from the active session. + Identité fournie par le BFF depuis la session active. + + + Display name + Nom d’affichage + + + Username + Identifiant + + + Entra object id + Identifiant Entra (oid) + + + Tenant id + Identifiant du tenant + diff --git a/libs/feature/auth/src/index.ts b/libs/feature/auth/src/index.ts index c952564..ce9cbb6 100644 --- a/libs/feature/auth/src/index.ts +++ b/libs/feature/auth/src/index.ts @@ -1,3 +1,6 @@ export { AUTH_BFF_BASE_URL, AUTH_NAVIGATOR } from './lib/auth.config'; +export { authGuard } from './lib/auth.guard'; export { AuthService } from './lib/auth.service'; export type { AuthState, CurrentUser } from './lib/auth.types'; +export { bffCredentialsInterceptor } from './lib/bff-credentials.interceptor'; +export { bffUnauthorizedInterceptor } from './lib/bff-unauthorized.interceptor'; diff --git a/libs/feature/auth/src/lib/auth.guard.spec.ts b/libs/feature/auth/src/lib/auth.guard.spec.ts new file mode 100644 index 0000000..647f3b6 --- /dev/null +++ b/libs/feature/auth/src/lib/auth.guard.spec.ts @@ -0,0 +1,89 @@ +import { provideHttpClient } from '@angular/common/http'; +import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; +import { TestBed } from '@angular/core/testing'; +import type { ActivatedRouteSnapshot, RouterStateSnapshot } from '@angular/router'; +import { AUTH_BFF_BASE_URL, AUTH_NAVIGATOR } from './auth.config'; +import { authGuard } from './auth.guard'; +import { AuthService } from './auth.service'; + +const BFF_BASE = 'http://bff.test/api'; +const ME_URL = `${BFF_BASE}/auth/me`; + +const USER = { + oid: 'user-oid', + tid: 'tenant-id', + username: 'jane@apf.example', + displayName: 'Jane Doe', +}; + +function setup() { + const navigate = vi.fn(); + TestBed.configureTestingModule({ + providers: [ + provideHttpClient(), + provideHttpClientTesting(), + { provide: AUTH_BFF_BASE_URL, useValue: BFF_BASE }, + { provide: AUTH_NAVIGATOR, useValue: navigate }, + ], + }); + return { + httpCtrl: TestBed.inject(HttpTestingController), + auth: TestBed.inject(AuthService), + navigate, + }; +} + +function runGuard(): Promise { + // Functional guards must run inside the injection context. + return TestBed.runInInjectionContext(() => + Promise.resolve( + (authGuard as unknown as (r: ActivatedRouteSnapshot, s: RouterStateSnapshot) => unknown)( + {} as ActivatedRouteSnapshot, + {} as RouterStateSnapshot, + ), + ), + ); +} + +describe('authGuard', () => { + afterEach(() => { + TestBed.resetTestingModule(); + }); + + it('allows navigation when state resolves to authenticated', async () => { + const { httpCtrl } = setup(); + const guardPromise = runGuard(); + // The bootstrap /me resolves to a user → state transitions to + // authenticated → guard returns true. + httpCtrl.expectOne(ME_URL).flush(USER); + expect(await guardPromise).toBe(true); + }); + + it('redirects via AuthService.login() and denies when state is anonymous', async () => { + const { httpCtrl, navigate } = setup(); + const guardPromise = runGuard(); + httpCtrl + .expectOne(ME_URL) + .flush({ error: 'unauthenticated' }, { status: 401, statusText: 'Unauthorized' }); + expect(await guardPromise).toBe(false); + expect(navigate).toHaveBeenCalledWith(`${BFF_BASE}/auth/login`); + }); + + it('redirects via AuthService.login() and denies when state is error', async () => { + const { httpCtrl, navigate } = setup(); + const guardPromise = runGuard(); + httpCtrl.expectOne(ME_URL).flush('boom', { status: 500, statusText: 'Internal Server Error' }); + expect(await guardPromise).toBe(false); + expect(navigate).toHaveBeenCalledWith(`${BFF_BASE}/auth/login`); + }); + + it('waits out the loading state before deciding (fresh-tab footgun)', async () => { + const { httpCtrl, auth } = setup(); + // Guard called before /me has resolved → state is `loading`. + expect(auth.state().kind).toBe('loading'); + const guardPromise = runGuard(); + // Resolve loading → authenticated; guard then unblocks. + httpCtrl.expectOne(ME_URL).flush(USER); + expect(await guardPromise).toBe(true); + }); +}); diff --git a/libs/feature/auth/src/lib/auth.guard.ts b/libs/feature/auth/src/lib/auth.guard.ts new file mode 100644 index 0000000..de601c2 --- /dev/null +++ b/libs/feature/auth/src/lib/auth.guard.ts @@ -0,0 +1,49 @@ +import { inject } from '@angular/core'; +import { toObservable } from '@angular/core/rxjs-interop'; +import type { CanActivateFn } from '@angular/router'; +import { filter, firstValueFrom, map } from 'rxjs'; +import { AuthService } from './auth.service'; + +/** + * Functional route guard that gates a route on the SPA-side + * authentication state held by {@link AuthService}. + * + * Behaviour by `AuthState`: + * - `loading` — block navigation until the first `/me` round-trip + * resolves, then re-evaluate. Avoids the "guard returns false on + * a brand-new tab" footgun while the bootstrap fetch is still in + * flight. + * - `authenticated` — allow. + * - `anonymous` — kick off `auth.login()` (full-page redirect to + * the BFF's `/auth/login`, which 302s through Entra) and deny + * the navigation. The browser leaves the SPA before the deny + * surfaces UI-side. + * - `error` — same redirect to `/login` as `anonymous`. The user + * re-attempts auth; if Redis / Entra is truly down, the flow + * fails again with a clearer surface. Deliberate: a "can't + * reach the server" page on a protected route is less useful + * than the BFF-side login screen's diagnostics. + * + * Usage in `app.routes.ts`: + * + * { path: 'profile', canActivate: [authGuard], loadComponent: ... } + */ +export const authGuard: CanActivateFn = async () => { + const auth = inject(AuthService); + + // Wait out the bootstrap fetch. After that, `state` is guaranteed + // to be one of the three terminal kinds. + const settled = await firstValueFrom( + toObservable(auth.state).pipe( + filter((s) => s.kind !== 'loading'), + map((s) => s), + ), + ); + + if (settled.kind === 'authenticated') { + return true; + } + + auth.login(); + return false; +}; diff --git a/libs/feature/auth/src/lib/auth.service.spec.ts b/libs/feature/auth/src/lib/auth.service.spec.ts index 83ebee3..7aac605 100644 --- a/libs/feature/auth/src/lib/auth.service.spec.ts +++ b/libs/feature/auth/src/lib/auth.service.spec.ts @@ -62,18 +62,6 @@ describe('AuthService', () => { expect(service.isLoading()).toBe(false); }); - it('issues /me with withCredentials so the session cookie crosses the SPA→BFF origin gap', () => { - const { http } = setup(); - const req = http.expectOne(ME_URL); - // Without this, `fetch`'s default `credentials: 'same-origin'` - // would suppress the session cookie on the cross-origin - // (localhost:4200 → localhost:3000) request and /me would - // always answer 401 in dev. Verified manually against the BFF - // log on 2026-05-12. - expect(req.request.withCredentials).toBe(true); - req.flush({ error: 'unauthenticated' }, { status: 401, statusText: 'Unauthorized' }); - }); - it('transitions to anonymous on 401', async () => { const { service, http } = setup(); http diff --git a/libs/feature/auth/src/lib/auth.service.ts b/libs/feature/auth/src/lib/auth.service.ts index 212fe0b..66ce5fc 100644 --- a/libs/feature/auth/src/lib/auth.service.ts +++ b/libs/feature/auth/src/lib/auth.service.ts @@ -58,18 +58,13 @@ export class AuthService { */ async refresh(): Promise { try { - // `withCredentials: true` is mandatory: the SPA at - // http://localhost:4200 calls the BFF at http://localhost:3000 — - // different origins, so `fetch`'s default `credentials: - // 'same-origin'` would drop the `__Host-portal_session` cookie - // and /me would always answer 401. Production (single origin - // behind the same edge) doesn't need it but it's harmless there. - // CORS on the BFF side already allows credentials - // (`apps/portal-bff/src/main.ts` → `enableCors({ credentials: - // true })`). - const user = await firstValueFrom( - this.http.get(this.meUrl, { withCredentials: true }), - ); + // `withCredentials: true` is mandatory for the SPA→BFF + // cross-origin call (different ports = different origins, so + // `fetch`'s default `credentials: 'same-origin'` would drop + // the session cookie). It's applied uniformly by the + // `bffCredentialsInterceptor` for every request whose URL + // starts with `AUTH_BFF_BASE_URL` — including this one. + const user = await firstValueFrom(this.http.get(this.meUrl)); this._state.set({ kind: 'authenticated', user }); } catch (err) { this._state.set(toErrorState(err)); diff --git a/libs/feature/auth/src/lib/bff-credentials.interceptor.spec.ts b/libs/feature/auth/src/lib/bff-credentials.interceptor.spec.ts new file mode 100644 index 0000000..2b2155d --- /dev/null +++ b/libs/feature/auth/src/lib/bff-credentials.interceptor.spec.ts @@ -0,0 +1,51 @@ +import { HttpClient, provideHttpClient, withInterceptors } from '@angular/common/http'; +import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; +import { TestBed } from '@angular/core/testing'; +import { AUTH_BFF_BASE_URL } from './auth.config'; +import { bffCredentialsInterceptor } from './bff-credentials.interceptor'; + +const BFF_BASE = 'http://bff.test/api'; + +function setup() { + TestBed.configureTestingModule({ + providers: [ + provideHttpClient(withInterceptors([bffCredentialsInterceptor])), + provideHttpClientTesting(), + { provide: AUTH_BFF_BASE_URL, useValue: BFF_BASE }, + ], + }); + return { + http: TestBed.inject(HttpClient), + httpCtrl: TestBed.inject(HttpTestingController), + }; +} + +describe('bffCredentialsInterceptor', () => { + afterEach(() => { + TestBed.resetTestingModule(); + }); + + it('sets withCredentials=true on requests to the BFF base URL', () => { + const { http, httpCtrl } = setup(); + http.get(`${BFF_BASE}/auth/me`).subscribe(); + const req = httpCtrl.expectOne(`${BFF_BASE}/auth/me`); + expect(req.request.withCredentials).toBe(true); + req.flush({}); + }); + + it('sets withCredentials=true on any BFF sub-path (forward-looking for future protected routes)', () => { + const { http, httpCtrl } = setup(); + http.get(`${BFF_BASE}/api/some/protected/route`).subscribe(); + const req = httpCtrl.expectOne(`${BFF_BASE}/api/some/protected/route`); + expect(req.request.withCredentials).toBe(true); + req.flush({}); + }); + + it('does NOT touch requests targeting other origins (OTel, third-party scripts)', () => { + const { http, httpCtrl } = setup(); + http.get('http://otel.example/v1/traces').subscribe(); + const req = httpCtrl.expectOne('http://otel.example/v1/traces'); + expect(req.request.withCredentials).toBe(false); + req.flush({}); + }); +}); diff --git a/libs/feature/auth/src/lib/bff-credentials.interceptor.ts b/libs/feature/auth/src/lib/bff-credentials.interceptor.ts new file mode 100644 index 0000000..ea55c45 --- /dev/null +++ b/libs/feature/auth/src/lib/bff-credentials.interceptor.ts @@ -0,0 +1,34 @@ +import type { HttpHandlerFn, HttpInterceptorFn, HttpRequest } from '@angular/common/http'; +import { inject } from '@angular/core'; +import { AUTH_BFF_BASE_URL } from './auth.config'; + +/** + * HTTP interceptor that flips `withCredentials: true` on every + * request targeting the BFF. Required because the SPA at + * `http://localhost:4200` calls the BFF at `http://localhost:3000` + * — different origins, so `fetch`'s default `credentials: + * 'same-origin'` would drop the `portal_session` cookie and every + * authenticated route would answer 401. Production (single origin + * behind the same edge) doesn't strictly need it, but it's harmless + * there and keeps the dev / prod code path identical. + * + * Replaces the per-call `withCredentials: true` we used to set on + * `AuthService.refresh()` — one place to remember, no chance of + * forgetting it on the next BFF call we wire up. + * + * Requests to other origins (third-party scripts, OTel collector, + * etc.) pass through untouched. + * + * Register via `withInterceptors([bffCredentialsInterceptor])` in + * the host's `ApplicationConfig`. + */ +export const bffCredentialsInterceptor: HttpInterceptorFn = ( + req: HttpRequest, + next: HttpHandlerFn, +) => { + const bffBaseUrl = inject(AUTH_BFF_BASE_URL); + if (!req.url.startsWith(bffBaseUrl)) { + return next(req); + } + return next(req.clone({ withCredentials: true })); +}; diff --git a/libs/feature/auth/src/lib/bff-unauthorized.interceptor.spec.ts b/libs/feature/auth/src/lib/bff-unauthorized.interceptor.spec.ts new file mode 100644 index 0000000..6b750e6 --- /dev/null +++ b/libs/feature/auth/src/lib/bff-unauthorized.interceptor.spec.ts @@ -0,0 +1,128 @@ +import { HttpClient, provideHttpClient, withInterceptors } from '@angular/common/http'; +import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; +import { TestBed } from '@angular/core/testing'; +import { firstValueFrom } from 'rxjs'; +import { AUTH_BFF_BASE_URL, AUTH_NAVIGATOR } from './auth.config'; +import { AuthService } from './auth.service'; +import { bffUnauthorizedInterceptor } from './bff-unauthorized.interceptor'; + +const BFF_BASE = 'http://bff.test/api'; +const ME_URL = `${BFF_BASE}/auth/me`; +const PROTECTED_URL = `${BFF_BASE}/api/protected/resource`; + +function setup() { + TestBed.configureTestingModule({ + providers: [ + provideHttpClient(withInterceptors([bffUnauthorizedInterceptor])), + provideHttpClientTesting(), + { provide: AUTH_BFF_BASE_URL, useValue: BFF_BASE }, + { provide: AUTH_NAVIGATOR, useValue: vi.fn() }, + ], + }); + return { + http: TestBed.inject(HttpClient), + httpCtrl: TestBed.inject(HttpTestingController), + auth: TestBed.inject(AuthService), + }; +} + +async function swallow(p: Promise): Promise { + try { + return await p; + } catch (e) { + return e; + } +} + +describe('bffUnauthorizedInterceptor', () => { + afterEach(() => { + TestBed.resetTestingModule(); + }); + + it('on 401 from a protected BFF route, fires AuthService.refresh()', async () => { + const { http, httpCtrl, auth } = setup(); + // AuthService's constructor fires the bootstrap /me — drain it + // to authenticated so we can observe the refresh triggered by + // the protected-route 401 below. + httpCtrl.expectOne(ME_URL).flush({ + oid: 'u', + tid: 't', + username: 'jane@apf.example', + displayName: 'Jane', + }); + await Promise.resolve(); + expect(auth.state().kind).toBe('authenticated'); + + const refreshSpy = vi.spyOn(auth, 'refresh'); + const reqPromise = firstValueFrom(http.get(PROTECTED_URL)); + httpCtrl + .expectOne(PROTECTED_URL) + .flush({ error: 'unauthenticated' }, { status: 401, statusText: 'Unauthorized' }); + + // The interceptor's refresh() triggers a second /me; respond + // anonymous so the state transitions. + httpCtrl + .expectOne(ME_URL) + .flush({ error: 'unauthenticated' }, { status: 401, statusText: 'Unauthorized' }); + + await swallow(reqPromise); + expect(refreshSpy).toHaveBeenCalledTimes(1); + expect(auth.state().kind).toBe('anonymous'); + }); + + it('does NOT fire refresh on a 401 from /auth/me itself (would loop)', async () => { + const { httpCtrl, auth } = setup(); + const refreshSpy = vi.spyOn(auth, 'refresh'); + // Bootstrap /me returns 401. + httpCtrl + .expectOne(ME_URL) + .flush({ error: 'unauthenticated' }, { status: 401, statusText: 'Unauthorized' }); + await Promise.resolve(); + // Interceptor should have stayed quiet — only the explicit + // AuthService.refresh() call from the constructor ran, which + // we count separately. + expect(refreshSpy).not.toHaveBeenCalled(); + expect(auth.state().kind).toBe('anonymous'); + }); + + it('does NOT fire refresh on 4xx other than 401', async () => { + const { http, httpCtrl, auth } = setup(); + httpCtrl + .expectOne(ME_URL) + .flush( + { oid: 'u', tid: 't', username: 'j', displayName: 'J' }, + { status: 200, statusText: 'OK' }, + ); + await Promise.resolve(); + + const refreshSpy = vi.spyOn(auth, 'refresh'); + const reqPromise = firstValueFrom(http.get(PROTECTED_URL)); + httpCtrl + .expectOne(PROTECTED_URL) + .flush({ error: 'forbidden' }, { status: 403, statusText: 'Forbidden' }); + + await swallow(reqPromise); + expect(refreshSpy).not.toHaveBeenCalled(); + expect(auth.state().kind).toBe('authenticated'); + }); + + it('does NOT touch 401s from non-BFF origins', async () => { + const { http, httpCtrl, auth } = setup(); + httpCtrl + .expectOne(ME_URL) + .flush( + { oid: 'u', tid: 't', username: 'j', displayName: 'J' }, + { status: 200, statusText: 'OK' }, + ); + await Promise.resolve(); + + const refreshSpy = vi.spyOn(auth, 'refresh'); + const reqPromise = firstValueFrom(http.get('http://third-party.example/api')); + httpCtrl + .expectOne('http://third-party.example/api') + .flush({}, { status: 401, statusText: 'Unauthorized' }); + + await swallow(reqPromise); + expect(refreshSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/libs/feature/auth/src/lib/bff-unauthorized.interceptor.ts b/libs/feature/auth/src/lib/bff-unauthorized.interceptor.ts new file mode 100644 index 0000000..13a80d6 --- /dev/null +++ b/libs/feature/auth/src/lib/bff-unauthorized.interceptor.ts @@ -0,0 +1,65 @@ +import { + HttpErrorResponse, + type HttpHandlerFn, + type HttpInterceptorFn, + type HttpRequest, +} from '@angular/common/http'; +import { Injector, inject } from '@angular/core'; +import { catchError, throwError } from 'rxjs'; +import { AUTH_BFF_BASE_URL } from './auth.config'; +import { AuthService } from './auth.service'; + +/** + * HTTP interceptor that keeps the SPA's `AuthService` state in sync + * with the BFF whenever a 401 leaks through. The session may have + * been destroyed server-side (absolute-timeout middleware, manual + * revoke, idle-TTL expiry) while the SPA still thinks the user is + * `authenticated` — without this, components would keep rendering + * stale identity until the next manual refresh. + * + * Behaviour on 401 from a BFF route: + * - call `auth.refresh()` so the next `/me` updates `state` to + * `anonymous` (or `error` if Redis is down too). + * - rethrow the original error so the call site still observes + * its failure and can show its own fallback UI / route guards + * can react on the next navigation. + * + * **Skips `/auth/me` itself.** `AuthService.refresh()` calls `/me`, + * which legitimately 401s when anonymous. Triggering `refresh()` + * again on that response would loop indefinitely. + * + * Other 4xx / 5xx pass through untouched — they are domain errors, + * not session-state signals. + */ +export const bffUnauthorizedInterceptor: HttpInterceptorFn = ( + req: HttpRequest, + next: HttpHandlerFn, +) => { + const bffBaseUrl = inject(AUTH_BFF_BASE_URL); + // Inject the parent injector here rather than `AuthService` directly: + // the bootstrap `/me` round-trip is fired from `AuthService`'s own + // constructor, so a synchronous `inject(AuthService)` here would + // re-enter DI while `AuthService` is still being constructed and + // raise a circular-construction error that swallows the original + // request before the testing backend can record it. Resolving + // `AuthService` lazily from `catchError` defers the lookup to a + // post-construction tick. + const injector = inject(Injector); + + return next(req).pipe( + catchError((err: unknown) => { + if ( + err instanceof HttpErrorResponse && + err.status === 401 && + req.url.startsWith(bffBaseUrl) && + !req.url.startsWith(`${bffBaseUrl}/auth/me`) + ) { + // Best-effort sync — swallow the refresh-side error so the + // original 401 is what bubbles up to the caller. + const auth = injector.get(AuthService); + void auth.refresh().catch(() => undefined); + } + return throwError(() => err); + }), + ); +};