import crypto from "node:crypto"; import bcrypt from "bcrypt"; import jwt, { type SignOptions } from "jsonwebtoken"; function resolveSecret(): string { const fromEnv = process.env["JWT_SECRET"] || process.env["AUTH_SECRET"] || process.env["SESSION_SECRET"]; if (fromEnv && fromEnv.length >= 16) return fromEnv; if (process.env["NODE_ENV"] === "production") { throw new Error( "JWT_SECRET (or AUTH_SECRET/SESSION_SECRET) must be set to a strong value (>=16 chars) in production.", ); } // Dev-only: generate a random secret per process. const generated = crypto.randomBytes(32).toString("hex"); process.env["AUTH_SECRET"] = generated; return generated; } const SECRET = resolveSecret(); const TOKEN_TTL_SECONDS = 60 * 60 * 24 * 30; // 30 days const BCRYPT_ROUNDS = 12; export interface TokenPayload { sub: string; iat: number; exp: number; } export function signToken(userId: string): string { const opts: SignOptions = { expiresIn: TOKEN_TTL_SECONDS, algorithm: "HS256", }; return jwt.sign({ sub: userId }, SECRET, opts); } export function tokenTtlSeconds(): number { return TOKEN_TTL_SECONDS; } export function verifyToken(token: string): TokenPayload | null { try { const decoded = jwt.verify(token, SECRET, { algorithms: ["HS256"], }) as jwt.JwtPayload; if (typeof decoded.sub !== "string" || typeof decoded.exp !== "number") { return null; } return { sub: decoded.sub, iat: typeof decoded.iat === "number" ? decoded.iat : 0, exp: decoded.exp, }; } catch { return null; } } export function hashPassword(password: string): string { return bcrypt.hashSync(password, BCRYPT_ROUNDS); } export function verifyPassword(password: string, stored: string): boolean { // Backwards-compat: tolerate the old scrypt format ("hex:hex") so existing // dev rows keep working until rehashed on next login. if (stored.includes(":") && !stored.startsWith("$2")) { const parts = stored.split(":"); if (parts.length !== 2) return false; const [saltHex, hashHex] = parts; if (!saltHex || !hashHex) return false; try { const salt = Buffer.from(saltHex, "hex"); const expected = Buffer.from(hashHex, "hex"); const actual = crypto.scryptSync(password, salt, 64); if (actual.length !== expected.length) return false; return crypto.timingSafeEqual(actual, expected); } catch { return false; } } try { return bcrypt.compareSync(password, stored); } catch { return false; } } /** * Cryptographically random token for public share links and similar * unguessable identifiers. 32 bytes -> 256 bits of entropy, base64url-encoded. */ export function randomShareToken(): string { return crypto .randomBytes(32) .toString("base64") .replace(/=+$/g, "") .replace(/\+/g, "-") .replace(/\//g, "_"); }