mfa-challenge.ts127 lines · main
1/**
2 * MFA second-factor challenge (SuperTokens-style factor progression).
3 *
4 * After password OK, issue a short-lived signed ticket. /totp/verify must
5 * present that ticket — bare userId + TOTP is not enough.
6 */
7
8import {
9 createHmac,
10 randomBytes,
11 timingSafeEqual,
12} from 'node:crypto';
13
14import { env } from '../../env.js';
15import { getRedis } from '../../lib/redis.js';
16
17const TTL_MS = 5 * 60 * 1000;
18const USED_PREFIX = 'mfa:chal:used:';
19
20function signingSecret(): string {
21 return (
22 env.BRIVEN_JWT_SIGNING_KEY ||
23 env.BRIVEN_BETTER_AUTH_SECRET ||
24 env.BRIVEN_ENCRYPTION_KEY ||
25 // Dev-only fallback — production always has one of the above.
26 'briven-dev-mfa-challenge-secret-min-32-chars!!'
27 );
28}
29
30function sign(payload: string): string {
31 return createHmac('sha256', signingSecret()).update(payload).digest('base64url');
32}
33
34/**
35 * Issue a one-shot MFA challenge after first factor succeeds.
36 * Returns opaque base64url token for the client.
37 */
38export function issueMfaChallenge(input: {
39 userId: string;
40 tenantId: string;
41}): string {
42 const exp = Date.now() + TTL_MS;
43 const nonce = randomBytes(16).toString('hex');
44 const payload = `${input.userId}|${input.tenantId}|${exp}|${nonce}`;
45 const sig = sign(payload);
46 return Buffer.from(`${payload}|${sig}`, 'utf8').toString('base64url');
47}
48
49export type MfaChallengeOk = {
50 ok: true;
51 userId: string;
52 tenantId: string;
53 nonce: string;
54};
55
56export type MfaChallengeFail = { ok: false; message: string };
57
58/**
59 * Verify challenge structure + signature + expiry (does not consume single-use yet).
60 */
61export function parseMfaChallenge(
62 token: string | undefined | null,
63): MfaChallengeOk | MfaChallengeFail {
64 if (!token || typeof token !== 'string' || token.length < 16) {
65 return { ok: false, message: 'mfaChallenge required' };
66 }
67 let raw: string;
68 try {
69 raw = Buffer.from(token, 'base64url').toString('utf8');
70 } catch {
71 return { ok: false, message: 'invalid mfaChallenge' };
72 }
73 const parts = raw.split('|');
74 if (parts.length !== 5) {
75 return { ok: false, message: 'invalid mfaChallenge' };
76 }
77 const [userId, tenantId, expStr, nonce, sig] = parts;
78 if (!userId || !tenantId || !expStr || !nonce || !sig) {
79 return { ok: false, message: 'invalid mfaChallenge' };
80 }
81 const exp = Number(expStr);
82 if (!Number.isFinite(exp) || Date.now() > exp) {
83 return { ok: false, message: 'mfaChallenge expired — sign in again' };
84 }
85 const payload = `${userId}|${tenantId}|${expStr}|${nonce}`;
86 const expected = sign(payload);
87 try {
88 const a = Buffer.from(sig);
89 const b = Buffer.from(expected);
90 if (a.length !== b.length || !timingSafeEqual(a, b)) {
91 return { ok: false, message: 'invalid mfaChallenge' };
92 }
93 } catch {
94 return { ok: false, message: 'invalid mfaChallenge' };
95 }
96 return { ok: true, userId, tenantId, nonce };
97}
98
99/**
100 * Mark challenge as used (single-use). Best-effort Redis; if Redis is down,
101 * still accept once (signature + short TTL remain).
102 */
103export async function consumeMfaChallenge(
104 token: string,
105 expectedUserId: string,
106): Promise<MfaChallengeOk | MfaChallengeFail> {
107 const parsed = parseMfaChallenge(token);
108 if (!parsed.ok) return parsed;
109 if (parsed.userId !== expectedUserId) {
110 return { ok: false, message: 'mfaChallenge does not match user' };
111 }
112 const redis = getRedis();
113 if (redis) {
114 try {
115 const key = `${USED_PREFIX}${parsed.nonce}`;
116 const set = await redis.set(key, '1', 'PX', TTL_MS, 'NX');
117 if (set !== 'OK') {
118 return { ok: false, message: 'mfaChallenge already used' };
119 }
120 } catch {
121 /* fail open on redis errors for availability; TTL still bounds abuse */
122 }
123 }
124 return parsed;
125}
126
127export const MFA_CHALLENGE_TTL_MS = TTL_MS;