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 { const req = context.switchToHttp().getRequest(); 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(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', }); } }