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:
44
docs/AUTH.md
44
docs/AUTH.md
@@ -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.
|
||||
|
||||
27
docs/CLI.md
27
docs/CLI.md
@@ -54,6 +54,33 @@ file-manager folders included — so `refresh` starts it and then polls the
|
||||
server's status, printing a note every half minute, rather than holding one
|
||||
request open (which Node's fetch abandons after five minutes).
|
||||
|
||||
### The server's Schulcloud token (`token`)
|
||||
|
||||
```
|
||||
schulcloud token when it expires, and whether the session is alive
|
||||
schulcloud token set hand the server a fresh one
|
||||
```
|
||||
|
||||
The monthly chore, with no restart and no `.env` edit:
|
||||
|
||||
1. Open a **private window** and log in to Schulcloud.
|
||||
2. DevTools → Application (Firefox: Storage) → Cookies → `jwt`: copy the value.
|
||||
3. `schulcloud token set` and paste it at the prompt. The input is hidden.
|
||||
4. **Close the private window.** Left open, it logs the token out about two
|
||||
hours after login (docs/AUTH.md).
|
||||
|
||||
Piping works too — `wl-paste | schulcloud token set` — and a pasted cookie
|
||||
line such as `jwt=…; Path=/` is cleaned up. The token is never a command-line
|
||||
argument, so it cannot end up in shell history.
|
||||
|
||||
The server checks the token with Schulcloud before swapping it in, so a bad
|
||||
paste changes nothing. It refuses a token that is malformed, expired, already
|
||||
logged out, or for a different account. A replacement is saved on the server
|
||||
(`STATE_DIR`), so a restart keeps it, and the keepalive picks it up at once.
|
||||
The same form lives at `https://<server>/token` for when no terminal is at hand.
|
||||
The cookie is HttpOnly, so no bookmarklet can read it for you; the DevTools
|
||||
copy is the step that remains.
|
||||
|
||||
### The file manager (`fs`)
|
||||
|
||||
The Schulcloud file manager ("Dateien") — Persönliche, Kurs-, Team- and
|
||||
|
||||
@@ -13,13 +13,19 @@ schulcloud CLI ─────┘ └─ Caddy
|
||||
└──▶ schulcloud-thueringen.de
|
||||
```
|
||||
|
||||
Both front ends use the same hostname and the same bearer token. `/mcp` speaks
|
||||
MCP; `/api` serves the CLI's manifest, file bytes and re-crawl requests.
|
||||
Both front ends use the same hostname and the same bearer token. `/mcp` speaks MCP; `/api` serves the
|
||||
CLI's manifest, file bytes, re-crawl requests and token replacement; `/token` is
|
||||
a page for pasting a fresh Schulcloud token.
|
||||
|
||||
Claude's custom connectors call the endpoint from Anthropic's cloud, so it must
|
||||
be publicly reachable over real TLS — a localhost tunnel or self-signed cert
|
||||
will not do. The VPS provides the public address; Caddy on the Pi terminates
|
||||
TLS and obtains the certificate.
|
||||
Claude's custom connectors call the endpoint from Anthropic's cloud
|
||||
(`160.79.104.0/21`), so it must be publicly reachable over real TLS — a
|
||||
localhost tunnel or self-signed cert will not do. The VPS provides the public
|
||||
address; Caddy on the Pi terminates TLS and obtains the certificate.
|
||||
|
||||
**The Pi must stay up.** More than two hours offline ends the Schulcloud session
|
||||
however long the token has left — a laptop that sleeps overnight loses it every
|
||||
night. A replacement token fixes that without a restart (see *Replacing the
|
||||
Schulcloud token*), but an always-on host is what avoids needing one.
|
||||
|
||||
The container publishes no host port. Caddy reaches it over the shared Docker
|
||||
network, so the only way in from the internet is through Caddy and then through
|
||||
@@ -140,6 +146,26 @@ newline from a copy-paste.
|
||||
Ask *"which courses am I in?"* as a first check — that exercises auth, the
|
||||
Schulcloud token and the API in one call.
|
||||
|
||||
## Replacing the Schulcloud token
|
||||
|
||||
The token lasts 30 days at most and can only come from a browser login (see
|
||||
docs/AUTH.md), so this is the monthly chore — but it no longer needs a restart
|
||||
or an `.env` edit:
|
||||
|
||||
1. Log in to Schulcloud in a **private window** and copy the `jwt` cookie's
|
||||
value (DevTools → Application → Cookies).
|
||||
2. Either run `schulcloud token set` and paste it, or open
|
||||
`https://mcp.example.org/token` and paste it together with `MCP_AUTH_TOKEN`.
|
||||
3. **Close the private window.**
|
||||
|
||||
The server checks the token with Schulcloud first — right account, not
|
||||
expired, not logged out — then swaps it in, restarts the keepalive and saves it
|
||||
to the `state` volume (`STATE_DIR=/data/state`), so a restart keeps it. At
|
||||
startup the newer of the saved token and `TSC_JWT_COOKIE` wins, unless the two
|
||||
are for different accounts: changing `TSC_JWT_COOKIE` is how you switch
|
||||
accounts. `schulcloud token` shows the days left; from a week before expiry the
|
||||
log, `whoami` and the CLI warn about it.
|
||||
|
||||
## Running it locally instead
|
||||
|
||||
For Claude Code or Claude Desktop on your own machine, skip all of the above and
|
||||
@@ -179,9 +205,10 @@ npm run probe # re-verify the API assumptions
|
||||
invalidates them; Claude re-initializes transparently.
|
||||
- **Logs** are capped at 3 × 10 MB. The Authorization header is never logged.
|
||||
- **The container is read-only** with `cap_drop: ALL` and `no-new-privileges`,
|
||||
running as the unprivileged `node` user. The one writable path is the mirror
|
||||
volume at `/data/mirror`, which holds downloaded file bytes; everything else
|
||||
stays read-only.
|
||||
running as the unprivileged `node` user. The writable paths are the mirror
|
||||
volume at `/data/mirror`, which holds downloaded file bytes, and the state
|
||||
volume at `/data/state`, which holds a replaced Schulcloud token (mode 0600).
|
||||
Both belong in no backup that leaves the Pi unencrypted.
|
||||
- **The mirror grows.** It holds a copy of every course file under
|
||||
`MIRROR_MAX_BYTES` (64 MiB default). Larger files — videos, mostly — are
|
||||
indexed as metadata and proxied live on request instead. Budget a few GB.
|
||||
@@ -196,6 +223,8 @@ npm run probe # re-verify the API assumptions
|
||||
the session and its auto-logout will revoke it ~2h after login. Copy the
|
||||
cookie in a private window and close it — see docs/AUTH.md.
|
||||
- **Downtime longer than two hours lapses the session** and restarting does not
|
||||
recover it: a long power cut means pasting a fresh `TSC_JWT_COOKIE`.
|
||||
- **Monthly chore**: refresh `TSC_JWT_COOKIE` before its 30-day hard expiry.
|
||||
`npm run probe` reports both clocks.
|
||||
recover it: a long power cut means handing the server a fresh token
|
||||
(`schulcloud token set`), no restart needed.
|
||||
- **Monthly chore**: replace the token before its 30-day hard expiry — see
|
||||
*Replacing the Schulcloud token*. `schulcloud token` and `npm run probe`
|
||||
report the clocks.
|
||||
|
||||
@@ -19,13 +19,17 @@ moves the published port when 8080 is taken, and `CRAWL_INTERVAL_MS` restores a
|
||||
crawl timer — worth doing against a real account, because `what_changed` can
|
||||
only report what happened between crawls.
|
||||
|
||||
After pasting a new `TSC_JWT_COOKIE` into `.env`, **recreate** the container —
|
||||
A fresh Schulcloud token goes in with `schulcloud token set`, or on the page at
|
||||
`http://127.0.0.1:8080/token` — no restart; see docs/CLI.md. Editing
|
||||
`TSC_JWT_COOKIE` in `.env` instead needs the container **recreated**, because
|
||||
`env_file` is read when the container is created, not on restart:
|
||||
|
||||
```bash
|
||||
docker compose up -d --force-recreate schulcloud-mcp
|
||||
``` On the Pi neither port is published — Caddy
|
||||
reaches the container over the Docker network.
|
||||
```
|
||||
|
||||
On the Pi neither port is published — Caddy reaches the container over the
|
||||
Docker network.
|
||||
|
||||
Loopback binding is deliberate. The bearer token is the only thing in front of
|
||||
your account's data, so it should not be listening on your LAN while you test.
|
||||
@@ -103,9 +107,9 @@ node dist/bin/cli.js sync
|
||||
## Run the test suites
|
||||
|
||||
```bash
|
||||
npm test # 135 offline tests
|
||||
npm test # 174 offline tests
|
||||
npm run smoke # end-to-end against the live instance, live-only mode
|
||||
DATABASE_URL=… npm run smoke # end-to-end with the index (69 checks)
|
||||
DATABASE_URL=… npm run smoke # end-to-end with the index (74 checks)
|
||||
```
|
||||
|
||||
Store tests need a database and skip without one:
|
||||
|
||||
Reference in New Issue
Block a user