feat(portal-shell): authGuard + BFF http interceptors + /profile demo route #117

Merged
julien merged 1 commits from feat/portal-shell/auth-guard-interceptors into main 2026-05-13 00:43:57 +02:00
Owner

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

  • pnpm nx test feature-auth19/19 pass (was 8; +11 across auth.guard.spec.ts, bff-credentials.interceptor.spec.ts, bff-unauthorized.interceptor.spec.ts).
  • pnpm nx test portal-shell34/34 pass (was 32; +2 for the Profile component).
  • pnpm nx lint feature-auth portal-shell → clean.
  • pnpm nx build portal-shell → clean. Bundle: main 492 kB raw / 131 kB transfer (well under the 300 KB gzip budget per ADR-0017).
  • 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".
## 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".
julien added 1 commit 2026-05-13 00:32:12 +02:00
feat(portal-shell): authGuard + BFF http interceptors + /profile demo route
CI / commits (pull_request) Successful in 1m29s
CI / scan (pull_request) Successful in 1m39s
CI / check (pull_request) Successful in 1m50s
CI / a11y (pull_request) Successful in 55s
CI / perf (pull_request) Successful in 2m22s
9247e5e02e
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)
julien merged commit 177f2f20c0 into main 2026-05-13 00:43:57 +02:00
julien deleted branch feat/portal-shell/auth-guard-interceptors 2026-05-13 00:43:58 +02:00
Sign in to join this conversation.
No Reviewers
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: julien/apf_portal#117