import { createHash, timingSafeEqual } from 'node:crypto'; import { sql } from 'drizzle-orm'; import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'; import { TtlCache } from '../lib/cache.js'; import { ServiceUnavailable, Unauthorized } from '../lib/errors.js'; import type { ApiKeyInfo } from '../types.js'; export const ANONYMOUS_LIMIT_PER_MINUTE = 60; export function hashApiKey(key: string): string { return createHash('sha256').update(key).digest('hex'); } /** * API key resolution (CLAUDE.md §190-191). `Authorization: Bearer ` → sha256 → api_keys. * Anonymous requests are allowed (60/min by IP); an invalid key is rejected with 401 rather than * silently downgraded so integrators notice misconfiguration. */ export function registerApiKeyAuth(app: FastifyInstance): void { const cache = new TtlCache(60_000); const lastTouched = new Map(); app.decorateRequest('apiKey', null); app.addHook('onRequest', async (req: FastifyRequest) => { const header = req.headers.authorization; if (!header) return; const m = /^Bearer\s+(\S+)$/i.exec(header); if (!m) throw new Unauthorized('malformed Authorization header (expected "Bearer ")'); const hash = hashApiKey(m[1]!); const info = await cache.getOrLoad(hash, async () => { const rows = await app.db.execute<{ id: number; prefix: string; tier: string; rate_limit_per_minute: number; label: string | null }>(sql` SELECT id, prefix, tier, rate_limit_per_minute, label FROM api_keys WHERE key_hash = ${hash} AND active LIMIT 1`); const r = rows[0]; return r ? { id: Number(r.id), prefix: r.prefix, tier: r.tier, rateLimitPerMinute: Number(r.rate_limit_per_minute), label: r.label } : 'invalid'; }); if (info === 'invalid') throw new Unauthorized('invalid or inactive API key'); req.apiKey = info; // Touch last_used_at at most every 5 minutes per key (fire-and-forget). const now = Date.now(); if ((lastTouched.get(info.id) ?? 0) < now - 300_000) { lastTouched.set(info.id, now); app.db.execute(sql`UPDATE api_keys SET last_used_at = now() WHERE id = ${info.id}`).catch((e: unknown) => app.log.warn({ err: e }, 'api_keys touch failed')); } }); } /** Rate-limit key: per API key when present, otherwise per client IP. */ export function rateLimitKey(req: FastifyRequest): string { return req.apiKey ? `key:${req.apiKey.id}` : `ip:${req.ip}`; } export function rateLimitMax(req: FastifyRequest): number { return req.apiKey?.rateLimitPerMinute ?? ANONYMOUS_LIMIT_PER_MINUTE; } /** Admin guard: constant-time comparison of `x-admin-token` with ADMIN_TOKEN. */ export async function requireAdmin(req: FastifyRequest, _reply: FastifyReply): Promise { const expected = process.env.ADMIN_TOKEN; if (!expected || expected === 'change-me') throw new ServiceUnavailable('admin endpoints disabled: set ADMIN_TOKEN'); const given = req.headers['x-admin-token']; const token = Array.isArray(given) ? given[0] : given; if (!token) throw new Unauthorized('missing x-admin-token'); const a = Buffer.from(token); const b = Buffer.from(expected); if (a.length !== b.length || !timingSafeEqual(a, b)) throw new Unauthorized('invalid admin token'); }