import { z } from "zod"; import bcrypt from "bcryptjs"; import { prisma } from "./prisma"; // Minimal internal auth accounts — see prisma/schema.prisma's User model // comment and CLAUDE.md's auth decision notes. A handful of trusted IT // staff accounts, created by hand via scripts/create-user.ts. No // self-registration, no `role` field (flat permission model: any logged-in // user can do anything), no public list/CRUD API route — just enough to // support login + account creation. // bcryptjs cost factor for hashing. 10 is bcrypt's long-standing default // and a reasonable balance of hashing cost vs. login latency for this use // case (a handful of staff logins, not a high-value target). const BCRYPT_SALT_ROUNDS = 10; // Deliberately chosen minimum — long enough to rule out trivially weak // passwords for a handful of trusted IT staff accounts, not a full password // policy (no complexity rules, no breach-list check). Revisit if this ever // grows into employee self-service. const MIN_PASSWORD_LENGTH = 8; // Precomputed bcrypt hash of a fixed dummy password, hashed with the same // cost factor (BCRYPT_SALT_ROUNDS) used for real user passwords. Used as a // timing-safety decoy in verifyUserCredentials() below: when the looked-up // email doesn't exist, we still run a bcrypt.compare() against this hash // (whose result is always discarded) so that the "unknown email" code path // takes roughly the same time as the "wrong password" path — otherwise the // early return would let an attacker distinguish "email doesn't exist" from // "email exists, password wrong" purely by response latency (bcrypt.compare // dominates request time). Computed once at module load via hashSync (not // per-request) since hashing itself is deliberately expensive. const DUMMY_PASSWORD_HASH = bcrypt.hashSync( "dummy-password-for-timing-safety", BCRYPT_SALT_ROUNDS ); export interface UserRecord { id: string; email: string; name: string; created_at: string; } export interface CreateUserInput { email: string; name: string; password: string; } export type ValidationResult = | { ok: true; value: CreateUserInput } | { ok: false; error: string }; // A string field that must be present and not just whitespace. Mirrors the // same helper in src/lib/requests.ts (not shared/exported from there since // that file doesn't export it — small enough to duplicate rather than // introduce a shared-helpers module for one function). function nonEmptyString(message: string) { return z.string({ required_error: message, invalid_type_error: message }).refine( (value) => value.trim() !== "", { message } ); } const createUserSchema = z.object({ email: z .string({ required_error: "email is required", invalid_type_error: "email is required" }) .email("email must be a valid email address"), name: nonEmptyString("name is required and must be a non-empty string"), password: z .string({ required_error: `password is required and must be at least ${MIN_PASSWORD_LENGTH} characters`, invalid_type_error: `password is required and must be at least ${MIN_PASSWORD_LENGTH} characters`, }) .min( MIN_PASSWORD_LENGTH, `password is required and must be at least ${MIN_PASSWORD_LENGTH} characters` ), }); /** * Validates raw (untyped) user-creation input. email must be a valid email * address, name must be a non-empty string, password must be at least * MIN_PASSWORD_LENGTH characters. The password is validated here in its * plaintext form — hashing happens separately in createUser(). */ export function validateCreateUserInput(input: { email?: unknown; name?: unknown; password?: unknown; }): ValidationResult { const result = createUserSchema.safeParse(input); if (!result.success) { const message = result.error.issues[0]?.message ?? "Invalid input"; return { ok: false, error: message }; } return { ok: true, value: result.data }; } function toRecord(row: { id: string; email: string; name: string; createdAt: Date; }): UserRecord { return { id: row.id, email: row.email, name: row.name, created_at: row.createdAt.toISOString(), }; } /** * Hashes the given plaintext password with bcryptjs and inserts a new User * row. Caller is expected to have already validated the input via * validateCreateUserInput() — this does not re-validate. */ export async function createUser(input: CreateUserInput): Promise { const passwordHash = await bcrypt.hash(input.password, BCRYPT_SALT_ROUNDS); const created = await prisma.user.create({ data: { email: input.email, name: input.name, passwordHash, }, }); return toRecord(created); } /** * Looks up a user by email and compares the given plaintext password * against the stored bcrypt hash. Returns the user record (never the * password hash) on success, or null if the email doesn't exist or the * password doesn't match — callers should treat both failure cases * identically (don't leak which one it was). * * Accepts `unknown` for email/password since this is also the core logic * behind the Credentials provider's authorize() callback, whose input * comes from unvalidated form/request data — see src/auth.ts. */ export async function verifyUserCredentials( email: unknown, password: unknown ): Promise { if (typeof email !== "string" || typeof password !== "string") { return null; } if (email.trim() === "" || password === "") { return null; } const user = await prisma.user.findUnique({ where: { email } }); if (!user) { // Run a dummy compare against a fixed decoy hash and discard the result // — see DUMMY_PASSWORD_HASH comment above. This keeps timing consistent // with the "email exists, password wrong" branch below instead of // returning early. await bcrypt.compare(password, DUMMY_PASSWORD_HASH); return null; } const passwordMatches = await bcrypt.compare(password, user.passwordHash); if (!passwordMatches) { return null; } return toRecord(user); }