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>
113 lines
3.8 KiB
TypeScript
113 lines
3.8 KiB
TypeScript
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>,
|
|
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;
|
|
const res = {
|
|
setHeader() {},
|
|
status(code: number) {
|
|
status = code;
|
|
return this;
|
|
},
|
|
json() {
|
|
return this;
|
|
},
|
|
} as never;
|
|
middleware(req, res, () => {
|
|
passed = true;
|
|
});
|
|
return { status, passed };
|
|
}
|
|
|
|
describe('bearerAuth', () => {
|
|
it('accepts the exact token', () => {
|
|
assert.equal(run({ authorization: 'Bearer correct-horse-battery-staple' }).passed, true);
|
|
});
|
|
|
|
it('accepts it via x-api-key, for connector UIs without an Authorization field', () => {
|
|
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);
|
|
});
|
|
|
|
it('rejects a missing, empty, wrong or truncated token with 401', () => {
|
|
for (const headers of [
|
|
{},
|
|
{ authorization: '' },
|
|
{ authorization: 'Bearer ' },
|
|
{ authorization: 'Bearer wrong' },
|
|
{ authorization: 'Bearer correct-horse-battery-stapl' },
|
|
{ authorization: 'Bearer correct-horse-battery-staple-extra' },
|
|
{ authorization: 'Basic correct-horse-battery-staple' },
|
|
]) {
|
|
const result = run(headers as Record<string, string>);
|
|
assert.equal(result.passed, false, `should reject ${JSON.stringify(headers)}`);
|
|
assert.equal(result.status, 401);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('pathSecret', () => {
|
|
const secret = 'a'.repeat(40) + 'B-_9';
|
|
|
|
function visit(presented: unknown): { status?: number; passed: boolean } {
|
|
const middleware = pathSecret(secret);
|
|
let status: number | undefined;
|
|
let passed = false;
|
|
const req = { params: { secret: presented } } as never;
|
|
const res = {
|
|
status(code: number) {
|
|
status = code;
|
|
return this;
|
|
},
|
|
json() {
|
|
return this;
|
|
},
|
|
} as never;
|
|
middleware(req, res, () => {
|
|
passed = true;
|
|
});
|
|
return { status, passed };
|
|
}
|
|
|
|
it('lets the exact secret through', () => {
|
|
assert.equal(visit(secret).passed, true);
|
|
});
|
|
|
|
it('answers anything else like an unknown path, not like a refused login', () => {
|
|
for (const presented of [undefined, '', 'mcp', secret.slice(0, -1), `${secret}x`, secret.toUpperCase()]) {
|
|
const result = visit(presented);
|
|
assert.equal(result.passed, false, `should reject ${JSON.stringify(presented)}`);
|
|
assert.equal(result.status, 404);
|
|
}
|
|
});
|
|
});
|