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
+20 -3
View File
@@ -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).
+11
View File
@@ -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
// `<base href="/{locale}/">`, so the router never actually sees
// the locale segment — `/fr/foo` is normalised to `foo` before
@@ -0,0 +1,71 @@
@if (user(); as currentUser) {
<section class="mx-auto max-w-2xl px-6 py-12">
<h1 class="text-3xl font-semibold text-gray-900 dark:text-gray-100" i18n="@@profile.heading">
My profile
</h1>
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400" i18n="@@profile.intro">
Identity served by the BFF from the active session.
</p>
<dl
class="mt-8 grid grid-cols-1 gap-4 rounded-lg border border-gray-200 bg-white p-6 sm:grid-cols-2 dark:border-gray-800 dark:bg-gray-900"
>
<div>
<dt
class="text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400"
i18n="@@profile.field.displayName"
>
Display name
</dt>
<dd
class="mt-1 text-sm font-medium text-gray-900 dark:text-gray-100"
data-testid="profile-displayname"
>
{{ currentUser.displayName }}
</dd>
</div>
<div>
<dt
class="text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400"
i18n="@@profile.field.username"
>
Username
</dt>
<dd
class="mt-1 text-sm font-medium text-gray-900 dark:text-gray-100"
data-testid="profile-username"
>
{{ currentUser.username }}
</dd>
</div>
<div>
<dt
class="text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400"
i18n="@@profile.field.oid"
>
Entra object id
</dt>
<dd
class="mt-1 break-all font-mono text-xs text-gray-700 dark:text-gray-300"
data-testid="profile-oid"
>
{{ currentUser.oid }}
</dd>
</div>
<div>
<dt
class="text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400"
i18n="@@profile.field.tid"
>
Tenant id
</dt>
<dd
class="mt-1 break-all font-mono text-xs text-gray-700 dark:text-gray-300"
data-testid="profile-tid"
>
{{ currentUser.tid }}
</dd>
</div>
</dl>
</section>
}
@@ -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');
});
});
@@ -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;
}
@@ -178,6 +178,36 @@
<source>APF Portal</source>
<target>Portail APF</target>
</trans-unit>
<trans-unit id="route.profile.title" datatype="html">
<source>My profile · APF Portal</source>
<target>Mon profil · Portail APF</target>
</trans-unit>
<!-- profile -->
<trans-unit id="profile.heading" datatype="html">
<source>My profile</source>
<target>Mon profil</target>
</trans-unit>
<trans-unit id="profile.intro" datatype="html">
<source>Identity served by the BFF from the active session.</source>
<target>Identité fournie par le BFF depuis la session active.</target>
</trans-unit>
<trans-unit id="profile.field.displayName" datatype="html">
<source>Display name</source>
<target>Nom daffichage</target>
</trans-unit>
<trans-unit id="profile.field.username" datatype="html">
<source>Username</source>
<target>Identifiant</target>
</trans-unit>
<trans-unit id="profile.field.oid" datatype="html">
<source>Entra object id</source>
<target>Identifiant Entra (oid)</target>
</trans-unit>
<trans-unit id="profile.field.tid" datatype="html">
<source>Tenant id</source>
<target>Identifiant du tenant</target>
</trans-unit>
<!-- locale switcher -->
<trans-unit id="locale.menu.aria" datatype="html">