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,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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user