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:
@@ -2,6 +2,7 @@ import { randomBytes } from 'node:crypto';
|
||||
import { Module } from '@nestjs/common';
|
||||
import { RedisStore } from 'connect-redis';
|
||||
import expressSession from 'express-session';
|
||||
import type { CookieOptions } from 'express';
|
||||
import { Logger } from 'nestjs-pino';
|
||||
import { assertSessionEncryptionKey } from '../config/check-session-encryption-key';
|
||||
import { assertSessionSecret } from '../config/check-session-secret';
|
||||
@@ -9,10 +10,12 @@ import { RedisModule } from '../redis/redis.module';
|
||||
import { REDIS_CLIENT, type Redis } from '../redis/redis.token';
|
||||
import { AuditWriter } from '../audit/audit.service';
|
||||
import { createAbsoluteTimeoutMiddleware } from './absolute-timeout.middleware';
|
||||
import { adminSessionCookieName } from './admin-session-cookie';
|
||||
import { adaptIoredisForConnectRedis } from './ioredis-connect-redis-adapter';
|
||||
import { SessionDecryptError, decrypt, encrypt } from './session-crypto';
|
||||
import { readSessionTimeouts, sessionCookieName, sessionCookieOptions } from './session-cookie';
|
||||
import {
|
||||
ADMIN_SESSION_MIDDLEWARE,
|
||||
SESSION_ABSOLUTE_TIMEOUT_MIDDLEWARE,
|
||||
SESSION_MIDDLEWARE,
|
||||
type RequestHandler,
|
||||
@@ -22,35 +25,108 @@ import {
|
||||
import './session.types';
|
||||
import { UserSessionIndexService } from './user-session-index.service';
|
||||
|
||||
/**
|
||||
* Build a configured `express-session` middleware. Shared between
|
||||
* the user-portal middleware ({@link SESSION_MIDDLEWARE}) and the
|
||||
* admin-portal middleware ({@link ADMIN_SESSION_MIDDLEWARE}) per
|
||||
* ADR-0020 §"Sessions — distinct from `portal-shell`".
|
||||
*
|
||||
* The two surfaces differ only on the cookie name and Redis key
|
||||
* prefix; the TTL policy, encryption key, signing secret, session-id
|
||||
* entropy, and error-handling all come from the same source so a
|
||||
* future hardening (e.g. tighter idle TTL on admin) is a parameter
|
||||
* change rather than a divergent code path.
|
||||
*/
|
||||
function buildSessionMiddleware(
|
||||
redis: Redis,
|
||||
logger: Logger,
|
||||
opts: { cookieName: string; redisKeyPrefix: string; surfaceTag: 'user' | 'admin' },
|
||||
): RequestHandler {
|
||||
const secret = assertSessionSecret();
|
||||
const key = assertSessionEncryptionKey();
|
||||
const timeouts = readSessionTimeouts();
|
||||
|
||||
const store = new RedisStore({
|
||||
// `connect-redis` v9 was rewritten against the `node-redis` v4
|
||||
// command surface; the adapter shims `ioredis` to look the same
|
||||
// for the handful of commands the store actually calls.
|
||||
client: adaptIoredisForConnectRedis(redis) as unknown as never,
|
||||
prefix: opts.redisKeyPrefix,
|
||||
ttl: timeouts.idleSeconds,
|
||||
serializer: {
|
||||
stringify: (sess) => encrypt(JSON.stringify(sess), key),
|
||||
parse: (payload) => {
|
||||
try {
|
||||
return JSON.parse(decrypt(payload, key));
|
||||
} catch (err) {
|
||||
// Tamper, wrong key, or unknown version. The phase-2
|
||||
// audit pipeline (ADR-0013) will turn this into a
|
||||
// first-class audit event; for now we surface a
|
||||
// structured Pino log so ops can spot it. The
|
||||
// `surface` tag lets dashboards split user-portal
|
||||
// decrypt failures from admin-portal ones.
|
||||
logger.warn(
|
||||
{
|
||||
event: 'session.decrypt_failed',
|
||||
surface: opts.surfaceTag,
|
||||
reason: err instanceof SessionDecryptError ? err.message : String(err),
|
||||
},
|
||||
'session',
|
||||
);
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const cookie: CookieOptions = sessionCookieOptions(timeouts.idleSeconds);
|
||||
|
||||
return expressSession({
|
||||
name: opts.cookieName,
|
||||
secret,
|
||||
// `crypto.randomBytes(32).toString('base64url')` ⇒ 256
|
||||
// bits of entropy in the session id, per ADR-0010
|
||||
// §"Confirmation".
|
||||
genid: () => randomBytes(32).toString('base64url'),
|
||||
store,
|
||||
// `resave: false` — RedisStore.touch refreshes the TTL on
|
||||
// every request; no need to rewrite the payload.
|
||||
resave: false,
|
||||
// `saveUninitialized: false` — don't create empty session
|
||||
// keys for unauthenticated visitors browsing public
|
||||
// routes. The session is born when the OIDC callback
|
||||
// populates it.
|
||||
saveUninitialized: false,
|
||||
// `rolling: true` — the cookie's `expires` slides forward
|
||||
// on every response, matching the sliding-idle policy.
|
||||
rolling: true,
|
||||
cookie,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Session module — wires `express-session` with a `connect-redis`
|
||||
* store on top of the shared `ioredis` client, with AES-256-GCM
|
||||
* encryption applied to the JSON payload before it lands in Redis.
|
||||
*
|
||||
* The configured middleware is exposed as a NestJS provider under
|
||||
* the {@link SESSION_MIDDLEWARE} token. `main.ts` resolves it from
|
||||
* the application context and mounts it once via `app.use(...)`
|
||||
* after `cookie-parser`. Mounting the express-session middleware
|
||||
* through DI (rather than constructing it in `main.ts`) keeps it on
|
||||
* the same Redis client the rest of the BFF uses, instead of
|
||||
* spinning up a second connection at the bootstrap layer.
|
||||
* Two middlewares are provided, path-routed in `main.ts`:
|
||||
*
|
||||
* What's wired today:
|
||||
* - Express-session middleware on every request (`SESSION_MIDDLEWARE`).
|
||||
* - Absolute-timeout middleware (`SESSION_ABSOLUTE_TIMEOUT_MIDDLEWARE`)
|
||||
* that enforces the 12 h hard ceiling per ADR-0010.
|
||||
* - `UserSessionIndexService`: maintains the
|
||||
* `user_sessions:{userId}` Redis set so a future admin endpoint
|
||||
* can "log out user X everywhere" — write-side hooks live in
|
||||
* the auth controller (create on `/auth/callback`, drop on
|
||||
* `/auth/logout` or absolute-timeout).
|
||||
* - {@link SESSION_MIDDLEWARE} — user-portal. Cookie
|
||||
* `portal_session` / `__Host-portal_session`. Redis prefix
|
||||
* `session:`. Bound to every path except `/api/admin/*`.
|
||||
*
|
||||
* Out of scope, landing in follow-ups:
|
||||
* - The admin "logout everywhere" route that consumes the
|
||||
* `UserSessionIndexService` (waits on the admin module + the
|
||||
* `@RequireAdmin` / `@RequireMfa` guards).
|
||||
* - Audit-pipeline wiring for `session.decrypt_failed` and
|
||||
* `session.absolute_timeout` (ADR-0013).
|
||||
* - {@link ADMIN_SESSION_MIDDLEWARE} — admin-portal per ADR-0020.
|
||||
* Cookie `portal_admin_session` / `__Host-portal_admin_session`.
|
||||
* Redis prefix `session:admin:`. Bound to `/api/admin/*` only.
|
||||
*
|
||||
* The {@link SESSION_ABSOLUTE_TIMEOUT_MIDDLEWARE} (ADR-0010 §"TTL
|
||||
* policy") and {@link UserSessionIndexService} are shared across
|
||||
* both surfaces — the absolute-timeout check reads `req.session`
|
||||
* which the dispatch has already resolved to one surface or the
|
||||
* other; the user-session index is keyed by Entra `oid`, so an
|
||||
* admin sign-in and a user-portal sign-in by the same person
|
||||
* naturally land in different Redis sets only because the session
|
||||
* id namespaces differ.
|
||||
*/
|
||||
@Module({
|
||||
imports: [RedisModule],
|
||||
@@ -68,66 +144,29 @@ import { UserSessionIndexService } from './user-session-index.service';
|
||||
{
|
||||
provide: SESSION_MIDDLEWARE,
|
||||
inject: [REDIS_CLIENT, Logger],
|
||||
useFactory: (redis: Redis, logger: Logger): RequestHandler => {
|
||||
const secret = assertSessionSecret();
|
||||
const key = assertSessionEncryptionKey();
|
||||
const timeouts = readSessionTimeouts();
|
||||
|
||||
const store = new RedisStore({
|
||||
// `connect-redis` v9 was rewritten against the
|
||||
// `node-redis` v4 command surface; the adapter shims
|
||||
// `ioredis` to look the same for the handful of commands
|
||||
// the store actually calls.
|
||||
client: adaptIoredisForConnectRedis(redis) as unknown as never,
|
||||
prefix: 'session:',
|
||||
ttl: timeouts.idleSeconds,
|
||||
serializer: {
|
||||
stringify: (sess) => encrypt(JSON.stringify(sess), key),
|
||||
parse: (payload) => {
|
||||
try {
|
||||
return JSON.parse(decrypt(payload, key));
|
||||
} catch (err) {
|
||||
// Tamper, wrong key, or unknown version. The phase-2
|
||||
// audit pipeline (ADR-0013) will turn this into a
|
||||
// first-class audit event; for now we surface a
|
||||
// structured Pino log so ops can spot it.
|
||||
logger.warn(
|
||||
{
|
||||
event: 'session.decrypt_failed',
|
||||
reason: err instanceof SessionDecryptError ? err.message : String(err),
|
||||
},
|
||||
'session',
|
||||
);
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return expressSession({
|
||||
name: sessionCookieName(),
|
||||
secret,
|
||||
// `crypto.randomBytes(32).toString('base64url')` ⇒ 256
|
||||
// bits of entropy in the session id, per ADR-0010
|
||||
// §"Confirmation".
|
||||
genid: () => randomBytes(32).toString('base64url'),
|
||||
store,
|
||||
// `resave: false` — RedisStore.touch refreshes the TTL on
|
||||
// every request; no need to rewrite the payload.
|
||||
resave: false,
|
||||
// `saveUninitialized: false` — don't create empty session
|
||||
// keys for unauthenticated visitors browsing public
|
||||
// routes. The session is born when `/auth/callback`
|
||||
// populates it.
|
||||
saveUninitialized: false,
|
||||
// `rolling: true` — the cookie's `expires` slides forward
|
||||
// on every response, matching the sliding-idle policy.
|
||||
rolling: true,
|
||||
cookie: sessionCookieOptions(timeouts.idleSeconds),
|
||||
});
|
||||
},
|
||||
useFactory: (redis: Redis, logger: Logger): RequestHandler =>
|
||||
buildSessionMiddleware(redis, logger, {
|
||||
cookieName: sessionCookieName(),
|
||||
redisKeyPrefix: 'session:',
|
||||
surfaceTag: 'user',
|
||||
}),
|
||||
},
|
||||
{
|
||||
provide: ADMIN_SESSION_MIDDLEWARE,
|
||||
inject: [REDIS_CLIENT, Logger],
|
||||
useFactory: (redis: Redis, logger: Logger): RequestHandler =>
|
||||
buildSessionMiddleware(redis, logger, {
|
||||
cookieName: adminSessionCookieName(),
|
||||
redisKeyPrefix: 'session:admin:',
|
||||
surfaceTag: 'admin',
|
||||
}),
|
||||
},
|
||||
],
|
||||
exports: [SESSION_MIDDLEWARE, SESSION_ABSOLUTE_TIMEOUT_MIDDLEWARE, UserSessionIndexService],
|
||||
exports: [
|
||||
SESSION_MIDDLEWARE,
|
||||
ADMIN_SESSION_MIDDLEWARE,
|
||||
SESSION_ABSOLUTE_TIMEOUT_MIDDLEWARE,
|
||||
UserSessionIndexService,
|
||||
],
|
||||
})
|
||||
export class SessionModule {}
|
||||
|
||||
Reference in New Issue
Block a user