Files
apf_portal/apps/portal-bff/src/session/session.module.ts
T
julien 940267e317
CI / check (push) Successful in 2m49s
CI / commits (push) Has been skipped
CI / scan (push) Successful in 2m50s
CI / a11y (push) Successful in 1m56s
CI / perf (push) Successful in 3m29s
feat(portal-bff): wire ADR-0013 audit pipeline to the auth lifecycle (#120)
## Summary

Wires the audit pipeline (ADR-0013) to the auth lifecycle. The foundation was already in place (Prisma `AuditEvent` model, Postgres roles + grants, `AuditWriter.recordEvent` with `SET LOCAL ROLE audit_writer`); this PR layers a typed event surface and emits the first four events on real code paths.

### What lands

- **Typed methods on `AuditWriter`**: `signIn`, `signInFailed`, `signOut`, `sessionExpired`. Callers pass the raw Entra `oid`; hashing happens inside the writer so the salt never leaves the audit module. ADR-0013 explicitly defers adding these typed methods "as the matching feature ships" — auth has shipped, so we add the four events tied to code paths that exist today.
- **`HashUserIdService`** — reads `LOG_USER_ID_SALT` once at injection, exposes `hash(userId)` → 16-hex-char digest used by both `audit_events.actor_id_hash` (ADR-0013) and the future Pino `user_id_hash` (ADR-0012). Same salt + same input ⇒ same output ⇒ join key between the two streams.
- **`LOG_USER_ID_SALT` env var** promoted from the "future vars" block in `.env.example` to the active section, with the same boot-time validator pattern as `SESSION_SECRET` / `SESSION_ENCRYPTION_KEY`: mandatory, base64url, ≥ 32 bytes decoded, placeholder rejected. Wired in `main.ts`.
- **`AuditModule` is now `@Global()`** and also provides `HashUserIdService`. The previous in-line comment said "imported globally by AppModule" but the decorator was missing — without it, AuthController and the absolute-timeout middleware couldn't inject `AuditWriter` without re-importing AuditModule.
- **Emission points**:
  - `/auth/callback` happy path → `auth.sign_in` after `session.save()` (blocking per ADR-0013 §"Blocking writes": a failed audit fails the sign-in).
  - `/auth/callback` failure paths → `auth.sign_in.failed` with a discriminator `failureKind` (`entra-error`, `missing-code-or-state`, `no-pre-auth-cookie`, or any of the `AuthCodeFlowError` kinds — `state-mismatch`, `flow-expired`, `token-exchange-failed`).
  - `/auth/logout` (authenticated only) → `auth.sign_out` before `session.destroy()` — once destroy runs we lose the actor id.
  - Absolute-timeout middleware → `auth.session.expired` with `reason: 'absolute'` and `ageMs` for forensic granularity.

### Out of scope (next PRs)

- The other four v1 events from ADR-0013's catalogue (`auth.session.revoked`, `auth.token.validation.failed`, `auth.mfa.assertion.failed`, `authz.deny`) — no triggering code path exists today. They land with the admin "logout everywhere" route, downstream API access (ADR-0014), and the eventual `@RequireMfa()` / `@RequireAdmin` guards.
- Idle-timeout expiry is intentionally silent — Redis lets the key disappear with no BFF observation point. Per ADR-0010.
- Separate `AUDIT_DATABASE_URL` connection pool with `audit_writer`-only credentials — ADR-0013 marks it as the production hardening step, deferred behind `SET LOCAL ROLE` in v1.
- Retention purge job + startup self-test probe — deferred to the on-prem infrastructure ADR per ADR-0013.

### Notable choices

- **No CLS-populating middleware.** ADR-0013 anticipates an interceptor that puts `actorIdHash` on the request CLS so `AuditWriter.recordEvent` can pick it up automatically. For the four call sites in this PR, every emission path already has the user object in hand, so we pass `actorIdHash` explicitly via the typed methods and skip the middleware. It can land later when more routes need it.
- **Blocking on the happy path = strict ADR posture.** `audit.signIn` is awaited before the 302; a Postgres outage makes the sign-in fail (5xx) rather than silently producing an un-audited session. That's "no audit ⇒ no action" applied to authentication itself. Matches ADR-0013 §"Blocking writes" verbatim.
- **`signInFailed` skips the actor hash by default.** Most failure paths reject before any claim is parsed (state mismatch, expired flow). The interface accepts an optional `actor` for the rare identity-after-rejection case (future MFA assertion failure, etc.).

### Test plan

- [x] `pnpm nx test portal-bff` (clean env) → **142/142 pass** (was 123; +19 new specs across `check-log-user-id-salt`, `hash-user-id.service`, `audit.service` typed-methods, `auth.controller`, `absolute-timeout.middleware`).
- [x] `pnpm nx lint portal-bff` → clean.
- [x] `pnpm nx build portal-bff` → clean.
- [x] **CI clean-env repro** (lesson from #115/#116/#117): every env var unset → tests still 142/142. The two module specs that previously sat on the boundary (`auth.module`, `session.module`) now bootstrap their own `@Global()` stub providers for `PrismaService` + `ClsService` so AuditWriter's transitive resolution works without booting Prisma for real.
- [ ] Manual smoke against running BFF + Postgres:
  - [ ] Sign in → `select * from audit.events where event_type = 'auth.sign_in'` returns one row with `actor_id_hash`, `subject = 'session:…'`, `payload.amr` populated.
  - [ ] Sign out → matching `auth.sign_out` row.
  - [ ] Force `SESSION_ABSOLUTE_TIMEOUT_SECONDS=5` + wait → `auth.session.expired` row with `payload.reason = 'absolute'` and `ageMs > 5000`.
  - [ ] Manual `UPDATE audit.events SET event_type = 'x' WHERE id = ...` as the BFF role → fails with "permission denied" (the role contract holds even when the migrator runs as a privileged login).

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #120
2026-05-13 14:21:42 +02:00

134 lines
5.7 KiB
TypeScript

import { randomBytes } from 'node:crypto';
import { Module } from '@nestjs/common';
import { RedisStore } from 'connect-redis';
import expressSession from 'express-session';
import { Logger } from 'nestjs-pino';
import { assertSessionEncryptionKey } from '../config/check-session-encryption-key';
import { assertSessionSecret } from '../config/check-session-secret';
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 { adaptIoredisForConnectRedis } from './ioredis-connect-redis-adapter';
import { SessionDecryptError, decrypt, encrypt } from './session-crypto';
import { readSessionTimeouts, sessionCookieName, sessionCookieOptions } from './session-cookie';
import {
SESSION_ABSOLUTE_TIMEOUT_MIDDLEWARE,
SESSION_MIDDLEWARE,
type RequestHandler,
} from './session.token';
// Side-effect import: brings the `req.session.user` declaration
// merging into the compile graph for every consumer of this module.
import './session.types';
import { UserSessionIndexService } from './user-session-index.service';
/**
* 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.
*
* 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).
*
* 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).
*/
@Module({
imports: [RedisModule],
providers: [
UserSessionIndexService,
{
provide: SESSION_ABSOLUTE_TIMEOUT_MIDDLEWARE,
inject: [UserSessionIndexService, Logger, AuditWriter],
useFactory: (
index: UserSessionIndexService,
logger: Logger,
audit: AuditWriter,
): RequestHandler => createAbsoluteTimeoutMiddleware(index, logger, audit),
},
{
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),
});
},
},
],
exports: [SESSION_MIDDLEWARE, SESSION_ABSOLUTE_TIMEOUT_MIDDLEWARE, UserSessionIndexService],
})
export class SessionModule {}