feat(portal-bff): /auth/callback route — token exchange + amr check (#107)
## 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:
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user