Give claude.ai a token of its own, sent as a request header
claude.ai's connector dialog does offer request headers, on its second step, after the URL has been probed, so the connector no longer needs the secret path. MCP_AUTH_TOKEN already worked there as a bearer or X-Api-Key, but it also opens /api, which can replace the Schulcloud token and stream the file mirror, and claude.ai stores the header's value. MCP_CONNECTOR_TOKEN is a second token, accepted on /mcp only and refused on /api, and rotated without touching Claude Code or the CLI. The config refuses one shorter than 32 characters, equal to MCP_AUTH_TOKEN, or set without it, and never echoes a value. Every accepted token is compared in full, so the timing does not tell which one matched. The gate also takes a bare Authorization value, because claude.ai sends a header exactly as typed and its docs warn that most servers reject a token entered without "Bearer ". It takes X-Auth-Token too, the other name its dialog offers. The docs now set up the header; the secret path stays as a fallback for clients that cannot send one. 184 tests. Smoke 79/79 and 77/77 on the local instance. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -9,13 +9,21 @@ import type { NextFunction, Request, Response } from 'express';
|
||||
* 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.
|
||||
*/
|
||||
export function bearerAuth(expected: string) {
|
||||
const expectedBytes = Buffer.from(expected, 'utf8');
|
||||
export function bearerAuth(accepted: string | string[]) {
|
||||
const expected = (Array.isArray(accepted) ? accepted : [accepted]).map((token) => Buffer.from(token, '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)) {
|
||||
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',
|
||||
@@ -50,10 +58,16 @@ export function pathSecret(expected: string) {
|
||||
|
||||
function extractToken(authorization: string | undefined, apiKey: string | undefined): string | undefined {
|
||||
if (authorization) {
|
||||
const match = /^Bearer\s+(.+)$/i.exec(authorization.trim());
|
||||
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;
|
||||
}
|
||||
// Some connector UIs only offer a custom header rather than Authorization.
|
||||
// Connector UIs also offer X-Api-Key and X-Auth-Token instead of Authorization.
|
||||
return apiKey?.trim() || undefined;
|
||||
}
|
||||
|
||||
|
||||
@@ -57,11 +57,12 @@ export function createHttpApp(config: Config, services?: Services): express.Expr
|
||||
res.json({ status: 'ok', sessions: sessions.size, index: services?.store ? 'on' : 'off' });
|
||||
});
|
||||
|
||||
// One token guards both surfaces: the MCP endpoint and the CLI's file/manifest
|
||||
// API. Splitting them was considered and rejected as unnecessary ceremony for
|
||||
// a single-user deployment.
|
||||
// MCP_AUTH_TOKEN opens both surfaces: the MCP endpoint and the CLI's API. The
|
||||
// connector token opens /mcp alone. claude.ai stores it as a request header,
|
||||
// and a credential held by a third party should reach the read-only tools,
|
||||
// not /api, which can replace the Schulcloud token and stream the file mirror.
|
||||
if (config.authToken) {
|
||||
app.use(MCP_PATH, bearerAuth(config.authToken));
|
||||
app.use(MCP_PATH, bearerAuth(config.connectorToken ? [config.authToken, config.connectorToken] : config.authToken));
|
||||
app.use(API_PATH, bearerAuth(config.authToken));
|
||||
} else {
|
||||
console.warn(
|
||||
|
||||
Reference in New Issue
Block a user