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>
This commit is contained in:
MechaCat02
2026-09-16 20:19:16 +02:00
parent 9d0272c622
commit 973b82ebf5
28 changed files with 1170 additions and 63 deletions

View File

@@ -77,6 +77,37 @@ 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`
@@ -145,9 +176,9 @@ 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. 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.
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
@@ -170,5 +201,8 @@ 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.
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.