feat(portal-bff): @RequireMfa decorator + freshness guard (ADR-0011) (#128)
## 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.
---------
Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #128
This commit was merged in pull request #128.
This commit is contained in:
@@ -0,0 +1,144 @@
|
||||
import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import type { Request } from 'express';
|
||||
import { AuditWriter } from '../audit/audit.service';
|
||||
import type { MfaRequiredReason } from '../audit/audit.types';
|
||||
import { readMfaConfig } from '../config/check-mfa-config';
|
||||
import { wasMultiFactor } from './mfa';
|
||||
import type { RequireMfaOptions } from './require-mfa.decorator';
|
||||
|
||||
/**
|
||||
* Reflector metadata key carrying the per-route `RequireMfaOptions`.
|
||||
* Re-exported by the decorator module so the two stay in lockstep.
|
||||
*/
|
||||
export const REQUIRE_MFA_METADATA = 'auth:require-mfa';
|
||||
|
||||
/**
|
||||
* 401 response code emitted on every MFA-step-up rejection. The SPA
|
||||
* interceptor (per ADR-0011 §"Step-up MFA — SPA cooperation") will
|
||||
* pivot on this exact value to distinguish a step-up challenge from
|
||||
* a plain "no session" 401 — and trigger an Entra re-auth round
|
||||
* trip with a claims challenge in a later PR. v1 surfaces the code
|
||||
* without the `WWW-Authenticate` header / MSAL claims blob; both
|
||||
* land when the SPA interceptor is built.
|
||||
*/
|
||||
const MFA_REQUIRED_CODE = 'mfa_required';
|
||||
|
||||
/**
|
||||
* `RequireMfaGuard` — enforces ADR-0011's step-up MFA freshness
|
||||
* window.
|
||||
*
|
||||
* Contract
|
||||
* --------
|
||||
* - **No session → 401 `unauthenticated`.** Same shape as the
|
||||
* `AdminRoleGuard` 401 branch: no audit row (unauthenticated
|
||||
* traffic is noise). The user just needs to sign in.
|
||||
*
|
||||
* - **Session with no MFA-class `amr` → 401 `mfa_required`.** The
|
||||
* session is authenticated but does not reflect an MFA assertion
|
||||
* at all (password-only sign-in, CA policy mis-skipped MFA, etc.).
|
||||
* Emits `auth.mfa_required` with `reason=no-mfa-in-amr`.
|
||||
*
|
||||
* - **Session with stale `mfaVerifiedAt` → 401 `mfa_required`.**
|
||||
* The user did MFA at some point but the assertion is older than
|
||||
* the freshness window. Emits `auth.mfa_required` with
|
||||
* `reason=mfa-stale`. The `mfaAgeMs` payload field lets auditors
|
||||
* see how far past freshness the rejection landed.
|
||||
*
|
||||
* - **Session with no `mfaVerifiedAt` at all → 401 `mfa_required`.**
|
||||
* Defensive — sessions created before this field was introduced
|
||||
* would otherwise leak past the guard. Emits
|
||||
* `auth.mfa_required` with `reason=no-mfa-verified-at`.
|
||||
*
|
||||
* - **Audit-write failures propagate.** Same posture as
|
||||
* `AdminRoleGuard`: no audit ⇒ no action per ADR-0013.
|
||||
*
|
||||
* Freshness resolution: per-route `RequireMfaOptions.freshness`
|
||||
* wins; otherwise the env-driven `MFA_FRESHNESS_SECONDS` (default
|
||||
* 600 s, min 60 s — validated by `readMfaConfig`).
|
||||
*/
|
||||
@Injectable()
|
||||
export class RequireMfaGuard implements CanActivate {
|
||||
constructor(
|
||||
private readonly reflector: Reflector,
|
||||
private readonly audit: AuditWriter,
|
||||
) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const req = context.switchToHttp().getRequest<Request>();
|
||||
const user = req.session?.user;
|
||||
|
||||
if (!user) {
|
||||
throw new UnauthorizedException({
|
||||
code: 'unauthenticated',
|
||||
message: 'Unauthenticated',
|
||||
});
|
||||
}
|
||||
|
||||
const options = this.resolveOptions(context);
|
||||
const freshnessSeconds = options.freshness ?? readMfaConfig().freshnessSeconds;
|
||||
const route = `${req.method} ${req.originalUrl}`;
|
||||
const now = Date.now();
|
||||
|
||||
if (!wasMultiFactor(user.amr)) {
|
||||
await this.audit.mfaRequired({
|
||||
actor: { oid: user.oid },
|
||||
attemptedRoute: route,
|
||||
reason: 'no-mfa-in-amr',
|
||||
freshnessSeconds,
|
||||
});
|
||||
throw this.mfaChallenge('no-mfa-in-amr');
|
||||
}
|
||||
|
||||
const verifiedAt = req.session?.mfaVerifiedAt;
|
||||
if (verifiedAt === undefined) {
|
||||
await this.audit.mfaRequired({
|
||||
actor: { oid: user.oid },
|
||||
attemptedRoute: route,
|
||||
reason: 'no-mfa-verified-at',
|
||||
freshnessSeconds,
|
||||
});
|
||||
throw this.mfaChallenge('no-mfa-verified-at');
|
||||
}
|
||||
|
||||
const ageMs = now - verifiedAt;
|
||||
if (ageMs > freshnessSeconds * 1000) {
|
||||
await this.audit.mfaRequired({
|
||||
actor: { oid: user.oid },
|
||||
attemptedRoute: route,
|
||||
reason: 'mfa-stale',
|
||||
freshnessSeconds,
|
||||
mfaAgeMs: ageMs,
|
||||
});
|
||||
throw this.mfaChallenge('mfa-stale');
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private resolveOptions(context: ExecutionContext): RequireMfaOptions {
|
||||
// Method-level metadata wins over class-level — same merge
|
||||
// strategy Nest's built-in guards use. `getAllAndOverride`
|
||||
// returns the first non-undefined match in the lookup order.
|
||||
return (
|
||||
this.reflector.getAllAndOverride<RequireMfaOptions>(REQUIRE_MFA_METADATA, [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
]) ?? {}
|
||||
);
|
||||
}
|
||||
|
||||
private mfaChallenge(reason: MfaRequiredReason): UnauthorizedException {
|
||||
// Structured-error envelope per ADR-0021 — the
|
||||
// StructuredErrorFilter expects `code` + `message` on the
|
||||
// exception response body. The `reason` is intentionally NOT
|
||||
// surfaced over the wire: an attacker shouldn't be able to
|
||||
// distinguish a stale session from a no-MFA session by probing.
|
||||
// The audit row carries the reason for forensic use.
|
||||
void reason;
|
||||
return new UnauthorizedException({
|
||||
code: MFA_REQUIRED_CODE,
|
||||
message: 'Fresh multi-factor authentication required',
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user