Files
Schulcloud-MCP/docs/AUTH.md
MechaCat02 973b82ebf5 Replace the Schulcloud token without a restart
A token lasts 30 days and only a browser login yields one — the account is
federated, so the server cannot mint it. Replacing it meant editing .env and
recreating the container, every month.

`schulcloud token set` (a hidden prompt, or piped input) and a /token page
both send it to PUT /api/token. The server checks it with Schulcloud first —
well-formed, unexpired, still logged in, the same account — then swaps it
into the config every request reads, restarts the keepalive and saves it in
STATE_DIR, a new volume, with mode 0600. At startup the newer of the saved
token and TSC_JWT_COOKIE wins, unless they belong to different accounts. A
refused paste changes nothing, and the token is never logged.

The keepalive's pings carry a generation, so a 401 for the old token that
arrives after a swap cannot stop the new cycle. `schulcloud token`, whoami
and the log report the expiry and warn a week ahead.

Found on the way: a host that is off for more than two hours loses the
session however long the token has left — this machine lost it overnight —
which is what the always-on Pi is for.

174 tests. Smoke 72/72 on the local instance, and a real swap verified end to
end there.

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

209 lines
10 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 …
```
## 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` 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. 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.