Files
Schulcloud-MCP/docs/AUTH.md
MechaCat02 ccbf3ad3e9 Document WebUntis
docs/API.md gains the findings: the endpoint and its required version
parameter, the one-time code and the two error codes worth naming, the Z that
means local time, a substitution being two periods, the unused exam module
that puts announced tests in the period notes, and a day without lessons that
is not a holiday. Those cost an afternoon of probing to learn and nothing
upstream states them.

docs/AUTH.md sets the key against the Schulcloud token it sits beside: no
password, nothing to keep alive, revocable on its own, and not read-only in
itself — which is why the allowlist exists. PI.md and DEPLOYMENT.md add the
four values, the container recreate a changed key needs, and the clock
requirement; the Pi's troubleshooting table gains both failure messages.

README and CLAUDE.md say what the server now is: Schulcloud for the material,
WebUntis for the day. Smoke is 89 checks with the index and a key, 87
live-only, 9 fewer without one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 20:43:24 +02:00

266 lines
13 KiB
Markdown

# 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. There *is* a session to keep alive, though not in the way the
cookies suggest; see the next section.
## Token lifetime, and the thing that actually kills it
Two clocks govern the token:
| Clock | Value | Notes |
|---|---|---|
| **Session** — a whitelist entry in Valkey, `jwt:{accountId}:{jti}` | `JWT_TIMEOUT_SECONDS` = **7200 s** | **Sliding**: every authenticated request re-sets it |
| **Hard expiry** — the JWT's own `exp` claim | **30 days** | Cannot be extended |
`JwtStrategy.validate()` calls `JwtWhitelistAdapter.isWhitelisted()`, which
reads the key, throws
`'Session was expired due to inactivity - autologout.'` if it is gone, and then
**re-sets it** with a fresh TTL. The legacy Feathers implementation does the
same. So the window really does slide with use, and a keepalive holds a session
up to the 30-day ceiling.
### But a browser tab will log this server out
The trap is not a clock at all. **The `jwt` cookie you copy is the browser's own
session token — same `jti`.** This server and that tab share one session, and
the tab will end it:
1. On login the front end starts a **purely client-side** timer:
`sessionTimeoutTimestamp = Date.now() + JWT_TIMEOUT_SECONDS`. It is reset
only on *route change*`watch(router.currentRoute, startTimer)` — never by
API activity, and never read back from the server's real TTL.
(`nuxt-client/src/modules/data/application/application.store.ts`)
2. After `JWT_SHOW_TIMEOUT_WARNING_SECONDS` (3600 s) remain it shows the
"Sitzung verlängern" dialog. (`AutoLogoutWarning.vue`)
3. At zero it calls `autoLogout()`
`location.replace('/logout?auto-logout=true')`.
4. That route, in the legacy client, issues `POST /api/v3/logout`
(`schulcloud-client/controllers/login.js:439`) →
`removeJwtFromWhitelist(jwt)`**the shared key is deleted.**
An idle tab therefore revokes this server's token about two hours after login,
no matter how diligently the server refreshes it. Measured exactly that way: a
keepalive pinged successfully at t+0/30/60/90 and was dead by t+120, ~2 h after
login, while `exp` still had 29 days left.
Note the timer is client-side and reset only by navigation. That is why the
dialog appears after an idle hour even in a tab you consider "in use", and why
no amount of API traffic prevents it.
### So: close the tab
**After copying the `jwt` cookie, close the Schulportal tab** — all of them; the
front end also syncs logout across tabs over a `BroadcastChannel`. Closing a tab
runs no logout itself (`autoLogout` fires only from the expiry timer), so the
session survives on the server with nothing left to revoke it.
The tidiest recipe is a **private/incognito window**: log in there, copy the
`jwt`, close the window. That session is separate from your normal browsing, and
`POST /api/v3/logout` only ever removes its own `jti`, so your everyday session
and this server cannot end each other.
With no tab attached, the keepalive holds the session to the 30-day hard expiry,
and replacing the token becomes the monthly chore it looked like at first.
## Replacing the token without a restart
`schulcloud token set` (paste at a hidden prompt, or pipe it in) and the
server's `/token` page both send a fresh token to `PUT /api/token`, behind the
bearer check. `core/session-token.ts` then:
1. **Cleans the paste.** A bare value, `jwt=…; Path=/`, quotes and newlines all
work.
2. **Checks before it swaps.** A malformed or expired token is refused without
asking Schulcloud; otherwise `GET /api/v3/me` must succeed *with the new
token*, and for the same `userId` the current one carries. A refused token
changes nothing. Switching accounts stays a deliberate act — change
`TSC_JWT_COOKIE` and restart.
3. **Swaps it in place.** Every request reads `config.jwt` at the moment it is
sent, so the next one uses the new token; nothing caches a copy.
4. **Restarts the keepalive**, which stopped for good on a 401. Its pings carry
a generation number, so one still in flight with the old token cannot stop
the new cycle when its 401 arrives.
5. **Saves it** to `STATE_DIR` (0600, written beside itself and renamed). At
startup the newer of the saved token and `TSC_JWT_COOKIE` wins, by `exp`
unless they belong to different accounts, when the environment does.
The token appears in no log line and no response; `/api/token` reports only
the expiry, where the token came from, and the keepalive's state. The claims
are decoded, never verified — Schulcloud verifies, this only reads dates.
The cookie is **HttpOnly**, so no script — no bookmarklet, no page on another
origin — can read it out of the browser. The DevTools copy is the one manual
step, and it cannot be automated away short of a headless browser holding the
Schulportal password (see below).
### There is no longer window available
`config/default.schema.json` documents `JWT_EXTENDED_TIMEOUT_SECONDS`
(2629746 s ≈ 1 month), reachable in the legacy stack via a `privateDevice`
login flag. On the current NestJS server that is vestigial: `privateDevice` has
no references in `apps/server/src`, and `generateJwtAndAddToWhitelist` calls
`addToWhitelist` with no TTL override. A "remember me" login buys nothing.
## How this server stays alive
`src/keepalive.ts` calls `POST /api/v3/authentication/refresh-session` every 30
minutes (`KEEPALIVE_INTERVAL_MS`; `0` disables it), from both entry points and
independently of whether any MCP client is connected — the session expires on
wall time, not on usage. It logs the budget each time:
```
[schulcloud-mcp] keepalive: session extended, 7200s (120 min) of budget left
```
A plain `GET` would also slide the TTL today. `refresh-session` is used anyway
because it states the intent contractually rather than relying on a side effect
of an unrelated read — upstream has refactored this whitelist twice in 2026, and
a GET-based keepalive would fail *silently* if extend-on-check ever went away.
The budget readout also turns "is the session healthy" into something the log
answers directly.
**It is the one non-GET request in the server.** No body, touches only the
caller's own session, cannot read or modify user data, and is not exposed as a
tool — so no model-driven call can ever be a POST, and the property that matters
is intact: nobody reaching this endpoint can act as the user in Schulcloud.
A transient failure retries in 5 minutes. A `401` stops the keepalive
permanently and says what to do, because a deleted whitelist entry cannot be
revived by retrying — and if it happens roughly two hours after login, suspect
an open tab before anything else.
Two ways to check that a token is holding:
- `npm run keepalive-status` reads the running container's logs and summarises
its extensions. It makes **no** API call on purpose — any authenticated
request slides the TTL, so a checker that talked to Schulcloud would be
keeping the session alive itself and could not tell you whether the keepalive
works.
- `npm run session-diagnose` calls refresh-session every 10 minutes for ~2.5 h
and logs the budget, for when there is no container to read logs from.
**Operational consequence:** if the container is down for over two hours the
session lapses on its own, and restarting will not recover it. The startup log
says so immediately:
```
[schulcloud-mcp] keepalive: token rejected (401). The session is gone …
```
## WebUntis: a second credential, of a different kind
The timetable is not in Schulcloud, so the server talks to WebUntis as well —
and that side authenticates far more comfortably. In WebUntis, open **Profil →
Freigaben → Untis Mobile → QR-Code**. The dialog shows four values, which go
into `.env` as `UNTIS_SERVER` (its "Url"), `UNTIS_SCHOOL`, `UNTIS_USER` and
`UNTIS_SECRET` (its "Schlüssel"). The school number shown there is not used.
Why this is the right credential for a server:
- **No password.** The key is what the Untis Mobile app is given, and it works
even where the WebUntis login goes through the school's SSO.
- **Nothing to keep alive.** Every request carries a fresh time-based code
derived from the key, so there is no session to refresh and nothing that dies
when the Pi is off for a day. Unlike the Schulcloud token, this needs no
monthly chore: the key stays valid until you replace it.
- **Revocable on its own.** Generating a new key in that dialog invalidates the
old one, and it has nothing to do with your password.
Two things to know:
- **The host's clock matters.** A time-based code from a drifting clock is
refused with `-8524 invalid client time`, which the tools report as such. Any
Pi with working NTP is fine.
- **The key is not read-only — the server is.** It can do what the app can, and
on this account that includes reporting an absence (`W_OWN_ABSENCE`). Untis'
API is JSON-RPC, where reads are POSTs too, so "GET only" cannot be the
guarantee here as it is for Schulcloud. Instead `core/untis.ts` carries an
allowlist of five read methods and refuses everything else; `test/untis.test.ts`
holds it to that. Replacing `UNTIS_SECRET` needs a restart — there is no live
swap for it, because it does not expire.
## 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 keepalive that holds a session to its 30-day ceiling, the pasted-JWT
approach is the right trade: one manual step a month against re-implementing an
OAuth client whose secret we cannot hold. If that monthly step ever becomes
unacceptable, the honest options are a service account issued by the school's
IDM, or driving the Keycloak login with a headless browser — not a
reimplementation of the code exchange. A headless browser would have to hold
the Schulportal password, which unlocks far more than this account's school
files, so it is not done here.
## 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` 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.
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 connector token, for claude.ai
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. 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 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
has. Keep it that way — adding a single write tool would change that property
entirely.