feat(portal-bff): /auth/callback route — token exchange + amr check (#107)
CI / check (push) Successful in 2m32s
CI / commits (push) Has been skipped
CI / scan (push) Successful in 2m19s
CI / a11y (push) Successful in 1m6s
CI / perf (push) Successful in 3m51s

## Summary

Fourth step of ADR-0009 wiring. Closes the OIDC round-trip on the BFF side (modulo session persistence — that's the next PR per ADR-0010). Entra now redirects the user back to `GET /api/auth/callback`; the BFF verifies the state, exchanges the code for tokens via MSAL's `acquireTokenByCode`, runs the ADR-0011 `amr` sanity-check, logs the resolved identity to Pino, clears the single-use pre-auth cookie, and 302s the user back to the SPA.

## What lands

- **[`auth.errors.ts`](apps/portal-bff/src/auth/auth.errors.ts)** — discriminated-union `AuthCodeFlowError` (`state-mismatch` / `flow-expired` / `amr-missing` / `token-exchange-failed`) + `AuthCodeFlowException` wrapper. The `kind` field doubles as the `?auth_error=<code>` query param on the SPA-bound redirect so the front-end can render an exact message without duplicating the string set.
- **[`AuthService.completeAuthCodeFlow(code, state, preAuth, now?)`](apps/portal-bff/src/auth/auth.service.ts)** — verifies state binding, refuses cookies older than the 5-minute flow TTL, calls MSAL Node's `acquireTokenByCode` with the stored verifier, validates `amr` is non-empty (the BFF sanity-check per ADR-0011 — Entra Conditional Access on the org side does the real enforcement), extracts `oid` / `tid` / `preferred_username` / `name` / `amr` into an `AuthenticatedUser` shape.
- **[`auth.cookie.ts`](apps/portal-bff/src/auth/auth.cookie.ts)** gains `clearPreAuthCookieOptions()` mirroring the set-options minus `maxAge` so the browser actually drops the cookie. (Cookies match by name + path + secure; getting any of those wrong leaves the old cookie in place.)
- **[`AuthController.callback()`](apps/portal-bff/src/auth/auth.controller.ts)** — `@Get('callback')`. Always clears the cookie first (single-use). Bails on Entra-side errors (`?error=`), missing query params, missing or malformed cookie — each branch logs a structured Pino warning and redirects with the right `auth_error` code. On `AuthCodeFlowException`, logs + redirects with the typed `kind`. On success, logs an `auth.signed_in` event with `oid`, `tid`, `username`, `amr` (PII-sensitive bits only; no tokens), then 302s to `entra.postLogoutRedirectUri`.

## Decisions worth flagging

- **`postLogoutRedirectUri` reused as the SPA root URL.** Semantically a tiny stretch (its OIDC role is the post-logout destination) but the value is the same. Avoids one more env var until / unless the two URLs need to diverge.
- **Cookie cleared FIRST**, before any branching. Single-use is a property we want guaranteed regardless of which path exits the handler — overlap with a parallel /login from the same browser session would otherwise leak a usable cookie.
- **`auth.signed_in` logged via Pino, not via the audit module.** ADR-0013 wants this in the audit table; pairing audit with the session that ships in the next PR keeps the audit row carrying a `session_id` (otherwise it'd reference a "phantom" auth event with no follow-up).
- **`amr` non-empty is the BFF's check; the Conditional Access policy is what enforces "MFA happened".** ADR-0011 explicitly factors it this way — empty `amr` would indicate a policy misconfiguration where MFA never fired.

## Verification

- `nx run-many -t lint test build --projects=portal-bff` — green.
- **52 / 52 specs** (was 39; +13 across the new completeFlow branches and callback branches).
- Service spec covers happy path + 6 failure modes (state mismatch, flow expired, amr missing, MSAL throws, MSAL returns null, oid claim missing).
- Controller spec covers happy redirect, Entra error, missing cookie, AuthCodeFlowException branch, missing query, malformed cookie.

## Manual smoke test (end-to-end)

1. `apps/portal-bff/.env` carries real `ENTRA_*` + `SESSION_SECRET`.
2. `nx serve portal-bff` and `nx serve portal-shell`.
3. Open `http://localhost:3000/api/auth/login` → redirects to Entra.
4. Authenticate. Entra redirects to `http://localhost:3000/api/auth/callback?code=…&state=…`.
5. BFF processes; redirects to `http://localhost:4200/`. Pino log shows `auth.signed_in` with the user's `oid`, `tid`, `username`, `amr`.
6. Tamper test: open the link again, hand-edit the `state=` in the callback URL → BFF redirects with `?auth_error=state-mismatch`.

## What this PR explicitly does NOT do

- **Persist a session.** The user is "authenticated" from the BFF's point of view (identity resolved + logged) but the next request lands anonymous. Closes in the Redis sessions PR per ADR-0010.
- **Audit log entry.** Pairs with sessions so the row carries a `session_id`.
- **Logout / `/me`.** Land after sessions.

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #107
This commit was merged in pull request #107.
This commit is contained in:
2026-05-12 12:16:39 +02:00
parent 9443a52bb7
commit c50794eceb
6 changed files with 604 additions and 45 deletions
+123 -1
View File
@@ -1,5 +1,11 @@
import { ConfidentialClientApplication, CryptoProvider } from '@azure/msal-node';
import {
ConfidentialClientApplication,
CryptoProvider,
type AuthenticationResult,
} from '@azure/msal-node';
import { Inject, Injectable } from '@nestjs/common';
import { PRE_AUTH_COOKIE_TTL_MS } from './auth.cookie';
import { AuthCodeFlowException } from './auth.errors';
import { ENTRA_CONFIG, type EntraConfig } from './entra-config.token';
import { MSAL_CLIENT } from './msal-client.token';
@@ -21,6 +27,25 @@ export interface AuthCodeFlowStart {
readonly preAuthPayload: PreAuthPayload;
}
/**
* Identity resolved by `completeAuthCodeFlow`. Carries the claims
* the rest of the BFF / SPA care about post-authentication:
* - `oid`: stable per-user object id inside the Entra tenant.
* - `tid`: tenant id the user authenticated against (for the
* dual-audience / multi-tenant logic to come).
* - `username` / `displayName`: surfaced in the UI.
* - `amr`: authentication methods reference — the array used by
* the BFF's MFA sanity-check (ADR-0011) and by `@RequireMfa`
* freshness checks once the session lands.
*/
export interface AuthenticatedUser {
readonly oid: string;
readonly tid: string;
readonly username: string;
readonly displayName: string;
readonly amr: readonly string[];
}
/**
* The minimum OIDC scopes — `openid` to get an ID token, `profile`
* for the user's display name / preferred_username, `email` for the
@@ -71,4 +96,101 @@ export class AuthService {
},
};
}
/**
* Second leg. Verifies the state round-trip, refuses cookies
* older than the flow TTL, asks MSAL to exchange the auth code
* for tokens using the stored PKCE verifier, then runs the
* BFF-side sanity-checks ADR-0009 mandates (state, expiry, `amr`).
*
* MSAL Node performs the heavy ID-token validation itself
* (signature against Entra's JWKS, issuer + audience, exp, nbf).
* What this method adds on top is the application-layer policy:
* - anti-CSRF state binding,
* - flow-TTL replay protection,
* - MFA presence (`amr`) per ADR-0011.
*/
async completeAuthCodeFlow(
code: string,
state: string,
preAuth: PreAuthPayload,
now: number = Date.now(),
): Promise<AuthenticatedUser> {
if (state !== preAuth.state) {
throw new AuthCodeFlowException({ kind: 'state-mismatch' });
}
if (now - preAuth.createdAt > PRE_AUTH_COOKIE_TTL_MS) {
throw new AuthCodeFlowException({ kind: 'flow-expired' });
}
let result: AuthenticationResult | null;
try {
result = await this.msal.acquireTokenByCode({
code,
codeVerifier: preAuth.codeVerifier,
redirectUri: this.config.redirectUri,
scopes: [...SCOPES],
});
} catch (err) {
throw new AuthCodeFlowException({
kind: 'token-exchange-failed',
cause: errorCause(err),
});
}
if (!result) {
throw new AuthCodeFlowException({
kind: 'token-exchange-failed',
cause: 'msal returned null result',
});
}
return this.toAuthenticatedUser(result);
}
private toAuthenticatedUser(result: AuthenticationResult): AuthenticatedUser {
const claims = result.idTokenClaims as Record<string, unknown>;
const amr = Array.isArray(claims['amr'])
? (claims['amr'] as unknown[]).filter((v): v is string => typeof v === 'string')
: [];
// ADR-0011 sanity-check: Conditional Access on the org side is
// the actual enforcement layer; the BFF refuses tokens that
// lack any `amr` evidence (an empty array would indicate a
// policy misconfiguration where MFA never fired).
if (amr.length === 0) {
throw new AuthCodeFlowException({ kind: 'amr-missing' });
}
return {
oid: requireString(claims['oid'], 'oid'),
tid: requireString(claims['tid'], 'tid'),
username:
typeof claims['preferred_username'] === 'string'
? (claims['preferred_username'] as string)
: (result.account?.username ?? ''),
displayName:
typeof claims['name'] === 'string'
? (claims['name'] as string)
: (result.account?.name ?? ''),
amr,
};
}
}
function requireString(value: unknown, claim: string): string {
if (typeof value !== 'string' || value === '') {
throw new AuthCodeFlowException({
kind: 'token-exchange-failed',
cause: `id token missing required string claim: ${claim}`,
});
}
return value;
}
function errorCause(err: unknown): string {
if (err instanceof Error) {
return err.message;
}
return String(err);
}