import "server-only"; import { randomUUID } from "node:crypto"; import { serverEnv } from "@/lib/server/env"; import { createSession, createUser, deleteSessionById, findSessionWithUser, findUserByUsername, findUserWithTotpByUsername, } from "@/lib/server/modules/auth/repository"; import { hashPassword, verifyPassword } from "@/lib/server/modules/auth/password"; import { createPartialAuthToken } from "@/lib/server/modules/auth/partial-auth-token"; import { createSessionToken, verifySessionToken, } from "@/lib/server/modules/auth/session-token"; export class AuthError extends Error { constructor( message: string, public readonly statusCode: number, ) { super(message); this.name = "AuthError"; } } /** * Equal-time defence for the user-not-found branch of {@link loginUser}. * Without this, the response time on an unknown username is noticeably * shorter (one DB miss, no scrypt) than on a known one (DB hit plus a full * password verification), letting an attacker enumerate valid usernames. * * Computed once at module load so first-call latency stays in line with * subsequent calls; the verifyPassword on the miss path then re-runs scrypt * against this fixed hash for the same shape of work as a real check. */ const DUMMY_PASSWORD_HASH_PROMISE: Promise = hashPassword( "__nonexistent_user_password__", ); DUMMY_PASSWORD_HASH_PROMISE.catch(() => { // Swallow to prevent an unhandled rejection if scrypt ever throws at // import time; loginUser awaits the same promise and will surface it. }); function normalizeUsername(username: string) { return username.trim().toLowerCase(); } function validateUsername(username: string) { const normalized = normalizeUsername(username); if (!/^[a-z0-9_-]{3,32}$/.test(normalized)) { throw new AuthError( "Username must be 3-32 chars and use only lowercase letters, numbers, underscore, or dash", 400, ); } return normalized; } function validatePassword(password: string) { if (password.length < 8) { throw new AuthError("Password must be at least 8 characters", 400); } } function getSessionExpiryDate() { return new Date(Date.now() + serverEnv.AUTH_SESSION_HOURS * 60 * 60 * 1000); } export async function registerUser(params: { username: string; password: string; confirmPassword: string; }) { const username = validateUsername(params.username); validatePassword(params.password); if (params.password !== params.confirmPassword) { throw new AuthError("Password confirmation does not match", 400); } const existingUser = await findUserByUsername(username); if (existingUser) { throw new AuthError("Username is already registered", 409); } const userId = randomUUID(); const passwordHash = await hashPassword(params.password); await createUser({ id: userId, username, passwordHash, }); return { id: userId, username, }; } export type LoginResult = | { kind: "session"; token: string; expiresAt: Date; user: { id: string; username: string }; } | { kind: "partial"; partialAuthToken: string; partialAuthExpiresAt: Date; user: { id: string; username: string }; }; export async function loginUser(params: { username: string; password: string; }): Promise { const username = normalizeUsername(params.username); const user = await findUserWithTotpByUsername(username); if (!user) { // Run a full scrypt verification against a fixed dummy hash so the miss // path takes the same wall-clock time as the user-exists / wrong-password // path. Without this the timing delta leaks username existence. const dummyHash = await DUMMY_PASSWORD_HASH_PROMISE; await verifyPassword(params.password, dummyHash); throw new AuthError("Invalid username or password", 401); } const isPasswordValid = await verifyPassword(params.password, user.passwordHash); if (!isPasswordValid) { throw new AuthError("Invalid username or password", 401); } if (user.totpEnabled) { // Password verified but the user has 2FA on — issue a stateless, // short-lived partial-auth token instead of a full session. The caller // must complete A8 to convert it into a real session cookie. const { token, expiresAtEpochSeconds } = createPartialAuthToken(user.id); return { kind: "partial", partialAuthToken: token, partialAuthExpiresAt: new Date(expiresAtEpochSeconds * 1000), user: { id: user.id, username: user.username, }, }; } const expiresAt = getSessionExpiryDate(); const sessionId = randomUUID(); await createSession({ id: sessionId, userId: user.id, expiresAt, }); const token = createSessionToken( sessionId, Math.floor(expiresAt.getTime() / 1000), ); return { kind: "session", token, expiresAt, user: { id: user.id, username: user.username, }, }; } export async function logoutSession(sessionToken: string | null | undefined) { if (!sessionToken) return; const verified = verifySessionToken(sessionToken); if (!verified) return; await deleteSessionById(verified.sessionId); } export async function authenticateSession(sessionToken: string | null | undefined) { if (!sessionToken) return null; const verified = verifySessionToken(sessionToken); if (!verified) return null; const session = await findSessionWithUser(verified.sessionId); if (!session) return null; return { sessionId: session.sessionId, userId: session.userId, username: session.username, passwordHash: session.passwordHash, expiresAt: session.expiresAt, }; } export async function verifyUnlockPassword(params: { sessionToken: string | null | undefined; password: string; }) { const session = await authenticateSession(params.sessionToken); if (!session) { throw new AuthError("Unauthorized", 401); } const isPasswordValid = await verifyPassword( params.password, session.passwordHash, ); if (!isPasswordValid) { throw new AuthError("Invalid password", 401); } return { userId: session.userId, username: session.username, }; }