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

@@ -185,9 +185,11 @@ files, so it is not done here.
Distinct from the above, and just as important. The MCP endpoint is reachable
from the public internet by construction: Claude's connectors call it from
Anthropic's cloud, not from your machine. It is protected by `MCP_AUTH_TOKEN`,
a shared secret checked in constant time on every `/mcp` request
(`src/http/auth.ts`), accepted as either `Authorization: Bearer …` or
`X-Api-Key`. `/healthz` is deliberately open and reveals nothing.
a shared secret checked in constant time on every `/mcp` and `/api` request
(`src/http/auth.ts`). It is accepted as `Authorization: Bearer …`, as a bare
`Authorization` value — claude.ai sends a header exactly as typed — or as
`X-Api-Key` or `X-Auth-Token`. `/healthz` is deliberately open and reveals
nothing.
Generate one with `openssl rand -hex 32`. If it is unset the server logs a loud
warning and serves unauthenticated — only acceptable bound to localhost.
@@ -195,25 +197,35 @@ warning and serves unauthenticated — only acceptable bound to localhost.
Rotating it: change `MCP_AUTH_TOKEN` in `.env`, recreate the container, and
update Claude Code and the CLI (`schulcloud login`). Nothing else stores it.
### The secret path, for claude.ai
### The connector token, for claude.ai
claude.ai's connector dialog takes a URL and no header, so `MCP_PATH_SECRET`
opens a second way in: `/<secret>/mcp`, with no bearer token. The path is the
credential there. `http/auth.ts` compares it in constant time and answers a
claude.ai sends a request header whose value it stores, so its token is a
credential held by a third party. `MCP_CONNECTOR_TOKEN` gives it one of its
own: accepted on `/mcp` alone, refused on `/api` — which can replace the
Schulcloud token and stream the file mirror — and rotated without touching
Claude Code or the CLI. `config.ts` requires at least 32 characters, a value
different from `MCP_AUTH_TOKEN`, and `MCP_AUTH_TOKEN` itself, so it can never
leave `/api` unguarded; its errors never echo a value. All accepted tokens are
compared in full, so the timing does not tell which one matched.
### The secret path, for clients without request headers
For a client that can send no header, `MCP_PATH_SECRET` opens another way in:
`/<secret>/mcp`, with no token at all. The path is the credential there. `http/auth.ts` compares it in constant time and answers a
wrong one with the same 404 as any unknown path; `config.ts` insists on at
least 32 URL-safe characters and never echoes the value; the server never logs
request paths; and `deploy/Caddyfile.snippet` rewrites the segment before an
access log entry is written. Rotating it means a new value, a recreated
container, and re-adding the connector. It is a stopgap: OAuth is how
connectors are meant to authenticate, and it would make the URL a plain
address again.
container, and re-adding the connector. Prefer the connector token wherever a
header can be sent: a URL is copied into more places than a header is.
## Blast radius
Every path in this server is a `GET`, including the `api_get` escape hatch,
which rejects anything not starting with `/api/` and anything carrying a scheme
or host. Someone who obtained both the endpoint URL and `MCP_AUTH_TOKEN` — or
the secret MCP path — could read this account's Schulcloud data; they could not
the connector token, or the secret MCP path — could read this account's
Schulcloud data; they could not
post, submit, delete, or otherwise act as the user. With `MCP_AUTH_TOKEN` they
could also call `PUT /api/token`, but it accepts only a live token for the same
account, so the most it can do is hand the server a session the owner already