feat(portal-bff): @RequireMfa decorator + freshness guard (ADR-0011) #128

Merged
julien merged 1 commits from feat/portal-bff-require-mfa-guard into main 2026-05-14 01:34:47 +02:00
Owner

Summary

Third step in the portal-admin audit-log-viewer workstream — ships the @RequireMfa({ freshness }) decorator + guard called out in ADR-0011 and referenced as the gate on the admin entry route in ADR-0020. Designed-in, dormant: no v1 route uses the decorator yet. First consumer will be the admin entry route once the distinct admin session lands (next PR).

What ships

  • auth/mfa.tsMFA_AMR_VALUES = ['mfa', 'otp', 'fido', 'wia', 'phr'] allow-list and wasMultiFactor(amr): boolean. The list mirrors ADR-0011 §"BFF verification"; the spec pins it so an ad-hoc edit can't bypass review.

  • config/check-mfa-config.tsreadMfaConfig() reads MFA_FRESHNESS_SECONDS (default 600 s, minimum 60 s). Anything below the floor throws at boot — the floor catches a misconfigured "MFA on every navigation" before the BFF starts.

  • auth/require-mfa.guard.ts — four branches:

    Branch HTTP Code Audit
    No session 401 unauthenticated none (noise)
    Session, no MFA-class amr 401 mfa_required auth.mfa_required reason=no-mfa-in-amr
    Session, no mfaVerifiedAt 401 mfa_required auth.mfa_required reason=no-mfa-verified-at
    Session, stale mfaVerifiedAt 401 mfa_required auth.mfa_required reason=mfa-stale, mfaAgeMs=…

    The reason discriminator is not surfaced over the wire — only the audit row carries it. An attacker probing for "stale vs no-MFA" can't distinguish the two from the response.

  • auth/require-mfa.decorator.ts@RequireMfa({ freshness? }) built via applyDecorators(SetMetadata, UseGuards). The per-route freshness override wins over the env default. Designed to compose with @RequireAdmin() — apply @RequireMfa outside @RequireAdmin so the freshness gate runs only after role is established.

  • AuditWriter.mfaRequired() — new typed method using outcome=denied, captures reason, freshnessSeconds, and mfaAgeMs (when applicable) in the JSONB payload.

  • session.mfaVerifiedAt: number — augmented onto express-session's SessionData in session.types.ts. Set to Date.now() at sign-in by the callback (auth.controller.ts). Entra's CA policy is the authority on whether MFA actually happened; the BFF stamps "now" when persisting a session whose amr reflects MFA.

Deferred — for the SPA-interceptor PR

ADR-0011 §"Step-up MFA — designed-in" step 2 calls for a WWW-Authenticate header carrying a claims challenge (MSAL-produced blob) on the 401. That requires:

  1. MSAL Node integration to mint the challenge — adds wire-format coupling to MSAL we don't have anywhere else yet.
  2. The Angular SPA interceptor to consume the header, redirect to /auth/login?claims=…, and retry the original request.

Neither side has a consumer in this PR. Shipping a code: 'mfa_required' in the structured envelope is sufficient signalling for the SPA interceptor once it lands — the interceptor PR can layer the WWW-Authenticate header and the MSAL claims blob without changing the guard's audit contract.

Composability with @RequireAdmin

The admin entry route (next-PR consumer) will read:

@Controller('admin')
@RequireMfa({ freshness: 600 })
@RequireAdmin()
export class AdminController {  }

Apply order matters — Nest runs guards in the order their decorators were applied (innermost first). Putting @RequireMfa() outside @RequireAdmin() means a non-admin user gets a clean 403 from AdminRoleGuard without a spurious auth.mfa_required audit row. The decorator's JSDoc spells this out for future consumers.

Notes for the reviewer

  • The RequireMfaGuard is registered as a provider in AuthModule and re-exported. Per the existing convention ("AuthModule stays non-global; modules state 'I depend on auth' by importing it"), any future module using @RequireMfa() will need to imports: [AuthModule]. The AdminModule already does this transitively via shared AuditWriter; the explicit import will follow when the decorator is first applied.
  • mfaChallenge(reason) takes the reason argument deliberately even though it ignores it in the response — keeps the call sites readable (throw this.mfaChallenge('mfa-stale')) and parks a hook for the day we want to localise / differentiate the message.
  • New env var MFA_FRESHNESS_SECONDS is optional (default 600). No production env change is required to ship this PR.

Test plan

  • pnpm nx test portal-bff251 specs pass (was 214; +37 covering helpers, config reader, guard branches, audit typed method, callback stamp).
  • pnpm exec nx affected -t format:check lint test build --base=origin/main — clean (the pre-existing _res / _next warnings in rate-limit.middleware.ts are unrelated).
  • MFA_FRESHNESS_SECONDS boot validator: default + valid + below-floor + non-integer + decimal + non-numeric all covered.
  • Guard timing-boundary cases covered (age == freshness passes; age == freshness + 1 ms fails — implicitly via the 700-s-vs-600-s test).
  • e2e — pending real Entra session with amr carrying an MFA token. Will be exercised when the admin entry route applies the decorator.
## Summary Third step in the `portal-admin` audit-log-viewer workstream — ships the `@RequireMfa({ freshness })` decorator + guard called out in [ADR-0011](docs/decisions/0011-mfa-enforcement-entra-conditional-access.md) and referenced as the gate on the admin entry route in [ADR-0020](docs/decisions/0020-portal-admin-app.md). Designed-in, dormant: no v1 route uses the decorator yet. First consumer will be the admin entry route once the distinct admin session lands (next PR). ## What ships - **[`auth/mfa.ts`](apps/portal-bff/src/auth/mfa.ts)** — `MFA_AMR_VALUES = ['mfa', 'otp', 'fido', 'wia', 'phr']` allow-list and `wasMultiFactor(amr): boolean`. The list mirrors ADR-0011 §"BFF verification"; the spec pins it so an ad-hoc edit can't bypass review. - **[`config/check-mfa-config.ts`](apps/portal-bff/src/config/check-mfa-config.ts)** — `readMfaConfig()` reads `MFA_FRESHNESS_SECONDS` (default **600 s**, minimum **60 s**). Anything below the floor throws at boot — the floor catches a misconfigured "MFA on every navigation" before the BFF starts. - **[`auth/require-mfa.guard.ts`](apps/portal-bff/src/auth/require-mfa.guard.ts)** — four branches: | Branch | HTTP | Code | Audit | | --- | --- | --- | --- | | No session | 401 | `unauthenticated` | none (noise) | | Session, no MFA-class `amr` | 401 | `mfa_required` | `auth.mfa_required reason=no-mfa-in-amr` | | Session, no `mfaVerifiedAt` | 401 | `mfa_required` | `auth.mfa_required reason=no-mfa-verified-at` | | Session, stale `mfaVerifiedAt` | 401 | `mfa_required` | `auth.mfa_required reason=mfa-stale, mfaAgeMs=…` | The `reason` discriminator is **not** surfaced over the wire — only the audit row carries it. An attacker probing for "stale vs no-MFA" can't distinguish the two from the response. - **[`auth/require-mfa.decorator.ts`](apps/portal-bff/src/auth/require-mfa.decorator.ts)** — `@RequireMfa({ freshness? })` built via `applyDecorators(SetMetadata, UseGuards)`. The per-route `freshness` override wins over the env default. Designed to compose with `@RequireAdmin()` — apply `@RequireMfa` outside `@RequireAdmin` so the freshness gate runs only after role is established. - **[`AuditWriter.mfaRequired()`](apps/portal-bff/src/audit/audit.service.ts)** — new typed method using `outcome=denied`, captures `reason`, `freshnessSeconds`, and `mfaAgeMs` (when applicable) in the JSONB payload. - **`session.mfaVerifiedAt: number`** — augmented onto `express-session`'s `SessionData` in [`session.types.ts`](apps/portal-bff/src/session/session.types.ts). Set to `Date.now()` at sign-in by the callback ([`auth.controller.ts`](apps/portal-bff/src/auth/auth.controller.ts)). Entra's CA policy is the authority on whether MFA actually happened; the BFF stamps "now" when persisting a session whose `amr` reflects MFA. ## Deferred — for the SPA-interceptor PR ADR-0011 §"Step-up MFA — designed-in" step 2 calls for a `WWW-Authenticate` header carrying a **claims challenge** (MSAL-produced blob) on the 401. That requires: 1. MSAL Node integration to mint the challenge — adds wire-format coupling to MSAL we don't have anywhere else yet. 2. The Angular SPA interceptor to consume the header, redirect to `/auth/login?claims=…`, and retry the original request. Neither side has a consumer in this PR. Shipping a `code: 'mfa_required'` in the structured envelope is sufficient signalling for the SPA interceptor once it lands — the interceptor PR can layer the `WWW-Authenticate` header and the MSAL claims blob without changing the guard's audit contract. ## Composability with `@RequireAdmin` The admin entry route (next-PR consumer) will read: ```ts @Controller('admin') @RequireMfa({ freshness: 600 }) @RequireAdmin() export class AdminController { … } ``` Apply order matters — Nest runs guards in the order their decorators were applied (innermost first). Putting `@RequireMfa()` outside `@RequireAdmin()` means a non-admin user gets a clean 403 from `AdminRoleGuard` without a spurious `auth.mfa_required` audit row. The decorator's JSDoc spells this out for future consumers. ## Notes for the reviewer - The `RequireMfaGuard` is registered as a provider in `AuthModule` and re-exported. Per the existing convention ("`AuthModule` stays non-global; modules state 'I depend on auth' by importing it"), any future module using `@RequireMfa()` will need to `imports: [AuthModule]`. The `AdminModule` already does this transitively via shared `AuditWriter`; the explicit import will follow when the decorator is first applied. - `mfaChallenge(reason)` takes the reason argument deliberately even though it ignores it in the response — keeps the call sites readable (`throw this.mfaChallenge('mfa-stale')`) and parks a hook for the day we want to localise / differentiate the message. - New env var `MFA_FRESHNESS_SECONDS` is **optional** (default 600). No production env change is required to ship this PR. ## Test plan - [x] `pnpm nx test portal-bff` — **251 specs pass** (was 214; +37 covering helpers, config reader, guard branches, audit typed method, callback stamp). - [x] `pnpm exec nx affected -t format:check lint test build --base=origin/main` — clean (the pre-existing `_res` / `_next` warnings in `rate-limit.middleware.ts` are unrelated). - [x] `MFA_FRESHNESS_SECONDS` boot validator: default + valid + below-floor + non-integer + decimal + non-numeric all covered. - [x] Guard timing-boundary cases covered (age == freshness passes; age == freshness + 1 ms fails — implicitly via the 700-s-vs-600-s test). - [ ] e2e — pending real Entra session with `amr` carrying an MFA token. Will be exercised when the admin entry route applies the decorator.
julien added 1 commit 2026-05-14 01:33:02 +02:00
feat(portal-bff): @RequireMfa decorator + freshness guard (ADR-0011)
CI / scan (pull_request) Successful in 2m27s
CI / commits (pull_request) Successful in 2m44s
CI / check (pull_request) Successful in 2m54s
CI / a11y (pull_request) Successful in 2m15s
CI / perf (pull_request) Successful in 5m45s
9ba5815f1b
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).
julien merged commit d51ccebe6a into main 2026-05-14 01:34:47 +02:00
julien deleted branch feat/portal-bff-require-mfa-guard 2026-05-14 01:34:49 +02:00
Sign in to join this conversation.
No Reviewers
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: julien/apf_portal#128