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

@@ -1,5 +1,6 @@
/**
* Runtime configuration, read once from the environment.
* Runtime configuration, read once from the environment — except `jwt`, which
* can be replaced while the server runs.
*
* The two Schulcloud values are named after the browser artefacts they come
* from (`TSC_URL`, `TSC_JWT_COOKIE`) so that copying a fresh token out of
@@ -11,10 +12,16 @@ import { resolve } from 'node:path';
export interface Config {
/** Instance base URL, no trailing slash, e.g. `https://schulcloud-thueringen.de`. */
baseUrl: string;
/** Raw JWT from the instance's `jwt` cookie. Sent as `Authorization: Bearer`. */
/**
* Raw JWT from the instance's `jwt` cookie. Sent as `Authorization: Bearer`.
* Replaced at runtime by core/session-token.ts, so read it at the moment of
* use and never keep a copy.
*/
jwt: string;
/** Shared secret callers must present to this MCP server. Unused in stdio mode. */
authToken: string | undefined;
/** Where state that must survive a restart is kept: a replaced session token. Unset = memory only. */
stateDir: string | undefined;
port: number;
bindHost: string;
/** Hard ceiling on how many bytes `download_file` will pull from the instance. */
@@ -82,6 +89,7 @@ export function loadConfig(): Config {
baseUrl: required('TSC_URL').replace(/\/+$/, ''),
jwt: required('TSC_JWT_COOKIE'),
authToken: process.env.MCP_AUTH_TOKEN?.trim() || undefined,
stateDir: process.env.STATE_DIR?.trim() ? resolve(process.env.STATE_DIR.trim()) : undefined,
port: int('PORT', 8080),
bindHost: process.env.BIND_HOST?.trim() || '0.0.0.0',
maxDownloadBytes: int('MAX_DOWNLOAD_BYTES', 25 * 1024 * 1024),