Repository Public
1/**2 * The single authentication / CSRF / Origin gate. Registered as a global `onRequest` hook, so3 * it also runs for WebSocket upgrade requests (a refused upgrade is answered with plain HTTP4 * 401/403 and never becomes a socket). Every route is `full` unless it opts down explicitly.5 */6import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';7import { HttpError, type AuthLevel, type Ctx } from '../http.js';8import { safeEqual } from './crypto.js';9import { effectiveExpiry, findSessionForRequest } from './sessions.js';1011const SAFE = new Set(['GET', 'HEAD', 'OPTIONS']);1213export function cookieOptions(ctx: Ctx, maxAgeSec: number): {14 httpOnly: true; secure: boolean; sameSite: 'strict'; path: '/'; maxAge: number;15} {16 return { httpOnly: true, secure: ctx.config.secureCookies, sameSite: 'strict', path: '/', maxAge: maxAgeSec };17}18export function setSessionCookie(ctx: Ctx, reply: FastifyReply, token: string, maxAgeSec: number): void {19 void reply.setCookie(ctx.config.cookieName, token, cookieOptions(ctx, maxAgeSec));20}21export function clearSessionCookie(ctx: Ctx, reply: FastifyReply): void {22 void reply.clearCookie(ctx.config.cookieName, cookieOptions(ctx, 0));23}2425export function originOk(ctx: Ctx, req: FastifyRequest): boolean {26 const origin = req.headers.origin;27 return typeof origin === 'string' && origin === ctx.config.publicOrigin;28}2930export function registerGuard(app: FastifyInstance, ctx: Ctx): void {31 app.decorateRequest('auth', null);32 app.addHook('onRequest', async (req, reply) => {33 const path = req.url.split('?', 1)[0] ?? '';34 if (!path.startsWith('/api/')) return; // static assets35 void reply.header('Cache-Control', 'no-store');36 if (req.is404) return; // unknown API path: the JSON 404 handler answers, nothing is exposed3738 const level: AuthLevel = req.routeOptions.config.auth ?? 'full';39 const token = req.cookies[ctx.config.cookieName];40 if (token) {41 const row = await findSessionForRequest(ctx.db, req, token);42 if (row) {43 req.auth = {44 sessionId: row.id,45 userId: row.user_id,46 username: row.username,47 csrfToken: row.csrf_token,48 secondFactor: row.second_factor,49 expiresAt: effectiveExpiry(row),50 };51 }52 }53 const mutating = !SAFE.has(req.method);54 const upgrade = (req.headers.upgrade ?? '').toLowerCase() === 'websocket';5556 if (level === 'public') return;57 if (level === 'preauth') {58 if (mutating) {59 if (!originOk(ctx, req)) throw new HttpError(403, 'bad_origin', 'origin not allowed');60 const ct = (req.headers['content-type'] ?? '').split(';', 1)[0]?.trim().toLowerCase();61 if (ct !== 'application/json') throw new HttpError(403, 'bad_content_type', 'application/json required');62 }63 return;64 }65 if (!req.auth) throw new HttpError(401, 'unauthenticated', 'authentication required');66 if (level === 'full' && !req.auth.secondFactor) {67 throw new HttpError(403, 'second_factor_required', 'second factor required');68 }69 if ((mutating || upgrade) && !originOk(ctx, req)) throw new HttpError(403, 'bad_origin', 'origin not allowed');70 if (mutating) {71 const csrf = req.headers['x-csrf-token'];72 if (typeof csrf !== 'string' || !safeEqual(csrf, req.auth.csrfToken)) {73 throw new HttpError(403, 'bad_csrf', 'missing or invalid CSRF token');74 }75 }76 });77}78