177f2f20c0
## 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
90 lines
3.0 KiB
TypeScript
90 lines
3.0 KiB
TypeScript
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);
|
|
});
|
|
});
|