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
+131 -9
View File
@@ -1,19 +1,29 @@
import { Controller, Get, Res } from '@nestjs/common';
import type { Response } from 'express';
import { AuthService } from './auth.service';
import { PRE_AUTH_COOKIE_NAME, preAuthCookieOptions } from './auth.cookie';
import { Controller, Get, Inject, Query, Req, Res } from '@nestjs/common';
import type { Request, Response } from 'express';
import { Logger } from 'nestjs-pino';
import {
PRE_AUTH_COOKIE_NAME,
clearPreAuthCookieOptions,
preAuthCookieOptions,
} from './auth.cookie';
import { AuthCodeFlowException, type AuthCodeFlowError, authErrorCode } from './auth.errors';
import { AuthService, type PreAuthPayload } from './auth.service';
import { ENTRA_CONFIG, type EntraConfig } from './entra-config.token';
/**
* OIDC routes mounted under `/api/auth/` per ADR-0009.
*
* v1 ships one route: `GET /login`, the entry point of the
* Authorization Code + PKCE flow. The follow-up PR adds
* `/callback`, then `/me` and `/logout` once Redis-backed sessions
* land (ADR-0010).
* v1 ships two routes — `GET /login` (PR #105) and `GET /callback`
* (this PR). The next PR adds session persistence (Redis,
* ADR-0010); after that, `/me` and `/logout` close the loop.
*/
@Controller('auth')
export class AuthController {
constructor(private readonly authService: AuthService) {}
constructor(
private readonly authService: AuthService,
private readonly logger: Logger,
@Inject(ENTRA_CONFIG) private readonly entra: EntraConfig,
) {}
/**
* Starts the auth flow. Generates a fresh state + PKCE pair via
@@ -31,4 +41,116 @@ export class AuthController {
res.cookie(PRE_AUTH_COOKIE_NAME, JSON.stringify(preAuthPayload), preAuthCookieOptions());
res.redirect(302, authUrl);
}
/**
* Second leg of the OIDC flow. Entra redirects the user here with
* `?code=…&state=…` (success) or `?error=…&error_description=…`
* (failure). The pre-auth cookie set by `/login` carries the
* matching state + PKCE verifier so we can verify the round-trip
* and finish the token exchange.
*
* On success, the user identity is logged via Pino and the
* browser is redirected to the SPA. No session is persisted yet —
* that's the next PR (Redis-backed sessions per ADR-0010); until
* then the SPA cannot tell the user is "logged in".
*
* On failure (any branch — state mismatch, expired cookie,
* missing `amr` per ADR-0011, MSAL token-exchange error, or
* Entra-side error from the query) the user lands on the SPA
* with `?auth_error=<code>` so the front-end can surface a
* specific message. The pre-auth cookie is cleared on every
* exit path: it is single-use by design.
*/
@Get('callback')
async callback(
@Req() req: Request,
@Res() res: Response,
@Query('code') code?: string,
@Query('state') state?: string,
@Query('error') entraError?: string,
@Query('error_description') entraErrorDescription?: string,
): Promise<void> {
// Drop the single-use cookie regardless of outcome.
res.clearCookie(PRE_AUTH_COOKIE_NAME, clearPreAuthCookieOptions());
// Entra signalled a failure (e.g. user cancelled the consent
// screen, MFA challenge failed, app-registration mismatch).
if (entraError) {
this.logger.warn(
{
event: 'auth.entra_error',
entraError,
entraErrorDescription,
},
'AuthCallback',
);
return this.redirectWithError(res, 'token-exchange-failed');
}
if (typeof code !== 'string' || typeof state !== 'string') {
return this.redirectWithError(res, 'token-exchange-failed');
}
const preAuth = readPreAuthCookie(req);
if (!preAuth) {
// No cookie → either the user opened /callback directly, or
// their browser dropped the cookie (TTL elapsed in
// a 3rd-party-cookie blocker, etc.). Treat as flow-expired.
this.logger.warn({ event: 'auth.no_pre_auth_cookie' }, 'AuthCallback');
return this.redirectWithError(res, 'flow-expired');
}
try {
const user = await this.authService.completeAuthCodeFlow(code, state, preAuth);
this.logger.log(
{
event: 'auth.signed_in',
oid: user.oid,
tid: user.tid,
username: user.username,
amr: user.amr,
},
'AuthCallback',
);
// No session persistence yet — next PR. SPA will see the user
// as anonymous on the landing page.
res.redirect(302, this.entra.postLogoutRedirectUri);
} catch (err) {
if (err instanceof AuthCodeFlowException) {
this.logger.warn({ event: 'auth.flow_error', failure: err.failure }, 'AuthCallback');
return this.redirectWithError(res, err.failure.kind);
}
throw err;
}
}
private redirectWithError(res: Response, kind: AuthCodeFlowError['kind']): void {
const url = new URL(this.entra.postLogoutRedirectUri);
url.searchParams.set('auth_error', authErrorCode({ kind } as AuthCodeFlowError));
res.redirect(302, url.toString());
}
}
function readPreAuthCookie(req: Request): PreAuthPayload | null {
const raw = (req.signedCookies as Record<string, unknown>)[PRE_AUTH_COOKIE_NAME];
if (typeof raw !== 'string') {
return null;
}
try {
const parsed = JSON.parse(raw) as Partial<PreAuthPayload>;
if (
typeof parsed.state === 'string' &&
typeof parsed.codeVerifier === 'string' &&
typeof parsed.createdAt === 'number'
) {
return {
state: parsed.state,
codeVerifier: parsed.codeVerifier,
createdAt: parsed.createdAt,
};
}
return null;
} catch {
return null;
}
}