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:
@@ -70,7 +70,8 @@ async function main(): Promise<void> {
|
||||
const token = services.session.status();
|
||||
console.log(
|
||||
`[schulcloud-mcp] listening on ${config.bindHost}:${config.port} — instance ${config.baseUrl}, ` +
|
||||
`auth ${config.authToken ? 'enabled' : 'DISABLED'}${config.mcpPathSecret ? ' (plus secret MCP path)' : ''}, ` +
|
||||
`auth ${config.authToken ? 'enabled' : 'DISABLED'}` +
|
||||
`${config.connectorToken ? ' (plus connector token)' : ''}${config.mcpPathSecret ? ' (plus secret MCP path)' : ''}, ` +
|
||||
`token from ${token.source}${token.daysLeft === undefined ? '' : `, ${token.daysLeft} day(s) left`}` +
|
||||
`${token.persistent ? '' : ' (replacements not saved: STATE_DIR unset)'}, ` +
|
||||
`keepalive ${keepalive ? `every ${Math.round(config.keepaliveIntervalMs / 60_000)}min` : 'off'}, ` +
|
||||
|
||||
@@ -20,6 +20,13 @@ export interface Config {
|
||||
jwt: string;
|
||||
/** Shared secret callers must present to this MCP server. Unused in stdio mode. */
|
||||
authToken: string | undefined;
|
||||
/**
|
||||
* A second token, accepted on `/mcp` only: the one claude.ai's connector
|
||||
* sends as a request header. It is stored by a third party, so it opens the
|
||||
* read-only MCP tools and not `/api`, which can replace the session token
|
||||
* and stream the file mirror — and it can be revoked on its own.
|
||||
*/
|
||||
connectorToken: string | undefined;
|
||||
/**
|
||||
* Serves MCP at `/<secret>/mcp` without a bearer token, for clients that can
|
||||
* send none — claude.ai's connector dialog takes only a URL. The path is then
|
||||
@@ -93,6 +100,20 @@ function pathSecret(name: string): string | undefined {
|
||||
return value;
|
||||
}
|
||||
|
||||
/** A token of at least 32 characters without whitespace, or undefined when unset. */
|
||||
function secretToken(name: string): string | undefined {
|
||||
const value = process.env[name]?.trim();
|
||||
if (!value) return undefined;
|
||||
// A credential: the error states the rule and never echoes the value.
|
||||
if (value.length < 32 || /\s/.test(value)) {
|
||||
throw new Error(
|
||||
`Environment variable ${name} must be at least 32 characters without spaces. ` +
|
||||
'Generate one with: openssl rand -hex 32',
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/** Like `int`, but 0 is meaningful (it disables the feature) rather than invalid. */
|
||||
function intAllowingZero(name: string, fallback: number): number {
|
||||
const raw = process.env[name]?.trim();
|
||||
@@ -105,10 +126,21 @@ function intAllowingZero(name: string, fallback: number): number {
|
||||
}
|
||||
|
||||
export function loadConfig(): Config {
|
||||
const authToken = process.env.MCP_AUTH_TOKEN?.trim() || undefined;
|
||||
const connectorToken = secretToken('MCP_CONNECTOR_TOKEN');
|
||||
if (connectorToken && !authToken) {
|
||||
// Without the main token /api would be open while /mcp is not.
|
||||
throw new Error('MCP_CONNECTOR_TOKEN needs MCP_AUTH_TOKEN as well, or /api would be left unauthenticated.');
|
||||
}
|
||||
if (connectorToken && connectorToken === authToken) {
|
||||
throw new Error('MCP_CONNECTOR_TOKEN must differ from MCP_AUTH_TOKEN, or it cannot be limited to /mcp or revoked on its own.');
|
||||
}
|
||||
|
||||
return {
|
||||
baseUrl: required('TSC_URL').replace(/\/+$/, ''),
|
||||
jwt: required('TSC_JWT_COOKIE'),
|
||||
authToken: process.env.MCP_AUTH_TOKEN?.trim() || undefined,
|
||||
authToken,
|
||||
connectorToken,
|
||||
mcpPathSecret: pathSecret('MCP_PATH_SECRET'),
|
||||
stateDir: process.env.STATE_DIR?.trim() ? resolve(process.env.STATE_DIR.trim()) : undefined,
|
||||
port: int('PORT', 8080),
|
||||
|
||||
@@ -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