SPB Git

Repository Public

auth.ts 3.4 KB 81 lines typescript

1/**2 * Runtime IPC authentication: `Authorization: Bearer <token>` on every HTTP request and3 * WebSocket upgrade. Two tokens exist:4 *   - the runtime token (CR_RUNTIME_TOKEN): full access, held only by apps/server;5 *   - the hook token: random, rotated at a runtime start when no terminal session survived, handed to terminal sessions, accepted ONLY6 *     on `POST /hooks/claude`.7 */8import crypto from 'node:crypto';9import fs from 'node:fs/promises';10import path from 'node:path';11import type { FastifyInstance } from 'fastify';12import { RtError } from './plugins.js';1314export const HOOK_ROUTE = '/hooks/claude';1516/** Constant-time comparison, independent of the lengths of the inputs. */17export function safeEqual(a: string, b: string): boolean {18  const ha = crypto.createHash('sha256').update(a, 'utf8').digest();19  const hb = crypto.createHash('sha256').update(b, 'utf8').digest();20  return crypto.timingSafeEqual(ha, hb);21}2223export function bearerToken(header: string | string[] | undefined): string | null {24  if (typeof header !== 'string') return null;25  const m = /^Bearer +([^\s]+)$/i.exec(header.trim());26  return m?.[1] ?? null;27}2829export type TokenScope = 'runtime' | 'hook';3031export function classifyToken(token: string | null, runtimeToken: string, hookToken: string): TokenScope | null {32  if (!token) return null;33  // Evaluate both so timing does not reveal which one matched.34  const isRuntime = safeEqual(token, runtimeToken);35  const isHook = safeEqual(token, hookToken);36  if (isRuntime) return 'runtime';37  if (isHook) return 'hook';38  return null;39}4041/**42 * Hook token stored at `<dataDir>/run/hook-token` (0600). Terminal sessions carry it in their43 * environment and tmux sessions outlive the runtime, so with `reuse` (sessions survived the44 * previous runtime) a well-formed existing token is kept: rotating it would silently cut every45 * surviving session off from `/hooks/claude` (no more WAITING_FOR_INPUT). Otherwise a fresh46 * token is generated.47 */48export async function createHookToken(dataDir: string, reuse = false): Promise<string> {49  const dir = path.join(dataDir, 'run');50  await fs.mkdir(dir, { recursive: true, mode: 0o700 });51  const file = path.join(dir, 'hook-token');52  if (reuse) {53    try {54      const st = await fs.lstat(file);55      const existing = (await fs.readFile(file, 'utf8')).trim();56      if (st.isFile() && (st.mode & 0o077) === 0 && /^[0-9a-f]{64}$/.test(existing)) return existing;57    } catch {58      /* no usable token: generate one */59    }60  }61  const token = crypto.randomBytes(32).toString('hex');62  await fs.rm(file, { force: true });63  await fs.writeFile(file, token + '\n', { mode: 0o600, flag: 'wx' });64  return token;65}6667/**68 * Installs the root `onRequest` hook. It runs before routing handlers for plain HTTP and69 * for WebSocket upgrades (which @fastify/websocket routes through the same lifecycle), so70 * an unauthenticated upgrade is answered with HTTP 401 and never becomes a WebSocket.71 */72export function installAuth(app: FastifyInstance, runtimeToken: string, hookToken: string): void {73  if (runtimeToken.length < 32) throw new Error('runtime token must be >= 32 chars');74  app.addHook('onRequest', async (req) => {75    const scope = classifyToken(bearerToken(req.headers.authorization), runtimeToken, hookToken);76    if (scope === 'runtime') return;77    if (scope === 'hook' && req.method === 'POST' && req.routeOptions.url === HOOK_ROUTE) return;78    throw new RtError(401, 'unauthorized', 'missing or invalid bearer token');79  });80}81
Files main
Menu

Theme