/** * Authentication and session utilities */ import { unwrapKeyWithToken } from "#crypto/keys.ts"; import type { WrappedKey } from "#crypto/sealed.ts"; import { apiKeyLimiter } from "#db/api-key-attempts.ts"; import { getApiKeyByToken, touchApiKeyLastUsed } from "#db/api-keys.ts"; import { deleteSession, getSessionWithUser } from "#db/sessions.ts"; import { decryptAdminLevel, getUserAuthFieldsById, type UserAuthFields, } from "#db/users.ts"; import { apiErrorResponse } from "#routes/api/cors.ts"; import type { JsonBodyReader } from "#routes/api/json-body.ts"; import { type AuthChannel, authFailure } from "#routes/auth-failures.ts"; import { applyFlash, parseFormData } from "#routes/csrf.ts"; import { lowerContentType } from "#routes/middleware.ts"; import { readJsonBody } from "#routes/read-json-body.ts"; import { htmlResponse } from "#routes/response.ts"; import { parseCookies } from "#routes/url.ts"; import { type AdminDestinationDef, ownerOnlyAudience, } from "#shared/admin-surface/definitions.ts"; import { adminPageAudience } from "#shared/admin-surface.ts"; import { getSessionCookieName } from "#shared/cookies.ts"; import { signCsrfToken, verifySignedCsrfToken } from "#shared/csrf.ts"; import type { Flash } from "#shared/flash-context.ts"; import type { FormParams } from "#shared/form-data.ts"; import { setSavedFormData } from "#shared/forms/saved-data.ts"; import { SCANNER_CSRF_MAX_AGE_S } from "#shared/limits.ts"; import { ErrorCode, logError } from "#shared/logger.ts"; import { nowMs } from "#shared/now.ts"; import { addPendingWork } from "#shared/pending-work.ts"; import { getRequestClientIp } from "#shared/request-context.ts"; import type { RequestRoute, ResponseHandler } from "#shared/response-steps.ts"; import { getCachedSession, setCachedSession } from "#shared/session-context.ts"; import { getSettingsNagItemsForOwner } from "#shared/settings-nags.ts"; import { type AdminLevel, ALL_ADMIN_LEVELS, CONTENT_ADMIN_LEVELS, DELIVERY_ADMIN_LEVELS, DOOR_ADMIN_LEVELS, isRecord, isStaffRole, type NagItem, SITE_ADMIN_LEVELS, } from "#types"; /** Session with wrapped data key for private key derivation, and user role */ export type AuthSession = { token: string; wrappedDataKey: WrappedKey | null; userId: number; adminLevel: AdminLevel; settingsNagItems?: readonly NagItem[]; }; /** Report a session or API key whose user has since been deleted. Always gives * back null so a caller can `return reportMissingAuthUser(...)`. */ const reportMissingAuthUser = (missingDetail: string): null => { logError({ code: ErrorCode.AUTH_INVALID_SESSION, detail: missingDetail }); return null; }; /** Load the user's auth fields for an API key. When the id points at no user, * log an invalid-session error and give back null so the caller can bail. */ const loadAuthUserFields = async ( userId: number, missingDetail: string, ): Promise => (await getUserAuthFieldsById(userId)) ?? reportMissingAuthUser(missingDetail); /** Drop the cached session (and, when the caller has a token, the stored * session row) — the one "this session is no longer valid" step every bail-out * below shares. Always resolves to null so callers can `return invalidateSession(...)`. */ const invalidateSession = async (token?: string): Promise => { if (token) await deleteSession(token); setCachedSession(null); return null; }; /** * Get authenticated session if valid * Returns null if not authenticated * Includes wrapped_data_key. Callers derive the private key from it when needed. * Loads user info and decrypts admin_level for role checking * * Validates that wrapped_data_key can be unwrapped with current DB_ENCRYPTION_KEY. * If unwrapping fails (e.g., after key rotation), the session is invalidated. */ export const getAuthenticatedSession = async ( request: Request, ): Promise => { const cached = getCachedSession(); if (cached !== undefined) return cached; const cookies = parseCookies(request); const token = cookies.get(getSessionCookieName()); if (!token) return invalidateSession(); const found = await getSessionWithUser(token); if (!found) return invalidateSession(); const { session, user } = found; if (session.expires < nowMs()) return invalidateSession(token); if (!user) { reportMissingAuthUser("Session references non-existent user, invalidating"); return invalidateSession(token); } const adminLevel = await decryptAdminLevel(user); await signCsrfToken(); const result: AuthSession = { adminLevel, token, userId: session.user_id, wrappedDataKey: session.wrapped_data_key, }; if (adminLevel === "owner") { result.settingsNagItems = await getSettingsNagItemsForOwner(); } setCachedSession(result); return result; }; /** * Extract Bearer token from Authorization header. */ const getBearerToken = (request: Request): string | null => { const auth = request.headers.get("authorization"); if (!auth?.startsWith("Bearer ")) return null; return auth.slice(7); }; /** * Authenticate via API key (Bearer token). * Returns an AuthSession (compatible with session-based auth) or null. * API key auth bypasses CSRF since the key itself is the secret. */ export const getAuthenticatedApiKey = async ( request: Request, ): Promise => { const token = getBearerToken(request); if (!token) return null; // Throttle brute-force guessing per IP. Only failed lookups are counted, so a // client with a valid key is never locked out; once an IP is locked, even a // correct token is rejected until the lockout expires. const ip = getRequestClientIp(); if (await apiKeyLimiter.isLimited(ip)) { logError({ code: ErrorCode.AUTH_INVALID_SESSION, detail: "API key authentication rate limited", }); return null; } const apiKeyRow = await getApiKeyByToken(token); if (!apiKeyRow) { await apiKeyLimiter.record(ip); logError({ code: ErrorCode.AUTH_INVALID_SESSION, detail: "Bearer token does not match any API key", }); return null; } const user = await loadAuthUserFields( apiKeyRow.user_id, "API key references non-existent user", ); if (!user) return null; try { await unwrapKeyWithToken(apiKeyRow.wrapped_data_key, token); } catch { logError({ code: ErrorCode.AUTH_INVALID_SESSION, detail: "API key wrapped data key corrupted", }); return null; } // getPrivateKey() expects token + wrappedDataKey in the session's format. const wrappedDataKey = apiKeyRow.wrapped_data_key; const adminLevel = await decryptAdminLevel(user); // Best-effort last_used update, queued as pending work so it settles. addPendingWork( touchApiKeyLastUsed(apiKeyRow.id).catch(() => { // A stats write must never fail the request. }), ); const result: AuthSession = { adminLevel, token, userId: apiKeyRow.user_id, wrappedDataKey, }; setCachedSession(result); return result; }; /** How the request was authenticated */ type AuthKind = "cookie" | "apiKey"; /** Body parsing mode for authenticated requests */ type BodyMode = "form" | "multipart" | "json"; /** Maps body mode to the parsed body type */ type ParsedBody = T extends "form" ? FormParams : T extends "multipart" ? FormData : Record; /** Policy controlling authentication, CSRF, role, and body parsing */ export type AuthPolicy = { body: T; /** Require exactly this role. */ role?: AdminLevel; /** Allow any of these roles (use instead of `role` for multi-role gates). */ roles?: readonly AdminLevel[]; allowApiKey?: boolean; /** Override the CSRF token max-age (seconds). Defaults to the standard 1 hour. */ csrfMaxAge?: number; }; /** * Whether a session's role satisfies a gate. * - `roles` given: membership test. * - single `role` given: exact match (owner-only or agent-only gates). * - neither: any back-office staff (owner|manager). Delivery agents are * excluded by default so introducing the agent class can never widen access * to an existing staff page that didn't explicitly opt them in. */ const sessionRoleAllowed = ( level: AdminLevel, role: AdminLevel | undefined, roles: readonly AdminLevel[] | undefined, ): boolean => { if (roles) return roles.includes(level); if (role) return level === role; return isStaffRole(level); }; /** Auth policy presets — use with withAuth to avoid repeating policy objects */ export const OWNER_FORM: AuthPolicy<"form"> = { body: "form", role: "owner" }; /** Agent-only form gate (delivery run-sheet actions). */ export const AGENT_FORM: AuthPolicy<"form"> = { body: "form", role: "agent" }; /** Content-editing form gate: staff plus the content-only `editor`. Used for the * listing/group create-edit actions and the public-site content saves editors * are opted into. */ export const CONTENT_FORM: AuthPolicy<"form"> = { body: "form", roles: CONTENT_ADMIN_LEVELS, }; /** Content-editing multipart gate (listing create/edit with image uploads). */ export const CONTENT_MULTIPART: AuthPolicy<"multipart"> = { body: "multipart", roles: CONTENT_ADMIN_LEVELS, }; /** Public-site content form gate: owner + editor only (managers stay excluded * from site editing — see {@link SITE_ADMIN_LEVELS}). */ export const SITE_FORM: AuthPolicy<"form"> = { body: "form", roles: SITE_ADMIN_LEVELS, }; /** Public-site content multipart gate (news-post image uploads). */ export const SITE_MULTIPART: AuthPolicy<"multipart"> = { body: "multipart", roles: SITE_ADMIN_LEVELS, }; /** Delivery run-sheet form gate: staff + agent, but NOT editor. */ export const DELIVERY_FORM: AuthPolicy<"form"> = { body: "form", roles: DELIVERY_ADMIN_LEVELS, }; /** Door check-in form gate: staff + the door-only `scanner`. The ticket * check-in page's check-in/out toggle is the one write a scanner login makes. */ export const DOOR_FORM: AuthPolicy<"form"> = { body: "form", roles: DOOR_ADMIN_LEVELS, }; /** Form gate that admits any authenticated user, agents and editors included — * used for actions every logged-in user must reach, like logout. */ export const ANY_USER_FORM: AuthPolicy<"form"> = { body: "form", roles: ALL_ADMIN_LEVELS, }; export const OWNER_MULTIPART: AuthPolicy<"multipart"> = { body: "multipart", role: "owner", }; export const ADMIN_API: AuthPolicy<"json"> = { allowApiKey: true, body: "json", }; /** * Content-admin JSON API: the audience the groups and listings pages declare, * so an editor does through the API what the dashboard allows, and no more. */ export const CONTENT_API: AuthPolicy<"json"> = { allowApiKey: true, body: "json", roles: CONTENT_ADMIN_LEVELS, }; /** * Owner-only JSON API: like ADMIN_API but restricted to the owner role, for * resources whose web management is owner-only (e.g. holidays). Keeps the JSON * API authorization aligned with the UI so a manager cannot perform via the API * what the dashboard denies them. */ export const OWNER_API: AuthPolicy<"json"> = { allowApiKey: true, body: "json", role: "owner", }; /** * Scanner check-in API: cookie-authenticated JSON with a CSRF max-age matching * the session lifetime, so a logged-in admin can keep the scanner page open for * a whole listing without check-ins failing on CSRF expiry. Door roles only — * the same audience the scanner pages declare. */ export const SCANNER_JSON: AuthPolicy<"json"> = { body: "json", csrfMaxAge: SCANNER_CSRF_MAX_AGE_S, roles: DOOR_ADMIN_LEVELS, }; /** Get the current cookie session, or the channel's not-authenticated failure. */ const cookieSessionOrFailure = async ( request: Request, channel: AuthChannel, ): Promise => { const session = await getAuthenticatedSession(request); return session ?? authFailure(channel, "not-authenticated"); }; /** * Core session + role gate. Returns the session on success, or a * channel-appropriate failure Response. */ const requireSessionFor = async ( request: Request, channel: AuthChannel, role?: AdminLevel, roles?: readonly AdminLevel[], ): Promise => { const session = await cookieSessionOrFailure(request, channel); if (isResponse(session)) return session; if (!sessionRoleAllowed(session.adminLevel, role, roles)) { return authFailure( channel, "forbidden", ownerOnlyAudience({ role, roles }) ? "owner-only" : undefined, ); } return session; }; const isResponse = (v: unknown): v is Response => v instanceof Response; /** A response callback that receives the signed-in session. */ export type AuthSessionHandler = ResponseHandler<[session: AuthSession]>; /** * Low-level session gate with custom no-session fallback (used by dashboard login page). * Only checks cookie-based auth. API key auth is intentionally excluded — * Bearer tokens should only authenticate /api/* endpoints via withAuth + ADMIN_API. */ export const withSession = async ( request: Request, handler: AuthSessionHandler, onNoSession: ResponseHandler, ): Promise => { const session = await getAuthenticatedSession(request); return session ? handler(session) : onNoSession(); }; /** Build a session guard that admits any of the given roles. */ const requireRolesOr = (roles: readonly AdminLevel[]) => async (request: Request, handler: AuthSessionHandler): Promise => { const result = await requireSessionFor(request, "html", undefined, roles); return isResponse(result) ? result : handler(result); }; /** Require a content-editing session (staff or editor) — 403 outside * {@link CONTENT_ADMIN_LEVELS}. Used for the listing/group create-edit pages. */ export const requireContentOr = requireRolesOr(CONTENT_ADMIN_LEVELS); /** Require a site-editing session (owner or editor) — 403 outside * {@link SITE_ADMIN_LEVELS}. Managers stay excluded from site editing. */ export const requireSiteOr = requireRolesOr(SITE_ADMIN_LEVELS); /** Require a delivery-run-sheet session (staff or agent, NOT editor) — 403 * outside {@link DELIVERY_ADMIN_LEVELS}. */ export const requireDeliveryOr = requireRolesOr(DELIVERY_ADMIN_LEVELS); /** * The gates below take the roles they admit from the route the handler serves, * so `admin-surface/areas.ts` is the one place a role is written down. * `test/integration/admin-role-matrix.test.ts` asks every page as every role * and proves the gate and the declaration still agree. */ /** A GET page's gate, admitting exactly the roles its route declares. */ export const pageGuardFor = ( route: AdminDestinationDef, ): SessionGuard => requireRolesOr(route.audience); /** One record page's gate: every role that can reach any route beneath it. * A tab open to a wider role sits under this same path and needs to get in; * the tab's own `visible` is what keeps the narrower tabs shut. A page whose * beneath-routes include a standalone tool rather than a tab names its own tab * audience instead — the listing and group scanner doors are such routes, and * the folded floor would otherwise admit the door-only `scanner` login to a * page whose every tab hides from them. The role matrix still catches a page * that freezes out a role one of its tab destinations declares. */ export const recordPageGuardFor = ( route: AdminDestinationDef, audience?: readonly AdminLevel[], ): SessionGuard => requireRolesOr(audience ?? adminPageAudience(route)); /** A form POST's policy, admitting exactly the roles its route declares. */ export const formPolicyFor = ( route: AdminDestinationDef, ): AuthPolicy<"form"> => ({ body: "form", roles: route.audience }); /** A gate a route runs before its handler: it authenticates/loads whatever * the handler needs, then calls it with that data — or answers the request * itself (a redirect, a 403) without ever calling it. */ export type Guard = ( request: Request, handler: ResponseHandler, ) => Promise; /** Session guard: require auth and call handler with session */ export type SessionGuard = Guard<[TSession]>; /** Turn a form auth policy into a route gate that yields its session and form. */ export const formGuard = ( policy: AuthPolicy<"form">, ): Guard<[session: AuthSession, form: FormParams]> => (request, handler) => withAuth(request, policy, (session, form) => handler(session, form)); /** Factory for authenticated GET routes whose builder returns the full * Response — for pages that may 404, redirect, or set headers. Applies any * flashed message first and hands its values to the builder. */ export const authResponsePage = (requireSession: SessionGuard) => ( build: ResponseHandler<[session: TSession, request: Request, flash: Flash]>, ): RequestRoute => (request) => requireSession(request, (session) => build(session, request, applyFlash(request)), ); /** Factory for creating authenticated page handlers. The render callback is * given the request too, for pages that read query params (e.g. the deliveries * run sheet's date picker), and the flashed message values, for pages that * thread them into the template; callers that need neither simply ignore * them. */ export const authPage = (requireSession: SessionGuard) => ( render: ( session: TSession, request: Request, flash: Flash, ) => string | Promise, ): RequestRoute => authResponsePage(requireSession)(async (session, request, flash) => htmlResponse(await render(session, request, flash)), ); /** What a role's response-page helper is: it takes the builder for the full * Response and returns the guarded route. */ type ResponsePageFactory = ( build: ResponseHandler< [session: AuthSession, request: Request, flash: Flash] >, ) => RequestRoute; /** Content-editing GET route (staff or editor) whose builder returns the full * Response (may 404 or redirect instead of rendering). */ export const contentResponsePage: ResponsePageFactory = authResponsePage(requireContentOr); /** Content-editing GET page (staff or editor): authenticate, apply flash, * render HTML. */ export const contentPage = authPage(requireContentOr); /** Site-editing GET page (owner or editor): authenticate, apply flash, * render HTML. */ export const sitePage = authPage(requireSiteOr); /** Delivery-run-sheet GET page (staff or agent): authenticate, apply flash, * render HTML. */ export const deliveryPage = authPage(requireDeliveryOr); /** Look up the current session (or null) and hand it to `handler`. The single * place a request's session is fetched for a branch on present/absent — callers * decide what each case means (fail, redirect, render). */ export const withOptionalSession = async ( request: Request, handler: ResponseHandler<[session: AuthSession | null]>, ): Promise => handler(await getAuthenticatedSession(request)); /** Require any authenticated user (owner, manager or agent). Used for pages * that staff and delivery agents alike must reach, like the deliveries run * sheet — agents are sent here, staff opt in via the Calendar submenu. Every * valid session already holds one of the three roles, so authentication is the * only gate. */ const requireAnyUserOr = ( request: Request, handler: AuthSessionHandler, ): Promise => withOptionalSession(request, (session) => session ? handler(session) : authFailure("html", "not-authenticated"), ); /** Any-authenticated-user GET page: authenticate, apply flash, render HTML */ export const anyUserPage = authPage(requireAnyUserOr); /** * Safe HTTP methods (RFC 7231 §4.2.1): read-only, so a request using one cannot * mutate state and carries no body. Such requests need no CSRF token — CSRF * defends state-changing submissions, and cross-origin reads of the response are * already blocked by the Same-Origin Policy. This lets cookie-authenticated * calendar clients fetch GET /caldav/events.ics without an x-csrf-token header * they have no way to attach. */ const isSafeMethod = (request: Request): boolean => request.method === "GET" || request.method === "HEAD"; /** Parse JSON body, returning empty object for non-JSON GET/HEAD requests */ const parseJsonBody: JsonBodyReader = async (request) => { const contentType = lowerContentType(request); const bodyRequired = !isSafeMethod(request); if (!contentType.includes("application/json")) { if (bodyRequired) { return apiErrorResponse("Invalid request body"); } return {}; } const body = await readJsonBody(request); if (!body.ok) { logError({ code: ErrorCode.VALIDATION_FORM, detail: "Malformed JSON body", }); return apiErrorResponse("Invalid request body"); } const parsed = body.value; if (!isRecord(parsed)) { return apiErrorResponse("Invalid request body"); } return parsed; }; /** Verify a CSRF token, returning a channel-appropriate failure or null */ const verifyCsrf = async ( token: string, channel: AuthChannel, maxAge?: number, ): Promise => { if (await verifySignedCsrfToken(token, maxAge)) return null; if (channel === "json") { logError({ code: ErrorCode.AUTH_CSRF_MISMATCH, detail: "JSON API" }); } return authFailure(channel, "invalid-csrf"); }; /** Validate CSRF and parse body for the given mode. * * `skipCsrf` only applies to JSON bodies (used for API key auth). Form and * multipart bodies are always CSRF-checked because API key clients use JSON. * Safe-method (GET/HEAD) JSON requests also skip CSRF: they cannot mutate state, * so the token is moot — this keeps read-only JSON routes (e.g. the calendar * feed) reachable by cookie sessions that can't send an x-csrf-token header. * `maxAge` overrides the CSRF token expiry window (seconds). */ const parseCsrfBody = async ( request: Request, mode: BodyMode, skipCsrf: boolean, maxAge?: number, ): Promise | Response> => { const channel = channelFor(mode); if (mode === "json") { if (!skipCsrf && !isSafeMethod(request)) { const err = await verifyCsrf( request.headers.get("x-csrf-token") ?? "", channel, maxAge, ); if (err) return err; } return parseJsonBody(request); } if (mode === "form") { const form = await parseFormData(request); // Capture the submission so a later validation-failure redirect can stash // it for re-filling, mirroring requireCsrfForm for public forms. setSavedFormData(form); const err = await verifyCsrf(form.getString("csrf_token"), channel, maxAge); return err ?? form; } const fd = await request.formData(); const err = await verifyCsrf( String(fd.get("csrf_token") ?? "").trim(), channel, maxAge, ); return err ?? fd; }; /** Derive the auth channel (html vs json) from the body mode */ const channelFor = (mode: BodyMode): AuthChannel => mode === "json" ? "json" : "html"; export const authenticateFor = async ( request: Request, policy: AuthPolicy, ): Promise<{ session: AuthSession; authKind: AuthKind } | Response> => { const channel = channelFor(policy.body); let auth: { session: AuthSession; authKind: AuthKind } | Response | null = null; if (policy.allowApiKey) { const session = await getAuthenticatedApiKey(request); if (session) auth = { authKind: "apiKey", session }; else if (getBearerToken(request)) { auth = authFailure(channel, "invalid-api-key"); } } if (auth === null) { const session = await cookieSessionOrFailure(request, channel); auth = isResponse(session) ? session : { authKind: "cookie", session }; } if (isResponse(auth)) return auth; return sessionRoleAllowed(auth.session.adminLevel, policy.role, policy.roles) ? auth : authFailure( channel, "forbidden", ownerOnlyAudience({ role: policy.role, roles: policy.roles }) ? "owner-only" : undefined, ); }; /** Unified auth pipeline: authenticate, enforce role, validate CSRF, parse body. */ export async function withAuth( request: Request, policy: AuthPolicy, handler: ResponseHandler< [session: AuthSession, body: ParsedBody, authKind: AuthKind] >, ): Promise { const auth = await authenticateFor(request, policy); if (isResponse(auth)) return auth; const body = await parseCsrfBody( request, policy.body, auth.authKind === "apiKey", policy.csrfMaxAge, ); if (isResponse(body)) return body; return handler(auth.session, body as ParsedBody, auth.authKind); } /** A curried POST route: authenticate under `policy`, then hand the session and * parsed body to `handle`. The shared building block for the per-gate route * wrappers (multipart listing saves, Site-tab content saves, and similar), so * the authenticate-then-delegate shape lives in one place. */ export const gatedPost = (policy: AuthPolicy) => (handle: ResponseHandler<[session: AuthSession, body: ParsedBody]>) => (request: Request): Promise => withAuth(request, policy, handle); /** A content-editor POST whose body may include uploaded files. */ export const contentMultipartRoute: ( handle: ResponseHandler<[session: AuthSession, body: FormData]>, ) => RequestRoute = gatedPost(CONTENT_MULTIPART); /** A {@link gatedPost} whose handler only needs the parsed form — for POST * routes that act on the form alone and never read the session. */ export const formPost = (policy: AuthPolicy<"form">) => (handle: ResponseHandler<[form: FormParams]>): RequestRoute => gatedPost(policy)((_session, form) => handle(form));