import { HttpError } from "@batch-cooking/error-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"; import { toSafeProfile } from "../lib/safe-profile.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 (or an `Authorization: Bearer` one, for cookie-less clients). */ userProfile: SafeUserProfile; } /** * Pulls the raw JWT out of an `Authorization: Bearer ` header, or * returns `null` if it's absent or malformed. This is the fallback path for * clients that can't rely on the `session` cookie — a Capacitor WebView, * whose origin never receives a `SameSite=Lax` cookie (see `AuthResponse` * in packages/shared). The cookie remains the primary mechanism; this only * kicks in when the cookie isn't there. */ function readBearerToken(header: string | undefined): string | null { if (!header?.startsWith("Bearer ")) { return null; } const token = header.slice("Bearer ".length).trim(); return token.length > 0 ? token : null; } /** * Express middleware guarding routes that require an authenticated * profile. Reads the session cookie (falling back to an `Authorization: * Bearer ` header when there's no cookie — see {@link readBearerToken}), * 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 — no cookie * and no usable Bearer header, 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 cookieToken = req.cookies?.[env.AUTH_COOKIE_NAME]; // Cookie first (the browser's mechanism, unchanged); `Authorization: // Bearer` as a fallback for cookie-less clients — see readBearerToken. const token = typeof cookieToken === "string" ? cookieToken : readBearerToken(req.headers.authorization); 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"); } res.locals.userProfile = toSafeProfile(profile); 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")); } } }