import { HttpError } from "@batch-cooking/express-tools"; import { ErrorCode, type SafeUserProfile } from "@batch-cooking/shared"; import type { NextFunction, Request, Response } from "express"; import { env } from "../config/env.js"; import { prisma } from "../db/prisma.js"; import { verifyAuthToken } from "../lib/jwt.js"; /** * Shape of `res.locals` once {@link requireAuth} has run successfully. Type * a route handler's response as `Response` (see * `auth.routes.ts`'s `/me` handler) to read `res.locals.userProfile` fully * typed, no cast needed. */ export interface AuthLocals { /** The authenticated profile, resolved from the session cookie's JWT. */ userProfile: SafeUserProfile; } /** * Express middleware guarding routes that require an authenticated * profile. Reads the session cookie, verifies the JWT, and re-checks * `tokenVersion` against the database — so a stateless JWT can still be * invalidated server-side (e.g. on password change / logout-everywhere, * once that feature exists) despite carrying no server-side session. * * On success, attaches the resolved profile to `res.locals.userProfile` * (typed via {@link AuthLocals}) for downstream handlers to use. * Deliberately `res.locals` rather than augmenting Express's global * `Request` type via `declare global`: `res.locals` is Express's own * built-in mechanism for exactly this (passing data from a middleware to * the next handler), typed per-route through a generic parameter — no * project-wide ambient augmentation silently changing every `Request` in * the codebase, whether or not it went through this middleware. * * @throws {HttpError} `401 NOT_AUTHENTICATED` for any failure — missing * cookie, malformed/expired JWT, unknown profile, or stale tokenVersion. * Never distinguishes the reason to the client. */ export async function requireAuth( req: Request, res: Response, next: NextFunction, ) { try { const token = req.cookies?.[env.AUTH_COOKIE_NAME]; if (typeof token !== "string") { throw new HttpError(401, ErrorCode.NOT_AUTHENTICATED, "Not authenticated"); } const payload = verifyAuthToken(token); const profile = await prisma.userProfile.findUnique({ where: { id: payload.userProfileId } }); if (!profile || profile.tokenVersion !== payload.tokenVersion) { throw new HttpError(401, ErrorCode.NOT_AUTHENTICATED, "Not authenticated"); } const { passwordHash: _passwordHash, ...safeProfile } = profile; res.locals.userProfile = safeProfile; next(); } catch (err) { if (err instanceof HttpError) { next(err); } else { // Covers jwt.verify failures (expired/invalid/malformed token). next(new HttpError(401, ErrorCode.NOT_AUTHENTICATED, "Not authenticated")); } } }