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 { 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=` 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 { // 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 { 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 { return new Promise((resolve, reject) => { req.session.save((err) => (err ? reject(err) : resolve())); }); } function destroySession(req: Request): Promise { 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)[PRE_AUTH_COOKIE_NAME]; if (typeof raw !== 'string') { return null; } try { const parsed = JSON.parse(raw) as Partial; 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; } }