feat(portal-bff): distinct admin session + /api/admin/auth flow
Phase-3a step per ADR-0020 §"Sessions — distinct from `portal-shell`".
Wires a second `express-session` middleware on `/api/admin/*` carrying
`__Host-portal_admin_session` over Redis prefix `session:admin:` and
ships the parallel `/api/admin/auth/{login,callback,me,logout}` flow
that populates it. Signing in to one surface no longer signs the user
into the other — Entra SSO at the IdP level still preserves the
click-through experience.
What lands
- `session/admin-session-cookie.ts`: `adminSessionCookieName()` mirrors
the existing user-portal pattern (`__Host-` prefix in prod, plain
name in dev).
- `SessionModule` provides two parallel `express-session` instances
via a shared `buildSessionMiddleware()` factory:
SESSION_MIDDLEWARE cookie portal_session prefix session:
ADMIN_SESSION_MIDDLEWARE cookie portal_admin_session prefix session:admin:
The TTL policy, encryption key, signing secret, and session-id
entropy are unchanged — only the cookie name + Redis key prefix
differ.
- `main.ts` mounts a tiny path-routed dispatch: requests under
`/api/admin` get the admin session, everything else gets the user
one. Running both middlewares unconditionally would have the second
overwrite `req.session` from the first, collapsing the two surfaces.
- `EntraConfig` gains `adminRedirectUri` + `adminPostLogoutRedirectUri`,
validated at boot. The validator refuses to start when admin and
user redirect URIs collide (would silently fuse the two surfaces).
Both URIs must be registered on the same Entra app registration.
- `AuthService.{beginAuthCodeFlow,completeAuthCodeFlow,buildLogoutUrl}`
now take their redirect / post-logout URI as a parameter. Callers
pick which set to pass.
- New shared service `SessionEstablisher`:
establish(user, req, res, surface) — full sign-in recipe: mint
CSRF, populate session fields, save, register in
user_sessions index, emit auth.sign_in audit, log.
destroy(actor | undefined, req) — sign-out recipe: when actor
is set, remove from index + emit auth.sign_out audit; always
destroy the session (with Redis-hiccup tolerance).
Both `AuthController` and the new `AdminAuthController` call it —
no duplication of the 150-LOC session lifecycle logic.
- `AdminAuthController` mounts `/api/admin/auth/{login,callback,me,logout}`.
Structurally identical to `AuthController` but passes
`adminRedirectUri` / `adminPostLogoutRedirectUri` and clears the
admin session cookie on logout. `me` exposes the `roles` claim
(the SPA needs it for conditional admin UI); the user-portal `me`
intentionally still doesn't.
New env vars (mandatory at boot)
- ENTRA_ADMIN_REDIRECT_URI
- ENTRA_ADMIN_POST_LOGOUT_REDIRECT_URI
Tests: +25 specs (admin cookie 3, session-establisher 11, admin auth
controller 9, entra config 2). Existing AuthController tests
preserved through the refactor by passing a real `SessionEstablisher`
constructed with the same audit / index / logger mocks.
This commit is contained in:
@@ -1,11 +1,9 @@
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { Controller, Get, Inject, Query, Req, Res, UnauthorizedException } from '@nestjs/common';
|
||||
import type { Request, Response } from 'express';
|
||||
import { Logger } from 'nestjs-pino';
|
||||
import { AuditWriter } from '../audit/audit.service';
|
||||
import { csrfCookieName, csrfCookieOptions } from '../security/csrf-cookie';
|
||||
import { readSessionTimeouts, sessionCookieName } from '../session/session-cookie';
|
||||
import { UserSessionIndexService } from '../session/user-session-index.service';
|
||||
import { csrfCookieName } from '../security/csrf-cookie';
|
||||
import { sessionCookieName } from '../session/session-cookie';
|
||||
import {
|
||||
PRE_AUTH_COOKIE_NAME,
|
||||
clearPreAuthCookieOptions,
|
||||
@@ -14,6 +12,7 @@ import {
|
||||
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';
|
||||
import { SessionEstablisher } from './session-establisher.service';
|
||||
|
||||
/**
|
||||
* OIDC routes mounted under `/api/auth/` per ADR-0009.
|
||||
@@ -31,8 +30,8 @@ export class AuthController {
|
||||
private readonly authService: AuthService,
|
||||
private readonly logger: Logger,
|
||||
@Inject(ENTRA_CONFIG) private readonly entra: EntraConfig,
|
||||
private readonly userSessionIndex: UserSessionIndexService,
|
||||
private readonly audit: AuditWriter,
|
||||
private readonly sessionEstablisher: SessionEstablisher,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -47,7 +46,9 @@ export class AuthController {
|
||||
*/
|
||||
@Get('login')
|
||||
async login(@Res() res: Response): Promise<void> {
|
||||
const { authUrl, preAuthPayload } = await this.authService.beginAuthCodeFlow();
|
||||
const { authUrl, preAuthPayload } = await this.authService.beginAuthCodeFlow(
|
||||
this.entra.redirectUri,
|
||||
);
|
||||
res.cookie(PRE_AUTH_COOKIE_NAME, JSON.stringify(preAuthPayload), preAuthCookieOptions());
|
||||
res.redirect(302, authUrl);
|
||||
}
|
||||
@@ -117,60 +118,13 @@ export class AuthController {
|
||||
}
|
||||
|
||||
try {
|
||||
const user = await this.authService.completeAuthCodeFlow(code, state, preAuth);
|
||||
const now = Date.now();
|
||||
const { idleSeconds, absoluteSeconds } = readSessionTimeouts();
|
||||
const csrfToken = randomBytes(32).toString('base64url');
|
||||
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;
|
||||
// CSRF token per ADR-0009 §"Double-submit CSRF". Server-side
|
||||
// source of truth lives on the session; the cookie below is
|
||||
// the SPA's read-only mirror used to echo the value in the
|
||||
// X-CSRF-Token header.
|
||||
req.session.csrfToken = csrfToken;
|
||||
// MFA freshness anchor per ADR-0011 §"Confirmation". Entra's
|
||||
// CA policy decides whether MFA actually happened — the BFF
|
||||
// does not re-validate factors. The stamp reflects "the
|
||||
// session was just established with whatever assurance Entra
|
||||
// returned"; `RequireMfaGuard` measures freshness against it.
|
||||
// Refreshed by future step-up re-auth flows.
|
||||
req.session.mfaVerifiedAt = now;
|
||||
// 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);
|
||||
// Mirror the CSRF token to a JS-readable cookie. maxAge
|
||||
// matches the session's idle TTL so the cookie expires at
|
||||
// the same time as the session (rolling, refreshed on each
|
||||
// request alongside express-session's own cookie).
|
||||
res.cookie(csrfCookieName(), csrfToken, csrfCookieOptions(idleSeconds * 1000));
|
||||
// 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);
|
||||
// First write to the audit trail — blocking, per ADR-0013.
|
||||
// If this throws the user does NOT see a successful sign-in:
|
||||
// the exception propagates and the controller emits a 5xx via
|
||||
// Nest's default exception filter. Same posture as the
|
||||
// session.save() above.
|
||||
await this.audit.signIn({ actor: user, sessionId: req.sessionID });
|
||||
this.logger.log(
|
||||
{
|
||||
event: 'auth.signed_in',
|
||||
oid: user.oid,
|
||||
tid: user.tid,
|
||||
username: user.username,
|
||||
amr: user.amr,
|
||||
},
|
||||
'AuthCallback',
|
||||
const user = await this.authService.completeAuthCodeFlow(
|
||||
code,
|
||||
state,
|
||||
preAuth,
|
||||
this.entra.redirectUri,
|
||||
);
|
||||
await this.sessionEstablisher.establish({ user, req, res, surface: 'user' });
|
||||
res.redirect(302, this.entra.postLogoutRedirectUri);
|
||||
} catch (err) {
|
||||
if (err instanceof AuthCodeFlowException) {
|
||||
@@ -226,41 +180,13 @@ export class AuthController {
|
||||
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();
|
||||
const logoutUrl = this.authService.buildLogoutUrl(this.entra.postLogoutRedirectUri);
|
||||
|
||||
// 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);
|
||||
// Audit the sign-out before tearing the session down — once
|
||||
// destroy() runs we lose the actor id. Blocking per ADR-0013:
|
||||
// if the audit row can't be written, the user does NOT get a
|
||||
// "you're logged out" experience, because we can't certify
|
||||
// the sign-out happened.
|
||||
await this.audit.signOut({ actor: user, 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',
|
||||
);
|
||||
}
|
||||
await this.sessionEstablisher.destroy({ actor: user, req });
|
||||
|
||||
res.clearCookie(sessionCookieName(), { path: '/' });
|
||||
res.clearCookie(csrfCookieName(), { path: '/' });
|
||||
this.logger.log({ event: 'auth.signed_out', wasAuthenticated }, 'AuthLogout');
|
||||
this.logger.log({ event: 'auth.signed_out', surface: 'user', wasAuthenticated }, 'AuthLogout');
|
||||
res.redirect(302, logoutUrl);
|
||||
}
|
||||
|
||||
@@ -287,18 +213,6 @@ function toPublicUser(user: AuthenticatedUser): PublicUser {
|
||||
};
|
||||
}
|
||||
|
||||
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') {
|
||||
|
||||
Reference in New Issue
Block a user