feat(portal-shell): wire SPA auth state to BFF /me + header login/logout widget
CI / scan (pull_request) Successful in 3m18s
CI / commits (pull_request) Successful in 3m19s
CI / check (pull_request) Successful in 3m31s
CI / a11y (pull_request) Successful in 2m28s
CI / perf (pull_request) Successful in 6m24s

first user-visible piece of the auth track. portal-shell now consumes
the bff auth surface and the header reflects sign-in state.

libs/feature/auth replaces the empty nx scaffold with an AuthService
that:
  - fetches /api/auth/me on first injection (unawaited so the first
    paint isn't blocked behind the round-trip).
  - holds a signal-backed discriminated AuthState — loading /
    anonymous / authenticated / error — so templates switch on .kind
    and the type narrows automatically.
  - exposes currentUser + isLoading computed signals on top.
  - provides login() / logout() / refresh() methods. navigation
    goes through an injected AUTH_NAVIGATOR token whose default
    calls window.location.assign(url) — tests substitute a vi.fn()
    instead of redefining window.location (which jsdom resists
    across multiple specs in the same file).

distinct error state vs. anonymous: a network failure surfaces a
"Can't reach the server" chip; a 401 surfaces the sign-in button.
treating any /me failure as anonymous would silently hide outages.

curated CurrentUser type mirrors the bff's /me response (oid, tid,
username, displayName) — no amr, no internal claims. consumers
import it without pulling in angular http types.

the host wires AUTH_BFF_BASE_URL from environment.ts (per adr-0018)
and AUTH_NAVIGATOR is providedIn:'root' with the window.location
default, so apps don't need to touch it. tsconfig.app.json picks up
the new lib reference via `nx sync`.

header user-widget renders four states with i18n message ids
(header.signIn / header.signOut / header.userMenu.loading /
header.authError). avatar shows the user's initials computed from
displayName ("JD" for "Jane Doe"); display name shows on sm: and
up so the rail stays compact on narrow viewports.

out of scope (next prs):
- route guards
- auto-refresh before idle timeout
- http interceptor that redirects to /auth/login on bff 401s
This commit is contained in:
Julien Gautier
2026-05-12 20:10:33 +02:00
parent 0464ce3ac8
commit 092ccb7bda
16 changed files with 529 additions and 72 deletions
+3 -1
View File
@@ -1 +1,3 @@
export * from './lib/feature-auth/feature-auth';
export { AUTH_BFF_BASE_URL, AUTH_NAVIGATOR } from './lib/auth.config';
export { AuthService } from './lib/auth.service';
export type { AuthState, CurrentUser } from './lib/auth.types';
+28
View File
@@ -0,0 +1,28 @@
import { InjectionToken } from '@angular/core';
/**
* BFF API base URL the AuthService prepends to every backend call
* (`${base}/auth/me`, `${base}/auth/login`, …). Provided by the host
* application from its per-environment config (`environment.ts`,
* per ADR-0018) so the lib stays decoupled from the app's
* environment file shape.
*
* Wiring lives in the host's `ApplicationConfig`:
*
* { provide: AUTH_BFF_BASE_URL, useValue: environment.bffApiBaseUrl }
*/
export const AUTH_BFF_BASE_URL = new InjectionToken<string>('AUTH_BFF_BASE_URL');
/**
* Indirection over `window.location.assign(...)` used by `login()`
* and `logout()`. Injecting the navigator lets specs assert the
* outbound URL without having to redefine `window.location` (which
* jsdom resists across multiple tests in the same file).
*
* The default implementation in `provideAuth()` calls
* `window.location.assign(url)`. Tests can override with a `vi.fn()`.
*/
export const AUTH_NAVIGATOR = new InjectionToken<(url: string) => void>('AUTH_NAVIGATOR', {
providedIn: 'root',
factory: () => (url: string) => window.location.assign(url),
});
@@ -0,0 +1,125 @@
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, AUTH_NAVIGATOR } from './auth.config';
import { AuthService } from './auth.service';
import type { CurrentUser } from './auth.types';
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',
};
interface Fixture {
service: AuthService;
http: HttpTestingController;
navigate: ReturnType<typeof vi.fn>;
}
function setup(): Fixture {
const navigate = vi.fn();
TestBed.configureTestingModule({
providers: [
provideHttpClient(),
provideHttpClientTesting(),
{ provide: AUTH_BFF_BASE_URL, useValue: BFF_BASE },
{ provide: AUTH_NAVIGATOR, useValue: navigate },
AuthService,
],
});
const service = TestBed.inject(AuthService);
const http = TestBed.inject(HttpTestingController);
return { service, http, navigate };
}
describe('AuthService', () => {
afterEach(() => {
TestBed.resetTestingModule();
});
describe('bootstrap fetch', () => {
it('starts in the loading state before /me resolves', () => {
const { service, http } = setup();
expect(service.state().kind).toBe('loading');
expect(service.isLoading()).toBe(true);
expect(service.currentUser()).toBeNull();
// Drain the in-flight bootstrap request to keep the controller clean.
http.expectOne(ME_URL).flush({}, { status: 401, statusText: 'Unauthorized' });
});
it('transitions to authenticated when /me returns the user', async () => {
const { service, http } = setup();
http.expectOne(ME_URL).flush(USER);
// Let the awaited firstValueFrom commit the state.
await Promise.resolve();
expect(service.state()).toEqual({ kind: 'authenticated', user: USER });
expect(service.currentUser()).toEqual(USER);
expect(service.isLoading()).toBe(false);
});
it('transitions to anonymous on 401', async () => {
const { service, http } = setup();
http
.expectOne(ME_URL)
.flush({ error: 'unauthenticated' }, { status: 401, statusText: 'Unauthorized' });
await Promise.resolve();
expect(service.state().kind).toBe('anonymous');
expect(service.currentUser()).toBeNull();
});
it('transitions to error on a non-401 failure (network / 5xx / malformed)', async () => {
const { service, http } = setup();
http.expectOne(ME_URL).flush('boom', { status: 500, statusText: 'Internal Server Error' });
await Promise.resolve();
expect(service.state().kind).toBe('error');
expect(service.currentUser()).toBeNull();
});
});
describe('refresh()', () => {
it('can be called again after the bootstrap fetch and re-fetches /me', async () => {
const { service, http } = setup();
http.expectOne(ME_URL).flush(USER);
await Promise.resolve();
expect(service.state().kind).toBe('authenticated');
const promise = service.refresh();
http
.expectOne(ME_URL)
.flush({ error: 'unauthenticated' }, { status: 401, statusText: 'Unauthorized' });
await promise;
expect(service.state().kind).toBe('anonymous');
});
});
describe('URL accessors', () => {
it('derives me / login / logout URLs from the injected base', () => {
const { service, http } = setup();
expect(service.meUrl).toBe(`${BFF_BASE}/auth/me`);
expect(service.loginUrl).toBe(`${BFF_BASE}/auth/login`);
expect(service.logoutUrl).toBe(`${BFF_BASE}/auth/logout`);
http.expectOne(ME_URL).flush({}, { status: 401, statusText: 'Unauthorized' });
});
});
describe('login() / logout()', () => {
it('navigates to /auth/login via AUTH_NAVIGATOR', () => {
const { service, http, navigate } = setup();
service.login();
expect(navigate).toHaveBeenCalledWith(`${BFF_BASE}/auth/login`);
http.expectOne(ME_URL).flush({}, { status: 401, statusText: 'Unauthorized' });
});
it('navigates to /auth/logout via AUTH_NAVIGATOR', () => {
const { service, http, navigate } = setup();
service.logout();
expect(navigate).toHaveBeenCalledWith(`${BFF_BASE}/auth/logout`);
http.expectOne(ME_URL).flush({}, { status: 401, statusText: 'Unauthorized' });
});
});
});
+114
View File
@@ -0,0 +1,114 @@
import { HttpClient, HttpErrorResponse } from '@angular/common/http';
import { Injectable, computed, inject, signal } from '@angular/core';
import { firstValueFrom } from 'rxjs';
import { AUTH_BFF_BASE_URL, AUTH_NAVIGATOR } from './auth.config';
import type { AuthState, CurrentUser } from './auth.types';
/**
* SPA-side authentication state, sourced from the BFF's
* `GET /api/auth/me` (ADR-0009 / ADR-0010). Acts as the single point
* of truth for "is the user signed in?" — every consumer (header
* widget, future route guards, downstream API gates) reads from the
* `state` / `currentUser` signals.
*
* Auto-bootstraps on first injection: the constructor fires an
* unawaited `refresh()`, so consuming components see `{kind:
* 'loading'}` briefly, then either `authenticated` or `anonymous`.
*
* `login()` and `logout()` perform full-page navigations to the BFF
* routes — the SPA never holds tokens (per ADR-0009), so a browser
* redirect through the Entra round-trip is the canonical path.
*/
@Injectable({ providedIn: 'root' })
export class AuthService {
private readonly http = inject(HttpClient);
private readonly bffBaseUrl = inject(AUTH_BFF_BASE_URL);
private readonly navigate = inject(AUTH_NAVIGATOR);
private readonly _state = signal<AuthState>({ kind: 'loading' });
readonly state = this._state.asReadonly();
/**
* Convenience: the user payload when authenticated, `null`
* otherwise. Lets templates write `@if (currentUser(); as user)`
* without unwrapping the discriminated state by hand.
*/
readonly currentUser = computed<CurrentUser | null>(() => {
const s = this._state();
return s.kind === 'authenticated' ? s.user : null;
});
/** True only while the very first /me round-trip is in flight. */
readonly isLoading = computed(() => this._state().kind === 'loading');
constructor() {
// Fire-and-forget so the constructor stays synchronous —
// Angular DI runs `providedIn: 'root'` services on first inject,
// which happens during app boot; awaiting here would push the
// first render behind the network round-trip.
void this.refresh();
}
/**
* Re-fetch `/auth/me`. Called automatically on first injection;
* consumers can call it again to refresh after a flow that may
* have changed the session (login redirect return, MFA step-up).
* Resolves once the new state is committed.
*/
async refresh(): Promise<void> {
try {
const user = await firstValueFrom(this.http.get<CurrentUser>(this.meUrl));
this._state.set({ kind: 'authenticated', user });
} catch (err) {
this._state.set(toErrorState(err));
}
}
/**
* Initiate sign-in by navigating to the BFF's `/auth/login`. The
* BFF builds the Entra authorize URL and 302s the browser; the
* round-trip lands on `/auth/callback` which writes the session
* and redirects back to the SPA.
*
* The navigation goes through the injected {@link AUTH_NAVIGATOR}
* function — `window.location.assign` in production, a `vi.fn()`
* in specs.
*/
login(): void {
this.navigate(this.loginUrl);
}
/**
* Initiate sign-out by navigating to the BFF's `/auth/logout`,
* which destroys the session, clears the cookie, and 302s through
* Entra's RP-initiated logout — single sign-out per ADR-0009.
*/
logout(): void {
this.navigate(this.logoutUrl);
}
get meUrl(): string {
return `${this.bffBaseUrl}/auth/me`;
}
get loginUrl(): string {
return `${this.bffBaseUrl}/auth/login`;
}
get logoutUrl(): string {
return `${this.bffBaseUrl}/auth/logout`;
}
}
function toErrorState(err: unknown): AuthState {
// The BFF returns 401 with `{error: 'unauthenticated'}` when no
// session is on the request. Anything else (network failure,
// 5xx, malformed response) lands in the explicit `error` state so
// the UI can distinguish "please sign in" from "can't reach the
// server right now".
if (err instanceof HttpErrorResponse && err.status === 401) {
return { kind: 'anonymous' };
}
return { kind: 'error' };
}
+30
View File
@@ -0,0 +1,30 @@
/**
* Curated SPA-facing view of the session user, served by the BFF at
* `GET /api/auth/me`. Mirrors the `PublicUser` shape the BFF returns
* (apps/portal-bff/src/auth/auth.controller.ts) — internal claims
* like `amr` stay server-side and never reach the SPA.
*/
export interface CurrentUser {
readonly oid: string;
readonly tid: string;
readonly username: string;
readonly displayName: string;
}
/**
* The four observable states of authentication on the SPA, modelled
* as a discriminated union so consumers can `switch` on `kind` and
* the type narrows automatically.
*
* - `loading` — first /me call hasn't returned yet.
* - `anonymous` — BFF answered 401, no session cookie or expired.
* - `authenticated` — BFF returned the user payload.
* - `error` — network failure, 5xx, or unexpected shape. Distinct
* from `anonymous` so the UI can surface a different message
* ("can't reach server" vs. "please sign in").
*/
export type AuthState =
| { readonly kind: 'loading' }
| { readonly kind: 'anonymous' }
| { readonly kind: 'authenticated'; readonly user: CurrentUser }
| { readonly kind: 'error' };
@@ -1 +0,0 @@
<p>FeatureAuth works!</p>
@@ -1,21 +0,0 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { FeatureAuth } from './feature-auth';
describe('FeatureAuth', () => {
let component: FeatureAuth;
let fixture: ComponentFixture<FeatureAuth>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [FeatureAuth],
}).compileComponents();
fixture = TestBed.createComponent(FeatureAuth);
component = fixture.componentInstance;
await fixture.whenStable();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});
@@ -1,9 +0,0 @@
import { Component } from '@angular/core';
@Component({
selector: 'lib-feature-auth',
imports: [],
templateUrl: './feature-auth.html',
styleUrl: './feature-auth.css',
})
export class FeatureAuth {}