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,
|
provideBrowserGlobalErrorListeners,
|
||||||
provideZonelessChangeDetection,
|
provideZonelessChangeDetection,
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { provideHttpClient, withFetch } from '@angular/common/http';
|
import { provideHttpClient, withFetch, withInterceptors } from '@angular/common/http';
|
||||||
import { provideRouter } from '@angular/router';
|
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 { environment } from '../environments/environment';
|
||||||
import { appRoutes } from './app.routes';
|
import { appRoutes } from './app.routes';
|
||||||
|
|
||||||
@@ -19,7 +23,20 @@ export const appConfig: ApplicationConfig = {
|
|||||||
// fetch` patches — every HttpClient request gets its own span and
|
// fetch` patches — every HttpClient request gets its own span and
|
||||||
// the W3C `traceparent` header propagated to the BFF
|
// the W3C `traceparent` header propagated to the BFF
|
||||||
// automatically. The legacy XHR backend would short-circuit that.
|
// 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
|
// `feature-auth` is environment-agnostic and reads the BFF base
|
||||||
// URL from this token — provided once per app from `environment.ts`
|
// URL from this token — provided once per app from `environment.ts`
|
||||||
// (per ADR-0018).
|
// (per ADR-0018).
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Route } from '@angular/router';
|
import { Route } from '@angular/router';
|
||||||
|
import { authGuard } from 'feature-auth';
|
||||||
|
|
||||||
// Tab titles per route — marked with `$localize` so each locale's
|
// Tab titles per route — marked with `$localize` so each locale's
|
||||||
// bundle ships its own. The accessibility statement now lives at the
|
// bundle ships its own. The accessibility statement now lives at the
|
||||||
@@ -6,6 +7,7 @@ import { Route } from '@angular/router';
|
|||||||
// previous `/accessibility` + `/accessibilite` pair).
|
// previous `/accessibility` + `/accessibilite` pair).
|
||||||
const homeTitle = $localize`:@@route.home.title:APF Portal`;
|
const homeTitle = $localize`:@@route.home.title:APF Portal`;
|
||||||
const accessibilityTitle = $localize`:@@route.accessibility.title:Accessibility statement · 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[] = [
|
export const appRoutes: Route[] = [
|
||||||
{
|
{
|
||||||
@@ -31,6 +33,15 @@ export const appRoutes: Route[] = [
|
|||||||
redirectTo: 'accessibility',
|
redirectTo: 'accessibility',
|
||||||
pathMatch: 'full',
|
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
|
// Catch-all. In production each locale ships with its own
|
||||||
// `<base href="/{locale}/">`, so the router never actually sees
|
// `<base href="/{locale}/">`, so the router never actually sees
|
||||||
// the locale segment — `/fr/foo` is normalised to `foo` before
|
// 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>
|
<source>APF Portal</source>
|
||||||
<target>Portail APF</target>
|
<target>Portail APF</target>
|
||||||
</trans-unit>
|
</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 -->
|
<!-- locale switcher -->
|
||||||
<trans-unit id="locale.menu.aria" datatype="html">
|
<trans-unit id="locale.menu.aria" datatype="html">
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
export { AUTH_BFF_BASE_URL, AUTH_NAVIGATOR } from './lib/auth.config';
|
export { AUTH_BFF_BASE_URL, AUTH_NAVIGATOR } from './lib/auth.config';
|
||||||
|
export { authGuard } from './lib/auth.guard';
|
||||||
export { AuthService } from './lib/auth.service';
|
export { AuthService } from './lib/auth.service';
|
||||||
export type { AuthState, CurrentUser } from './lib/auth.types';
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
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 () => {
|
it('transitions to anonymous on 401', async () => {
|
||||||
const { service, http } = setup();
|
const { service, http } = setup();
|
||||||
http
|
http
|
||||||
|
|||||||
@@ -58,18 +58,13 @@ export class AuthService {
|
|||||||
*/
|
*/
|
||||||
async refresh(): Promise<void> {
|
async refresh(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
// `withCredentials: true` is mandatory: the SPA at
|
// `withCredentials: true` is mandatory for the SPA→BFF
|
||||||
// http://localhost:4200 calls the BFF at http://localhost:3000 —
|
// cross-origin call (different ports = different origins, so
|
||||||
// different origins, so `fetch`'s default `credentials:
|
// `fetch`'s default `credentials: 'same-origin'` would drop
|
||||||
// 'same-origin'` would drop the `__Host-portal_session` cookie
|
// the session cookie). It's applied uniformly by the
|
||||||
// and /me would always answer 401. Production (single origin
|
// `bffCredentialsInterceptor` for every request whose URL
|
||||||
// behind the same edge) doesn't need it but it's harmless there.
|
// starts with `AUTH_BFF_BASE_URL` — including this one.
|
||||||
// CORS on the BFF side already allows credentials
|
const user = await firstValueFrom(this.http.get<CurrentUser>(this.meUrl));
|
||||||
// (`apps/portal-bff/src/main.ts` → `enableCors({ credentials:
|
|
||||||
// true })`).
|
|
||||||
const user = await firstValueFrom(
|
|
||||||
this.http.get<CurrentUser>(this.meUrl, { withCredentials: true }),
|
|
||||||
);
|
|
||||||
this._state.set({ kind: 'authenticated', user });
|
this._state.set({ kind: 'authenticated', user });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this._state.set(toErrorState(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);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user