# Authentication ## What this server uses The `jwt` cookie from a logged-in browser session, sent verbatim as `Authorization: Bearer `. 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 → 200 # what this server does Cookie: 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.