Files
HiveGrid-Wireframes/hivegrid-cloud/apps/web/app/utils/auth/mfa/step-up.server.ts
T

110 lines
3.2 KiB
TypeScript

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<MfaStepUpMethod>(["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<Record<keyof MfaStepUpState, unknown>>;
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 };
}