import { timingSafeEqual } from 'node:crypto'; import type { NextFunction, Request, Response } from 'express'; /** * Bearer-token gate for the public endpoint. * * This server is reachable from the internet by construction — Claude's * connectors call it from Anthropic's cloud, not from the user's machine — so * the token is the only thing between a stranger and the account's data. * Comparison is constant-time, and a miss returns a bare 401 with a * `WWW-Authenticate` challenge and no detail about why. */ export function bearerAuth(expected: string) { const expectedBytes = Buffer.from(expected, 'utf8'); return function authenticate(req: Request, res: Response, next: NextFunction): void { const presented = extractToken(req.get('authorization'), req.get('x-api-key')); if (presented === undefined || !constantTimeEquals(Buffer.from(presented, 'utf8'), expectedBytes)) { res.setHeader('WWW-Authenticate', 'Bearer realm="schulcloud-mcp"'); res.status(401).json({ jsonrpc: '2.0', error: { code: -32001, message: 'Unauthorized' }, id: null, }); return; } next(); }; } function extractToken(authorization: string | undefined, apiKey: string | undefined): string | undefined { if (authorization) { const match = /^Bearer\s+(.+)$/i.exec(authorization.trim()); if (match?.[1]) return match[1].trim(); } // Some connector UIs only offer a custom header rather than Authorization. return apiKey?.trim() || undefined; } function constantTimeEquals(a: Buffer, b: Buffer): boolean { // timingSafeEqual throws on length mismatch, which would itself leak length. // Hash-free equalisation: compare against a padded copy of the same size. if (a.length !== b.length) { const padded = Buffer.alloc(b.length); a.copy(padded, 0, 0, Math.min(a.length, b.length)); timingSafeEqual(padded, b); return false; } return timingSafeEqual(a, b); }