feat(portal-bff): close the auth loop — callback persists session, /me, RP-initiated /logout (#112)
CI / check (push) Successful in 2m39s
CI / commits (push) Has been skipped
CI / scan (push) Successful in 1m33s
CI / a11y (push) Successful in 1m40s
CI / perf (push) Successful in 4m26s

## Summary

Closes the OIDC loop end-to-end on the BFF side:

- `/auth/callback` now writes the resolved `AuthenticatedUser` into `req.session.user` and waits for `req.session.save()` before redirecting, so the SPA reaches the landing page with a populated session.
- `GET /auth/me` returns the curated public view of the session user (`oid`, `tid`, `username`, `displayName`) or `401 {"error": "unauthenticated"}`. `amr` and other internal claims stay server-side.
- `GET /auth/logout` destroys the BFF session (Redis `DEL`), clears the session cookie, and 302s to Entra's `/oauth2/v2.0/logout` so the IdP-side session is killed too — RP-initiated logout per ADR-0009.

Scope intentionally stops here: the absolute-timeout interceptor (12 h hard ceiling) and the `user_sessions:{userId}` secondary index land in dedicated follow-ups.

## Notable choices

**`req.session.save()` is awaited before the redirect.** Express-session writes to its store on response end; emitting the 302 closes the response before `connect-redis` finishes the write, so without an explicit await the browser can race the SPA into requesting `/me` against a missing key. Awaiting `save()` is the documented fix.

**Logout via `GET`.** Matches `/login` (also `GET`) and keeps the UX a plain anchor / top-level navigation. The CSRF surface is mitigated by `SameSite=Lax` on the session cookie — cross-site subresource requests (`<img src>`, `fetch`) don't carry it. A dedicated CSRF middleware lands with phase-2 security; if we want POST-only logout earlier, easy follow-up.

**`/me` strips `amr`.** The session payload mirrors `AuthenticatedUser` (used internally by the future `@RequireMfa()` guard, ADR-0011), but the SPA only ever needs the curated subset. Mapping happens in the controller — no leak by default.

**Logout URL skips `id_token_hint`.** ADR-0009 mentions it for single-account logout UX, but v1 doesn't persist the `id_token` in the session yet (the encrypted `tokens` blob lands with downstream API support per ADR-0014). Without `id_token_hint`, Entra shows an account picker — the conservative default until token persistence ships.

**Cookie name in logout.** Uses `sessionCookieName()` from `session/session-cookie.ts` so logout clears the same cookie the middleware sets — `__Host-portal_session` in prod, `portal_session` in dev.

## Out of scope (next PRs)

- Absolute-timeout interceptor (12 h hard ceiling, ADR-0010).
- `user_sessions:{userId}` secondary index for admin "logout everywhere".
- Persisting the `id_token` / `access_token` / `refresh_token` blob in the encrypted session (ADR-0014 dependency).
- CSRF middleware (phase-2 security).
- Renaming `ENTRA_POST_LOGOUT_REDIRECT_URI` if we want a distinct post-login redirect target — for now both flows land on the same SPA URL.

## Test plan

- [x] `pnpm nx test portal-bff` → **110/110 pass** (was 99 before this PR; +11 specs across `auth.controller.spec.ts` and `auth.service.spec.ts`).
- [x] `pnpm nx lint portal-bff` → clean.
- [x] `pnpm nx build portal-bff` → webpack compiled successfully.
- [x] Prettier-clean on all touched files.
- [ ] Manual end-to-end smoke test:
  - [ ] `/api/auth/login` → Entra → back at `/api/auth/callback` → session cookie set, redirect to SPA.
  - [ ] `/api/auth/me` → 200 JSON when authenticated, 401 when anonymous.
  - [ ] `/api/auth/logout` → Redis key gone, cookie cleared, lands at SPA via Entra logout.

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #112
This commit was merged in pull request #112.
This commit is contained in:
2026-05-12 19:46:38 +02:00
parent 2e9605a078
commit 0464ce3ac8
6 changed files with 383 additions and 24 deletions
+102 -6
View File
@@ -1,21 +1,25 @@
import { Controller, Get, Inject, Query, Req, Res } from '@nestjs/common';
import type { Request, Response } from 'express';
import { Logger } from 'nestjs-pino';
import { sessionCookieName } from '../session/session-cookie';
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 { AuthService, type AuthenticatedUser, 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 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.
* Routes shipped here: `GET /login`, `GET /callback`, `GET /me`,
* `GET /logout`. The callback persists the resolved identity into
* the express-session store (ADR-0010); `/me` reads it back; the
* logout endpoint destroys the session, clears the cookie, and
* redirects to Entra's RP-initiated logout endpoint so the user is
* signed out at the IdP too.
*/
@Controller('auth')
export class AuthController {
@@ -102,6 +106,13 @@ export class AuthController {
try {
const user = await this.authService.completeAuthCodeFlow(code, state, preAuth);
req.session.user = user;
// Force the save before the redirect: express-session writes
// on response end, but the 302 we're about to emit closes the
// response before the async store-write would otherwise
// complete. Without this, the browser hits the SPA before
// Redis carries the new payload.
await saveSession(req);
this.logger.log(
{
event: 'auth.signed_in',
@@ -112,8 +123,6 @@ export class AuthController {
},
'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) {
@@ -124,6 +133,65 @@ export class AuthController {
}
}
/**
* Read-only view of the current session for the SPA. Returns the
* curated subset of the resolved identity — `amr` and other
* auth-internal claims stay server-side.
*
* 401 (with no body beyond a `{error}` tag) on missing session
* lets the SPA distinguish "anonymous" from any other failure;
* the browser's session cookie was either absent, expired, or
* pointed at a Redis key that no longer exists.
*/
@Get('me')
me(@Req() req: Request, @Res() res: Response): void {
const user = req.session.user;
if (!user) {
res.status(401).json({ error: 'unauthenticated' });
return;
}
res.json(toPublicUser(user));
}
/**
* RP-initiated logout per ADR-0009. Destroys the BFF session
* (DEL on the Redis key), clears the session cookie, then
* redirects the browser to Entra's `/oauth2/v2.0/logout` so the
* IdP-side session is killed too — single sign-out behaviour. The
* post-logout redirect on the Entra end lands the user back on
* the SPA root.
*
* Idempotent: if the user was already anonymous, the destroy is
* a no-op and we redirect anyway. CSRF for v1 relies on
* SameSite=Lax (subresource requests don't carry the cookie); a
* dedicated CSRF middleware lands with phase-2 security.
*/
@Get('logout')
async logout(@Req() req: Request, @Res() res: Response): Promise<void> {
const wasAuthenticated = Boolean(req.session.user);
const logoutUrl = this.authService.buildLogoutUrl();
try {
await destroySession(req);
} catch (err) {
// The Redis DEL failed — log and continue. Clearing the
// cookie still gets the user effectively logged out from the
// BFF's point of view; the orphan Redis key will hit its idle
// TTL on its own.
this.logger.error(
{
event: 'session.destroy_failed',
message: err instanceof Error ? err.message : String(err),
},
'AuthLogout',
);
}
res.clearCookie(sessionCookieName(), { path: '/' });
this.logger.log({ event: 'auth.signed_out', wasAuthenticated }, 'AuthLogout');
res.redirect(302, logoutUrl);
}
private redirectWithError(res: Response, kind: AuthCodeFlowError['kind']): void {
const url = new URL(this.entra.postLogoutRedirectUri);
url.searchParams.set('auth_error', authErrorCode({ kind } as AuthCodeFlowError));
@@ -131,6 +199,34 @@ export class AuthController {
}
}
interface PublicUser {
oid: string;
tid: string;
username: string;
displayName: string;
}
function toPublicUser(user: AuthenticatedUser): PublicUser {
return {
oid: user.oid,
tid: user.tid,
username: user.username,
displayName: user.displayName,
};
}
function saveSession(req: Request): Promise<void> {
return new Promise((resolve, reject) => {
req.session.save((err) => (err ? reject(err) : resolve()));
});
}
function destroySession(req: Request): Promise<void> {
return new Promise((resolve, reject) => {
req.session.destroy((err) => (err ? reject(err) : resolve()));
});
}
function readPreAuthCookie(req: Request): PreAuthPayload | null {
const raw = (req.signedCookies as Record<string, unknown>)[PRE_AUTH_COOKIE_NAME];
if (typeof raw !== 'string') {