feat(portal-shell): wire SPA auth state to BFF /me + header login/logout widget (#113)
## Summary First user-visible piece of the auth track. The portal-shell SPA now consumes the BFF auth surface (`/api/auth/me`, `/api/auth/login`, `/api/auth/logout`) and the header reflects sign-in state. - `libs/feature/auth` ships an `AuthService` that fetches `/auth/me` on first injection, holds a signal-backed `AuthState` (`loading` / `anonymous` / `authenticated` / `error`) and exposes `currentUser` + `isLoading` computed signals plus `login()` / `logout()` / `refresh()` methods. - The header's right-side widget renders four states: a sign-in button when anonymous, the user avatar (initials, `JD` for "Jane Doe") + display name + sign-out button when authenticated, a loading dot before `/me` resolves, and a "Can't reach the server" chip on non-401 failures. - `login()` / `logout()` go through an injected `AUTH_NAVIGATOR` token whose default calls `window.location.assign(url)`. Specs override it with `vi.fn()` — no `window.location` mocking required. ## Notable choices **Auto-bootstrap on construction, not via `provideAppInitializer`.** The service fires `/me` from its constructor (unawaited) so consuming components transition through the explicit `loading` state. Blocking app boot on the round-trip would push the first paint behind the network call — bad for TTFB, especially on slow links. The header handles `loading` as a first-class state. **Discriminated `AuthState` over flat fields.** A single source of truth (`state()`) with four `kind`s lets templates `switch` and narrow automatically. `currentUser` and `isLoading` are computed conveniences but never out of sync with `state`. **`AUTH_BFF_BASE_URL` + `AUTH_NAVIGATOR` injection tokens.** Decouples the lib from the host's `environment.ts` shape and keeps tests free of `window.location` redefinition (which jsdom resists across multiple specs in the same file — first redefine works, second throws "Cannot redefine property"). The host wires both in `app.config.ts`. **Curated public user type.** `CurrentUser` mirrors the BFF's `/me` response (`oid`, `tid`, `username`, `displayName`) — no `amr`, no internal claims. The shape lives in `auth.types.ts` so feature code can import it without depending on Angular HTTP details. **Distinct `error` state.** A network failure / 5xx surfaces a different UI than "not signed in" — "Can't reach the server" chip vs. sign-in button. Avoids the trap of treating any `/me` failure as "anonymous". ## Out of scope (next PRs) - Route guards (protecting routes from anonymous users). For now the header is the only consumer. - Auto-refresh of the session before idle timeout. - HTTP interceptor that redirects to `/auth/login` on a 401 from any other BFF call. - Per-locale styling polish on the new header strings. ## Test plan - [x] `pnpm nx test feature-auth` → **8/8 pass**. - [x] `pnpm nx test portal-shell` → **32/32 pass** (was 27 before). - [x] `pnpm nx lint portal-shell feature-auth` → clean. - [x] `pnpm nx build portal-shell` → main bundle 488 kB raw / 129.94 kB transfer; well under the 300 kB gzip budget from ADR-0017. - [ ] Manual smoke once the BFF is up: - [ ] Anonymous landing → header shows "Sign in". - [ ] Click "Sign in" → BFF /login → Entra → callback → SPA lands with avatar + display name in the header. - [ ] Click "Sign out" → BFF /logout → Entra logout → back at SPA, header back to "Sign in". --------- Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr> Reviewed-on: #113
This commit was merged in pull request #113.
This commit is contained in:
@@ -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';
|
||||
|
||||
@@ -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' });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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' };
|
||||
}
|
||||
@@ -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 {}
|
||||
Reference in New Issue
Block a user