187 lines · 7.6 KB
import { env } from "cloudflare:workers";
import { deleteCookie, getCookie, setCookie } from "@tanstack/react-start/server";
import { all, batch, first, run, stmt } from "./db";
import { decryptSecret, encryptSecret, randomToken, sha256Hex, toHex } from "./crypto";
import { attemptAuthToken, deleteAuthToken, peekAuthToken, storeAuthToken } from "./auth-tokens";
import { badRequest } from "./errors";
import { generateTotpSecret, totpUri, verifyTotp } from "./totp";

const PENDING_COOKIE = "stig_2fa";
const PENDING_TTL_SECONDS = 5 * 60;
const SETUP_TTL_SECONDS = 10 * 60;
const MAX_PENDING_ATTEMPTS = 5;
const RECOVERY_CODE_COUNT = 10;
const RECOVERY_HALF_BYTES = 3;

function masterKey(): string {
  if (!env.SECRETS_KEY) throw badRequest("SECRETS_KEY must be configured to use two-factor authentication");
  return env.SECRETS_KEY;
}

export async function secondFactorMethods(userId: string): Promise<{ totp: boolean; passkey: boolean }> {
  const row = await first<{ totp: number | null; passkeys: number }>(
    `SELECT u.totp_enabled_at AS totp, (SELECT COUNT(*) FROM passkeys p WHERE p.user_id = u.account_id) AS passkeys
     FROM users u WHERE u.account_id = ?`,
    userId,
  );
  return { totp: typeof row?.totp === "number", passkey: (row?.passkeys ?? 0) > 0 };
}

export async function requiresSecondFactor(userId: string): Promise<boolean> {
  const methods = await secondFactorMethods(userId);
  return methods.totp || methods.passkey;
}

async function storePending(userId: string): Promise<string> {
  const token = randomToken("mfa");
  await storeAuthToken("pending-login", token, userId, "{}", PENDING_TTL_SECONDS);
  return token;
}