feat(portal-shell): authGuard + BFF http interceptors + /profile demo route
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)
This commit is contained in:
@@ -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).
|
||||
|
||||
@@ -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 d’affichage</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">
|
||||
|
||||
Reference in New Issue
Block a user