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

@@ -39,9 +39,10 @@ index.
read-only with respect to Schulcloud. Run `smoke` after touching `src/core/`,
`src/mcp/` or `src/http/` — the unit tests cover only pure functions.
Run smoke **both ways**: with `DATABASE_URL` set (69 checks, index-backed) and
without (67 checks, live-only). The degradation path is a supported mode, not a
fallback nobody exercises.
Run smoke **both ways**: with `DATABASE_URL` set (74 checks, index-backed) and
without (72 checks, live-only). The degradation path is a supported mode, not a
fallback nobody exercises. Every Schulcloud check fails with 401 when the live
session has lapsed — check the container's keepalive log before suspecting code.
Store tests need a database and skip without one:
`TEST_DATABASE_URL=postgresql://… npm test`. They use a real Postgres on
@@ -74,6 +75,11 @@ bin/cli.ts ──HTTP──────────┘ cli/{config,client,sync
- `legacy-files.ts` — the file manager ("Dateien": Persönliche, Kurs-, Team-,
Geteilte Dateien) as one path tree, parsed from the legacy client's pages.
A separate store from files-storage; the `fs_*` tools and `/api/fs` sit on it.
- `session-token.ts` — the Schulcloud token, replaceable at runtime: checked
with `GET /me` (same `userId`), swapped into `config.jwt`, saved to
`STATE_DIR`. **Read `config.jwt` at the moment of use; never keep a copy.**
- **`http/`** — besides `/mcp` and `/api`: `/token`, a page that PUTs a fresh
token to `/api/token`. docs/AUTH.md says why it exists.
- **`store/`** — crawl generations, identity diffs, `german` + `pg_trgm` FTS.
`Store.open` returns `undefined` when Postgres is down; callers degrade.
- **`indexer/`** — crawl → persist → mirror bytes → extract text → index.
@@ -105,7 +111,8 @@ permission, so `getFileManagerPage` allows only the listing routes, by pattern
widen that pattern only with a route you have read the handler of. A pre-signed
download URL is fetched with **no** credentials: it names another host, and
neither the bearer nor the `jwt` cookie may go with it. `refresh_index` and `POST /api/refresh` write only to the Pi's
own index and mirror — every upstream call they make is still a GET.
own index and mirror, and `PUT /api/token` only to the server's own token — every
upstream call they make is still a GET.
**Filenames from Schulcloud are untrusted paths.** Course titles, card titles
and filenames are all user-supplied upstream, and both the server's mirror and
@@ -121,6 +128,8 @@ user explicitly asking for one and understanding this.
**Never log or echo secrets.** `TSC_JWT_COOKIE` grants full read access to the
account; `MCP_AUTH_TOKEN` guards the endpoint. Neither belongs in
logs, error messages, or tool output. `.env` is git-ignored — keep it that way.
One more counts as a secret: a token replaced at runtime, which lives only in
`STATE_DIR`, mode 0600.
**Live behaviour beats upstream source.** The clones in `vendor/` track `main`
and may be ahead of what is deployed. When they disagree with the instance, the
@@ -260,7 +269,10 @@ These cost real time to discover; `docs/API.md` has the full list with evidence.
- **`exp` (30 days) is not the session lifetime.** The binding limit is a Valkey
whitelist entry with a `JWT_TIMEOUT_SECONDS` TTL (7200s; live value at
`GET /api/v3/config/public`) that every authenticated request re-sets.
`src/keepalive.ts` holds it open — don't remove it.
`src/keepalive.ts` holds it open — don't remove it. It cannot hold it across
downtime: a host off for more than two hours loses the session (seen when a
dev machine was off overnight), which is why the deployment is an always-on
Pi and why a fresh token can be swapped in without a restart.
- **A Schulportal tab left open revokes our token.** The `jwt` cookie *is* the
browser's session token, same `jti`. The front end runs a client-side timer
(reset only on route change, never from the server TTL) and calls
@@ -316,6 +328,8 @@ bundle (2.1.272), not its docs:
## Environment
`.env` holds `TSC_URL`, `TSC_JWT_COOKIE`, `MCP_AUTH_TOKEN`. See `.env.example`
for the full set and `docs/AUTH.md` for refreshing the JWT. `npm run probe`
reports both clocks: days until hard expiry and seconds of idle budget left.
`.env` holds `TSC_URL`, `TSC_JWT_COOKIE`, `MCP_AUTH_TOKEN`; docker-compose sets
`STATE_DIR`. See `.env.example` for the
full set and `docs/AUTH.md` for refreshing the JWT — `schulcloud token set`,
no restart. `npm run probe` and `schulcloud token` report the clocks: days until
hard expiry and the session budget.