queue-med/server/auth.ts

113 lines
3.9 KiB
TypeScript

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 = 30 * 24 * 60 * 60 * 1000; // 30 days
function getJwtSecret(): string {
const secret = process.env.JWT_SECRET;
if (!secret) throw new Error("JWT_SECRET is not set");
return secret;
}
export interface JwtPayload {
sub: number;
email: string;
role: "user" | "admin";
}
// ─── Password hashing ────────────────────────────────────────────────────────
export async function hashPassword(password: string): Promise<string> {
return bcrypt.hash(password, 12);
}
export async function verifyPassword(password: string, hash: string): Promise<boolean> {
return bcrypt.compare(password, hash);
}
// ─── JWT ─────────────────────────────────────────────────────────────────────
export function createToken(user: Pick<User, "id" | "email" | "role">): string {
const payload: JwtPayload = {
sub: user.id,
email: user.email,
role: user.role,
};
return jwt.sign(payload, getJwtSecret(), { expiresIn: "30d" });
}
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<string, string> }).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<User | null> {
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<void> {
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;