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>
This commit is contained in:
93
docs/AUTH.md
Normal file
93
docs/AUTH.md
Normal file
@@ -0,0 +1,93 @@
|
||||
# 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 → **Application** → **Cookies** → 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.
|
||||
Reference in New Issue
Block a user