import { Injectable, UnauthorizedException, type CanActivate, type ExecutionContext, } from '@nestjs/common'; // eslint-disable-next-line @typescript-eslint/consistent-type-imports -- NestJS DI requires value imports for emitDecoratorMetadata import { Reflector } from '@nestjs/core'; // eslint-disable-next-line @typescript-eslint/consistent-type-imports -- NestJS DI requires value imports for emitDecoratorMetadata import { LoggerService } from '@modules/shared'; import type { JwtPayload } from '../../infrastructure/services/token.service'; import { REQUIRE_MFA_RECENT_KEY, DEFAULT_MFA_RECENT_WINDOW_SECONDS, type RequireMfaRecentOptions, } from '../decorators/require-mfa-recent.decorator'; /** * MFA-aware view of the JwtPayload. The `mfa` and `mfaAt` claims are added * by the auth/MFA flow (see GOO-230). Tokens that predate that change have * neither value, in which case this guard treats the caller as not recently * verified. */ type MfaJwtPayload = JwtPayload & { mfa?: 'verified' | 'enrollment_required' | 'grace' | 'none'; mfaAt?: number; }; /** * Guard that enforces a recent MFA verification window for routes decorated * with {@link RequireMfaRecent}. If the route (or its containing controller) * carries the `require_mfa_recent` metadata, the caller must: * * 1. Be authenticated (have `req.user`). * 2. Have `mfa === 'verified'` on the access token. * 3. Have `mfaAt` within `windowSeconds` of `now` (default 5 minutes). * * Otherwise responds with `401 { error: 'mfa_step_up_required' }` so clients * can trigger an MFA step-up and retry. */ @Injectable() export class MfaRecentlyVerifiedGuard implements CanActivate { constructor( private readonly reflector: Reflector, private readonly logger: LoggerService, ) {} canActivate(context: ExecutionContext): boolean { if (context.getType() !== 'http') { return true; } const options = this.reflector.getAllAndOverride< Required | undefined >(REQUIRE_MFA_RECENT_KEY, [context.getHandler(), context.getClass()]); if (!options) { return true; } const windowSeconds = options.windowSeconds ?? DEFAULT_MFA_RECENT_WINDOW_SECONDS; const request = context.switchToHttp().getRequest<{ user?: MfaJwtPayload; path?: string; url?: string; ip?: string; headers?: Record; }>(); const user = request.user; // No user → let JwtAuthGuard handle it elsewhere; but if this guard runs // without a user in-context, fail closed with step-up required. if (!user) { throw new UnauthorizedException({ error: 'mfa_step_up_required' }); } const mfaAtSec = typeof user.mfaAt === 'number' ? user.mfaAt : null; const nowSec = Math.floor(Date.now() / 1000); const fresh = user.mfa === 'verified' && mfaAtSec !== null && nowSec - mfaAtSec <= windowSeconds; if (fresh) { return true; } const path = (request.path ?? request.url ?? '').split('?')[0] ?? ''; const ip = request.ip || (Array.isArray(request.headers?.['x-forwarded-for']) ? request.headers?.['x-forwarded-for']?.[0] : (request.headers?.['x-forwarded-for'] as string | undefined)) || 'unknown'; this.logger.warn( `MFA step-up required: userId=${user.sub}, path=${path}, ip=${ip}, ` + `mfa=${user.mfa ?? 'none'}, mfaAt=${mfaAtSec ?? 'null'}, ` + `windowSeconds=${windowSeconds}`, 'MfaRecentlyVerifiedGuard', ); throw new UnauthorizedException({ error: 'mfa_step_up_required' }); } }