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:
MechaCat02
2026-09-16 22:03:56 +02:00
parent bfccb3f343
commit ab581aa5ca
15 changed files with 279 additions and 79 deletions

View File

@@ -2,8 +2,11 @@ import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import { bearerAuth, pathSecret } from '../src/http/auth.ts';
function run(headers: Record<string, string>): { status?: number; passed: boolean } {
const middleware = bearerAuth('correct-horse-battery-staple');
function run(
headers: Record<string, string>,
accepted: string | string[] = 'correct-horse-battery-staple',
): { status?: number; passed: boolean } {
const middleware = bearerAuth(accepted);
let status: number | undefined;
let passed = false;
const req = { get: (name: string) => headers[name.toLowerCase()] } as never;
@@ -32,6 +35,24 @@ describe('bearerAuth', () => {
assert.equal(run({ 'x-api-key': 'correct-horse-battery-staple' }).passed, true);
});
it('accepts the token bare in Authorization, as claude.ai sends a header typed without "Bearer "', () => {
assert.equal(run({ authorization: 'correct-horse-battery-staple' }).passed, true);
});
it('accepts it via x-auth-token, the other header connector dialogs offer', () => {
assert.equal(run({ 'x-auth-token': 'correct-horse-battery-staple' }).passed, true);
});
it('accepts any of several tokens, and nothing else', () => {
const accepted = ['correct-horse-battery-staple', 'connector-token-0123456789abcdef'];
assert.equal(run({ authorization: 'Bearer correct-horse-battery-staple' }, accepted).passed, true);
assert.equal(run({ authorization: 'Bearer connector-token-0123456789abcdef' }, accepted).passed, true);
assert.equal(run({ 'x-api-key': 'connector-token-0123456789abcdef' }, accepted).passed, true);
const refused = run({ authorization: 'Bearer connector-token-0123456789abcde' }, accepted);
assert.equal(refused.passed, false);
assert.equal(refused.status, 401);
});
it('is case-insensitive about the scheme but not the token', () => {
assert.equal(run({ authorization: 'bearer correct-horse-battery-staple' }).passed, true);
assert.equal(run({ authorization: 'Bearer CORRECT-HORSE-BATTERY-STAPLE' }).passed, false);

View File

@@ -63,3 +63,39 @@ describe('loadConfig: secret MCP path and state directory', () => {
assert.match(loadConfig().stateDir ?? '', /^\/.*\/tmp\/state$/);
});
});
describe('loadConfig: connector token', () => {
const connector = 'connector-0123456789abcdef0123456789';
it('is off by default, and accepted alongside the main token', () => {
process.env.TSC_URL = 'https://example.org';
process.env.TSC_JWT_COOKIE = 'x';
process.env.MCP_AUTH_TOKEN = 'main-token';
assert.equal(loadConfig().connectorToken, undefined);
process.env.MCP_CONNECTOR_TOKEN = connector;
assert.equal(loadConfig().connectorToken, connector);
});
it('refuses a short or spaced token without echoing it', () => {
process.env.TSC_URL = 'https://example.org';
process.env.TSC_JWT_COOKIE = 'x';
process.env.MCP_AUTH_TOKEN = 'main-token';
for (const token of ['too-short', 'long enough but with spaces in it 0123']) {
process.env.MCP_CONNECTOR_TOKEN = token;
assert.throws(
() => loadConfig(),
(error: Error) => /MCP_CONNECTOR_TOKEN/.test(error.message) && !error.message.includes(token),
);
}
});
it('refuses to leave /api open, or to be the main token under another name', () => {
process.env.TSC_URL = 'https://example.org';
process.env.TSC_JWT_COOKIE = 'x';
process.env.MCP_CONNECTOR_TOKEN = connector;
delete process.env.MCP_AUTH_TOKEN;
assert.throws(() => loadConfig(), /needs MCP_AUTH_TOKEN/);
process.env.MCP_AUTH_TOKEN = connector;
assert.throws(() => loadConfig(), /must differ from MCP_AUTH_TOKEN/);
});
});