Files
Schulcloud-MCP/docs/AUTH.md
Fabian Hamm 35125b7683 Initial schulcloud-mcp server
Read-only MCP server exposing a Schulcloud account to Claude: courses,
column boards, lessons, tasks, and file downloads with text extraction.

The API surface was verified against the live instance rather than
inferred from upstream source, which changed several design decisions:

- The `jwt` cookie works verbatim as `Authorization: Bearer` and lasts 30
  days, so there is no cookie jar and no refresh-session timer.
- Course contents live at /api/v3/course-rooms/{courseId}/board; there is
  no GET /api/v3/courses/{id}.
- Files are a separate service (/api/v3/file/*) with its own OpenAPI doc.
- Board file elements carry no file id; attachments are resolved by
  listing files-storage with parentType=boardnodes and the element id.

Read-only by construction: every client method is a GET, including the
api_get escape hatch. The endpoint is internet-facing by necessity, so a
leaked token being unable to act as the user is the key safety property.

Deploys as a container behind the Pi's existing Caddy, guarded by a
constant-time bearer check. Stateless — no database.

Verified: 28 unit tests, plus a 30-check end-to-end run driving a real
MCP client over Streamable HTTP against the live account.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-11 23:52:12 +02:00

3.8 KiB

Authentication

What this server uses

The jwt cookie from a logged-in browser session, sent verbatim as Authorization: Bearer <token>. That is the whole mechanism.

This was worth confirming rather than assuming, because the obvious reading of "it's a cookie" leads somewhere much more complicated. Verified live:

Authorization: Bearer <jwt>  → 200    # what this server does
Cookie: jwt=<jwt>            → 200    # also works
(no auth)                    → 401

connect.sid, SERVERID and isLoggedIn are not needed. There is no cookie jar, no session to keep alive, and no refresh-session call on a timer.

Token lifetime: 30 days

The token is a standard JWT. Decoded from the live instance:

iss / aud : schulcloud-thueringen.de
iat → exp : 720 hours  (exactly 30 days)
claims    : accountId, userId, schoolId, roles, systemId, jti,
            isExternalUser, isServiceAccount, support

So a token copied today works for a month, and refreshing it is a calendar chore rather than an engineering problem. npm run probe prints the days remaining.

Getting a fresh token

  1. Log in to the instance in a normal browser.
  2. DevTools → ApplicationCookies → the instance's origin.
  3. Copy the value of the jwt cookie.
  4. Put it in TSC_JWT_COOKIE in .env and restart the server (docker compose restart schulcloud-mcp).

There is no need to log out afterwards; the token stays valid independently of the browser session.

How you will know it expired

Every tool returns a specific message on 401 rather than a generic failure:

Schulcloud rejected the token … The JWT in TSC_JWT_COOKIE has expired or been revoked.

That message is the signal to redo the four steps above. A 403 means the account genuinely lacks access to that resource and is not a token problem.

Why not username + password

The instance's login redirects to Keycloak (realm TIS) with a redirect_uri pointing back at Schulcloud's own server, so the authorization-code exchange happens server-side with a client secret only Schulcloud holds. A third party cannot replicate that flow. POST /api/v3/authentication/local exists but is for accounts with local credentials, which federated school accounts do not have.

Given a 30-day token, the pasted-JWT approach is the right trade: one manual step a month against re-implementing an OAuth client we cannot hold the secret for. If this ever needs to be unattended, the honest options are a service account issued by the school's IDM, or a headless browser login — not a reimplementation of the Keycloak exchange.

Protecting this server's own endpoint

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.

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.

Rotating it: change MCP_AUTH_TOKEN in .env, restart the container, update the connector in Claude. Nothing else stores it.

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 could read this account's Schulcloud data; they could not post, submit, delete, or otherwise act as the user. Keep it that way — adding a single write tool would change that property entirely.