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

@@ -10,6 +10,8 @@ TSC_URL=https://schulcloud-thueringen.de
# TTL that the built-in keepalive holds open. IMPORTANT: close the Schulportal # TTL that the built-in keepalive holds open. IMPORTANT: close the Schulportal
# window after copying this — an open tab shares the session and its auto-logout # window after copying this — an open tab shares the session and its auto-logout
# will revoke this token ~2h after login. See docs/AUTH.md. # will revoke this token ~2h after login. See docs/AUTH.md.
# Needed for the first start. Later tokens go in with `schulcloud token set` or
# the server's /token page, without a restart; a saved newer one wins over this.
TSC_JWT_COOKIE= TSC_JWT_COOKIE=
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -22,6 +24,11 @@ TSC_JWT_COOKIE=
# openssl rand -hex 32 # openssl rand -hex 32
MCP_AUTH_TOKEN= MCP_AUTH_TOKEN=
# Where a Schulcloud token replaced at runtime (`schulcloud token set`, /token)
# is saved, so a restart keeps it. docker-compose.yml sets /data/state; unset =
# replacements last until the next restart.
# STATE_DIR=/data/state
# Listen address inside the container. Leave as-is when running behind Caddy. # Listen address inside the container. Leave as-is when running behind Caddy.
PORT=8080 PORT=8080
BIND_HOST=0.0.0.0 BIND_HOST=0.0.0.0

View File

@@ -39,9 +39,10 @@ index.
read-only with respect to Schulcloud. Run `smoke` after touching `src/core/`, read-only with respect to Schulcloud. Run `smoke` after touching `src/core/`,
`src/mcp/` or `src/http/` — the unit tests cover only pure functions. `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 Run smoke **both ways**: with `DATABASE_URL` set (74 checks, index-backed) and
without (67 checks, live-only). The degradation path is a supported mode, not a without (72 checks, live-only). The degradation path is a supported mode, not a
fallback nobody exercises. 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: Store tests need a database and skip without one:
`TEST_DATABASE_URL=postgresql://… npm test`. They use a real Postgres on `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-, - `legacy-files.ts` — the file manager ("Dateien": Persönliche, Kurs-, Team-,
Geteilte Dateien) as one path tree, parsed from the legacy client's pages. 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. 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/`** — crawl generations, identity diffs, `german` + `pg_trgm` FTS.
`Store.open` returns `undefined` when Postgres is down; callers degrade. `Store.open` returns `undefined` when Postgres is down; callers degrade.
- **`indexer/`** — crawl → persist → mirror bytes → extract text → index. - **`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 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 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 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 **Filenames from Schulcloud are untrusted paths.** Course titles, card titles
and filenames are all user-supplied upstream, and both the server's mirror and 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 **Never log or echo secrets.** `TSC_JWT_COOKIE` grants full read access to the
account; `MCP_AUTH_TOKEN` guards the endpoint. Neither belongs in account; `MCP_AUTH_TOKEN` guards the endpoint. Neither belongs in
logs, error messages, or tool output. `.env` is git-ignored — keep it that way. 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` **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 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 - **`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 whitelist entry with a `JWT_TIMEOUT_SECONDS` TTL (7200s; live value at
`GET /api/v3/config/public`) that every authenticated request re-sets. `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 - **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 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 (reset only on route change, never from the server TTL) and calls
@@ -316,6 +328,8 @@ bundle (2.1.272), not its docs:
## Environment ## Environment
`.env` holds `TSC_URL`, `TSC_JWT_COOKIE`, `MCP_AUTH_TOKEN`. See `.env.example` `.env` holds `TSC_URL`, `TSC_JWT_COOKIE`, `MCP_AUTH_TOKEN`; docker-compose sets
for the full set and `docs/AUTH.md` for refreshing the JWT. `npm run probe` `STATE_DIR`. See `.env.example` for the
reports both clocks: days until hard expiry and seconds of idle budget left. 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.

View File

@@ -30,12 +30,14 @@ COPY --from=deps /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist COPY --from=build /app/dist ./dist
COPY package.json ./ COPY package.json ./
# The mirror is the one writable path. Creating it in the image with the right # The mirror and the state directory (a replaced session token) are the only
# owner matters: Docker initialises a new named volume from the image directory, # writable paths. Creating them in the image with the right owner matters: Docker
# including its ownership, so without this the volume lands root-owned and the # initialises a new named volume from the image directory, including its
# unprivileged user gets EACCES on every write — with the failure recorded per # ownership, so without this the volume lands root-owned and the unprivileged
# file rather than crashing, which makes it easy to miss. # user gets EACCES on every write — with the failure recorded rather than
RUN mkdir -p /data/mirror && chown -R node:node /data # crashing, which makes it easy to miss. The state directory holds a credential,
# so only its owner may enter it.
RUN mkdir -p /data/mirror /data/state && chown -R node:node /data && chmod 700 /data/state
# node:alpine ships an unprivileged `node` user. # node:alpine ships an unprivileged `node` user.
USER node USER node

View File

@@ -77,10 +77,13 @@ schulcloud sync # mirror coursework to ~/Schulcloud
schulcloud refresh --course <id> schulcloud refresh --course <id>
schulcloud fs tree /courses # browse the file manager ("Dateien") schulcloud fs tree /courses # browse the file manager ("Dateien")
schulcloud fs get "/courses/<course>/<folder>" schulcloud fs get "/courses/<course>/<folder>"
schulcloud token set # the monthly chore: hand the Pi a fresh Schulcloud token
``` ```
It talks only to the Pi and holds no Schulcloud credential — see It talks only to the Pi and holds no Schulcloud credential — see
[docs/CLI.md](docs/CLI.md). [docs/CLI.md](docs/CLI.md). A fresh token can also be pasted into the server's
`/token` page; either way the server checks it with Schulcloud and swaps it in
without a restart.
## Quick start ## Quick start
@@ -174,9 +177,9 @@ npm run typecheck
``` ```
`npm run smoke` starts the HTTP server, connects a real MCP client over `npm run smoke` starts the HTTP server, connects a real MCP client over
Streamable HTTP and exercises every tool against the live account — 67 checks (69 with the index) Streamable HTTP and exercises every tool against the live account — 72 checks (74 with the index)
covering the auth gate, the protocol handshake, every content chain, file covering the auth gate, the protocol handshake, every content chain, file
extraction, resources and prompts, `api_get`'s guard rails and error handling. extraction, resources and prompts, token replacement, `api_get`'s guard rails and error handling.
## Upstream ## Upstream

View File

@@ -37,13 +37,16 @@ services:
PORT: 8080 PORT: 8080
BIND_HOST: 0.0.0.0 BIND_HOST: 0.0.0.0
MIRROR_DIR: /data/mirror MIRROR_DIR: /data/mirror
STATE_DIR: /data/state
depends_on: depends_on:
postgres: postgres:
condition: service_healthy condition: service_healthy
volumes: volumes:
# The mirror is the one thing this server writes; everything else stays # The mirror and a replaced Schulcloud token are the only things this
# read-only, so it gets its own volume rather than loosening read_only. # server writes; everything else stays read-only, so each gets its own
# volume rather than loosening read_only.
- mirror:/data/mirror - mirror:/data/mirror
- state:/data/state
# No ports are published to the host: Caddy reaches the container over the # No ports are published to the host: Caddy reaches the container over the
# shared Docker network, so the only way in from the internet is through # shared Docker network, so the only way in from the internet is through
# Caddy's TLS and this server's bearer check. # Caddy's TLS and this server's bearer check.
@@ -67,6 +70,7 @@ services:
volumes: volumes:
pgdata: pgdata:
mirror: mirror:
state:
networks: networks:
caddy: caddy:

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, 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. 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 ### There is no longer window available
`config/default.schema.json` documents `JWT_EXTENDED_TIMEOUT_SECONDS` `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 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 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 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 reimplementation of the code exchange. A headless browser would have to hold
account issued by the school's IDM, or a headless browser login — not a the Schulportal password, which unlocks far more than this account's school
reimplementation of the Keycloak exchange. files, so it is not done here.
## Protecting this server's own endpoint ## 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 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 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 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 otherwise act as the user. With `MCP_AUTH_TOKEN` they
change that property entirely. 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.

View File

@@ -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 server's status, printing a note every half minute, rather than holding one
request open (which Node's fetch abandons after five minutes). 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 file manager (`fs`)
The Schulcloud file manager ("Dateien") — Persönliche, Kurs-, Team- and The Schulcloud file manager ("Dateien") — Persönliche, Kurs-, Team- and

View File

@@ -13,13 +13,19 @@ schulcloud CLI ─────┘ └─ Caddy
└──▶ schulcloud-thueringen.de └──▶ schulcloud-thueringen.de
``` ```
Both front ends use the same hostname and the same bearer token. `/mcp` speaks Both front ends use the same hostname and the same bearer token. `/mcp` speaks MCP; `/api` serves the
MCP; `/api` serves the CLI's manifest, file bytes and re-crawl requests. 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 Claude's custom connectors call the endpoint from Anthropic's cloud
be publicly reachable over real TLS — a localhost tunnel or self-signed cert (`160.79.104.0/21`), so it must be publicly reachable over real TLS — a
will not do. The VPS provides the public address; Caddy on the Pi terminates localhost tunnel or self-signed cert will not do. The VPS provides the public
TLS and obtains the certificate. 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 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 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 Ask *"which courses am I in?"* as a first check — that exercises auth, the
Schulcloud token and the API in one call. 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 ## Running it locally instead
For Claude Code or Claude Desktop on your own machine, skip all of the above and 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. invalidates them; Claude re-initializes transparently.
- **Logs** are capped at 3 × 10 MB. The Authorization header is never logged. - **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`, - **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 running as the unprivileged `node` user. The writable paths are the mirror
volume at `/data/mirror`, which holds downloaded file bytes; everything else volume at `/data/mirror`, which holds downloaded file bytes, and the state
stays read-only. 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 - **The mirror grows.** It holds a copy of every course file under
`MIRROR_MAX_BYTES` (64 MiB default). Larger files — videos, mostly — are `MIRROR_MAX_BYTES` (64 MiB default). Larger files — videos, mostly — are
indexed as metadata and proxied live on request instead. Budget a few GB. 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 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. cookie in a private window and close it — see docs/AUTH.md.
- **Downtime longer than two hours lapses the session** and restarting does not - **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`. recover it: a long power cut means handing the server a fresh token
- **Monthly chore**: refresh `TSC_JWT_COOKIE` before its 30-day hard expiry. (`schulcloud token set`), no restart needed.
`npm run probe` reports both clocks. - **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.

View File

@@ -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 crawl timer — worth doing against a real account, because `what_changed` can
only report what happened between crawls. 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: `env_file` is read when the container is created, not on restart:
```bash ```bash
docker compose up -d --force-recreate schulcloud-mcp 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 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. 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 ## Run the test suites
```bash ```bash
npm test # 135 offline tests npm test # 174 offline tests
npm run smoke # end-to-end against the live instance, live-only mode 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: Store tests need a database and skip without one:

View File

@@ -217,7 +217,7 @@ be pointed at the instance in between:
```bash ```bash
eval "$(./scripts/mcp-env.sh)" # as the demo student eval "$(./scripts/mcp-env.sh)" # as the demo student
cd .. && npm run smoke # 69 checks against the local instance cd .. && npm run smoke # 74 checks against the local instance
``` ```
`mcp-env.sh` points the index at its own database, `schulcloud_local`, and the `mcp-env.sh` points the index at its own database, `schulcloud_local`, and the

View File

@@ -6,6 +6,9 @@
* Requires TSC_URL and TSC_JWT_COOKIE in the environment (load .env first). * Requires TSC_URL and TSC_JWT_COOKIE in the environment (load .env first).
* Read-only — it never writes to Schulcloud. * Read-only — it never writes to Schulcloud.
*/ */
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
import { loadConfig } from '../dist/config.js'; import { loadConfig } from '../dist/config.js';
@@ -14,6 +17,9 @@ import { closeServices, createServices } from '../dist/services.js';
const TOKEN = 'smoke-test-token-' + Math.random().toString(36).slice(2); const TOKEN = 'smoke-test-token-' + Math.random().toString(36).slice(2);
process.env.MCP_AUTH_TOKEN = TOKEN; process.env.MCP_AUTH_TOKEN = TOKEN;
// A state directory of its own, so the run can neither read nor leave a saved token.
const STATE_DIR = await mkdtemp(join(tmpdir(), 'schulcloud-smoke-state-'));
process.env.STATE_DIR = STATE_DIR;
// The app is bound by this script on an ephemeral port, so config.port is unused. // The app is bound by this script on an ephemeral port, so config.port is unused.
const config = loadConfig(); const config = loadConfig();
@@ -464,9 +470,61 @@ console.log('\n== error handling ==');
const bogus = await call('get_course', { courseId: '000000000000000000000000' }); const bogus = await call('get_course', { courseId: '000000000000000000000000' });
check('unknown id returns a tool error, not a crash', bogus.isError, bogus.text.split('\n')[0]); check('unknown id returns a tool error, not a crash', bogus.isError, bogus.text.split('\n')[0]);
console.log('\n== session token ==');
// The Schulcloud token can be replaced at runtime. Nothing here replaces the
// live token: the one PUT that succeeds sends the token already in use, which
// the server answers without a swap.
{
const root = `http://127.0.0.1:${port}`;
const anonymous = await fetch(`${root}/api/token`);
check('/api/token needs the bearer token', anonymous.status === 401, `got ${anonymous.status}`);
const bearer = { authorization: `Bearer ${TOKEN}` };
const statusResponse = await fetch(`${root}/api/token`, { headers: bearer });
const statusText = await statusResponse.text();
const tokenStatus = JSON.parse(statusText);
check(
'/api/token reports the expiry and never the token',
statusResponse.ok && typeof tokenStatus.expiresAt === 'string' && Number.isInteger(tokenStatus.daysLeft) && !statusText.includes(config.jwt),
`${tokenStatus.daysLeft} day(s) left, from ${tokenStatus.source}`,
);
const jwtInUse = config.jwt;
const malformed = await fetch(`${root}/api/token`, {
method: 'PUT',
headers: { ...bearer, 'content-type': 'application/json' },
body: JSON.stringify({ jwt: 'not-a-token' }),
});
const refusal = await malformed.json();
check(
'a malformed token is refused and the one in use stays',
malformed.status === 422 && refusal.error === 'malformed' && config.jwt === jwtInUse,
`${malformed.status} ${refusal.error}`,
);
const same = await fetch(`${root}/api/token`, {
method: 'PUT',
headers: { ...bearer, 'content-type': 'application/json' },
body: JSON.stringify({ jwt: `jwt=${jwtInUse};` }),
});
const sameResult = await same.json();
check('the token already in use is accepted without a swap', same.ok && sameResult.changed === false, `${same.status}`);
const page = await fetch(`${root}/token`);
const script = await fetch(`${root}/token.js`);
check(
'/token page is served with a strict content security policy',
page.ok && /text\/html/.test(page.headers.get('content-type') ?? '') &&
/default-src 'none'/.test(page.headers.get('content-security-policy') ?? '') &&
script.ok && /javascript/.test(script.headers.get('content-type') ?? ''),
);
}
await client.close(); await client.close();
httpServer.close(); httpServer.close();
await closeServices(services); await closeServices(services);
await rm(STATE_DIR, { recursive: true, force: true });
console.log(`\n${results.length - failures}/${results.length} checks passed`); console.log(`\n${results.length - failures}/${results.length} checks passed`);
process.exit(failures === 0 ? 0 : 1); process.exit(failures === 0 ? 0 : 1);

View File

@@ -4,7 +4,8 @@ import { mkdir } from 'node:fs/promises';
import { basename, dirname, resolve } from 'node:path'; import { basename, dirname, resolve } from 'node:path';
import { Readable } from 'node:stream'; import { Readable } from 'node:stream';
import { pipeline } from 'node:stream/promises'; import { pipeline } from 'node:stream/promises';
import { ApiClient, ApiError } from '../cli/client.ts'; import { ApiClient, ApiError, type TokenInfo } from '../cli/client.ts';
import { readHidden, readPiped } from '../cli/prompt.ts';
import { defaultSyncDir, loadCliConfig, saveCliConfig, configPath } from '../cli/config.ts'; import { defaultSyncDir, loadCliConfig, saveCliConfig, configPath } from '../cli/config.ts';
import { formatBytes } from '../core/extract.ts'; import { formatBytes } from '../core/extract.ts';
import { fsFind, fsGet, fsList, fsTree } from '../cli/fs.ts'; import { fsFind, fsGet, fsList, fsTree } from '../cli/fs.ts';
@@ -26,6 +27,8 @@ const USAGE = `schulcloud — browse and mirror your Schulcloud files
schulcloud get <fileId> [--out <path>] schulcloud get <fileId> [--out <path>]
schulcloud sync [--dry-run] [--full] [--prune] [--dir <path>] [--jobs <n>] schulcloud sync [--dry-run] [--full] [--prune] [--dir <path>] [--jobs <n>]
schulcloud refresh [--course <id>] [--force] schulcloud refresh [--course <id>] [--force]
schulcloud token when the server's Schulcloud token expires
schulcloud token set hand the server a fresh one (paste, or pipe it in)
The file manager ("Dateien") — /my, /courses/<course>, /teams/<team>, /shared: The file manager ("Dateien") — /my, /courses/<course>, /teams/<team>, /shared:
@@ -71,6 +74,8 @@ async function main(argv: string[]): Promise<number> {
return refresh(flags); return refresh(flags);
case 'fs': case 'fs':
return fileManager(flags); return fileManager(flags);
case 'token':
return token(flags);
default: default:
process.stderr.write(`Unknown command "${command}".\n\n${USAGE}`); process.stderr.write(`Unknown command "${command}".\n\n${USAGE}`);
return 2; return 2;
@@ -256,6 +261,57 @@ async function refresh(flags: Flags): Promise<number> {
return 0; return 0;
} }
/**
* The monthly chore: log in to Schulcloud in a private window, copy the `jwt`
* cookie, paste it here, close the window. The server checks the token with
* Schulcloud before swapping it in, so a bad paste changes nothing.
*/
async function token(flags: Flags): Promise<number> {
const api = new ApiClient(await loadCliConfig());
const sub = flags._[0];
if (sub === undefined || sub === 'status') {
process.stdout.write(`${describeToken(await api.token())}\n`);
return 0;
}
if (sub !== 'set') {
process.stderr.write(`Unknown token command "${sub}". Use "schulcloud token" or "schulcloud token set".\n`);
return 2;
}
const pasted = process.stdin.isTTY
? await readHidden('Paste the value of the "jwt" cookie (input hidden): ')
: await readPiped();
if (!pasted.trim()) {
process.stderr.write('No token given.\n');
return 2;
}
process.stderr.write('Checking it with Schulcloud…\n');
const result = await api.replaceToken(pasted);
process.stdout.write(`${result.changed ? 'Replaced' : 'Already in use'}: ${describeToken(result)}\n`);
if (result.changed && !result.persisted) {
process.stderr.write('Not saved on the server (STATE_DIR is unset): a restart falls back to TSC_JWT_COOKIE.\n');
}
process.stdout.write('Now close the private window — left open, it logs this token out about two hours after login.\n');
return 0;
}
function describeToken(info: TokenInfo): string {
const expiry = info.expiresAt
? `expires ${info.expiresAt.slice(0, 16).replace('T', ' ')} UTC (${info.daysLeft} day(s) left)`
: 'expiry unknown';
const keepalive = info.keepalive;
const session = !keepalive
? 'keepalive off'
: keepalive.running
? `session alive${keepalive.budgetSeconds === undefined ? '' : `, ${Math.round(keepalive.budgetSeconds / 60)} min budget`}`
: 'session ENDED — Schulcloud rejected the token; run: schulcloud token set';
const source =
info.source === 'environment' ? 'from TSC_JWT_COOKIE' : info.source === 'state file' ? 'saved from an earlier replacement' : info.source;
const warning = info.daysLeft !== undefined && info.daysLeft <= 7 ? '\nRenew it soon: schulcloud token set' : '';
return `${expiry}; ${session}; ${source}${warning}`;
}
function describe(event: SyncEvent, dryRun: boolean): string { function describe(event: SyncEvent, dryRun: boolean): string {
switch (event.type) { switch (event.type) {
case 'download': case 'download':

View File

@@ -20,7 +20,25 @@ async function main(): Promise<void> {
console.log(message), console.log(message),
) )
: undefined; : undefined;
services.keepalive = keepalive;
keepalive?.start(); keepalive?.start();
// A replaced token is a new session to hold, and very likely the fix for a
// keepalive that stopped on a 401.
services.session.onReplaced(() => keepalive?.restart());
// The token's 30 days end on a date nothing else announces, and the only fix
// needs a person at a browser — so warn a week ahead, twice a day.
const warnIfExpiring = () => {
const { daysLeft } = services.session.status();
if (daysLeft === undefined || daysLeft > 7) return;
console.log(
daysLeft < 0
? '[schulcloud-mcp] session token: EXPIRED. Replace it with `schulcloud token set` or on the /token page.'
: `[schulcloud-mcp] session token: expires in ${daysLeft} day(s). Replace it with \`schulcloud token set\` or on the /token page.`,
);
};
warnIfExpiring();
setInterval(warnIfExpiring, 12 * 60 * 60_000).unref();
// Periodic re-crawl so the index does not drift. Each run only downloads // Periodic re-crawl so the index does not drift. Each run only downloads
// files it has never seen, so a steady state costs a few hundred cheap GETs. // files it has never seen, so a steady state costs a few hundred cheap GETs.
@@ -49,9 +67,12 @@ async function main(): Promise<void> {
} }
const server = app.listen(config.port, config.bindHost, () => { const server = app.listen(config.port, config.bindHost, () => {
const token = services.session.status();
console.log( console.log(
`[schulcloud-mcp] listening on ${config.bindHost}:${config.port} — instance ${config.baseUrl}, ` + `[schulcloud-mcp] listening on ${config.bindHost}:${config.port} — instance ${config.baseUrl}, ` +
`auth ${config.authToken ? 'enabled' : 'DISABLED'}, ` + `auth ${config.authToken ? 'enabled' : 'DISABLED'}, ` +
`token from ${token.source}${token.daysLeft === undefined ? '' : `, ${token.daysLeft} day(s) left`}` +
`${token.persistent ? '' : ' (replacements not saved: STATE_DIR unset)'}, ` +
`keepalive ${keepalive ? `every ${Math.round(config.keepaliveIntervalMs / 60_000)}min` : 'off'}, ` + `keepalive ${keepalive ? `every ${Math.round(config.keepaliveIntervalMs / 60_000)}min` : 'off'}, ` +
`index ${services.store ? (config.crawlIntervalMs > 0 ? `every ${Math.round(config.crawlIntervalMs / 3_600_000)}h` : 'on demand') : 'off'}`, `index ${services.store ? (config.crawlIntervalMs > 0 ? `every ${Math.round(config.crawlIntervalMs / 3_600_000)}h` : 'on demand') : 'off'}`,
); );

View File

@@ -59,6 +59,15 @@ export interface FsWalk {
failures?: { path: string; reason: string }[]; failures?: { path: string; reason: string }[];
} }
/** The server's Schulcloud token, as `/api/token` reports it — never the token itself. */
export interface TokenInfo {
expiresAt?: string;
daysLeft?: number;
source: string;
persistent: boolean;
keepalive: { running: boolean; budgetSeconds?: number; lastExtendedAt?: string; rejectedAt?: string } | null;
}
export class ApiError extends Error { export class ApiError extends Error {
readonly status: number; readonly status: number;
@@ -98,6 +107,20 @@ export class ApiClient {
return (await (await this.request('/api/status')).json()) as Record<string, unknown>; return (await (await this.request('/api/status')).json()) as Record<string, unknown>;
} }
async token(): Promise<TokenInfo> {
return (await (await this.request('/api/token')).json()) as TokenInfo;
}
/** Hands the server a fresh Schulcloud token; it checks the token before using it. */
async replaceToken(jwt: string): Promise<TokenInfo & { changed: boolean; persisted: boolean }> {
const response = await this.request('/api/token', {
method: 'PUT',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ jwt }),
});
return (await response.json()) as TokenInfo & { changed: boolean; persisted: boolean };
}
async manifest(since?: string): Promise<Manifest> { async manifest(since?: string): Promise<Manifest> {
const query = since ? `?since=${encodeURIComponent(since)}` : ''; const query = since ? `?since=${encodeURIComponent(since)}` : '';
return (await (await this.request(`/api/manifest${query}`)).json()) as Manifest; return (await (await this.request(`/api/manifest${query}`)).json()) as Manifest;

53
src/cli/prompt.ts Normal file
View File

@@ -0,0 +1,53 @@
/**
* Reading a secret the user pastes.
*
* The Schulcloud token grants read access to the whole account, so it is never
* a command-line argument (shell history) and never echoed (scrollback).
*/
/** A line typed or pasted at the terminal, not echoed. */
export function readHidden(prompt: string): Promise<string> {
const input = process.stdin;
return new Promise((resolve, reject) => {
let value = '';
const finish = () => {
input.off('data', onData);
input.setRawMode(false);
input.pause();
process.stderr.write('\n');
};
const onData = (chunk: string) => {
for (const char of chunk) {
if (char === '\r' || char === '\n') {
finish();
// A terminal with bracketed paste on wraps a paste in markers.
resolve(value.replace(/\[20[01]~/g, ''));
return;
}
if (char === '' || char === '') {
finish();
reject(new Error('Cancelled.'));
return;
}
if (char === '' || char === '\b') {
value = value.slice(0, -1);
continue;
}
value += char;
}
};
process.stderr.write(prompt);
input.setRawMode(true);
input.setEncoding('utf8');
input.resume();
input.on('data', onData);
});
}
/** Everything piped in, for `wl-paste | schulcloud token set` and the like. */
export async function readPiped(): Promise<string> {
let data = '';
process.stdin.setEncoding('utf8');
for await (const chunk of process.stdin) data += chunk;
return data;
}

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 * 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 * 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 { export interface Config {
/** Instance base URL, no trailing slash, e.g. `https://schulcloud-thueringen.de`. */ /** Instance base URL, no trailing slash, e.g. `https://schulcloud-thueringen.de`. */
baseUrl: string; 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; jwt: string;
/** Shared secret callers must present to this MCP server. Unused in stdio mode. */ /** Shared secret callers must present to this MCP server. Unused in stdio mode. */
authToken: string | undefined; authToken: string | undefined;
/** Where state that must survive a restart is kept: a replaced session token. Unset = memory only. */
stateDir: string | undefined;
port: number; port: number;
bindHost: string; bindHost: string;
/** Hard ceiling on how many bytes `download_file` will pull from the instance. */ /** 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(/\/+$/, ''), baseUrl: required('TSC_URL').replace(/\/+$/, ''),
jwt: required('TSC_JWT_COOKIE'), jwt: required('TSC_JWT_COOKIE'),
authToken: process.env.MCP_AUTH_TOKEN?.trim() || undefined, 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), port: int('PORT', 8080),
bindHost: process.env.BIND_HOST?.trim() || '0.0.0.0', bindHost: process.env.BIND_HOST?.trim() || '0.0.0.0',
maxDownloadBytes: int('MAX_DOWNLOAD_BYTES', 25 * 1024 * 1024), maxDownloadBytes: int('MAX_DOWNLOAD_BYTES', 25 * 1024 * 1024),

View File

@@ -133,12 +133,15 @@ export class SchulcloudClient {
url: URL, url: URL,
accept: string, accept: string,
auth: 'bearer' | 'cookie' | 'none' = 'bearer', auth: 'bearer' | 'cookie' | 'none' = 'bearer',
options: { idleTimeout?: boolean } = {}, options: { idleTimeout?: boolean; token?: string } = {},
): Promise<Response> { ): Promise<Response> {
let lastError: unknown; let lastError: unknown;
const headers: Record<string, string> = { Accept: accept }; const headers: Record<string, string> = { Accept: accept };
if (auth === 'bearer') headers.Authorization = `Bearer ${this.config.jwt}`; // Read at the moment of use, never earlier: the token can be replaced
if (auth === 'cookie') headers.Cookie = `jwt=${this.config.jwt}`; // while the server runs (core/session-token.ts).
const jwt = options.token ?? this.config.jwt;
if (auth === 'bearer') headers.Authorization = `Bearer ${jwt}`;
if (auth === 'cookie') headers.Cookie = `jwt=${jwt}`;
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) { for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
if (attempt > 0) await delay(backoffMs(attempt)); if (attempt > 0) await delay(backoffMs(attempt));
@@ -253,6 +256,15 @@ export class SchulcloudClient {
return this.getJson<MeResponse>('/api/v3/me'); return this.getJson<MeResponse>('/api/v3/me');
} }
/**
* `/me` as another token sees it, leaving the token in use untouched — how a
* replacement is checked before it is swapped in.
*/
async meAs(token: string): Promise<MeResponse> {
const response = await this.request(this.url('/api/v3/me'), 'application/json', 'bearer', { token });
return (await response.json()) as MeResponse;
}
// --- session --------------------------------------------------------- // --- session ---------------------------------------------------------
/** /**

View File

@@ -24,9 +24,27 @@ import { SchulcloudApiError } from './client.ts';
* shared key out from under us. See docs/AUTH.md — the fix is to close the tab, * shared key out from under us. See docs/AUTH.md — the fix is to close the tab,
* not to ping harder. * not to ping harder.
*/ */
export interface KeepaliveState {
running: boolean;
/** Seconds of session the instance reported at the last successful extension. */
budgetSeconds: number | undefined;
lastExtendedAt: string | undefined;
/** Set once the instance refused the token; cleared by a restart. */
rejectedAt: string | undefined;
}
export class SessionKeepalive { export class SessionKeepalive {
private timer: NodeJS.Timeout | undefined; private timer: NodeJS.Timeout | undefined;
private stopped = false; private stopped = false;
/**
* Bumped by every start and stop. A ping still in flight when the token is
* replaced was sent with the old token, and its 401 must not stop the
* keepalive that is already running with the new one.
*/
private generation = 0;
private budgetSeconds: number | undefined;
private lastExtendedAt: Date | undefined;
private rejectedAt: Date | undefined;
private readonly client: SchulcloudClient; private readonly client: SchulcloudClient;
private readonly intervalMs: number; private readonly intervalMs: number;
/** Retry delay after a failed ping — shorter, to use up the remaining budget. */ /** Retry delay after a failed ping — shorter, to use up the remaining budget. */
@@ -47,27 +65,52 @@ export class SessionKeepalive {
/** Pings once now (validating the token at startup), then on the interval. */ /** Pings once now (validating the token at startup), then on the interval. */
start(): void { start(): void {
if (this.timer) clearTimeout(this.timer);
this.timer = undefined;
this.stopped = false; this.stopped = false;
void this.tick(); this.rejectedAt = undefined;
void this.tick(++this.generation);
}
/** Starts over with the token now in use — after a replacement, including one that follows a 401. */
restart(): void {
this.start();
} }
stop(): void { stop(): void {
this.stopped = true; this.stopped = true;
this.generation++;
if (this.timer) clearTimeout(this.timer); if (this.timer) clearTimeout(this.timer);
this.timer = undefined; this.timer = undefined;
} }
private schedule(delayMs: number): void { state(): KeepaliveState {
if (this.stopped) return; return {
this.timer = setTimeout(() => void this.tick(), delayMs); running: !this.stopped,
budgetSeconds: this.budgetSeconds,
lastExtendedAt: this.lastExtendedAt?.toISOString(),
rejectedAt: this.rejectedAt?.toISOString(),
};
}
private current(generation: number): boolean {
return !this.stopped && generation === this.generation;
}
private schedule(delayMs: number, generation: number): void {
if (!this.current(generation)) return;
this.timer = setTimeout(() => void this.tick(generation), delayMs);
// Never hold the process open just for a keepalive. // Never hold the process open just for a keepalive.
this.timer.unref(); this.timer.unref();
} }
private async tick(): Promise<void> { private async tick(generation: number): Promise<void> {
if (this.stopped) return; if (!this.current(generation)) return;
try { try {
const { expiresInSeconds } = await this.client.extendSession(); const { expiresInSeconds } = await this.client.extendSession();
if (!this.current(generation)) return;
this.budgetSeconds = expiresInSeconds;
this.lastExtendedAt = new Date();
// A budget well below the instance's JWT_TIMEOUT_SECONDS means the // A budget well below the instance's JWT_TIMEOUT_SECONDS means the
// extension is not taking effect — worth seeing in the log, because it // extension is not taking effect — worth seeing in the log, because it
// is the early warning that the session is about to be lost. // is the early warning that the session is about to be lost.
@@ -75,8 +118,9 @@ export class SessionKeepalive {
`[schulcloud-mcp] keepalive: session extended, ${expiresInSeconds}s ` + `[schulcloud-mcp] keepalive: session extended, ${expiresInSeconds}s ` +
`(${Math.round(expiresInSeconds / 60)} min) of budget left`, `(${Math.round(expiresInSeconds / 60)} min) of budget left`,
); );
this.schedule(this.intervalMs); this.schedule(this.intervalMs, generation);
} catch (error) { } catch (error) {
if (!this.current(generation)) return;
if (error instanceof SchulcloudApiError && error.isAuthFailure) { if (error instanceof SchulcloudApiError && error.isAuthFailure) {
// Past saving: the whitelist entry is gone, or the JWT hit its 30-day // Past saving: the whitelist entry is gone, or the JWT hit its 30-day
// ceiling. Pinging harder cannot revive it — a human must paste a new // ceiling. Pinging harder cannot revive it — a human must paste a new
@@ -86,16 +130,18 @@ export class SessionKeepalive {
'If this is ~2h after login, the likely cause is a Schulportal tab left open ' + 'If this is ~2h after login, the likely cause is a Schulportal tab left open ' +
'on the same token, whose auto-logout revoked it — close the tab. Otherwise ' + 'on the same token, whose auto-logout revoked it — close the tab. Otherwise ' +
'the server was down past the 2h window, or the JWT hit its 30-day limit. ' + 'the server was down past the 2h window, or the JWT hit its 30-day limit. ' +
'Put a fresh jwt cookie in TSC_JWT_COOKIE and restart. Keepalive stopped.', 'Hand the server a fresh jwt cookie with `schulcloud token set` or on its /token page; ' +
'the keepalive resumes by itself. Keepalive stopped.',
); );
this.stop(); this.stop();
this.rejectedAt = new Date();
return; return;
} }
this.log( this.log(
`[schulcloud-mcp] keepalive: ping failed (${error instanceof Error ? error.message : String(error)}); ` + `[schulcloud-mcp] keepalive: ping failed (${error instanceof Error ? error.message : String(error)}); ` +
`retrying in ${Math.round(this.retryMs / 1000)}s`, `retrying in ${Math.round(this.retryMs / 1000)}s`,
); );
this.schedule(this.retryMs); this.schedule(this.retryMs, generation);
} }
} }
} }

222
src/core/session-token.ts Normal file
View File

@@ -0,0 +1,222 @@
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
import { dirname, join } from 'node:path';
import type { Config } from '../config.ts';
import { SchulcloudApiError, type SchulcloudClient } from './client.ts';
/**
* The Schulcloud session token, replaceable while the server runs.
*
* A token lives 30 days at most, and a new one can only come from a browser:
* the login is federated single sign-on, so this server cannot mint one
* (docs/AUTH.md). What it can do is take a fresh one without a restart — check
* it against the instance, swap it into the config every request reads it
* from, and keep it in a state file, so a later restart does not fall back to
* the older token still sitting in `.env`.
*
* The token is a credential with read access to the whole account. It goes
* into the state file and nowhere else: not into logs, errors or responses.
*/
/** The claims this server reads. Decoded, never verified — Schulcloud does that. */
export interface TokenClaims {
userId?: string;
/** Seconds since the epoch. */
exp?: number;
}
export type TokenSource = 'environment' | 'state file' | 'replaced at runtime';
export interface TokenStatus {
expiresAt: string | undefined;
/** Whole days until expiry; negative once expired. */
daysLeft: number | undefined;
source: TokenSource;
/** Whether a replacement survives a restart, which needs STATE_DIR. */
persistent: boolean;
}
export type TokenProblem = 'malformed' | 'expired' | 'rejected' | 'other_account';
/** A replacement that was refused, with a message saying what to do instead. */
export class TokenRejected extends Error {
readonly problem: TokenProblem;
constructor(problem: TokenProblem, message: string) {
super(message);
this.name = 'TokenRejected';
this.problem = problem;
}
}
const STATE_FILE = 'schulcloud-jwt';
const DAY_MS = 86_400_000;
/**
* The token inside whatever was pasted.
*
* DevTools copies the bare value, but a cookie line (`jwt=…; Path=/`), quotes
* and a trailing newline all happen on the way from a browser to a terminal,
* and none of them is worth a refused replacement.
*/
export function normalizeToken(input: string): string {
const unquote = (value: string) => value.trim().replace(/^(["'])(.*)\1$/s, '$2').trim();
return unquote(
unquote(input)
.replace(/^jwt\s*=\s*/i, '')
.replace(/;.*$/s, ''),
);
}
export function decodeClaims(token: string): TokenClaims | undefined {
const parts = token.split('.');
if (parts.length !== 3 || parts.some((part) => !/^[A-Za-z0-9_-]+$/.test(part))) return undefined;
try {
const payload: unknown = JSON.parse(Buffer.from(parts[1]!, 'base64url').toString('utf8'));
if (!payload || typeof payload !== 'object') return undefined;
const { userId, exp } = payload as Record<string, unknown>;
return {
userId: typeof userId === 'string' ? userId : undefined,
exp: typeof exp === 'number' ? exp : undefined,
};
} catch {
return undefined;
}
}
export class SessionToken {
private readonly config: Config;
private readonly client: Pick<SchulcloudClient, 'meAs'>;
private readonly stateFile: string | undefined;
private readonly listeners = new Set<() => void>();
private source: TokenSource = 'environment';
/** One replacement at a time, so two pastes cannot interleave a swap and a write. */
private queue: Promise<unknown> = Promise.resolve();
constructor(config: Config, client: Pick<SchulcloudClient, 'meAs'>, stateDir?: string) {
this.config = config;
this.client = client;
this.stateFile = stateDir ? join(stateDir, STATE_FILE) : undefined;
}
/**
* Picks the token to start with: the saved one when it is the newer of the
* two, since that is what a runtime replacement leaves behind — but never one
* for a different account than `TSC_JWT_COOKIE`, because changing that
* variable is how accounts are switched.
*/
async load(log: (message: string) => void = (message) => console.error(message)): Promise<void> {
if (!this.stateFile) return;
let saved: string;
try {
saved = normalizeToken(await readFile(this.stateFile, 'utf8'));
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (code !== 'ENOENT') log(`[schulcloud-mcp] session token: could not read the saved one (${code}); using TSC_JWT_COOKIE`);
return;
}
if (!saved || saved === this.config.jwt) return;
const fromState = decodeClaims(saved);
const fromEnvironment = decodeClaims(this.config.jwt);
if (!fromState) {
log('[schulcloud-mcp] session token: the saved one is unreadable; using TSC_JWT_COOKIE');
return;
}
if (fromEnvironment?.userId && fromState.userId !== fromEnvironment.userId) {
log('[schulcloud-mcp] session token: the saved one belongs to another account than TSC_JWT_COOKIE; using TSC_JWT_COOKIE');
return;
}
if ((fromState.exp ?? 0) > (fromEnvironment?.exp ?? 0)) {
this.config.jwt = saved;
this.source = 'state file';
log('[schulcloud-mcp] session token: using the one replaced at runtime, which is newer than TSC_JWT_COOKIE');
}
}
status(): TokenStatus {
const exp = decodeClaims(this.config.jwt)?.exp;
return {
expiresAt: exp === undefined ? undefined : new Date(exp * 1000).toISOString(),
daysLeft: exp === undefined ? undefined : Math.floor((exp * 1000 - Date.now()) / DAY_MS),
source: this.source,
persistent: this.stateFile !== undefined,
};
}
/** Called after every successful replacement — the keepalive restarts on it. */
onReplaced(listener: () => void): void {
this.listeners.add(listener);
}
replace(input: string): Promise<{ changed: boolean; persisted: boolean; status: TokenStatus }> {
const run = this.queue.then(() => this.swap(input));
this.queue = run.catch(() => {});
return run;
}
private async swap(input: string): Promise<{ changed: boolean; persisted: boolean; status: TokenStatus }> {
const token = normalizeToken(input);
const claims = decodeClaims(token);
if (!claims) {
throw new TokenRejected(
'malformed',
'That is not a jwt cookie value: it should be three parts separated by dots, starting with "eyJ". ' +
'Copy the Value column of the cookie named "jwt".',
);
}
if (claims.exp !== undefined && claims.exp * 1000 <= Date.now()) {
throw new TokenRejected(
'expired',
`That token expired on ${new Date(claims.exp * 1000).toISOString().slice(0, 10)}. Log in again and copy the new cookie.`,
);
}
if (token === this.config.jwt) return { changed: false, persisted: false, status: this.status() };
let userId: string;
try {
userId = (await this.client.meAs(token)).user.id;
} catch (error) {
if (error instanceof SchulcloudApiError && error.isAuthFailure) {
throw new TokenRejected(
'rejected',
'Schulcloud rejected that token (401): its session has already ended. Log in again in a private ' +
'window and copy the cookie — then close the window, because left open it logs the token out ' +
'about two hours after login.',
);
}
throw error;
}
const current = decodeClaims(this.config.jwt)?.userId;
if (current && userId !== current) {
throw new TokenRejected(
'other_account',
'That token belongs to a different Schulcloud account than the one this server reads. ' +
'To switch accounts, change TSC_JWT_COOKIE and restart the server.',
);
}
this.config.jwt = token;
this.source = 'replaced at runtime';
const persisted = await this.persist(token);
for (const listener of this.listeners) listener();
return { changed: true, persisted, status: this.status() };
}
/** Written beside itself and renamed into place, so a crash cannot leave half a token. */
private async persist(token: string): Promise<boolean> {
if (!this.stateFile) return false;
try {
await mkdir(dirname(this.stateFile), { recursive: true, mode: 0o700 });
const temporary = `${this.stateFile}.${process.pid}.tmp`;
await writeFile(temporary, `${token}\n`, { mode: 0o600 });
await rename(temporary, this.stateFile);
return true;
} catch (error) {
console.error(
`[schulcloud-mcp] session token: replaced, but not saved (${(error as NodeJS.ErrnoException).code ?? 'error'}); ` +
'a restart will fall back to TSC_JWT_COOKIE',
);
return false;
}
}
}

View File

@@ -13,6 +13,7 @@ import {
type WalkEntry, type WalkEntry,
} from '../core/legacy-files.ts'; } from '../core/legacy-files.ts';
import { resolveWithin } from '../core/paths.ts'; import { resolveWithin } from '../core/paths.ts';
import { TokenRejected } from '../core/session-token.ts';
import type { Services } from '../services.ts'; import type { Services } from '../services.ts';
/** /**
@@ -24,7 +25,8 @@ import type { Services } from '../services.ts';
* from the mirror does neither, which matters for the video files. * from the mirror does neither, which matters for the video files.
* *
* Nothing here can write to Schulcloud. `/refresh` writes only to the Pi's own * Nothing here can write to Schulcloud. `/refresh` writes only to the Pi's own
* index and mirror, and every upstream call it triggers is a GET. * index and mirror, `/token` only to the server's own token, and every upstream
* call either triggers is a GET.
*/ */
export function createApiRouter(services: Services): Router { export function createApiRouter(services: Services): Router {
const router = express.Router(); const router = express.Router();
@@ -229,9 +231,57 @@ export function createApiRouter(services: Services): Router {
} }
}); });
// --- the Schulcloud session token -------------------------------------------
//
// A write, but to this server's own state: the token it reads Schulcloud with.
// The only upstream call is the GET /me a replacement must pass first. Works
// without an index, since a server without one still needs a token.
router.get('/token', (_req: Request, res: Response) => {
res.json(tokenStatus(services));
});
router.put('/token', express.json({ limit: '16kb' }), async (req: Request, res: Response) => {
const jwt = (req.body as { jwt?: unknown } | undefined)?.jwt;
if (typeof jwt !== 'string' || !jwt.trim()) {
return res.status(400).json({ error: 'missing_jwt', message: 'Send {"jwt": "<the value of the jwt cookie>"}.' });
}
try {
const { changed, persisted } = await services.session.replace(jwt);
if (changed) console.log('[schulcloud-mcp] session token replaced at runtime');
return res.json({ changed, persisted, ...tokenStatus(services) });
} catch (error) {
if (error instanceof TokenRejected) return res.status(422).json({ error: error.problem, message: error.message });
// Anything else is the instance failing to answer the check. The error
// cannot contain the token — SchulcloudApiError carries only a path — but
// the response still says no more than that.
const detail = error instanceof SchulcloudApiError ? `HTTP ${error.status}` : error instanceof Error ? error.name : 'error';
console.error(`[schulcloud-mcp] token check failed: ${detail}`);
return res.status(502).json({
error: 'check_failed',
message: `Schulcloud did not answer the check (${detail}); the token in use is unchanged. Try again shortly.`,
});
}
});
// A body that is not JSON would otherwise reach Express's default handler,
// which logs it — and here the body is a credential.
router.use((error: unknown, _req: Request, res: Response, next: (error?: unknown) => void) => {
const type = (error as { type?: string } | undefined)?.type;
if (type === 'entity.parse.failed' || type === 'entity.too.large') {
res.status(400).json({ error: 'bad_request' });
return;
}
next(error);
});
return router; return router;
} }
function tokenStatus(services: Services) {
return { ...services.session.status(), keepalive: services.keepalive?.state() ?? null };
}
/** Falls back to Schulcloud for anything not in the mirror, streaming through. */ /** Falls back to Schulcloud for anything not in the mirror, streaming through. */
/** Streams a file-manager file live, via its pre-signed URL; no credentials leave for the storage host. */ /** Streams a file-manager file live, via its pre-signed URL; no credentials leave for the storage host. */
async function proxyFileManager( async function proxyFileManager(

View File

@@ -7,6 +7,7 @@ import { createServer } from '../mcp/server.ts';
import type { Services } from '../services.ts'; import type { Services } from '../services.ts';
import { createApiRouter } from './api.ts'; import { createApiRouter } from './api.ts';
import { bearerAuth } from './auth.ts'; import { bearerAuth } from './auth.ts';
import { tokenPage, tokenScript } from './token-page.ts';
/** /**
* Streamable-HTTP front end, for use as a remote MCP connector. * Streamable-HTTP front end, for use as a remote MCP connector.
@@ -71,6 +72,10 @@ export function createHttpApp(config: Config, services?: Services): express.Expr
if (services) { if (services) {
app.use(API_PATH, createApiRouter(services)); app.use(API_PATH, createApiRouter(services));
// The page to paste a fresh Schulcloud token into. It holds no secret: what
// it sends goes to /api/token, behind the bearer check above.
app.get('/token', tokenPage);
app.get('/token.js', tokenScript);
} }
app.use(MCP_PATH, express.json({ limit: '4mb' })); app.use(MCP_PATH, express.json({ limit: '4mb' }));

142
src/http/token-page.ts Normal file
View File

@@ -0,0 +1,142 @@
import type { Request, Response } from 'express';
/**
* `/token`: a page to paste a fresh Schulcloud token into, for when a terminal
* is not at hand. `schulcloud token set` does the same from the CLI.
*
* The page carries no secret and needs no login of its own. It sends what is
* typed into it to `PUT /api/token` with the server access token as a bearer,
* so it is exactly as protected as the API — and the server checks the pasted
* token against Schulcloud before using it.
*
* The cookie is HttpOnly, so no script on the Schulcloud page can read it and a
* one-click bookmarklet is impossible; copying it out of DevTools is the step
* that remains.
*/
const SECURITY_HEADERS = {
// The page's script is a separate file only because this policy forbids
// inline script; nothing it loads comes from anywhere else.
'Content-Security-Policy':
"default-src 'none'; script-src 'self'; connect-src 'self'; style-src 'unsafe-inline'; " +
"base-uri 'none'; form-action 'none'; frame-ancestors 'none'",
'Referrer-Policy': 'no-referrer',
'X-Content-Type-Options': 'nosniff',
'Cache-Control': 'no-store',
};
const PAGE = `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Schulcloud token</title>
<style>
:root { color-scheme: light dark; font-family: system-ui, sans-serif; }
body { margin: 0; padding: 2rem 1rem; }
main { max-width: 34rem; margin: 0 auto; }
h1 { font-size: 1.4rem; }
ol { padding-left: 1.2rem; line-height: 1.5; }
label { display: block; margin: 1rem 0 0.25rem; font-weight: 600; }
input { box-sizing: border-box; width: 100%; padding: 0.5rem; font: inherit; }
.actions { display: flex; gap: 0.5rem; margin-top: 1rem; flex-wrap: wrap; }
button { padding: 0.5rem 1rem; font: inherit; cursor: pointer; }
.visually-hidden { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); }
#result { margin-top: 1rem; min-height: 1.5em; }
.ok { color: #1a7f37; }
.error { color: #cf222e; }
@media (prefers-color-scheme: dark) { .ok { color: #3fb950; } .error { color: #f85149; } }
</style>
</head>
<body>
<main>
<h1>Replace the Schulcloud token</h1>
<ol>
<li>Open a private window and log in to Schulcloud.</li>
<li>DevTools → Application (Firefox: Storage) → Cookies → the cookie named <code>jwt</code>: copy its value.</li>
<li>Paste it below and press <em>Replace</em>. The server checks it with Schulcloud first.</li>
<li><strong>Close the private window.</strong> Left open, it logs the token out about two hours after login.</li>
</ol>
<form id="form">
<input class="visually-hidden" type="text" name="username" value="schulcloud-mcp" autocomplete="username" tabindex="-1" aria-hidden="true">
<label for="access">Server access token (MCP_AUTH_TOKEN)</label>
<input id="access" name="password" type="password" autocomplete="current-password" required>
<label for="jwt">jwt cookie</label>
<input id="jwt" type="password" autocomplete="off" spellcheck="false">
<div class="actions">
<button type="submit">Replace</button>
<button type="button" id="check">Check current token</button>
</div>
</form>
<p id="result" role="status" aria-live="polite"></p>
</main>
<script src="token.js"></script>
</body>
</html>
`;
const SCRIPT = `'use strict';
const form = document.getElementById('form');
const access = document.getElementById('access');
const jwt = document.getElementById('jwt');
const result = document.getElementById('result');
function show(text, ok) {
result.textContent = text;
result.className = ok ? 'ok' : 'error';
}
function describe(status) {
const expiry = status.expiresAt
? 'expires ' + status.expiresAt.slice(0, 10) + ' (' + status.daysLeft + ' days left)'
: 'expiry unknown';
const keepalive = status.keepalive;
const session = !keepalive ? '' : keepalive.running ? 'session alive' : 'session ended — replace the token';
return [expiry, session].filter(Boolean).join('; ');
}
async function call(method, body) {
const headers = { authorization: 'Bearer ' + access.value.trim() };
if (body) headers['content-type'] = 'application/json';
const response = await fetch('api/token', {
method,
headers,
body: body ? JSON.stringify(body) : undefined,
cache: 'no-store',
});
const data = await response.json().catch(() => ({}));
if (response.status === 401) throw new Error('The server access token is wrong.');
if (!response.ok) throw new Error(data.message || 'HTTP ' + response.status);
return data;
}
form.addEventListener('submit', async (event) => {
event.preventDefault();
if (!jwt.value.trim()) return show('Paste the jwt cookie first.', false);
show('Checking it with Schulcloud…', true);
try {
const data = await call('PUT', { jwt: jwt.value });
jwt.value = '';
const saved = data.changed && !data.persisted ? ' Not saved on the server: a restart falls back to TSC_JWT_COOKIE.' : '';
show((data.changed ? 'Replaced — ' : 'Already in use — ') + describe(data) + '.' + saved + ' Now close the private window.', true);
} catch (error) {
show(error.message, false);
}
});
document.getElementById('check').addEventListener('click', async () => {
try {
show('Current token ' + describe(await call('GET')) + '.', true);
} catch (error) {
show(error.message, false);
}
});
`;
export function tokenPage(_req: Request, res: Response): void {
res.set(SECURITY_HEADERS).type('html').send(PAGE);
}
export function tokenScript(_req: Request, res: Response): void {
res.set(SECURITY_HEADERS).type('application/javascript').send(SCRIPT);
}

View File

@@ -1,6 +1,7 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { z } from 'zod'; import { z } from 'zod';
import type { ServerContext } from '../../context.ts'; import type { ServerContext } from '../../context.ts';
import { decodeClaims } from '../../core/session-token.ts';
import { dueLabel, formatDate, heading, htmlToText, joinSections } from '../../core/text.ts'; import { dueLabel, formatDate, heading, htmlToText, joinSections } from '../../core/text.ts';
import type { CourseMetadata, TaskContent } from '../../core/types.ts'; import type { CourseMetadata, TaskContent } from '../../core/types.ts';
import { text, toToolError } from './result.ts'; import { text, toToolError } from './result.ts';
@@ -31,7 +32,10 @@ export function registerOverviewTools(server: McpServer, context: ServerContext)
`- Roles: ${me.roles.map((role) => role.name).join(', ') || 'none'}`, `- Roles: ${me.roles.map((role) => role.name).join(', ') || 'none'}`,
`- Instance: ${context.config.baseUrl}`, `- Instance: ${context.config.baseUrl}`,
`- Permissions: ${me.permissions.length}`, `- Permissions: ${me.permissions.length}`,
].join('\n'), tokenExpiryLine(context.config.jwt),
]
.filter(Boolean)
.join('\n'),
]), ]),
); );
} catch (error) { } catch (error) {
@@ -184,6 +188,18 @@ export function registerOverviewTools(server: McpServer, context: ServerContext)
); );
} }
/**
* When the server's Schulcloud token runs out. Only a person can renew it, so
* the week before is worth saying out loud wherever the account is shown.
*/
function tokenExpiryLine(jwt: string): string | undefined {
const exp = decodeClaims(jwt)?.exp;
if (exp === undefined) return undefined;
const days = Math.floor((exp * 1000 - Date.now()) / 86_400_000);
const renew = days <= 7 ? ' — **renew it soon** with `schulcloud token set` or the server\'s /token page' : '';
return `- Server's Schulcloud token: expires ${formatDate(new Date(exp * 1000).toISOString())} (${days} day(s) left)${renew}`;
}
function isCurrentlyRunning(course: CourseMetadata): boolean { function isCurrentlyRunning(course: CourseMetadata): boolean {
const now = Date.now(); const now = Date.now();
const start = course.startDate ? new Date(course.startDate).getTime() : undefined; const start = course.startDate ? new Date(course.startDate).getTime() : undefined;

View File

@@ -56,9 +56,9 @@ function describeFailure(error: unknown, action: string): string {
if (error.isAuthFailure) { if (error.isAuthFailure) {
return ( return (
`Schulcloud rejected the token while trying to ${action} (HTTP 401).\n\n` + `Schulcloud rejected the token while trying to ${action} (HTTP 401).\n\n` +
`The JWT in TSC_JWT_COOKIE has expired or been revoked. Copy a fresh one from ` + `The server's Schulcloud token has expired or been logged out. The user has to log in in a ` +
`the browser (DevTools → Application → Cookies → the "jwt" cookie) into the server's ` + `browser, copy the "jwt" cookie (DevTools → Application → Cookies) and hand it to the server ` +
`environment and restart it. See docs/AUTH.md.` `with \`schulcloud token set\` or on the server's /token page — no restart needed. See docs/AUTH.md.`
); );
} }
if (error.status === 403) { if (error.status === 403) {

View File

@@ -1,6 +1,8 @@
import type { Config } from './config.ts'; import type { Config } from './config.ts';
import { SchulcloudClient } from './core/client.ts'; import { SchulcloudClient } from './core/client.ts';
import type { SessionKeepalive } from './core/keepalive.ts';
import { FileManager } from './core/legacy-files.ts'; import { FileManager } from './core/legacy-files.ts';
import { SessionToken } from './core/session-token.ts';
import { Indexer } from './indexer/indexer.ts'; import { Indexer } from './indexer/indexer.ts';
import { Store } from './store/store.ts'; import { Store } from './store/store.ts';
@@ -23,10 +25,22 @@ export interface Services {
files: FileManager; files: FileManager;
store: Store | undefined; store: Store | undefined;
indexer: Indexer | undefined; indexer: Indexer | undefined;
/** The Schulcloud token, which `/api/token` can replace without a restart. */
session: SessionToken;
/**
* Set by the entry point that runs one, for status reports. Created there
* rather than here because each entry point logs to a different stream.
*/
keepalive?: SessionKeepalive;
} }
export async function createServices(config: Config): Promise<Services> { export async function createServices(config: Config): Promise<Services> {
const client = new SchulcloudClient(config); const client = new SchulcloudClient(config);
// Before anything else reads config.jwt: a token replaced at runtime and
// saved may be newer than the one in the environment.
const session = new SessionToken(config, client, config.stateDir);
await session.load();
const files = new FileManager(client); const files = new FileManager(client);
const store = await Store.open(config.databaseUrl); const store = await Store.open(config.databaseUrl);
const indexer = store ? new Indexer(client, store, config) : undefined; const indexer = store ? new Indexer(client, store, config) : undefined;
@@ -37,7 +51,7 @@ export async function createServices(config: Config): Promise<Services> {
'/files, /manifest and refresh_index are unavailable. Set DATABASE_URL to enable them.', '/files, /manifest and refresh_index are unavailable. Set DATABASE_URL to enable them.',
); );
} }
return { config, client, files, store, indexer }; return { config, client, files, store, indexer, session };
} }
export async function closeServices(services: Services): Promise<void> { export async function closeServices(services: Services): Promise<void> {

View File

@@ -34,3 +34,12 @@ describe('loadConfig', () => {
assert.equal(loadConfig().authToken, undefined); assert.equal(loadConfig().authToken, undefined);
}); });
}); });
describe('loadConfig: state directory', () => {
it('resolves the state directory to an absolute path', () => {
process.env.TSC_URL = 'https://example.org';
process.env.TSC_JWT_COOKIE = 'x';
process.env.STATE_DIR = 'tmp/state';
assert.match(loadConfig().stateDir ?? '', /^\/.*\/tmp\/state$/);
});
});

View File

@@ -88,3 +88,52 @@ describe('SessionKeepalive logging', () => {
assert.match(messages[0]!, /session extended, 7200s \(120 min\)/); assert.match(messages[0]!, /session extended, 7200s \(120 min\)/);
}); });
}); });
describe('SessionKeepalive after a token replacement', () => {
it('resumes when restarted after a 401', async () => {
const unauthorized = new SchulcloudApiError(401, '/api/v3/authentication/refresh-session', '');
const { client, calls } = fakeClient([unauthorized]);
const keepalive = new SessionKeepalive(client, 15, 15, () => {});
keepalive.start();
await settle();
assert.equal(keepalive.state().running, false);
assert.ok(keepalive.state().rejectedAt);
keepalive.restart();
await new Promise((resolve) => setTimeout(resolve, 100));
keepalive.stop();
assert.ok(calls.length >= 3, `expected pings to resume, got ${calls.length}`);
assert.equal(keepalive.state().rejectedAt, undefined);
assert.equal(keepalive.state().budgetSeconds, 7200);
});
it('ignores a 401 for the old token that arrives after the restart', async () => {
// The first ping is still in flight, with the old token, when the token is
// replaced; its 401 must not stop the keepalive now holding the new one.
let release: (() => void) | undefined;
let first = true;
const client = {
extendSession: async () => {
if (first) {
first = false;
await new Promise<void>((resolve) => {
release = resolve;
});
throw new SchulcloudApiError(401, '/api/v3/authentication/refresh-session', '');
}
return { expiresInSeconds: 7200 };
},
} as never;
const messages: string[] = [];
const keepalive = new SessionKeepalive(client, 60_000, 60_000, (message) => messages.push(message));
keepalive.start();
await settle();
keepalive.restart();
await settle();
release?.();
await settle();
assert.equal(keepalive.state().running, true);
assert.equal(messages.filter((message) => /401/.test(message)).length, 0);
keepalive.stop();
});
});

199
test/session-token.test.ts Normal file
View File

@@ -0,0 +1,199 @@
import assert from 'node:assert/strict';
import { mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, it } from 'node:test';
import type { Config } from '../src/config.ts';
import { SchulcloudApiError } from '../src/core/client.ts';
import { decodeClaims, normalizeToken, SessionToken, TokenRejected } from '../src/core/session-token.ts';
const DAY = 86_400;
const now = () => Math.floor(Date.now() / 1000);
/** An unsigned JWT with the given claims — the server only decodes them. */
function jwt(claims: Record<string, unknown>): string {
const part = (value: object) => Buffer.from(JSON.stringify(value)).toString('base64url');
return `${part({ alg: 'HS256', typ: 'JWT' })}.${part(claims)}.c2lnbmF0dXJl`;
}
function config(token: string): Config {
return { jwt: token } as Config;
}
/** A client whose /me answers for the user a token names, or refuses it. */
function client(options: { refuse?: boolean; error?: Error } = {}) {
const seen: string[] = [];
return {
seen,
client: {
meAs: async (token: string) => {
seen.push(token);
if (options.error) throw options.error;
if (options.refuse) throw new SchulcloudApiError(401, '/api/v3/me', '');
return { user: { id: decodeClaims(token)?.userId } } as never;
},
},
};
}
async function rejection(promise: Promise<unknown>): Promise<TokenRejected> {
try {
await promise;
} catch (error) {
assert.ok(error instanceof TokenRejected, `expected TokenRejected, got ${error}`);
return error;
}
assert.fail('the replacement should have been refused');
}
describe('normalizeToken', () => {
it('takes the token out of whatever the copy produced', () => {
const token = jwt({ userId: 'u1' });
for (const pasted of [token, ` ${token}\n`, `jwt=${token}`, `jwt=${token}; Path=/; Secure`, `"${token}"`, `'jwt=${token};'`]) {
assert.equal(normalizeToken(pasted), token, JSON.stringify(pasted));
}
});
});
describe('decodeClaims', () => {
it('reads the user and the expiry', () => {
assert.deepEqual(decodeClaims(jwt({ userId: 'u1', exp: 123, roles: ['x'] })), { userId: 'u1', exp: 123 });
});
it('gives up on anything that is not three base64url parts of JSON', () => {
for (const value of ['', 'abc', 'a.b', 'a.b.c.d', 'a.!!.c', `x.${Buffer.from('not json').toString('base64url')}.y`]) {
assert.equal(decodeClaims(value), undefined, value);
}
});
});
describe('SessionToken.replace', () => {
const current = jwt({ userId: 'u1', exp: now() + 2 * DAY });
const fresh = jwt({ userId: 'u1', exp: now() + 30 * DAY });
it('checks a new token with Schulcloud, swaps it in and tells the listeners', async () => {
const cfg = config(current);
const { client: fake, seen } = client();
const session = new SessionToken(cfg, fake);
let notified = 0;
session.onReplaced(() => notified++);
const result = await session.replace(`jwt=${fresh};`);
assert.equal(result.changed, true);
assert.equal(result.persisted, false, 'nothing to persist to without a state directory');
assert.equal(cfg.jwt, fresh);
assert.deepEqual(seen, [fresh], 'the check uses the new token, not the one in use');
assert.equal(notified, 1);
assert.equal(result.status.source, 'replaced at runtime');
assert.equal(result.status.daysLeft, 29);
});
it('refuses a paste that is not a token, without asking Schulcloud', async () => {
const cfg = config(current);
const { client: fake, seen } = client();
const error = await rejection(new SessionToken(cfg, fake).replace('hello'));
assert.equal(error.problem, 'malformed');
assert.equal(cfg.jwt, current);
assert.equal(seen.length, 0);
});
it('refuses an expired token', async () => {
const cfg = config(current);
const { client: fake } = client();
const error = await rejection(new SessionToken(cfg, fake).replace(jwt({ userId: 'u1', exp: now() - 60 })));
assert.equal(error.problem, 'expired');
assert.equal(cfg.jwt, current);
});
it('refuses a token Schulcloud no longer accepts, and keeps the one in use', async () => {
const cfg = config(current);
const { client: fake } = client({ refuse: true });
const error = await rejection(new SessionToken(cfg, fake).replace(fresh));
assert.equal(error.problem, 'rejected');
assert.match(error.message, /close the window/);
assert.equal(cfg.jwt, current);
});
it('refuses another account\'s token: switching accounts is a restart', async () => {
const cfg = config(current);
const { client: fake } = client();
const error = await rejection(new SessionToken(cfg, fake).replace(jwt({ userId: 'someone-else', exp: now() + 30 * DAY })));
assert.equal(error.problem, 'other_account');
assert.equal(cfg.jwt, current);
});
it('passes other failures through untouched, leaving the token in use', async () => {
const cfg = config(current);
const { client: fake } = client({ error: new SchulcloudApiError(503, '/api/v3/me', '') });
await assert.rejects(new SessionToken(cfg, fake).replace(fresh), SchulcloudApiError);
assert.equal(cfg.jwt, current);
});
it('treats the token already in use as nothing to do', async () => {
const cfg = config(current);
const { client: fake, seen } = client();
const session = new SessionToken(cfg, fake);
let notified = 0;
session.onReplaced(() => notified++);
const result = await session.replace(current);
assert.equal(result.changed, false);
assert.equal(seen.length, 0);
assert.equal(notified, 0);
});
});
describe('SessionToken state file', () => {
let dir: string;
beforeEach(async () => {
dir = await mkdtemp(join(tmpdir(), 'session-token-'));
});
afterEach(async () => {
await rm(dir, { recursive: true, force: true });
});
const older = jwt({ userId: 'u1', exp: now() + 2 * DAY });
const newer = jwt({ userId: 'u1', exp: now() + 30 * DAY });
const quiet = () => {};
it('saves a replacement readable by its owner only, and a restart picks it up', async () => {
const { client: fake } = client();
await new SessionToken(config(older), fake, join(dir, 'state')).replace(newer);
const file = join(dir, 'state', 'schulcloud-jwt');
assert.equal((await readFile(file, 'utf8')).trim(), newer);
assert.equal((await stat(file)).mode & 0o777, 0o600);
// A restart: the environment still holds the older token.
const restarted = config(older);
const session = new SessionToken(restarted, fake, join(dir, 'state'));
await session.load(quiet);
assert.equal(restarted.jwt, newer);
assert.equal(session.status().source, 'state file');
});
it('prefers the environment once it holds the newer token', async () => {
await writeFile(join(dir, 'schulcloud-jwt'), `${older}\n`);
const cfg = config(newer);
const session = new SessionToken(cfg, client().client, dir);
await session.load(quiet);
assert.equal(cfg.jwt, newer);
assert.equal(session.status().source, 'environment');
});
it('never lets a saved token for another account override the environment', async () => {
await writeFile(join(dir, 'schulcloud-jwt'), jwt({ userId: 'previous-account', exp: now() + 30 * DAY }));
const cfg = config(older);
const messages: string[] = [];
await new SessionToken(cfg, client().client, dir).load((message) => messages.push(message));
assert.equal(cfg.jwt, older);
assert.match(messages.join('\n'), /another account/);
});
it('starts from the environment when nothing was saved', async () => {
const cfg = config(older);
const messages: string[] = [];
await new SessionToken(cfg, client().client, dir).load((message) => messages.push(message));
assert.equal(cfg.jwt, older);
assert.deepEqual(messages, [], 'a missing state file is the normal first start');
});
});