SPB Git

Repository Public

guard.ts 3.4 KB 78 lines typescript

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
Files main
Menu

Theme