feat(portal-bff): @RequireMfa decorator + freshness guard (ADR-0011)
Designed-in, dormant per ADR-0011 §"Step-up MFA". This PR ships the
guard, the decorator, and the audit integration — no v1 route uses
the decorator yet. First consumer will be the admin entry route per
ADR-0020 (`@RequireMfa({ freshness: 600 })`) once the distinct admin
session is in place.
Mechanics:
- `auth/mfa.ts` exports the documented `MFA_AMR_VALUES` allow-list
(mfa, otp, fido, wia, phr) and `wasMultiFactor(amr)`. Adding a
value is an ADR-recorded decision; the spec pins the list.
- `config/check-mfa-config.ts` reads `MFA_FRESHNESS_SECONDS` with
default 600 s + minimum 60 s. Anything below the floor fails the
validator (the floor catches "MFA on every navigation"
misconfiguration).
- `RequireMfaGuard` (`auth/require-mfa.guard.ts`):
- No session → 401 `unauthenticated`, no audit.
- Session with no MFA-class `amr` → 401 `mfa_required` + audit
`auth.mfa_required reason=no-mfa-in-amr`.
- Session with no `mfaVerifiedAt` → 401 + audit `reason=no-mfa-verified-at`.
- Session with stale `mfaVerifiedAt` → 401 + audit
`reason=mfa-stale` (includes `mfaAgeMs` payload field).
- Same audit-write propagation posture as `AdminRoleGuard`.
- The 401 carries `code: 'mfa_required'` in the structured error
envelope. The `reason` discriminator is NOT surfaced over the
wire — only the audit row carries it, so an attacker can't
fingerprint sessions by probing.
- `RequireMfaOptions.freshness` overrides the env default per-route.
Read via `Reflector.getAllAndOverride` with method-level metadata
winning over class-level — Nest's standard merge.
- `WWW-Authenticate` header + MSAL claims-challenge blob (ADR-0011
§"Step-up MFA — designed-in" step 2) defer to a later PR — they
need MSAL Node integration AND the SPA interceptor to consume
them. The structured `code: 'mfa_required'` is sufficient for the
SPA to pivot on once the interceptor lands.
Session payload:
- `session.mfaVerifiedAt` added to the express-session augmentation
in `session.types.ts`. Set to `Date.now()` at sign-in by the
callback — Entra's CA policy is the authority on whether MFA
actually happened; the BFF just stamps "now" when persisting a
session whose `amr` reflects MFA. Refreshed by future step-up
re-auth flows.
Tests: +37 specs (mfa helpers 9, config reader 9, guard 12, audit
typed method 3, callback assertion +1, +3 parametric expansions).
This commit is contained in:
@@ -5,6 +5,7 @@ import { PrismaService } from 'nestjs-prisma';
|
||||
import type {
|
||||
AdminAccessDeniedInput,
|
||||
AuditEventInput,
|
||||
MfaRequiredInput,
|
||||
SignInActor,
|
||||
SignInFailedInput,
|
||||
SignOutInput,
|
||||
@@ -114,6 +115,32 @@ export class AuditWriter {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Typed event: a request was rejected by `RequireMfaGuard` because
|
||||
* the session did not satisfy the route's MFA freshness gate.
|
||||
* Per ADR-0011 §"Step-up MFA — designed-in, dormant", every
|
||||
* step-up challenge emitted by the BFF lands here so auditors can
|
||||
* track the distribution of step-up prompts (and the rate of stale
|
||||
* `mfaVerifiedAt` rejections that would suggest the default
|
||||
* freshness is too tight). `outcome=denied` matches the
|
||||
* `AdminRoleGuard` posture: the user *is* authenticated, the
|
||||
* action is just refused.
|
||||
*/
|
||||
async mfaRequired(input: MfaRequiredInput): Promise<void> {
|
||||
await this.recordEvent({
|
||||
eventType: 'auth.mfa_required',
|
||||
audience: 'workforce',
|
||||
outcome: 'denied',
|
||||
actorIdHash: this.hashUserId.hash(input.actor.oid),
|
||||
subject: input.attemptedRoute,
|
||||
payload: {
|
||||
reason: input.reason,
|
||||
freshnessSeconds: input.freshnessSeconds,
|
||||
...(input.mfaAgeMs !== undefined ? { mfaAgeMs: input.mfaAgeMs } : {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Typed event: `/api/admin/*` request rejected by `AdminRoleGuard`
|
||||
* because the session's `roles` claim does not include `admin`.
|
||||
|
||||
Reference in New Issue
Block a user