Files
apf_portal/apps/portal-bff/src/auth/auth.controller.ts
T
Julien Gautier 5c346820ea
CI / commits (pull_request) Successful in 1m25s
CI / scan (pull_request) Successful in 1m30s
CI / check (pull_request) Failing after 1m34s
CI / a11y (pull_request) Successful in 1m6s
CI / perf (pull_request) Successful in 3m30s
feat(portal-bff): absolute-timeout middleware + user_sessions index per ADR-0010
closes the ttl-policy + revocation pieces left from #110.

absolute-timeout middleware:
  every request that survives express-session runs through a new
  middleware that checks req.session.absoluteExpiresAt. past the
  12 h hard ceiling, it destroys the redis-side session, clears the
  portal_session cookie, drops the entry from the per-user index,
  and lets the request keep flowing anonymously. route-level
  guards (/me today, future @requireauth) turn that into a 401
  where auth is actually needed — public routes stay accessible.
  not the "every route returns 401" reading of adr-0010 §"ttl
  policy" (validated with the project lead): "returns 401" is
  interpreted as "the user eventually sees a 401 when they touch
  something that needs auth", not as a hard refusal on the
  current in-flight request.

user_sessions:{userId} secondary index (adr-0010 §"revocation"):
  a new UserSessionIndexService maintains a redis set of active
  session ids per user. wired on /auth/callback (sadd at sign-in,
  after session.save() so the session row exists before the index
  points at it) and on /auth/logout + the absolute-timeout
  middleware (srem on destroy). best-effort: a failed sadd/srem
  logs a pino warning and the auth flow continues — the index is
  a convenience for admin operations, not a security invariant.
  orphans (entries whose session:<id> redis key has expired on
  idle ttl) are tolerated per the adr; future consumer code
  filters them out.

  no in-product consumer ships in this pr — the admin "logout
  everywhere" endpoint lands with the admin module (+ @requireadmin
  / @requiremfa guards). today the index is write-only on the bff
  side.

session payload extension:
  createdAt and absoluteExpiresAt are now set on the session at
  the same moment as req.session.user, in /auth/callback. the
  session.types.ts declaration merging exposes them as optional
  SessionData fields so every consumer sees a typed payload.

wiring:
  - SessionModule provides UserSessionIndexService and a
    SESSION_ABSOLUTE_TIMEOUT_MIDDLEWARE token whose factory builds
    the middleware via createAbsoluteTimeoutMiddleware(index,
    logger). same pattern as the existing SESSION_MIDDLEWARE token.
  - AuthModule now imports SessionModule so AuthController can
    inject UserSessionIndexService.
  - main.ts mounts the absolute-timeout middleware immediately
    after the session middleware.

per-user identifier is the entra `oid` claim (stable per-user
inside the tenant, matches req.session.user.oid). future
multi-tenant scenarios may want `${tid}:${oid}` — easy refactor
when external id activation lands (adr-0008). not in scope here.

out of scope, landing in follow-ups:
- admin "logout everywhere" endpoint consuming
  UserSessionIndexService.list(userId)
- audit-pipeline first-class events for session.absolute_timeout
  + user_session_index.* (adr-0013)
- token blob persistence (id_token / access_token / refresh_token)
  in the encrypted session — adr-0014 dependency

123/123 tests pass (was 110); +13 specs across the two new files
and the auth controller spec.
2026-05-12 23:22:08 +02:00

276 lines
9.9 KiB
TypeScript

import { Controller, Get, Inject, Query, Req, Res } from '@nestjs/common';
import type { Request, Response } from 'express';
import { Logger } from 'nestjs-pino';
import { readSessionTimeouts, sessionCookieName } from '../session/session-cookie';
import { UserSessionIndexService } from '../session/user-session-index.service';
import {
PRE_AUTH_COOKIE_NAME,
clearPreAuthCookieOptions,
preAuthCookieOptions,
} from './auth.cookie';
import { AuthCodeFlowException, type AuthCodeFlowError, authErrorCode } from './auth.errors';
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.
*
* 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 {
constructor(
private readonly authService: AuthService,
private readonly logger: Logger,
@Inject(ENTRA_CONFIG) private readonly entra: EntraConfig,
private readonly userSessionIndex: UserSessionIndexService,
) {}
/**
* Starts the auth flow. Generates a fresh state + PKCE pair via
* `AuthService`, stashes them in a short-lived signed cookie so
* `/callback` can verify the round-trip, and 302s the browser to
* Entra's authorize endpoint.
*
* The cookie carries everything the callback needs — no
* server-side state. That keeps `/login` stateless and friendly
* to horizontal scaling once the BFF runs more than one instance.
*/
@Get('login')
async login(@Res() res: Response): Promise<void> {
const { authUrl, preAuthPayload } = await this.authService.beginAuthCodeFlow();
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);
const now = Date.now();
const { absoluteSeconds } = readSessionTimeouts();
req.session.user = user;
req.session.createdAt = now;
// Hard ceiling per ADR-0010 §"TTL policy" — checked on every
// request by the absolute-timeout middleware, independent of
// idle TTL.
req.session.absoluteExpiresAt = now + absoluteSeconds * 1000;
// 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);
// Register the freshly-minted session id in the per-user
// index so a future admin "logout everywhere" can enumerate
// and revoke. Best-effort: a Redis hiccup here doesn't fail
// sign-in (the service swallows + logs).
await this.userSessionIndex.add(user.oid, req.sessionID);
this.logger.log(
{
event: 'auth.signed_in',
oid: user.oid,
tid: user.tid,
username: user.username,
amr: user.amr,
},
'AuthCallback',
);
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;
}
}
/**
* 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 user = req.session.user;
const wasAuthenticated = Boolean(user);
const sessionId = req.sessionID;
const logoutUrl = this.authService.buildLogoutUrl();
// Drop the user_sessions index entry before destroy() removes
// the session id from `req`. Skipped on already-anonymous
// requests (nothing was ever added).
if (user) {
await this.userSessionIndex.remove(user.oid, sessionId);
}
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));
res.redirect(302, url.toString());
}
}
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') {
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;
}
}