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. * * A route can accept more than one token: `/mcp` also takes the connector * token claude.ai stores, which `/api` refuses. * * `alsoAccept` is the other kind of caller: a person logged into the web app, * carrying a session cookie rather than a token. `/api` takes it because the * app is built on `/api` and a session *is* the user; `/mcp` does not, because * nothing in a browser speaks MCP and a surface not needed is a surface not * offered. */ export function bearerAuth(accepted: string | string[], alsoAccept?: (req: Request) => boolean) { const expected = (Array.isArray(accepted) ? accepted : [accepted]).map((token) => Buffer.from(token, 'utf8')); return function authenticate(req: Request, res: Response, next: NextFunction): void { // A logged-in browser instead of a token. Checked first because the app's // own fetches carry no Authorization header at all, and running them // through the token comparison would only waste it. if (alsoAccept?.(req)) { next(); return; } const presented = extractToken(req.get('authorization'), req.get('x-api-key') ?? req.get('x-auth-token')); // Every token is compared even after a match, so the timing does not // tell which one was presented. const matched = presented !== undefined && expected.map((token) => constantTimeEquals(Buffer.from(presented, 'utf8'), token)).includes(true); if (!matched) { res.setHeader('WWW-Authenticate', 'Bearer realm="schulcloud-mcp"'); res.status(401).json({ jsonrpc: '2.0', error: { code: -32001, message: 'Unauthorized' }, id: null, }); return; } next(); }; } /** * Gate for `/:secret/mcp`, the header-free way in. * * A wrong secret answers exactly like any other unknown path, so guessing * learns nothing — not even that the route exists. The comparison is * constant-time for the same reason as the bearer check's. */ export function pathSecret(expected: string) { const expectedBytes = Buffer.from(expected, 'utf8'); return function checkPathSecret(req: Request, res: Response, next: NextFunction): void { const presented = req.params.secret; if (typeof presented !== 'string' || !constantTimeEquals(Buffer.from(presented, 'utf8'), expectedBytes)) { res.status(404).json({ error: 'not_found' }); return; } next(); }; } function extractToken(authorization: string | undefined, apiKey: string | undefined): string | undefined { if (authorization) { const value = authorization.trim(); const match = /^Bearer\s+(.+)$/i.exec(value); if (match?.[1]) return match[1].trim(); // claude.ai sends a request header exactly as typed, so a token entered // without "Bearer " arrives bare — its own docs warn most servers reject // that. A bare credential is still the whole credential; one with another // scheme ("Basic …") has a space in it and is not taken for one. if (value && !/\s/.test(value)) return value; } // Connector UIs also offer X-Api-Key and X-Auth-Token instead of 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); }