feat(portal-shell): authGuard + BFF http interceptors + /profile demo route #117
Reference in New Issue
Block a user
Delete Branch "feat/portal-shell/auth-guard-interceptors"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
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 onAuthService.state. Waits out the bootstraploadingstate, allows whenauthenticated, redirects throughauth.login()(full-page navigation to the BFF's/auth/login→ Entra round-trip) whenanonymousorerror.bffCredentialsInterceptor— flipswithCredentials: trueon every request whose URL starts withAUTH_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— callsAuthService.refresh()when a BFF route (other than/auth/meitself) answers 401. Keeps the SPA's auth state in sync after server-side session destruction (absolute-timeout, manual revoke, idle-TTL expiry)./profiledemo route — first real consumer of the guard. Lazy-loaded component that renders the curatedCurrentUserpayload (display name, username, oid, tid). Exercises the full loop end-to-end: guard waits on /me → BFF answers → SPA renders.Notable choices
Lazy
AuthServiceresolution in the 401 interceptor. A naiveinject(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 injectAuthServicewhile it's still being constructed. The fix is to inject the parentInjectorand resolveAuthServicelazily insidecatchError— by the time a 401 actually fires, construction is done. Standard Angular pattern for "interceptor depends on a service that uses HttpClient"./auth/meis excluded from the 401 refresh trigger. The interceptor's whole job is to catch session-state drift;/meis the probeAuthService.refresh()itself uses. Without the exclusion, a 401 from /me would callrefresh()→ another /me → another 401 → infinite loop.On
errorstate, 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: trueremoved fromAuthService.refresh(). The interceptor now applies it uniformly. The spec that pinned the per-call flag is also gone — that contract moved tobff-credentials.interceptor.spec.tswhere it belongs.profileTitle+ 6 new i18n message ids.route.profile.title,profile.heading,profile.intro,profile.field.{displayName,username,oid,tid}shipped inmessages.fr.xlfwith FR translations.Out of scope (next PRs)
/profileis just an auth-loop fixture today.Test plan
pnpm nx test feature-auth→ 19/19 pass (was 8; +11 acrossauth.guard.spec.ts,bff-credentials.interceptor.spec.ts,bff-unauthorized.interceptor.spec.ts).pnpm nx test portal-shell→ 34/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).env -u REDIS_URL -u SESSION_* ... pnpm exec nx run-many -t test→ 123 + 19 + 34 = 176/176 pass./profile→ redirect to/auth/login→ Entra → callback → SPA lands at/profilewith identity card filled in.SESSION_ABSOLUTE_TIMEOUT_SECONDS=5in BFF.env, wait) → next BFF call returns 401 → header flips to "Sign in".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)