import bcrypt from "bcryptjs"; import jwt from "jsonwebtoken"; import type { Request, Response, NextFunction } from "express"; import { getUserById } from "./db.js"; import type { User } from "./schema.js"; const COOKIE_NAME = "qm_auth"; const COOKIE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; // 7 days function getJwtSecret(): string { const secret = process.env.JWT_SECRET; if (!secret || secret.length < 32) { throw new Error( "JWT_SECRET is not set or is too short (>= 32 chars required). " + "Generate one with `openssl rand -hex 64` and set it in the environment." ); } return secret; } /** * Fail-fast validation called from the server bootstrap so a misconfigured * deployment refuses to start instead of erroring lazily on the first login. */ export function assertAuthEnv(): void { getJwtSecret(); } export interface JwtPayload { sub: number; email: string; role: "user" | "admin"; } // ─── Password hashing ──────────────────────────────────────────────────────── export async function hashPassword(password: string): Promise { return bcrypt.hash(password, 12); } export async function verifyPassword(password: string, hash: string): Promise { return bcrypt.compare(password, hash); } // ─── JWT ───────────────────────────────────────────────────────────────────── export function createToken(user: Pick): string { const payload: JwtPayload = { sub: user.id, email: user.email, role: user.role, }; return jwt.sign(payload, getJwtSecret(), { expiresIn: "7d" }); } export function verifyToken(token: string): JwtPayload | null { try { const decoded = jwt.verify(token, getJwtSecret()); if (typeof decoded === "string") return null; if ( typeof decoded.sub !== "number" || typeof decoded.email !== "string" || (decoded.role !== "user" && decoded.role !== "admin") ) { return null; } return { sub: decoded.sub, email: decoded.email, role: decoded.role }; } catch { return null; } } // ─── Cookie helpers ────────────────────────────────────────────────────────── export function setAuthCookie(res: Response, token: string): void { res.cookie(COOKIE_NAME, token, { httpOnly: true, secure: process.env.NODE_ENV === "production", sameSite: "lax", maxAge: COOKIE_MAX_AGE_MS, path: "/", }); } export function clearAuthCookie(res: Response): void { res.clearCookie(COOKIE_NAME, { path: "/" }); } export function readAuthCookie(req: Request): string | null { const cookies = (req as Request & { cookies?: Record }).cookies; if (cookies && typeof cookies[COOKIE_NAME] === "string") return cookies[COOKIE_NAME]; const header = req.headers.authorization; if (header && header.startsWith("Bearer ")) return header.slice(7); return null; } // ─── User from request ─────────────────────────────────────────────────────── export async function getUserFromRequest(req: Request): Promise { const token = readAuthCookie(req); if (!token) return null; const payload = verifyToken(token); if (!payload) return null; return getUserById(payload.sub); } // ─── Express middleware ────────────────────────────────────────────────────── export interface AuthedRequest extends Request { user?: User; } export async function authMiddleware( req: AuthedRequest, _res: Response, next: NextFunction ): Promise { const user = await getUserFromRequest(req); if (user) req.user = user; next(); } export function requireAuth(req: AuthedRequest, res: Response, next: NextFunction): void { if (!req.user) { res.status(401).json({ error: "Unauthorized" }); return; } next(); } export const AUTH_COOKIE_NAME = COOKIE_NAME;