import type { Session } from "react-router"; export const MFA_STEP_UP_SESSION_KEY = "adminMfaStepUp"; export const MFA_STEP_UP_MAX_AGE_MS = 10 * 60 * 1000; export type MfaStepUpMethod = "totp" | "recovery-code"; export type MfaStepUpState = { userId: string; method: MfaStepUpMethod; verifiedAt: string; redirectTo?: string; }; export type MfaStepUpCheckReason = "missing" | "malformed" | "wrong-user" | "stale"; export type MfaStepUpCheckResult = | { verified: true; state: MfaStepUpState; ageMs: number } | { verified: false; reason: MfaStepUpCheckReason }; const MFA_STEP_UP_METHODS = new Set(["totp", "recovery-code"]); function isMfaStepUpMethod(method: unknown): method is MfaStepUpMethod { return typeof method === "string" && MFA_STEP_UP_METHODS.has(method as MfaStepUpMethod); } function parseStepUpState(value: unknown): MfaStepUpState | null { if (!value || typeof value !== "object" || Array.isArray(value)) { return null; } const candidate = value as Partial>; if (typeof candidate.userId !== "string" || candidate.userId.length === 0) { return null; } if (!isMfaStepUpMethod(candidate.method)) { return null; } if (typeof candidate.verifiedAt !== "string" || Number.isNaN(Date.parse(candidate.verifiedAt))) { return null; } if (candidate.redirectTo !== undefined && typeof candidate.redirectTo !== "string") { return null; } return { userId: candidate.userId, method: candidate.method, verifiedAt: candidate.verifiedAt, ...(candidate.redirectTo !== undefined ? { redirectTo: candidate.redirectTo } : {}), }; } export function getMfaStepUpState(session: Session): MfaStepUpState | null { return parseStepUpState(session.get(MFA_STEP_UP_SESSION_KEY)); } export function setMfaStepUpState( session: Session, input: { userId: string; method: MfaStepUpMethod; redirectTo?: string; now?: Date; }, ): MfaStepUpState { const state: MfaStepUpState = { userId: input.userId, method: input.method, verifiedAt: (input.now ?? new Date()).toISOString(), ...(input.redirectTo !== undefined ? { redirectTo: input.redirectTo } : {}), }; session.set(MFA_STEP_UP_SESSION_KEY, state); return state; } export function clearMfaStepUpState(session: Session) { session.unset(MFA_STEP_UP_SESSION_KEY); } export function checkMfaStepUpState( session: Session, userId: string, options: { now?: Date; maxAgeMs?: number } = {}, ): MfaStepUpCheckResult { const rawValue = session.get(MFA_STEP_UP_SESSION_KEY); if (rawValue === undefined || rawValue === null) { return { verified: false, reason: "missing" }; } const state = parseStepUpState(rawValue); if (!state) { return { verified: false, reason: "malformed" }; } if (state.userId !== userId) { return { verified: false, reason: "wrong-user" }; } const nowMs = (options.now ?? new Date()).getTime(); const ageMs = nowMs - Date.parse(state.verifiedAt); const maxAgeMs = options.maxAgeMs ?? MFA_STEP_UP_MAX_AGE_MS; if (ageMs < 0 || ageMs > maxAgeMs) { return { verified: false, reason: "stale" }; } return { verified: true, state, ageMs }; }