5c346820ea
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.
276 lines
9.9 KiB
TypeScript
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;
|
|
}
|
|
}
|