diff --git a/.env.example b/.env.example index 20396fa..c3ef2bd 100644 --- a/.env.example +++ b/.env.example @@ -10,6 +10,8 @@ TSC_URL=https://schulcloud-thueringen.de # 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 # 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= # --------------------------------------------------------------------------- @@ -22,6 +24,11 @@ TSC_JWT_COOKIE= # openssl rand -hex 32 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. PORT=8080 BIND_HOST=0.0.0.0 diff --git a/CLAUDE.md b/CLAUDE.md index f12e661..f1b0345 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -39,9 +39,10 @@ index. read-only with respect to Schulcloud. Run `smoke` after touching `src/core/`, `src/mcp/` or `src/http/` — the unit tests cover only pure functions. -Run smoke **both ways**: with `DATABASE_URL` set (69 checks, index-backed) and -without (67 checks, live-only). The degradation path is a supported mode, not a -fallback nobody exercises. +Run smoke **both ways**: with `DATABASE_URL` set (74 checks, index-backed) and +without (72 checks, live-only). The degradation path is a supported mode, not a +fallback nobody exercises. Every Schulcloud check fails with 401 when the live +session has lapsed — check the container's keepalive log before suspecting code. Store tests need a database and skip without one: `TEST_DATABASE_URL=postgresql://… npm test`. They use a real Postgres on @@ -74,6 +75,11 @@ bin/cli.ts ──HTTP──────────┘ cli/{config,client,sync - `legacy-files.ts` — the file manager ("Dateien": Persönliche, Kurs-, Team-, Geteilte Dateien) as one path tree, parsed from the legacy client's pages. A separate store from files-storage; the `fs_*` tools and `/api/fs` sit on it. + - `session-token.ts` — the Schulcloud token, replaceable at runtime: checked + with `GET /me` (same `userId`), swapped into `config.jwt`, saved to + `STATE_DIR`. **Read `config.jwt` at the moment of use; never keep a copy.** +- **`http/`** — besides `/mcp` and `/api`: `/token`, a page that PUTs a fresh + token to `/api/token`. docs/AUTH.md says why it exists. - **`store/`** — crawl generations, identity diffs, `german` + `pg_trgm` FTS. `Store.open` returns `undefined` when Postgres is down; callers degrade. - **`indexer/`** — crawl → persist → mirror bytes → extract text → index. @@ -105,7 +111,8 @@ permission, so `getFileManagerPage` allows only the listing routes, by pattern widen that pattern only with a route you have read the handler of. A pre-signed download URL is fetched with **no** credentials: it names another host, and neither the bearer nor the `jwt` cookie may go with it. `refresh_index` and `POST /api/refresh` write only to the Pi's -own index and mirror — every upstream call they make is still a GET. +own index and mirror, and `PUT /api/token` only to the server's own token — every +upstream call they make is still a GET. **Filenames from Schulcloud are untrusted paths.** Course titles, card titles and filenames are all user-supplied upstream, and both the server's mirror and @@ -121,6 +128,8 @@ user explicitly asking for one and understanding this. **Never log or echo secrets.** `TSC_JWT_COOKIE` grants full read access to the account; `MCP_AUTH_TOKEN` guards the endpoint. Neither belongs in logs, error messages, or tool output. `.env` is git-ignored — keep it that way. +One more counts as a secret: a token replaced at runtime, which lives only in +`STATE_DIR`, mode 0600. **Live behaviour beats upstream source.** The clones in `vendor/` track `main` and may be ahead of what is deployed. When they disagree with the instance, the @@ -260,7 +269,10 @@ These cost real time to discover; `docs/API.md` has the full list with evidence. - **`exp` (30 days) is not the session lifetime.** The binding limit is a Valkey whitelist entry with a `JWT_TIMEOUT_SECONDS` TTL (7200s; live value at `GET /api/v3/config/public`) that every authenticated request re-sets. - `src/keepalive.ts` holds it open — don't remove it. + `src/keepalive.ts` holds it open — don't remove it. It cannot hold it across + downtime: a host off for more than two hours loses the session (seen when a + dev machine was off overnight), which is why the deployment is an always-on + Pi and why a fresh token can be swapped in without a restart. - **A Schulportal tab left open revokes our token.** The `jwt` cookie *is* the browser's session token, same `jti`. The front end runs a client-side timer (reset only on route change, never from the server TTL) and calls @@ -316,6 +328,8 @@ bundle (2.1.272), not its docs: ## Environment -`.env` holds `TSC_URL`, `TSC_JWT_COOKIE`, `MCP_AUTH_TOKEN`. See `.env.example` -for the full set and `docs/AUTH.md` for refreshing the JWT. `npm run probe` -reports both clocks: days until hard expiry and seconds of idle budget left. +`.env` holds `TSC_URL`, `TSC_JWT_COOKIE`, `MCP_AUTH_TOKEN`; docker-compose sets +`STATE_DIR`. See `.env.example` for the +full set and `docs/AUTH.md` for refreshing the JWT — `schulcloud token set`, +no restart. `npm run probe` and `schulcloud token` report the clocks: days until +hard expiry and the session budget. diff --git a/Dockerfile b/Dockerfile index 7027526..3114666 100644 --- a/Dockerfile +++ b/Dockerfile @@ -30,12 +30,14 @@ COPY --from=deps /app/node_modules ./node_modules COPY --from=build /app/dist ./dist COPY package.json ./ -# The mirror is the one writable path. Creating it in the image with the right -# owner matters: Docker initialises a new named volume from the image directory, -# including its ownership, so without this the volume lands root-owned and the -# unprivileged user gets EACCES on every write — with the failure recorded per -# file rather than crashing, which makes it easy to miss. -RUN mkdir -p /data/mirror && chown -R node:node /data +# The mirror and the state directory (a replaced session token) are the only +# writable paths. Creating them in the image with the right owner matters: Docker +# initialises a new named volume from the image directory, including its +# ownership, so without this the volume lands root-owned and the unprivileged +# user gets EACCES on every write — with the failure recorded rather than +# 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. USER node diff --git a/README.md b/README.md index 0cb8375..18c9fe3 100644 --- a/README.md +++ b/README.md @@ -77,10 +77,13 @@ schulcloud sync # mirror coursework to ~/Schulcloud schulcloud refresh --course schulcloud fs tree /courses # browse the file manager ("Dateien") schulcloud fs get "/courses//" +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 -[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 @@ -174,9 +177,9 @@ npm run typecheck ``` `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 -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 diff --git a/docker-compose.yml b/docker-compose.yml index 76ce2a2..72f8b7a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -37,13 +37,16 @@ services: PORT: 8080 BIND_HOST: 0.0.0.0 MIRROR_DIR: /data/mirror + STATE_DIR: /data/state depends_on: postgres: condition: service_healthy volumes: - # The mirror is the one thing this server writes; everything else stays - # read-only, so it gets its own volume rather than loosening read_only. + # The mirror and a replaced Schulcloud token are the only things this + # server writes; everything else stays read-only, so each gets its own + # volume rather than loosening read_only. - mirror:/data/mirror + - state:/data/state # 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 # Caddy's TLS and this server's bearer check. @@ -67,6 +70,7 @@ services: volumes: pgdata: mirror: + state: networks: caddy: diff --git a/docs/AUTH.md b/docs/AUTH.md index f776fbc..e2c1b1d 100644 --- a/docs/AUTH.md +++ b/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. diff --git a/docs/CLI.md b/docs/CLI.md index 1e5c10f..ba2859a 100644 --- a/docs/CLI.md +++ b/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:///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 diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index 860c8db..5146afd 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -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. diff --git a/docs/LOCAL.md b/docs/LOCAL.md index 8880eb7..0ad73a4 100644 --- a/docs/LOCAL.md +++ b/docs/LOCAL.md @@ -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: diff --git a/local-instance/README.md b/local-instance/README.md index c8f5956..3728886 100644 --- a/local-instance/README.md +++ b/local-instance/README.md @@ -217,7 +217,7 @@ be pointed at the instance in between: ```bash 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 diff --git a/scripts/smoke.mjs b/scripts/smoke.mjs index 4492f94..ad6fb23 100644 --- a/scripts/smoke.mjs +++ b/scripts/smoke.mjs @@ -6,6 +6,9 @@ * Requires TSC_URL and TSC_JWT_COOKIE in the environment (load .env first). * 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 { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.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); 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. const config = loadConfig(); @@ -464,9 +470,61 @@ console.log('\n== error handling =='); 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]); +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(); httpServer.close(); await closeServices(services); +await rm(STATE_DIR, { recursive: true, force: true }); console.log(`\n${results.length - failures}/${results.length} checks passed`); process.exit(failures === 0 ? 0 : 1); diff --git a/src/bin/cli.ts b/src/bin/cli.ts index 955308b..ae09135 100644 --- a/src/bin/cli.ts +++ b/src/bin/cli.ts @@ -4,7 +4,8 @@ import { mkdir } from 'node:fs/promises'; import { basename, dirname, resolve } from 'node:path'; import { Readable } from 'node:stream'; 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 { formatBytes } from '../core/extract.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 [--out ] schulcloud sync [--dry-run] [--full] [--prune] [--dir ] [--jobs ] schulcloud refresh [--course ] [--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/, /teams/, /shared: @@ -71,6 +74,8 @@ async function main(argv: string[]): Promise { return refresh(flags); case 'fs': return fileManager(flags); + case 'token': + return token(flags); default: process.stderr.write(`Unknown command "${command}".\n\n${USAGE}`); return 2; @@ -256,6 +261,57 @@ async function refresh(flags: Flags): Promise { 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 { + 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 { switch (event.type) { case 'download': diff --git a/src/bin/http.ts b/src/bin/http.ts index 19f9686..91001a0 100644 --- a/src/bin/http.ts +++ b/src/bin/http.ts @@ -20,7 +20,25 @@ async function main(): Promise { console.log(message), ) : undefined; + services.keepalive = keepalive; 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 // files it has never seen, so a steady state costs a few hundred cheap GETs. @@ -49,9 +67,12 @@ async function main(): Promise { } const server = app.listen(config.port, config.bindHost, () => { + const token = services.session.status(); console.log( `[schulcloud-mcp] listening on ${config.bindHost}:${config.port} — instance ${config.baseUrl}, ` + `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'}, ` + `index ${services.store ? (config.crawlIntervalMs > 0 ? `every ${Math.round(config.crawlIntervalMs / 3_600_000)}h` : 'on demand') : 'off'}`, ); diff --git a/src/cli/client.ts b/src/cli/client.ts index c355ca8..33a470a 100644 --- a/src/cli/client.ts +++ b/src/cli/client.ts @@ -59,6 +59,15 @@ export interface FsWalk { 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 { readonly status: number; @@ -98,6 +107,20 @@ export class ApiClient { return (await (await this.request('/api/status')).json()) as Record; } + async token(): Promise { + 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 { + 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 { const query = since ? `?since=${encodeURIComponent(since)}` : ''; return (await (await this.request(`/api/manifest${query}`)).json()) as Manifest; diff --git a/src/cli/prompt.ts b/src/cli/prompt.ts new file mode 100644 index 0000000..69c4c10 --- /dev/null +++ b/src/cli/prompt.ts @@ -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 { + 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 { + let data = ''; + process.stdin.setEncoding('utf8'); + for await (const chunk of process.stdin) data += chunk; + return data; +} diff --git a/src/config.ts b/src/config.ts index b8b1dd2..fdda6bc 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,5 +1,6 @@ /** - * Runtime configuration, read once from the environment. + * Runtime configuration, read once from the environment — except `jwt`, which + * can be replaced while the server runs. * * The two Schulcloud values are named after the browser artefacts they come * from (`TSC_URL`, `TSC_JWT_COOKIE`) so that copying a fresh token out of @@ -11,10 +12,16 @@ import { resolve } from 'node:path'; export interface Config { /** Instance base URL, no trailing slash, e.g. `https://schulcloud-thueringen.de`. */ baseUrl: string; - /** Raw JWT from the instance's `jwt` cookie. Sent as `Authorization: Bearer`. */ + /** + * Raw JWT from the instance's `jwt` cookie. Sent as `Authorization: Bearer`. + * Replaced at runtime by core/session-token.ts, so read it at the moment of + * use and never keep a copy. + */ jwt: string; /** Shared secret callers must present to this MCP server. Unused in stdio mode. */ authToken: string | undefined; + /** Where state that must survive a restart is kept: a replaced session token. Unset = memory only. */ + stateDir: string | undefined; port: number; bindHost: string; /** Hard ceiling on how many bytes `download_file` will pull from the instance. */ @@ -82,6 +89,7 @@ export function loadConfig(): Config { baseUrl: required('TSC_URL').replace(/\/+$/, ''), jwt: required('TSC_JWT_COOKIE'), authToken: process.env.MCP_AUTH_TOKEN?.trim() || undefined, + stateDir: process.env.STATE_DIR?.trim() ? resolve(process.env.STATE_DIR.trim()) : undefined, port: int('PORT', 8080), bindHost: process.env.BIND_HOST?.trim() || '0.0.0.0', maxDownloadBytes: int('MAX_DOWNLOAD_BYTES', 25 * 1024 * 1024), diff --git a/src/core/client.ts b/src/core/client.ts index 232f331..df62a1c 100644 --- a/src/core/client.ts +++ b/src/core/client.ts @@ -133,12 +133,15 @@ export class SchulcloudClient { url: URL, accept: string, auth: 'bearer' | 'cookie' | 'none' = 'bearer', - options: { idleTimeout?: boolean } = {}, + options: { idleTimeout?: boolean; token?: string } = {}, ): Promise { let lastError: unknown; const headers: Record = { Accept: accept }; - if (auth === 'bearer') headers.Authorization = `Bearer ${this.config.jwt}`; - if (auth === 'cookie') headers.Cookie = `jwt=${this.config.jwt}`; + // Read at the moment of use, never earlier: the token can be replaced + // 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++) { if (attempt > 0) await delay(backoffMs(attempt)); @@ -253,6 +256,15 @@ export class SchulcloudClient { return this.getJson('/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 { + const response = await this.request(this.url('/api/v3/me'), 'application/json', 'bearer', { token }); + return (await response.json()) as MeResponse; + } + // --- session --------------------------------------------------------- /** diff --git a/src/core/keepalive.ts b/src/core/keepalive.ts index cb0ceeb..2efa8d1 100644 --- a/src/core/keepalive.ts +++ b/src/core/keepalive.ts @@ -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, * 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 { private timer: NodeJS.Timeout | undefined; 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 intervalMs: number; /** 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. */ start(): void { + if (this.timer) clearTimeout(this.timer); + this.timer = undefined; 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 { this.stopped = true; + this.generation++; if (this.timer) clearTimeout(this.timer); this.timer = undefined; } - private schedule(delayMs: number): void { - if (this.stopped) return; - this.timer = setTimeout(() => void this.tick(), delayMs); + state(): KeepaliveState { + return { + 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. this.timer.unref(); } - private async tick(): Promise { - if (this.stopped) return; + private async tick(generation: number): Promise { + if (!this.current(generation)) return; try { 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 // extension is not taking effect — worth seeing in the log, because it // 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 ` + `(${Math.round(expiresInSeconds / 60)} min) of budget left`, ); - this.schedule(this.intervalMs); + this.schedule(this.intervalMs, generation); } catch (error) { + if (!this.current(generation)) return; if (error instanceof SchulcloudApiError && error.isAuthFailure) { // 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 @@ -86,16 +130,18 @@ export class SessionKeepalive { '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 ' + '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.rejectedAt = new Date(); return; } this.log( `[schulcloud-mcp] keepalive: ping failed (${error instanceof Error ? error.message : String(error)}); ` + `retrying in ${Math.round(this.retryMs / 1000)}s`, ); - this.schedule(this.retryMs); + this.schedule(this.retryMs, generation); } } } diff --git a/src/core/session-token.ts b/src/core/session-token.ts new file mode 100644 index 0000000..a3a6e91 --- /dev/null +++ b/src/core/session-token.ts @@ -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; + 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; + 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 = Promise.resolve(); + + constructor(config: Config, client: Pick, 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 { + 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 { + 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; + } + } +} diff --git a/src/http/api.ts b/src/http/api.ts index 2b5b15d..11cf2a5 100644 --- a/src/http/api.ts +++ b/src/http/api.ts @@ -13,6 +13,7 @@ import { type WalkEntry, } from '../core/legacy-files.ts'; import { resolveWithin } from '../core/paths.ts'; +import { TokenRejected } from '../core/session-token.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. * * 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 { 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": ""}.' }); + } + 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; } +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. */ /** Streams a file-manager file live, via its pre-signed URL; no credentials leave for the storage host. */ async function proxyFileManager( diff --git a/src/http/server.ts b/src/http/server.ts index a671693..e6e72df 100644 --- a/src/http/server.ts +++ b/src/http/server.ts @@ -7,6 +7,7 @@ import { createServer } from '../mcp/server.ts'; import type { Services } from '../services.ts'; import { createApiRouter } from './api.ts'; import { bearerAuth } from './auth.ts'; +import { tokenPage, tokenScript } from './token-page.ts'; /** * 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) { 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' })); diff --git a/src/http/token-page.ts b/src/http/token-page.ts new file mode 100644 index 0000000..0653127 --- /dev/null +++ b/src/http/token-page.ts @@ -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 = ` + + + + +Schulcloud token + + + +
+

Replace the Schulcloud token

+
    +
  1. Open a private window and log in to Schulcloud.
  2. +
  3. DevTools → Application (Firefox: Storage) → Cookies → the cookie named jwt: copy its value.
  4. +
  5. Paste it below and press Replace. The server checks it with Schulcloud first.
  6. +
  7. Close the private window. Left open, it logs the token out about two hours after login.
  8. +
+
+ + + + + +
+ + +
+
+

+
+ + + +`; + +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); +} diff --git a/src/mcp/tools/overview.ts b/src/mcp/tools/overview.ts index ad4f59c..483bc40 100644 --- a/src/mcp/tools/overview.ts +++ b/src/mcp/tools/overview.ts @@ -1,6 +1,7 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { z } from 'zod'; import type { ServerContext } from '../../context.ts'; +import { decodeClaims } from '../../core/session-token.ts'; import { dueLabel, formatDate, heading, htmlToText, joinSections } from '../../core/text.ts'; import type { CourseMetadata, TaskContent } from '../../core/types.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'}`, `- Instance: ${context.config.baseUrl}`, `- Permissions: ${me.permissions.length}`, - ].join('\n'), + tokenExpiryLine(context.config.jwt), + ] + .filter(Boolean) + .join('\n'), ]), ); } 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 { const now = Date.now(); const start = course.startDate ? new Date(course.startDate).getTime() : undefined; diff --git a/src/mcp/tools/result.ts b/src/mcp/tools/result.ts index 6a03c81..8bfe688 100644 --- a/src/mcp/tools/result.ts +++ b/src/mcp/tools/result.ts @@ -56,9 +56,9 @@ function describeFailure(error: unknown, action: string): string { if (error.isAuthFailure) { return ( `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 browser (DevTools → Application → Cookies → the "jwt" cookie) into the server's ` + - `environment and restart it. See docs/AUTH.md.` + `The server's Schulcloud token has expired or been logged out. The user has to log in in a ` + + `browser, copy the "jwt" cookie (DevTools → Application → Cookies) and hand it to the server ` + + `with \`schulcloud token set\` or on the server's /token page — no restart needed. See docs/AUTH.md.` ); } if (error.status === 403) { diff --git a/src/services.ts b/src/services.ts index 338abe8..7183c63 100644 --- a/src/services.ts +++ b/src/services.ts @@ -1,6 +1,8 @@ import type { Config } from './config.ts'; import { SchulcloudClient } from './core/client.ts'; +import type { SessionKeepalive } from './core/keepalive.ts'; import { FileManager } from './core/legacy-files.ts'; +import { SessionToken } from './core/session-token.ts'; import { Indexer } from './indexer/indexer.ts'; import { Store } from './store/store.ts'; @@ -23,10 +25,22 @@ export interface Services { files: FileManager; store: Store | 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 { 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 store = await Store.open(config.databaseUrl); const indexer = store ? new Indexer(client, store, config) : undefined; @@ -37,7 +51,7 @@ export async function createServices(config: Config): Promise { '/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 { diff --git a/test/config.test.ts b/test/config.test.ts index 4982f63..19e89d8 100644 --- a/test/config.test.ts +++ b/test/config.test.ts @@ -34,3 +34,12 @@ describe('loadConfig', () => { 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$/); + }); +}); diff --git a/test/keepalive.test.ts b/test/keepalive.test.ts index 2ff29e0..5d6187c 100644 --- a/test/keepalive.test.ts +++ b/test/keepalive.test.ts @@ -88,3 +88,52 @@ describe('SessionKeepalive logging', () => { 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((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(); + }); +}); diff --git a/test/session-token.test.ts b/test/session-token.test.ts new file mode 100644 index 0000000..71d50f2 --- /dev/null +++ b/test/session-token.test.ts @@ -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 { + 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): Promise { + 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'); + }); +});