feat(portal-shell): authGuard + BFF http interceptors + /profile demo route (#117)
CI / commits (push) Has been skipped
CI / scan (push) Successful in 1m57s
CI / check (push) Successful in 2m19s
CI / a11y (push) Successful in 51s
CI / perf (push) Successful in 3m33s

## Summary

Brings the SPA auth track to the level of polish the BFF surface deserves. After #113/#114, the header reflects sign-in state — but the SPA had no protected routes and no global handling of session-state drift. This PR adds three building blocks (one guard, two interceptors) plus one demo consumer.

- **`authGuard`** (`CanActivateFn`) — gates routes on `AuthService.state`. Waits out the bootstrap `loading` state, allows when `authenticated`, redirects through `auth.login()` (full-page navigation to the BFF's `/auth/login` → Entra round-trip) when `anonymous` or `error`.
- **`bffCredentialsInterceptor`** — flips `withCredentials: true` on every request whose URL starts with `AUTH_BFF_BASE_URL`. Replaces the per-call flag we had on `/me` (#114) with a single point of truth. Future BFF calls inherit it automatically — no chance of forgetting it.
- **`bffUnauthorizedInterceptor`** — calls `AuthService.refresh()` when a BFF route (other than `/auth/me` itself) answers 401. Keeps the SPA's auth state in sync after server-side session destruction (absolute-timeout, manual revoke, idle-TTL expiry).
- **`/profile`** demo route — first real consumer of the guard. Lazy-loaded component that renders the curated `CurrentUser` payload (display name, username, oid, tid). Exercises the full loop end-to-end: guard waits on /me → BFF answers → SPA renders.

## Notable choices

**Lazy `AuthService` resolution in the 401 interceptor.** A naive `inject(AuthService)` at the top of the interceptor caused a circular-construction error: `AuthService`'s own constructor fires the bootstrap `/me`, which goes through the interceptor chain, which tries to inject `AuthService` while it's still being constructed. The fix is to inject the parent `Injector` and resolve `AuthService` lazily inside `catchError` — by the time a 401 actually fires, construction is done. Standard Angular pattern for "interceptor depends on a service that uses HttpClient".

**`/auth/me` is excluded from the 401 refresh trigger.** The interceptor's whole job is to catch session-state drift; `/me` is the probe `AuthService.refresh()` itself uses. Without the exclusion, a 401 from /me would call `refresh()` → another /me → another 401 → infinite loop.

**On `error` state, the guard still redirects to `/auth/login`.** Could have shown a "can't reach the server" page on the protected route, but the BFF-side login screen surfaces diagnostics more usefully (Entra's own error path) than a generic SPA outage page would.

**Per-call `withCredentials: true` removed from `AuthService.refresh()`.** The interceptor now applies it uniformly. The spec that pinned the per-call flag is also gone — that contract moved to `bff-credentials.interceptor.spec.ts` where it belongs.

**`profileTitle` + 6 new i18n message ids.** `route.profile.title`, `profile.heading`, `profile.intro`, `profile.field.{displayName,username,oid,tid}` shipped in `messages.fr.xlf` with FR translations.

## Out of scope (next PRs)

- A real user-profile feature (settings, preferences, etc.) — `/profile` is just an auth-loop fixture today.
- Showing the auth-loading state on protected routes (currently the guard blocks navigation; the user sees the previous route until /me resolves). Acceptable for v1.

## Test plan

- [x] `pnpm nx test feature-auth` → **19/19 pass** (was 8; +11 across `auth.guard.spec.ts`, `bff-credentials.interceptor.spec.ts`, `bff-unauthorized.interceptor.spec.ts`).
- [x] `pnpm nx test portal-shell` → **34/34 pass** (was 32; +2 for the Profile component).
- [x] `pnpm nx lint feature-auth portal-shell` → clean.
- [x] `pnpm nx build portal-shell` → clean. Bundle: main 492 kB raw / 131 kB transfer (well under the 300 KB gzip budget per ADR-0017).
- [x] **CI clean-env repro** (lesson from #115/#116): `env -u REDIS_URL -u SESSION_*  ... pnpm exec nx run-many -t test` → 123 + 19 + 34 = **176/176 pass**.
- [ ] Manual smoke against running BFF:
  - [ ] Anonymous → visit `/profile` → redirect to `/auth/login` → Entra → callback → SPA lands at `/profile` with identity card filled in.
  - [ ] Trigger an absolute-timeout (set `SESSION_ABSOLUTE_TIMEOUT_SECONDS=5` in BFF `.env`, wait) → next BFF call returns 401 → header flips to "Sign in".

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #117
This commit was merged in pull request #117.
This commit is contained in:
2026-05-13 00:43:56 +02:00
parent c427e5d4fe
commit 177f2f20c0
15 changed files with 642 additions and 27 deletions
+3
View File
@@ -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';
@@ -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<boolean | unknown> {
// 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);
});
});
+49
View File
@@ -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;
};
@@ -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
+7 -12
View File
@@ -58,18 +58,13 @@ export class AuthService {
*/
async refresh(): Promise<void> {
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<CurrentUser>(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<CurrentUser>(this.meUrl));
this._state.set({ kind: 'authenticated', user });
} catch (err) {
this._state.set(toErrorState(err));
@@ -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({});
});
});
@@ -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<unknown>,
next: HttpHandlerFn,
) => {
const bffBaseUrl = inject(AUTH_BFF_BASE_URL);
if (!req.url.startsWith(bffBaseUrl)) {
return next(req);
}
return next(req.clone({ withCredentials: true }));
};
@@ -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<T>(p: Promise<T>): Promise<unknown> {
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();
});
});
@@ -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<unknown>,
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);
}),
);
};