import crypto from "crypto"; import type { Request } from "express"; import { getDatabasePool } from "./db"; const SCRYPT_PARAMS = { N: 16384, r: 8, p: 1, maxmem: 64 * 1024 * 1024 }; const SCRYPT_KEYLEN = 32; const PASSWORD_PREFIX = "scrypt:v1:"; const PILOT_SIGNUP_LOCK_NAMESPACE = 0x6d696b65; // "mike" const PILOT_SIGNUP_LOCK_RESOURCE = 0x61757468; // "auth" export type LocalAuthUser = { id: string; email: string; is_operator: boolean; disabled: boolean; }; function sessionDays(): number { const parsed = parseInt(process.env.LOCAL_AUTH_SESSION_DAYS ?? "14", 10); return Number.isFinite(parsed) && parsed > 0 ? parsed : 14; } export function hashPassword(password: string): Promise { const salt = crypto.randomBytes(16); return new Promise((resolve, reject) => { crypto.scrypt(password, salt, SCRYPT_KEYLEN, SCRYPT_PARAMS, (err, derived) => { if (err) { reject(err); return; } resolve( `${PASSWORD_PREFIX}${salt.toString("base64url")}:${derived.toString("base64url")}`, ); }); }); } export async function verifyPassword( password: string, storedHash: string, ): Promise { if (!storedHash.startsWith(PASSWORD_PREFIX)) { return false; } const payload = storedHash.slice(PASSWORD_PREFIX.length); const separator = payload.indexOf(":"); if (separator <= 0) { return false; } const salt = Buffer.from(payload.slice(0, separator), "base64url"); const expected = Buffer.from(payload.slice(separator + 1), "base64url"); if (expected.length !== SCRYPT_KEYLEN) { return false; } const derived = await new Promise((resolve, reject) => { crypto.scrypt(password, salt, SCRYPT_KEYLEN, SCRYPT_PARAMS, (err, key) => { if (err) { reject(err); return; } resolve(key); }); }); return crypto.timingSafeEqual(expected, derived); } export function hashSessionToken(token: string): string { return crypto.createHash("sha256").update(token).digest("hex"); } export function generateSessionToken(): string { return crypto.randomBytes(32).toString("base64url"); } export function generateUserId(): string { return crypto.randomUUID(); } export function getPilotConfig() { const enabled = process.env.PILOT_SIGNUP_ENABLED === "1"; const limit = Math.max( 0, parseInt(process.env.PILOT_SIGNUP_LIMIT ?? "6", 10) || 0, ); const exemptEmails = (process.env.PILOT_SIGNUP_EXEMPT_EMAILS ?? "") .split(",") .map((email) => email.trim().toLowerCase()) .filter(Boolean); return { enabled, limit, exemptEmails }; } export function isExemptEmail(email: string, exemptEmails: string[]): boolean { return exemptEmails.includes(email.toLowerCase()); } export async function deleteExpiredSessions(): Promise { await getDatabasePool().query( `DELETE FROM auth.sessions WHERE expires_at <= now()`, ); } export async function countPilotUsers(exemptEmails: string[]): Promise { const result = await getDatabasePool().query<{ count: string }>( `SELECT count(*)::text AS count FROM auth.users WHERE disabled = false AND is_operator = false AND NOT (lower(email) = ANY($1::text[]))`, [exemptEmails], ); return parseInt(result.rows[0]?.count ?? "0", 10) || 0; } export async function getSignupStatus() { const { enabled, limit, exemptEmails } = getPilotConfig(); if (!enabled) { return { enabled: false, limit, used: 0, remaining: 0 }; } await deleteExpiredSessions(); const used = await countPilotUsers(exemptEmails); const remaining = Math.max(0, limit - used); return { enabled: true, limit, used, remaining }; } type CreateLocalUserOptions = { isOperator?: boolean; skipCapCheck?: boolean; }; export async function createLocalUser( email: string, password: string, options: CreateLocalUserOptions = {}, ): Promise { const normalizedEmail = email.trim().toLowerCase(); const passwordHash = await hashPassword(password); const userId = generateUserId(); const isOperator = options.isOperator === true; const pool = getDatabasePool(); const client = await pool.connect(); try { await client.query("BEGIN"); if (!options.skipCapCheck && !isOperator) { const { enabled, limit, exemptEmails } = getPilotConfig(); if (!enabled) { throw new Error("Pilot signup is closed"); } if (isExemptEmail(normalizedEmail, exemptEmails)) { throw new Error( "This email is reserved for operator access and cannot use pilot signup", ); } await client.query(`SELECT pg_advisory_xact_lock($1::integer, $2::integer)`, [ PILOT_SIGNUP_LOCK_NAMESPACE, PILOT_SIGNUP_LOCK_RESOURCE, ]); const countResult = await client.query<{ count: string }>( `SELECT count(*)::text AS count FROM auth.users WHERE disabled = false AND is_operator = false AND NOT (lower(email) = ANY($1::text[]))`, [exemptEmails], ); const used = parseInt(countResult.rows[0]?.count ?? "0", 10) || 0; if (used >= limit) { throw new Error("Pilot signup is full"); } } const insertResult = await client.query( `INSERT INTO auth.users (id, email, password_hash, is_operator) VALUES ($1, $2, $3, $4) RETURNING id, email, is_operator, disabled`, [userId, normalizedEmail, passwordHash, isOperator], ); await client.query("COMMIT"); return insertResult.rows[0]; } catch (err) { await client.query("ROLLBACK"); throw err; } finally { client.release(); } } export async function findUserByEmail( email: string, ): Promise<(LocalAuthUser & { password_hash: string | null }) | null> { const result = await getDatabasePool().query< LocalAuthUser & { password_hash: string | null } >( `SELECT id, email, password_hash, is_operator, disabled FROM auth.users WHERE lower(email) = lower($1) LIMIT 1`, [email.trim()], ); return result.rows[0] ?? null; } export async function createSession( userId: string, reqMeta?: { userAgent?: string; ip?: string }, ): Promise<{ token: string; expiresAt: Date }> { const token = generateSessionToken(); const tokenHash = hashSessionToken(token); const expiresAt = new Date( Date.now() + sessionDays() * 24 * 60 * 60 * 1000, ); await getDatabasePool().query( `INSERT INTO auth.sessions (token_hash, user_id, expires_at, user_agent, ip) VALUES ($1, $2, $3, $4, $5)`, [ tokenHash, userId, expiresAt.toISOString(), reqMeta?.userAgent ?? null, reqMeta?.ip ?? null, ], ); return { token, expiresAt }; } export async function verifySessionToken( token: string, ): Promise { const tokenHash = hashSessionToken(token); const result = await getDatabasePool().query( `SELECT u.id, u.email, u.is_operator, u.disabled FROM auth.sessions s JOIN auth.users u ON u.id = s.user_id WHERE s.token_hash = $1 AND s.expires_at > now() LIMIT 1`, [tokenHash], ); const user = result.rows[0]; if (!user || user.disabled) { return null; } return user; } export async function deleteSession(token: string): Promise { const tokenHash = hashSessionToken(token); await getDatabasePool().query(`DELETE FROM auth.sessions WHERE token_hash = $1`, [ tokenHash, ]); } export async function deleteUserSessions(userId: string): Promise { await getDatabasePool().query(`DELETE FROM auth.sessions WHERE user_id = $1`, [ userId, ]); } export async function updateUserPassword( email: string, password: string, ): Promise { const passwordHash = await hashPassword(password); const result = await getDatabasePool().query( `UPDATE auth.users SET password_hash = $1, updated_at = now() WHERE lower(email) = lower($2)`, [passwordHash, email.trim()], ); return (result.rowCount ?? 0) > 0; } export function getRequestMeta(req: Request) { const forwarded = req.headers["x-forwarded-for"]; const ip = typeof forwarded === "string" ? forwarded.split(",")[0]?.trim() : req.socket.remoteAddress; return { userAgent: typeof req.headers["user-agent"] === "string" ? req.headers["user-agent"] : undefined, ip, }; } export function isValidEmail(email: string): boolean { return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email); }