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:
34
docs/AUTH.md
34
docs/AUTH.md
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user