110 lines
3.2 KiB
TypeScript
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 };
|
|
}
|