From ab581aa5ca94d910902d58d4c7451d0eb3b16e5d Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Wed, 16 Sep 2026 22:03:56 +0200 Subject: [PATCH] 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) --- .env.example | 15 +++++++--- CLAUDE.md | 14 ++++++---- README.md | 15 +++++----- docs/AUTH.md | 34 +++++++++++++++-------- docs/DEPLOYMENT.md | 48 ++++++++++++++++++++++---------- docs/LOCAL.md | 4 +-- docs/PI.md | 59 ++++++++++++++++++++++++++-------------- local-instance/README.md | 2 +- scripts/smoke.mjs | 34 +++++++++++++++++++++++ src/bin/http.ts | 3 +- src/config.ts | 34 ++++++++++++++++++++++- src/http/auth.ts | 26 ++++++++++++++---- src/http/server.ts | 9 +++--- test/auth.test.ts | 25 +++++++++++++++-- test/config.test.ts | 36 ++++++++++++++++++++++++ 15 files changed, 279 insertions(+), 79 deletions(-) diff --git a/.env.example b/.env.example index daa197d..4bdb175 100644 --- a/.env.example +++ b/.env.example @@ -24,10 +24,17 @@ TSC_JWT_COOKIE= # openssl rand -hex 32 MCP_AUTH_TOKEN= -# claude.ai only: serve MCP at //mcp WITHOUT the bearer token, -# because its connector dialog cannot send a header. The URL becomes the -# credential — see "Connecting Claude" in docs/DEPLOYMENT.md before using it. -# At least 32 URL-safe characters; unset = off. Generate one with: +# claude.ai: the token its connector sends as a request header +# (`authorization: Bearer `). Accepted on /mcp only, never on /api — which +# can replace the Schulcloud token — because claude.ai stores it. At least 32 +# characters, different from MCP_AUTH_TOKEN; unset = off. Generate one with: +# openssl rand -hex 32 +# MCP_CONNECTOR_TOKEN= + +# Clients that cannot send a header: serve MCP at //mcp with no +# token at all. The URL becomes the credential — see "Connecting Claude" in +# docs/DEPLOYMENT.md before using it. At least 32 URL-safe characters; unset = +# off. Generate one with: # openssl rand -hex 32 # MCP_PATH_SECRET= diff --git a/CLAUDE.md b/CLAUDE.md index 183c0c4..9cc3c36 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -45,8 +45,8 @@ index. read-only with respect to Schulcloud. Run `smoke` after touching `src/core/`, `src/mcp/` or `src/http/` — the unit tests cover only pure functions. -Run smoke **both ways**: with `DATABASE_URL` set (76 checks, index-backed) and -without (74 checks, live-only). The degradation path is a supported mode, not a +Run smoke **both ways**: with `DATABASE_URL` set (79 checks, index-backed) and +without (77 checks, live-only). The degradation path is a supported mode, not a fallback nobody exercises. Every Schulcloud check fails with 401 when the live session has lapsed — check the container's keepalive log before suspecting code. @@ -84,8 +84,10 @@ bin/cli.ts ──HTTP──────────┘ cli/{config,client,sync - `session-token.ts` — the Schulcloud token, replaceable at runtime: checked with `GET /me` (same `userId`), swapped into `config.jwt`, saved to `STATE_DIR`. **Read `config.jwt` at the moment of use; never keep a copy.** -- **`http/`** — besides `/mcp` and `/api`: the optional `//mcp` for - claude.ai (`MCP_PATH_SECRET`), and `/token`, a page that PUTs a fresh token to +- **`http/`** — `/mcp` and `/api` take `MCP_AUTH_TOKEN`; `/mcp` alone also takes + `MCP_CONNECTOR_TOKEN`, the request header claude.ai stores, which must never + open `/api`. Besides those: the optional `//mcp` for clients without + headers (`MCP_PATH_SECRET`), and `/token`, a page that PUTs a fresh token to `/api/token`. docs/AUTH.md and docs/DEPLOYMENT.md say why each exists. - **`store/`** — crawl generations, identity diffs, `german` + `pg_trgm` FTS. `Store.open` returns `undefined` when Postgres is down; callers degrade. @@ -133,7 +135,7 @@ the property that makes that acceptable. Do not add a write tool without the user explicitly asking for one and understanding this. **Never log or echo secrets.** `TSC_JWT_COOKIE` grants full read access to the -account; `MCP_AUTH_TOKEN` guards the endpoint. Neither belongs in +account; `MCP_AUTH_TOKEN` and `MCP_CONNECTOR_TOKEN` guard the endpoint. None belongs in logs, error messages, or tool output. `.env` is git-ignored — keep it that way. Two more count as secrets: a token replaced at runtime (it lives only in `STATE_DIR`, mode 0600) and, when `MCP_PATH_SECRET` is set, **request paths** — @@ -337,7 +339,7 @@ bundle (2.1.272), not its docs: ## Environment `.env` holds `TSC_URL`, `TSC_JWT_COOKIE`, `MCP_AUTH_TOKEN`, and optionally -`MCP_PATH_SECRET`; docker-compose sets `STATE_DIR`. See `.env.example` for the +`MCP_CONNECTOR_TOKEN` or `MCP_PATH_SECRET`; docker-compose sets `STATE_DIR`. See `.env.example` for the full set and `docs/AUTH.md` for refreshing the JWT — `schulcloud token set`, no restart. `npm run probe` and `schulcloud token` report the clocks: days until hard expiry and the session budget. diff --git a/README.md b/README.md index d4b0933..00894c8 100644 --- a/README.md +++ b/README.md @@ -116,11 +116,12 @@ copy is the browser's own session token**, so a Schulportal tab left open will auto-logout after ~2 hours and revoke this server's token with it. Copy the token in a private window and close it. See [docs/AUTH.md](docs/AUTH.md). -**claude.ai reaches it by a secret path, for now.** Its connector dialog takes -a URL and no header, so `MCP_PATH_SECRET` serves MCP at `//mcp` without -the bearer token — never logged, redacted by the Caddy snippet, and a stopgap -until the endpoint speaks OAuth. Claude Code and the CLI keep the bearer token. -See [docs/DEPLOYMENT.md](docs/DEPLOYMENT.md). +**claude.ai gets a token of its own.** Its connector stores a request header, +so `MCP_CONNECTOR_TOKEN` opens `/mcp` and nothing else — it is refused on +`/api`, which can replace the Schulcloud token — and rotates without touching +Claude Code or the CLI, which keep `MCP_AUTH_TOKEN`. A client that cannot send +headers can use a secret path instead (`MCP_PATH_SECRET`). See +[docs/DEPLOYMENT.md](docs/DEPLOYMENT.md). **Read-only by construction.** Every method on the API client is a `GET`, including `api_get`. The endpoint is internet-facing by necessity (Claude's @@ -185,8 +186,8 @@ npm run typecheck ``` `npm run smoke` starts the HTTP server, connects a real MCP client over -Streamable HTTP and exercises every tool against the live account — 74 checks (76 with the index) -covering the auth gate and the secret path, the protocol handshake, every content chain, file +Streamable HTTP and exercises every tool against the live account — 77 checks (79 with the index) +covering the auth gate, the connector token and the secret path, the protocol handshake, every content chain, file extraction, resources and prompts, token replacement, `api_get`'s guard rails and error handling. ## Upstream diff --git a/docs/AUTH.md b/docs/AUTH.md index 177a3e8..1bf7f94 100644 --- a/docs/AUTH.md +++ b/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: `//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: +`//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 diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index 3cf1fab..73df5b2 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -25,7 +25,7 @@ Claude's custom connectors call the endpoint from Anthropic's cloud localhost tunnel or self-signed cert will not do. The VPS provides the public address; Caddy on the Pi terminates TLS and obtains the certificate. **Keep the VPS forwarding raw TCP** rather than terminating TLS itself: then it never sees -a request path, which matters once a path carries a secret (below). +a request, whose header or path carries a credential (below). **The Pi must stay up.** More than two hours offline ends the Schulcloud session however long the token has left — a laptop that sleeps overnight loses it every @@ -137,22 +137,42 @@ newline from a copy-paste. ## Connecting Claude -### claude.ai — a secret path, for now +### claude.ai — a request header with a token of its own -claude.ai's *Add custom connector* dialog takes a name and a URL. Sending a -bearer token needs its "Request headers" section, a beta most accounts do not -have, and the proper answer — OAuth — is not built yet. Until it is, the -server can serve MCP at a path that is itself the secret: +claude.ai's *Add custom connector* dialog asks for a name and a URL first. Its +authentication settings, **Request headers** among them, appear on the next +step, once it has probed the URL. + +1. Put `MCP_CONNECTOR_TOKEN=` in `.env` and recreate the + container: `docker compose up -d --force-recreate schulcloud-mcp`. The + startup line then says `(plus connector token)`. +2. claude.ai → **Customize → Connectors → Add custom connector**. Name it, and + give the URL `https://mcp.example.org/mcp`. +3. On the next step keep **No sign-in**, which is what Claude detects, and add a + request header: name `authorization`, value `Bearer `, + space included. A bare token, or the header `x-api-key`, works too. +4. Enable it in a conversation via **+ → Connectors**. + +Ask *"which courses am I in?"* as a first check — that exercises the header, +the Schulcloud token and the API in one call. + +**Why a token of its own.** claude.ai stores the header value, so the token is +a credential held by a third party. `MCP_CONNECTOR_TOKEN` opens `/mcp`, the +read-only tools, and is refused on `/api`, which can replace the Schulcloud +token and stream the file mirror. It also rotates alone: set a new value, +recreate the container, then remove the connector and add it again, because +claude.ai cannot edit a stored header. Claude Code and the CLI are unaffected. + +### Without request headers — a secret path + +For a client that cannot send a header, the server can serve MCP at a path +that is itself the secret: 1. Put `MCP_PATH_SECRET=` in `.env` and recreate the - container: `docker compose up -d --force-recreate schulcloud-mcp`. The - startup line then says `(plus secret MCP path)` — never the secret itself. -2. claude.ai → **Customize → Connectors → Add custom connector**. Name it, and - give the URL `https://mcp.example.org//mcp`. No sign-in. -3. Enable it in a conversation via **+ → Connectors**. - -Ask *"which courses am I in?"* as a first check — that exercises the path, the -Schulcloud token and the API in one call. + container. The startup line then says `(plus secret MCP path)` — never the + secret itself. +2. Give the client the URL `https://mcp.example.org//mcp`, with no + header and no sign-in. **Know what this trades away.** The URL is now the credential, and Anthropic's connector documentation calls credentials in URLs a security vulnerability, diff --git a/docs/LOCAL.md b/docs/LOCAL.md index d72d4c6..5e9984b 100644 --- a/docs/LOCAL.md +++ b/docs/LOCAL.md @@ -107,9 +107,9 @@ node dist/bin/cli.js sync ## Run the test suites ```bash -npm test # 178 offline tests +npm test # 184 offline tests npm run smoke # end-to-end against the live instance, live-only mode -DATABASE_URL=… npm run smoke # end-to-end with the index (76 checks) +DATABASE_URL=… npm run smoke # end-to-end with the index (79 checks) ``` Store tests need a database and skip without one: diff --git a/docs/PI.md b/docs/PI.md index a11d0f3..48f27b2 100644 --- a/docs/PI.md +++ b/docs/PI.md @@ -82,7 +82,7 @@ COMPOSE_FILE=docker-compose.yml:deploy/docker-compose.pi.yml CADDY_NETWORK= POSTGRES_PASSWORD=$(openssl rand -hex 24) MCP_AUTH_TOKEN=$(openssl rand -hex 32) -MCP_PATH_SECRET=$(openssl rand -hex 32) +MCP_CONNECTOR_TOKEN=$(openssl rand -hex 32) INDEX_PERSONAL_FILES=true # SCHULCLOUD_MCP_TAG=latest EOF @@ -96,15 +96,15 @@ What those lines do: | `CADDY_NETWORK` | The network Caddy reaches this server on, by the name `schulcloud-mcp`. | | `POSTGRES_PASSWORD` | The bundled Postgres, which sits on a private network with this server only. Hex, so it needs no escaping inside the connection URL. | | `MCP_AUTH_TOKEN` | What Claude Code, the CLI and the `/token` page present. | -| `MCP_PATH_SECRET` | The secret path claude.ai uses until OAuth exists. | +| `MCP_CONNECTOR_TOKEN` | What claude.ai sends as a request header. It opens `/mcp` only, never `/api`, because claude.ai stores it. | | `INDEX_PERSONAL_FILES` | Also indexes your own files and handed-in work, including teachers' feedback. Optional. | | `SCHULCLOUD_MCP_TAG` | Which published image to run. Unset means `latest`; a commit id such as `bac9130` pins it, so updates happen only when you change it. Optional. | -**Copy `MCP_AUTH_TOKEN` and `MCP_PATH_SECRET` into your password manager now** — -you need both again in step 9, and neither is ever printed by the server: +**Copy `MCP_AUTH_TOKEN` and `MCP_CONNECTOR_TOKEN` into your password manager +now** — you need both again in step 9, and neither is ever printed by the server: ```bash -grep -E '^(MCP_AUTH_TOKEN|MCP_PATH_SECRET)=' .env +grep -E '^(MCP_AUTH_TOKEN|MCP_CONNECTOR_TOKEN)=' .env ``` ## 4. The first Schulcloud token @@ -134,7 +134,7 @@ with the Pi file in place no `docker compose` command can fall back to a build. Expect, within a few seconds: ``` -[schulcloud-mcp] listening on 0.0.0.0:8080 — instance https://schulcloud-thueringen.de, auth enabled (plus secret MCP path), token from environment, 29 day(s) left, keepalive every 30min, index every 6h +[schulcloud-mcp] listening on 0.0.0.0:8080 — instance https://schulcloud-thueringen.de, auth enabled (plus connector token), token from environment, 29 day(s) left, keepalive every 30min, index every 6h [schulcloud-mcp] keepalive: session extended, 7200s (120 min) of budget left ``` @@ -163,7 +163,8 @@ Keep the snippet's three easily-missed settings: - `flush_interval -1`, or claude.ai's connection hangs without an error. - The long timeouts, or a slow `search` is cut off. -- The `format filter` in `log`, which keeps the secret path out of the access log. +- The `format filter` in `log`, which keeps a secret path out of the access log if + you ever use one (step 9). Caddy gets its certificate once DNS and the forwarding work (step 7). Watch for it with `docker logs -f | grep -i certificate`. @@ -174,9 +175,9 @@ it with `docker logs -f | grep -i certificate`. Publish no `AAAA` record unless the VPS forwards IPv6 as well. **The forwarding must pass TCP through, untouched.** TLS has to end at Caddy on -the Pi. A VPS that terminates TLS itself, or proxies HTTP, sees every request -path — including the secret one — and may log it. Both ports are needed: 80 for -the certificate challenge, 443 for everything else. +the Pi. A VPS that terminates TLS itself, or proxies HTTP, sees every request — +the claude.ai token in its header included — and may log it. Both ports are +needed: 80 for the certificate challenge, 443 for everything else. If the VPS already forwards to the Pi, check how. On the VPS: @@ -314,14 +315,29 @@ claude mcp list # schulcloud: https://mcp.example.org/mcp (HTTP) - ✔ Con **claude.ai:** -1. *Customize → Connectors → Add custom connector*. -2. Name: `Schulcloud`. URL: `https://mcp.example.org//mcp`. No sign-in. +1. *Customize → Connectors → Add custom connector*. Name: `Schulcloud`. URL: + `https://mcp.example.org/mcp`. +2. The next step shows **No sign-in** as detected — keep it — and a **Request + headers** section. Add one: name `authorization`, value + `Bearer `, with the space. Add the connector. 3. In a chat: **+ → Connectors** → switch *Schulcloud* on, and ask *"Welche Kurse habe ich?"* -The URL is the credential for as long as the secret path is in use: keep it out -of screenshots and notes. [DEPLOYMENT.md](DEPLOYMENT.md#claudeai--a-secret-path-for-now) -says what that trades away. +claude.ai stores the header and never shows it again; to change it, remove the +connector and add it again. + +*Without request headers* — say, for a client that cannot send them — use a +secret path instead: + +```bash +echo "MCP_PATH_SECRET=$(openssl rand -hex 32)" >> .env +docker compose up -d --force-recreate schulcloud-mcp +``` + +The URL is then `https://mcp.example.org//mcp`, with no header. +It is the credential itself, so keep it out of screenshots and notes; +[DEPLOYMENT.md](DEPLOYMENT.md#without-request-headers--a-secret-path) says what +that trades away. **Retire the laptop's container** once the Pi answers — in the laptop checkout, `docker compose down` keeps its index and mirror volumes. Its session is separate @@ -389,7 +405,8 @@ Going back works the same way: set the previous commit id, then `pull` and |---|---|---| | `keepalive: token rejected (401)` | The session ended: the Pi was off for more than two hours, a Schulportal tab was left open, or 30 days passed | Step 11 | | 401 about two hours after pasting a token | A Schulportal tab still open on that login | Close it, then step 11 | -| claude.ai cannot add the connector | DNS, forwarding or certificate not in place, or a wrong secret (404) | Step 8's checks, in order | +| claude.ai cannot add the connector | DNS, forwarding or certificate not in place; a missing or mistyped request header (401); or, with a secret path, a wrong secret (404) | Step 8's checks, in order, then the header | +| The connector token works on `/mcp` but not with the CLI | By design: it is refused on `/api` | The CLI uses `MCP_AUTH_TOKEN` | | claude.ai connects, then tools hang | `flush_interval -1` missing from the Caddy site | Step 6 | | `/mcp` answers 401 with the right token | Whitespace copied along with the token | Re-copy it | | Caddy never obtains a certificate | Port 80 not forwarded, or DNS not yet pointing at the VPS | Step 7 | @@ -408,11 +425,13 @@ Going back works the same way: set the previous commit id, then `pull` and Pi user's home private, and use an account that may only pull if your registry can issue one. - The VPS forwards raw TCP and opens only 80, 443, 51820/udp and SSH. -- The secret path stays out of Caddy's access log — this prints a count, never - the secret: +- With a secret path set, it stays out of Caddy's access log — this prints a + count, never the secret: ```bash docker exec grep -c "$(grep '^MCP_PATH_SECRET=' .env | cut -d= -f2)" /var/log/caddy/schulcloud-mcp.log # 0 ``` -- If the claude.ai URL may have leaked: set a new `MCP_PATH_SECRET`, run - `docker compose up -d --force-recreate schulcloud-mcp`, and re-add the connector. +- If claude.ai's token may have leaked: set a new `MCP_CONNECTOR_TOKEN`, run + `docker compose up -d --force-recreate schulcloud-mcp`, and re-add the + connector with the new header. A leaked secret path is replaced the same way. + `MCP_AUTH_TOKEN` stays valid either way. diff --git a/local-instance/README.md b/local-instance/README.md index 5db810b..cfb3777 100644 --- a/local-instance/README.md +++ b/local-instance/README.md @@ -217,7 +217,7 @@ be pointed at the instance in between: ```bash eval "$(./scripts/mcp-env.sh)" # as the demo student -cd .. && npm run smoke # 76 checks against the local instance +cd .. && npm run smoke # 79 checks against the local instance ``` `mcp-env.sh` points the index at its own database, `schulcloud_local`, and the diff --git a/scripts/smoke.mjs b/scripts/smoke.mjs index b37bd66..29b5b96 100644 --- a/scripts/smoke.mjs +++ b/scripts/smoke.mjs @@ -20,6 +20,8 @@ const TOKEN = 'smoke-test-token-' + Math.random().toString(36).slice(2); process.env.MCP_AUTH_TOKEN = TOKEN; const PATH_SECRET = randomBytes(32).toString('hex'); process.env.MCP_PATH_SECRET = PATH_SECRET; +const CONNECTOR_TOKEN = randomBytes(32).toString('hex'); +process.env.MCP_CONNECTOR_TOKEN = CONNECTOR_TOKEN; // A state directory of its own, so the run can neither read nor leave a saved token. const STATE_DIR = await mkdtemp(join(tmpdir(), 'schulcloud-smoke-state-')); process.env.STATE_DIR = STATE_DIR; @@ -473,6 +475,38 @@ console.log('\n== error handling =='); const bogus = await call('get_course', { courseId: '000000000000000000000000' }); check('unknown id returns a tool error, not a crash', bogus.isError, bogus.text.split('\n')[0]); +console.log('\n== connector token =='); +// claude.ai sends a request header it stores, so the token in it opens /mcp and +// nothing else: /api can replace the Schulcloud token and stream the mirror. +{ + const root = `http://127.0.0.1:${port}`; + const viaHeader = new Client({ name: 'smoke-connector', version: '0' }, { capabilities: {} }); + await viaHeader.connect( + new StreamableHTTPClientTransport(new URL(`${root}/mcp`), { + requestInit: { headers: { authorization: `Bearer ${CONNECTOR_TOKEN}` } }, + }), + ); + const connectorTools = await viaHeader.listTools(); + check('the connector token opens /mcp', connectorTools.tools.length === tools.length, `${connectorTools.tools.length} tools`); + await viaHeader.close(); + + const asApiKey = await fetch(`${root}/mcp`, { + method: 'POST', + headers: { 'content-type': 'application/json', accept: 'application/json, text/event-stream', 'x-api-key': CONNECTOR_TOKEN }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { protocolVersion: '2025-06-18', capabilities: {}, clientInfo: { name: 'smoke-api-key', version: '0' } }, + }), + }); + check('the connector token works as x-api-key too', asApiKey.ok, `got ${asApiKey.status}`); + await asApiKey.body?.cancel(); + + const onApi = await fetch(`${root}/api/token`, { headers: { authorization: `Bearer ${CONNECTOR_TOKEN}` } }); + check('the connector token is refused on /api', onApi.status === 401, `got ${onApi.status}`); +} + console.log('\n== secret path and session token =='); // claude.ai's connector dialog takes only a URL, so //mcp serves MCP // without a bearer token; and the Schulcloud token can be replaced at runtime. diff --git a/src/bin/http.ts b/src/bin/http.ts index 8db4cbe..d1e5d4a 100644 --- a/src/bin/http.ts +++ b/src/bin/http.ts @@ -70,7 +70,8 @@ async function main(): Promise { 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'}, ` + diff --git a/src/config.ts b/src/config.ts index af1bcaf..e57ac93 100644 --- a/src/config.ts +++ b/src/config.ts @@ -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 `//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), diff --git a/src/http/auth.ts b/src/http/auth.ts index 8d73efa..701b1a3 100644 --- a/src/http/auth.ts +++ b/src/http/auth.ts @@ -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; } diff --git a/src/http/server.ts b/src/http/server.ts index f9518aa..807223f 100644 --- a/src/http/server.ts +++ b/src/http/server.ts @@ -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( diff --git a/test/auth.test.ts b/test/auth.test.ts index ac070de..1193b7e 100644 --- a/test/auth.test.ts +++ b/test/auth.test.ts @@ -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): { status?: number; passed: boolean } { - const middleware = bearerAuth('correct-horse-battery-staple'); +function run( + headers: Record, + 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); diff --git a/test/config.test.ts b/test/config.test.ts index f5e10bc..2c2a688 100644 --- a/test/config.test.ts +++ b/test/config.test.ts @@ -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/); + }); +});