All files / src/lib otp.ts

91.66% Statements 22/24
87.5% Branches 7/8
100% Functions 5/5
91.66% Lines 22/24

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80          7x 7x 7x       25x 25x         25x 1x     24x 24x   24x 24x       24x         21x                         21x 1x     20x         20x         20x 1x     19x         2x           1x      
import crypto from "node:crypto";
import { eq, and, gt } from "drizzle-orm";
import { db } from "../db/index.js";
import { otpCodes } from "../db/schema.js";
 
const OTP_MAX_ATTEMPTS = 5;
const OTP_TTL_MINUTES = 10;
const OTP_RATE_LIMIT_PER_HOUR = 3;
 
/** Rate-limit, generate, store, and send an OTP for the given email. */
export async function requestOtp(email: string): Promise<void> {
  const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000);
  const recentCodes = await db
    .select()
    .from(otpCodes)
    .where(and(eq(otpCodes.email, email), gt(otpCodes.createdAt, oneHourAgo)));
 
  if (recentCodes.length >= OTP_RATE_LIMIT_PER_HOUR) {
    throw new OtpRateLimitError("Too many OTP requests. Try again later.");
  }
 
  const code = crypto.randomInt(100_000, 999_999).toString();
  const expiresAt = new Date(Date.now() + OTP_TTL_MINUTES * 60 * 1000);
 
  await db.insert(otpCodes).values({ email, code, expiresAt });
  sendOtp(email, code);
}
 
function sendOtp(email: string, code: string): void {
  console.log(`[OTP] Code for ${email}: ${code}`);
}
 
/** Validate an OTP code for the given email. Returns the email on success, throws on failure. */
export async function verifyOtp(email: string, code: string): Promise<void> {
  const [otp] = await db
    .select()
    .from(otpCodes)
    .where(
      and(
        eq(otpCodes.email, email),
        eq(otpCodes.used, false),
        gt(otpCodes.expiresAt, new Date()),
      ),
    )
    .orderBy(otpCodes.createdAt)
    .limit(1);
 
  if (!otp) {
    throw new OtpError("Invalid or expired code");
  }
 
  Iif (otp.attempts >= OTP_MAX_ATTEMPTS) {
    await db.update(otpCodes).set({ used: true }).where(eq(otpCodes.id, otp.id));
    throw new OtpError("Too many attempts. Request a new code.");
  }
 
  await db
    .update(otpCodes)
    .set({ attempts: otp.attempts + 1 })
    .where(eq(otpCodes.id, otp.id));
 
  if (otp.code !== code) {
    throw new OtpError("Invalid or expired code");
  }
 
  await db.update(otpCodes).set({ used: true }).where(eq(otpCodes.id, otp.id));
}
 
export class OtpError extends Error {
  constructor(message: string) {
    super(message);
  }
}
 
export class OtpRateLimitError extends Error {
  constructor(message: string) {
    super(message);
  }
}