c3de2340e7
## Summary
Hardens the BFF session per ADR-0010 §"TTL policy" and §"Revocation":
- **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, the middleware destroys the Redis-side session, clears the `portal_session` cookie, drops the entry from the per-user index, and lets the request continue anonymously. Route-level guards (`/me`, future `@RequireAuth`) turn that into a 401 where the user actually needs auth — public routes keep serving.
- **`user_sessions:{userId}` secondary index** — a new `UserSessionIndexService` maintains a Redis set of active session ids per user. Hooked into `/auth/callback` (SADD on sign-in) and `/auth/logout` + the absolute-timeout middleware (SREM on destroy). Best-effort: a failed `SADD`/`SREM` logs a warning and the auth flow continues. No in-product consumer in this PR — the admin "logout everywhere" endpoint lands with the admin module.
- **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.
## Notable choices
**Non-intrusive enforcement on expiry.** ADR-0010 says "returns 401"; we interpret that as "the user eventually sees a 401 when they touch something that needs auth", not "every route returns 401 the moment we notice the ceiling". The middleware destroys the session and calls `next()` — `/me` returns 401 on its own (no user on the session), public routes stay accessible. Validated with the project lead 2026-05-12.
**Express middleware exposed via DI, not a NestJS `MiddlewareConsumer`.** Same pattern as `SESSION_MIDDLEWARE`: factory inside `SessionModule`, resolved from the application context in `main.ts` with `app.get<RequestHandler>(SESSION_ABSOLUTE_TIMEOUT_MIDDLEWARE)`. Keeps the wiring co-located with the session middleware and avoids the `AppModule.configure(consumer)` boilerplate for a one-off enforcement layer.
**Best-effort index maintenance.** `UserSessionIndexService.add` / `remove` catch Redis errors and log a Pino warning instead of throwing. Rationale (per ADR-0010): the index is a convenience for admin operations, not a security invariant — a Redis hiccup must not break sign-in / sign-out. Orphans (entries pointing to keys that have expired idle-TTL on their own) are tolerated and will be filtered by future consumer code.
**Per-user index identifier = Entra `oid`.** Stable per-user inside the tenant, matches `req.session.user.oid`. Admin "logout user X" will work against this same key. Future multi-tenant scenarios may want `${tid}:${oid}` — easy refactor when External ID activation lands (ADR-0008).
## Out of scope (next PRs)
- Admin "logout everywhere" endpoint consuming `UserSessionIndexService.list(userId)`. Waits on the admin module + `@RequireAdmin` / `@RequireMfa` guards.
- Audit-pipeline first-class events for `session.absolute_timeout` and `user_session_index.*` (ADR-0013). For now they're structured Pino logs.
- Token blob persistence (id_token / access_token / refresh_token) in the encrypted session — ADR-0014 dependency.
## Test plan
- [x] `pnpm nx test portal-bff` → **123/123 pass** (was 110 before; +13 specs across new `user-session-index.service.spec.ts`, `absolute-timeout.middleware.spec.ts`, and added cases in `auth.controller.spec.ts`).
- [x] `pnpm nx lint portal-bff` → clean.
- [x] `pnpm nx build portal-bff` → clean webpack build.
- [x] Prettier-clean for all touched files.
- [ ] Manual smoke against running BFF:
- [ ] Sign in normally → Redis has `session:<id>` + `user_sessions:<oid>` SISMEMBER returns `<id>`.
- [ ] Logout → both keys gone.
- [ ] Forge a past `absoluteExpiresAt` in Redis (or shorten `SESSION_ABSOLUTE_TIMEOUT_SECONDS=5` in `.env`) → next request after expiry returns 401 on `/me`, cookie cleared, index entry SREM-ed.
---------
Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #115
94 lines
3.5 KiB
TypeScript
94 lines
3.5 KiB
TypeScript
import { ConfidentialClientApplication, LogLevel } from '@azure/msal-node';
|
|
import { Module } from '@nestjs/common';
|
|
import { Logger } from 'nestjs-pino';
|
|
import { assertEntraConfig } from '../config/check-entra-config';
|
|
import { SessionModule } from '../session/session.module';
|
|
import { AuthController } from './auth.controller';
|
|
import { AuthService } from './auth.service';
|
|
import { ENTRA_CONFIG, type EntraConfig } from './entra-config.token';
|
|
import { MSAL_CLIENT } from './msal-client.token';
|
|
|
|
/**
|
|
* Auth module — owns the Entra ID configuration, the MSAL Node
|
|
* confidential client, and (in subsequent PRs) the OIDC routes, the
|
|
* session integration, and the route guards. Per ADR-0009.
|
|
*
|
|
* v1 providers:
|
|
*
|
|
* - `ENTRA_CONFIG` — the parsed, validated Entra app-registration
|
|
* config (PR #102).
|
|
* - `MSAL_CLIENT` — a `ConfidentialClientApplication` instance
|
|
* wired with that config plus a logger callback that forwards
|
|
* MSAL's internal log lines into the Pino stream so auth flow
|
|
* diagnostics land alongside the rest of the BFF logs (per
|
|
* ADR-0012). PII logging is disabled by default — MSAL won't
|
|
* include tokens or user identifiers in the messages it emits.
|
|
* - `AuthService` — first-leg-of-the-flow logic: PKCE + state
|
|
* generation, MSAL auth-code URL building. The controller
|
|
* stays a thin shell around it.
|
|
*
|
|
* v1 routes (per ADR-0009):
|
|
*
|
|
* - `GET /api/auth/login` — 302 to Entra's authorize endpoint
|
|
* with the freshly-generated state + code challenge; sets a
|
|
* short-lived signed cookie carrying the state + PKCE
|
|
* verifier so the next-PR callback can verify the round-trip.
|
|
*
|
|
* The module stays non-global: modules state "I depend on auth" by
|
|
* importing it. Re-exports both tokens so a single
|
|
* `imports: [AuthModule]` is enough to consume either.
|
|
*/
|
|
@Module({
|
|
imports: [SessionModule],
|
|
controllers: [AuthController],
|
|
providers: [
|
|
AuthService,
|
|
{
|
|
provide: ENTRA_CONFIG,
|
|
useFactory: () => assertEntraConfig(),
|
|
},
|
|
{
|
|
provide: MSAL_CLIENT,
|
|
inject: [ENTRA_CONFIG, Logger],
|
|
useFactory: (config: EntraConfig, logger: Logger) =>
|
|
new ConfidentialClientApplication({
|
|
auth: {
|
|
clientId: config.clientId,
|
|
authority: config.authority,
|
|
clientSecret: config.clientSecret,
|
|
},
|
|
system: {
|
|
loggerOptions: {
|
|
piiLoggingEnabled: false,
|
|
logLevel: LogLevel.Info,
|
|
loggerCallback: (level, message) => {
|
|
// MSAL levels: 0=Error, 1=Warning, 2=Info, 3=Verbose, 4=Trace.
|
|
// Forward to Pino with the matching level. The whole
|
|
// payload lives under the `msal` context key so a log
|
|
// search filtering on `msal` returns every MSAL line
|
|
// without false positives from app code.
|
|
const ctx = 'msal';
|
|
switch (level) {
|
|
case LogLevel.Error:
|
|
logger.error(message, ctx);
|
|
break;
|
|
case LogLevel.Warning:
|
|
logger.warn(message, ctx);
|
|
break;
|
|
case LogLevel.Verbose:
|
|
case LogLevel.Trace:
|
|
logger.debug(message, ctx);
|
|
break;
|
|
default:
|
|
logger.log(message, ctx);
|
|
}
|
|
},
|
|
},
|
|
},
|
|
}),
|
|
},
|
|
],
|
|
exports: [ENTRA_CONFIG, MSAL_CLIENT],
|
|
})
|
|
export class AuthModule {}
|