Repository Public
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