import 'server-only'; import { and, desc, eq, gt, isNull } from '@/lib/db'; import { newId } from '@rareindex/shared'; import { authCodes, db } from '@/lib/db'; import { hmacToken, numericCode, safeEqual } from './crypto'; export type CodePurpose = 'verify_email' | 'mfa_email' | 'password_reset' | 'change_email' | 'new_device'; export const CODE_TTL_MIN: Record = { verify_email: 10, mfa_email: 10, password_reset: 30, change_email: 15, new_device: 10 }; const MAX_ATTEMPTS = 5; const RESEND_COOLDOWN_S = 45; export interface IssuedCode { code: string; expiresAt: Date; minutes: number; } /** Issue a fresh code, invalidating previous unconsumed codes for the same (email, purpose). */ export async function issueCode(params: { email: string; purpose: CodePurpose; userId?: string | null; payload?: Record }): Promise { const email = params.email.toLowerCase(); const last = await db().select({ createdAt: authCodes.createdAt }).from(authCodes).where(and(eq(authCodes.email, email), eq(authCodes.purpose, params.purpose), isNull(authCodes.consumedAt))).orderBy(desc(authCodes.createdAt)).limit(1); const lastAt = last[0]?.createdAt; if (lastAt && Date.now() - lastAt.getTime() < RESEND_COOLDOWN_S * 1000) { return { cooldownSeconds: Math.ceil((RESEND_COOLDOWN_S * 1000 - (Date.now() - lastAt.getTime())) / 1000) }; } await db().update(authCodes).set({ consumedAt: new Date() }).where(and(eq(authCodes.email, email), eq(authCodes.purpose, params.purpose), isNull(authCodes.consumedAt))); const code = numericCode(6); const minutes = CODE_TTL_MIN[params.purpose]; const expiresAt = new Date(Date.now() + minutes * 60_000); await db().insert(authCodes).values({ id: newId('event'), userId: params.userId ?? null, email, purpose: params.purpose, codeHash: hmacToken(code, `code:${params.purpose}`), expiresAt, payload: params.payload ?? {} }); return { code, expiresAt, minutes }; } export type VerifyOutcome = { ok: true; payload: Record; userId: string | null } | { ok: false; reason: 'invalid' | 'expired' | 'locked' | 'missing' }; /** Verify and consume a code. Counts attempts; locks after MAX_ATTEMPTS. */ export async function verifyCode(params: { email: string; purpose: CodePurpose; code: string }): Promise { const email = params.email.toLowerCase(); const clean = params.code.replace(/\D/g, ''); const rows = await db().select().from(authCodes).where(and(eq(authCodes.email, email), eq(authCodes.purpose, params.purpose), isNull(authCodes.consumedAt))).orderBy(desc(authCodes.createdAt)).limit(1); const row = rows[0]; if (!row) return { ok: false, reason: 'missing' }; if (row.attempts >= MAX_ATTEMPTS) return { ok: false, reason: 'locked' }; if (row.expiresAt.getTime() < Date.now()) return { ok: false, reason: 'expired' }; const good = clean.length === 6 && safeEqual(hmacToken(clean, `code:${params.purpose}`), row.codeHash); if (!good) { await db().update(authCodes).set({ attempts: row.attempts + 1 }).where(eq(authCodes.id, row.id)); return { ok: false, reason: row.attempts + 1 >= MAX_ATTEMPTS ? 'locked' : 'invalid' }; } await db().update(authCodes).set({ consumedAt: new Date() }).where(and(eq(authCodes.id, row.id), isNull(authCodes.consumedAt), gt(authCodes.expiresAt, new Date()))); return { ok: true, payload: row.payload ?? {}, userId: row.userId }; }