diff --git a/.env.example b/.env.example index 3495d05..f48e25e 100644 --- a/.env.example +++ b/.env.example @@ -6,9 +6,9 @@ TSC_URL=https://schulcloud-thueringen.de # The value of the `jwt` cookie from a logged-in browser session. -# Two clocks apply: a 30-day hard expiry, and a 2-hour idle timeout that every -# API call resets. The built-in keepalive handles the second one, so in practice -# this needs replacing monthly. See docs/AUTH.md. +# The 30-day `exp` in the token is NOT its lifetime: sessions have been measured +# ending ~2h after login, and ordinary API reads do not extend them. The +# keepalive calls refresh-session to try to hold it. See docs/AUTH.md. TSC_JWT_COOKIE= # --------------------------------------------------------------------------- @@ -39,8 +39,7 @@ BIND_HOST=0.0.0.0 # Per-request timeout against the Schulcloud API, in ms. Default 30000. # REQUEST_TIMEOUT_MS=30000 -# How often to ping Schulcloud to hold the session open, in ms. Default 1800000 -# (30 min). Must stay well under the instance's JWT_TIMEOUT_SECONDS — 7200s -# here, readable from GET /api/v3/config/public. Set to 0 to disable, which -# will let the token die after two hours of inactivity. +# How often to call refresh-session to hold the session open, in ms. Default +# 1800000 (30 min). Must stay well under the instance's JWT_TIMEOUT_SECONDS — +# 7200s here, readable from GET /api/v3/config/public. Set to 0 to disable. # KEEPALIVE_INTERVAL_MS=1800000 diff --git a/CLAUDE.md b/CLAUDE.md index aac41ac..c73e206 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -21,6 +21,7 @@ npm test # unit tests (node:test), no network npm run typecheck npm run probe # verify token + API assumptions against the LIVE instance npm run smoke # full end-to-end: real server + real MCP client + real data +npm run session-diagnose # ~2.5h: measure what actually ends the session ``` `probe` and `smoke` hit the live Schulcloud and need a valid `.env`. Both are @@ -52,8 +53,11 @@ bin/{http,stdio}.ts → server.ts (createServer) ## Invariants -**Everything is read-only.** Every client method is a `GET`, and `api_get` -rejects non-`/api/` paths and anything carrying a scheme or host. The endpoint +**Everything that touches user data is read-only.** Every client method is a +`GET` except `extendSession` (the keepalive's `refresh-session` call, which +touches only our own session and is not exposed as a tool, so no model-driven +call can be a POST). `api_get` rejects non-`/api/` paths and anything carrying +a scheme or host. The endpoint is internet-facing by necessity, so "a leaked token cannot act as the user" is the property that makes that acceptable. Do not add a write tool without the user explicitly asking for one and understanding this. @@ -84,10 +88,15 @@ These cost real time to discover; `docs/API.md` has the full list with evidence. its own OpenAPI document. It is not in the main `docs-json`. - Legacy lesson responses return ids as `{buffer:{data:[...]}}`; use `normalizeObjectId`. -- **The JWT dies after 2h idle, not 30 days.** `exp` is a hard ceiling; the - real limit is a Valkey whitelist entry (`JWT_TIMEOUT_SECONDS`, live value at - `GET /api/v3/config/public`) that every authenticated request re-sets. - `src/keepalive.ts` holds it open. Do not "simplify" it away. +- **The JWT dies ~2h after login, not after 30 days, and reads do not extend + it.** `exp` is only a hard ceiling. Both the current and legacy upstream + implementations re-set a Valkey TTL on every authenticated request, so the + source reads as if the window slides — measured against the live instance, it + does not: four successful `GET /me` pings at 30-min intervals did not prevent + a 401 by t+120. `src/keepalive.ts` therefore calls `refresh-session`. Do not + "simplify" it back to a GET, and do not trust the upstream source here. + Open question and the instrument to settle it: `docs/AUTH.md`, + `npm run session-diagnose`. ## Conventions diff --git a/README.md b/README.md index ca82984..bf9ea02 100644 --- a/README.md +++ b/README.md @@ -50,20 +50,21 @@ npm run probe # verifies the token and API against the live instan Then either deploy it as a remote connector, or point Claude Code at `dist/bin/stdio.js`. Both paths are in [docs/DEPLOYMENT.md](docs/DEPLOYMENT.md). -Getting `TSC_JWT_COOKIE` takes four clicks in DevTools. With the server running -it lasts up to 30 days; left idle for two hours it dies regardless — see -[docs/AUTH.md](docs/AUTH.md). +Getting `TSC_JWT_COOKIE` takes four clicks in DevTools. Expect to redo it +often until the session question in [docs/AUTH.md](docs/AUTH.md) is settled — +sessions have been observed ending ~2 h after login. ## Design decisions **Bearer token plus a keepalive.** The instance's `jwt` cookie works verbatim -as `Authorization: Bearer` — no cookie jar, no `connect.sid`. But the token has -two clocks: a 30-day `exp` claim, and a **2-hour idle timeout** held in a -server-side whitelist that *every* authenticated request resets. Only the first -is visible in the token, which makes "valid for 30 days" an easy and wrong -conclusion. So the server pings `GET /api/v3/me` every 30 minutes to hold the -window open. See [docs/AUTH.md](docs/AUTH.md) — this one cost a day-old token to -pin down. +as `Authorization: Bearer` — no cookie jar, no `connect.sid`. But the token's +`exp` claim (30 days) is not its lifetime: the session ends about **two hours +after login**, and measurement showed that ordinary API reads do *not* extend +it, despite the upstream source saying they should. So the server calls +`refresh-session` every 30 minutes — the one non-GET request here, and not +exposed as a tool. Whether that is enough is still being measured; see +[docs/AUTH.md](docs/AUTH.md), which has the endurance test and the open +question. **Read-only by construction.** Every method on the API client is a `GET`, including `api_get`. The endpoint is internet-facing by necessity (Claude's @@ -104,6 +105,7 @@ npm run dev # watch mode, runs src/ directly npm test # unit tests, no network npm run probe # check assumptions against the live instance npm run smoke # full end-to-end: real server, real client, real data +npm run session-diagnose # instrument what actually ends the session (~2.5h) npm run typecheck ``` diff --git a/docs/API.md b/docs/API.md index aef2e76..fd948d3 100644 --- a/docs/API.md +++ b/docs/API.md @@ -117,11 +117,12 @@ ids as `{buffer:{type:'Buffer',data:[...]}}` rather than hex strings — a leak from the legacy Mongo serialisation. `normalizeObjectId` in `src/render.ts` converts them. -**The JWT's `exp` is not the session lifetime.** A server-side whitelist entry -in Valkey (`jwt:{accountId}:{jti}`) expires after `JWT_TIMEOUT_SECONDS` — 7200 s -on this instance — and every authenticated request re-sets it. Two hours idle -and the token is rejected with 29 days still on `exp`. The live values are -public at `GET /api/v3/config/public`. Full write-up in `docs/AUTH.md`. +**The JWT's `exp` is not the session lifetime, and the source misleads here.** +Both the current and legacy whitelist implementations re-set a Valkey TTL on +every authenticated request, which reads as a sliding window. The live instance +does not behave that way: a session ends ~2 h after **login**, and successful +reads in between do not extend it (measured — see `docs/AUTH.md`). This is the +clearest case in this API of live behaviour diverging from upstream source. **`GET /api/v3/config/public` is unauthenticated and useful.** 78 keys of instance configuration, including the session timeouts and feature flags. Handy diff --git a/docs/AUTH.md b/docs/AUTH.md index 87f6028..25b3a0e 100644 --- a/docs/AUTH.md +++ b/docs/AUTH.md @@ -18,36 +18,59 @@ Cookie: jwt= → 200 # also works cookie jar. There *is* a session to keep alive, though not in the way the cookies suggest; see the next section. -## Token lifetime: two clocks, and the short one is the one that bites +## Token lifetime: what the source says, and what the instance does -This is the part that is easy to get wrong, because the JWT lies to you by -omission. Two independent limits govern the token: +Two clocks govern the token, and only one of them is in the JWT: -| Clock | Value | Extendable? | +| Clock | Value | Moves with use? | |---|---|---| -| **Idle timeout** — a whitelist entry in the server's Valkey store | **7200 s (2 hours)** | Yes — reset by *every* authenticated request | -| **Hard expiry** — the JWT's own `exp` claim | **30 days** | No | +| **Session** — a whitelist entry in the server's Valkey store | ends **~2 h after login** | **No** (measured) | +| **Hard expiry** — the JWT's own `exp` claim | 30 days | No (cannot be extended) | -The token dies at whichever comes first. Decoding the JWT shows only the second -one, which is how "valid for 30 days" becomes a plausible and wrong conclusion. +The token dies at whichever comes first. Decoding the JWT shows only the +second, which is how "valid for 30 days" becomes a plausible and wrong +conclusion — it was the first conclusion drawn here, and it is wrong. -### How the idle timeout works +### What the source says -`JwtStrategy.validate()` runs on every request behind `@JwtAuthentication()` -and calls `JwtWhitelistAdapter.isWhitelisted(accountId, jti)`. That method: +`JwtStrategy.validate()` runs on every route behind `@JwtAuthentication()` and +calls `JwtWhitelistAdapter.isWhitelisted(accountId, jti)`, which reads Valkey +key `jwt:{accountId}:{jti}`, throws +`'Session was expired due to inactivity - autologout.'` if it is gone, and then +**re-sets the key with a fresh `JWT_TIMEOUT_SECONDS` TTL**. The legacy Feathers +implementation it replaced (`ensureTokenIsWhitelisted`) does the same thing, +with the comment "extend token expiration if token is already whitelisted". -1. reads Valkey key `jwt:{accountId}:{jti}`; if it is gone, throws - `UnauthorizedException('Session was expired due to inactivity - autologout.')`; -2. **re-sets the key with a fresh `JWT_TIMEOUT_SECONDS` TTL.** +Read on its own, that says the window slides with ordinary use. -Step 2 is the whole mechanism. The window *slides*: any successful API call -buys another two hours. Two hours of silence and the token is gone, with 29 -days still left on `exp`. +### What the instance actually does -Source: `apps/server/src/infra/auth-guard/strategy/jwt.strategy.ts` and -`apps/server/src/infra/jwt-whitelist/adapter/jwt-whitelist.adapter.ts`. +It does not slide. A keepalive doing nothing but `GET /api/v3/me` every 30 +minutes was measured against the live instance: -### The instance publishes these values +``` +[t+0.0min] ping #1 -> 200 OK +[t+30.0min] ping #2 -> 200 OK +[t+60.0min] ping #3 -> 200 OK +[t+90.0min] ping #4 -> 200 OK +[t+120.0min] 401 Unauthorized ← token gone +``` + +The timing is what makes this conclusive: + +| If the limit were… | Predicted death | Observed? | +|---|---|---| +| 2 h after the **last request** | t+210 min | **no** — died by t+120 | +| 2 h after **login** | t+107 min (login was ~t−13) | **yes** — inside the (t+90, t+120] window | + +Four requests succeeded inside the window and bought nothing. The binding clock +is anchored at **login**, and ordinary reads do not move it. + +A separate 100-second test does show `POST /authentication/refresh-session` +holding the reported budget at 7200 s rather than letting it decay to ~7100, so +that endpoint does *something* the reads do not. + +### Values the instance publishes Unauthenticated, so you can check them any time: @@ -56,64 +79,79 @@ curl -s "$TSC_URL/api/v3/config/public" | jq '{JWT_TIMEOUT_SECONDS, JWT_SHOW_TIM # { "JWT_TIMEOUT_SECONDS": 7200, "JWT_SHOW_TIMEOUT_WARNING_SECONDS": 3600 } ``` -`JWT_SHOW_TIMEOUT_WARNING_SECONDS: 3600` is exactly the web UI's behaviour: it -warns when 3600 s of the 7200 s budget remain — i.e. after one hour of -inactivity — and offers "Sitzung verlängern". Missing that prompt really does -log you out; it is not just the frontend discarding the token. +7200 s matches the observed ~2 h, and `JWT_SHOW_TIMEOUT_WARNING_SECONDS: 3600` +is exactly the web UI's prompt one hour in, warning of expiry an hour later, +with a "Sitzung verlängern" button. That the prompt appears at all is itself +evidence: if ordinary activity extended the session, an actively used portal +would never show it. -### Verified empirically +### The open question -A token issued 2026-09-11T21:12Z was last used at ~21:47Z. At 2026-09-12T11:00Z -— 13.8 hours later, with 29 days left on `exp` — `GET /api/v3/me` returned -`401`. Consistent with the 2-hour idle timeout, and decisive against the -30-day reading. +Two mechanisms fit the evidence, and they differ in what they mean for this +server: -### `refresh-session` is not special +1. **An idle TTL that ordinary reads fail to refresh.** Then explicitly calling + `refresh-session` on a timer holds the session indefinitely, up to the + 30-day hard expiry, and the keepalive below is the whole fix. +2. **An absolute cap at login + 2 h, or revocation from outside** — for + instance the identity provider ending its SSO session, whose back-channel + logout (`POST /api/v3/logout/oidc` → `removeUserFromWhitelist(account)`) + clears *every* token for the account, not just one. Then no keepalive can + help, and a pasted JWT is only ever good for ~2 h from login. -`POST /api/v3/authentication/refresh-session` is what the "Sitzung verlängern" -button calls, but it carries `@JwtAuthentication()` like every other route, so -the extension is a side effect of the guard — the same side effect a plain -`GET /api/v3/me` produces. Its handler only calls `getJwtTtlFromWhitelist` and -returns `{ expiresInSeconds }`. +`npm run session-diagnose` settles it. It calls `refresh-session` every 10 +minutes and logs the reported budget, so the shape of the log at death is the +answer: a budget decaying 7200 → 0 means (1); a budget sitting at 7200 until an +abrupt 401 means (2). -That makes it useful as a **measuring instrument** rather than a necessity: -it is the only way to read how much idle budget is left. `npm run probe` -reports it. It is also the only non-`GET` call anywhere in this repository, and -it lives in that diagnostic script rather than in the server. +Note that plain `POST /api/v3/logout` removes only its own `jti`, so logging +out in a browser does **not** kill this server's token. Only the OIDC +back-channel variant clears all of them. ### There is no longer window available `config/default.schema.json` documents `JWT_EXTENDED_TIMEOUT_SECONDS` (2629746 s ≈ 1 month), reachable in the legacy stack via a `privateDevice` -login flag. On the current NestJS server that is vestigial: -`privateDevice` has no references in `apps/server/src`, and -`generateJwtAndAddToWhitelist` calls `addToWhitelist(accountId, jti)` with no -TTL override, so every token gets `jwtTimeoutSeconds`. A "remember me" login -will not buy a longer idle window. +login flag. On the current NestJS server that is vestigial: `privateDevice` has +no references in `apps/server/src`, and `generateJwtAndAddToWhitelist` calls +`addToWhitelist` with no TTL override. A "remember me" login will not buy a +longer window. -## How this server stays alive +## How this server tries to stay alive -`src/keepalive.ts` pings `GET /api/v3/me` every 30 minutes (configurable via -`KEEPALIVE_INTERVAL_MS`; `0` disables it). Started by both entry points, -independent of whether any MCP client is connected — the token expires on wall -time, not on usage. - -Thirty minutes against a 7200 s budget tolerates three consecutive failures -before the session is at risk. A transient failure retries in 5 minutes; a -`401` stops the keepalive permanently and logs what to do, because a lapsed -whitelist entry cannot be revived by retrying — only by pasting a new token. - -**Operational consequence worth knowing:** if the container is down for more -than two hours — a long power cut, a Pi left off overnight — the token is dead -when it comes back, and restarting will not fix it. The startup log says so -immediately: +`src/keepalive.ts` calls `POST /api/v3/authentication/refresh-session` every 30 +minutes (`KEEPALIVE_INTERVAL_MS`; `0` disables it), started by both entry +points, independent of whether any MCP client is connected — the session +expires on wall time, not on usage. It logs the budget each time, so the log +shows whether the extension is taking: ``` -[schulcloud-mcp] keepalive: token rejected (401). The session has expired … +[schulcloud-mcp] keepalive: session extended, 7200s (120 min) of budget left ``` -With the keepalive running, a token survives up to its 30-day hard expiry, at -which point it must be replaced by hand regardless. +A budget well under 7200 s is the early warning that the session is being lost. +A transient failure retries in 5 minutes; a `401` stops the keepalive +permanently and says what to do, because a cleared whitelist entry cannot be +revived by retrying. + +**This is the one non-GET request in the server.** It takes no body, touches +only the caller's own session, and cannot read or modify user data, so it does +not weaken the property that matters — nobody reaching this endpoint can act as +the user inside Schulcloud. No MCP tool exposes it, so a model-driven call can +never be a POST. A plain `GET` was tried first and is what the endurance test +above ruled out. + +**Whether this is sufficient is not yet established.** If +`npm run session-diagnose` reports mechanism (2), the keepalive cannot work and +the auth approach itself needs revisiting. + +**Operational consequence either way:** if the container is down for more than +two hours, the token is dead when it returns and restarting will not fix it. +The startup log says so immediately: + +``` +[schulcloud-mcp] keepalive: token rejected (401). The session is gone … +``` ## Why not username + password @@ -124,9 +162,13 @@ cannot replicate that flow. `POST /api/v3/authentication/local` exists but is for accounts with local credentials, which federated school accounts do not have. -Given a keepalive that holds a token to its 30-day ceiling, the pasted-JWT -approach is still the right trade: one manual step a month against -re-implementing an OAuth client we cannot hold the secret for. If this ever needs to be unattended, the honest options are a service +Whether the pasted-JWT approach remains the right trade depends on the open +question above. If a keepalive can hold a session to its 30-day ceiling, it is: +one manual step a month against re-implementing an OAuth client whose secret we +cannot hold. If the session is instead capped at ~2 h from login, a pasted JWT +is not viable for an unattended server, and the realistic options become a +service account issued by the school's IDM, or driving the Keycloak login with +a headless browser. 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. diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index 352a3c9..cd74ae0 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -155,11 +155,12 @@ npm run probe # re-verify the API assumptions `no-new-privileges`, running as the unprivileged `node` user. It writes nothing to disk — downloads are streamed through memory, capped at `MAX_DOWNLOAD_BYTES` (25 MiB default). -- **The Schulcloud session dies after 2 hours of inactivity**, so the server - pings `/api/v3/me` every 30 minutes to hold it open. This has a consequence - worth planning for: **downtime longer than two hours kills the token**, and +- **The Schulcloud session ends ~2 hours after login**, and API reads do not + extend it, so the server calls `refresh-session` every 30 minutes. Watch for + `keepalive: session extended, 7200s` in the logs; a falling budget is the + early warning. **Downtime longer than two hours kills the token** and restarting does not recover it — a long power cut means pasting a fresh - `TSC_JWT_COOKIE`. The startup log says so immediately. Background in - docs/AUTH.md. + `TSC_JWT_COOKIE`. Whether the keepalive suffices at all is still being + measured; see docs/AUTH.md. - **Monthly chore**: refresh `TSC_JWT_COOKIE` before its 30-day hard expiry. `npm run probe` reports both clocks. diff --git a/package.json b/package.json index 1aa1931..a61e2e2 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,8 @@ "typecheck": "tsc -p tsconfig.json --noEmit", "test": "node --test test/*.test.ts", "probe": "node --env-file=.env scripts/probe.mjs", - "smoke": "node --env-file=.env scripts/smoke.mjs" + "smoke": "node --env-file=.env scripts/smoke.mjs", + "session-diagnose": "node --env-file=.env scripts/session-diagnose.mjs" }, "dependencies": { "@modelcontextprotocol/sdk": "^1.20.0", diff --git a/scripts/session-diagnose.mjs b/scripts/session-diagnose.mjs new file mode 100644 index 0000000..7426e80 --- /dev/null +++ b/scripts/session-diagnose.mjs @@ -0,0 +1,91 @@ +#!/usr/bin/env node +/** + * Instruments the Schulcloud session to find out what actually ends it. + * + * Measured behaviour so far: a token survives ~2h from *login* and no amount + * of `GET` traffic extends that. Two mechanisms could produce it, and they + * imply very different things for this server: + * + * (1) an idle TTL that ordinary reads fail to refresh — a keepalive calling + * refresh-session would hold the session indefinitely; + * (2) an absolute cap anchored at login, or outright revocation (e.g. the + * identity provider ending its SSO session and back-channel logout + * clearing every token for the account) — in which case no keepalive + * can help and the pasted-JWT approach caps out at ~2h. + * + * This script distinguishes them. It calls refresh-session on a short interval + * and records the reported budget each time. The shape of the log at death is + * the answer: + * + * budget decays 7200 → 0 across pings → (1), and pinging more often fixes it + * budget sits at 7200, then 401 abruptly → (2), revocation from outside + * + * Read-only with respect to user data; refresh-session touches only this + * session. Usage: `npm run session-diagnose [minutes]` (default 150). + */ +import { loadConfig } from '../dist/config.js'; +import { SchulcloudClient, SchulcloudApiError } from '../dist/schulcloud/client.js'; + +const totalMinutes = Number(process.argv[2] ?? 150); +const INTERVAL_MS = 10 * 60_000; + +const config = loadConfig(); +const client = new SchulcloudClient(config); + +const payload = JSON.parse(Buffer.from(config.jwt.split('.')[1], 'base64url').toString('utf8')); +const login = new Date(payload.iat * 1000); +const start = Date.now(); + +const stamp = () => { + const now = new Date(); + const sinceStart = ((Date.now() - start) / 60000).toFixed(1); + const sinceLogin = ((Date.now() - login.getTime()) / 60000).toFixed(1); + return `${now.toISOString().slice(11, 19)}Z t+${sinceStart.padStart(6)}min login+${sinceLogin.padStart(6)}min`; +}; + +console.log(`session diagnosis — instance ${config.baseUrl}`); +console.log(`token jti ${payload.jti}`); +console.log(`login (iat) ${login.toISOString()} → login+2h = ${new Date(login.getTime() + 7200_000).toISOString()}`); +console.log(`pinging refresh-session every ${INTERVAL_MS / 60000} min for ${totalMinutes} min\n`); + +const samples = []; +const deadline = start + totalMinutes * 60_000; + +while (Date.now() < deadline) { + try { + const { expiresInSeconds } = await client.extendSession(); + samples.push(expiresInSeconds); + console.log(`${stamp()} budget ${expiresInSeconds}s (${Math.round(expiresInSeconds / 60)} min)`); + } catch (error) { + const status = error instanceof SchulcloudApiError ? error.status : '—'; + console.log(`${stamp()} FAILED ${status} — session is gone`); + verdict(samples, true); + process.exit(0); + } + await new Promise((resolve) => setTimeout(resolve, Math.min(INTERVAL_MS, deadline - Date.now()))); +} + +console.log(`\n${stamp()} reached the end of the window with the session still alive`); +verdict(samples, false); + +function verdict(series, died) { + const minutesAlive = (Date.now() - login.getTime()) / 60000; + console.log('\n--- verdict ---'); + console.log(`budget series: ${series.join(', ')}`); + if (!died) { + console.log(`PASS: session survived ${minutesAlive.toFixed(0)} min since login with refresh-session pings.`); + console.log('=> refresh-session DOES hold the session. The keepalive works; ship it.'); + return; + } + const decayed = series.length >= 2 && series.at(-1) < series[0] - 60; + if (decayed) { + console.log(`Session ended ${minutesAlive.toFixed(0)} min after login, and the budget was DECAYING.`); + console.log('=> mechanism (1): an idle TTL that these pings did not fully refresh.'); + console.log(' Try a shorter KEEPALIVE_INTERVAL_MS; the budget series shows the real decay rate.'); + } else { + console.log(`Session ended ${minutesAlive.toFixed(0)} min after login while the budget still read ${series.at(-1)}s.`); + console.log('=> mechanism (2): revoked from outside, not expired by inactivity.'); + console.log(' No keepalive can prevent this. The pasted-JWT approach is capped at ~2h from'); + console.log(' login, and the auth strategy needs revisiting (see docs/AUTH.md).'); + } +} diff --git a/src/keepalive.ts b/src/keepalive.ts index 1931a68..6251021 100644 --- a/src/keepalive.ts +++ b/src/keepalive.ts @@ -4,20 +4,27 @@ import { SchulcloudApiError } from './schulcloud/client.ts'; /** * Keeps the Schulcloud session alive. * - * The JWT's `exp` claim says 30 days, but that is only an outer ceiling. The - * server also keeps a whitelist entry per token in Valkey, keyed - * `jwt:{accountId}:{jti}`, whose TTL is `JWT_TIMEOUT_SECONDS` — 7200s (2h) on - * this instance, readable from `GET /api/v3/config/public`. Every request that - * passes the JWT guard re-sets that key, so the window slides; let it lapse - * and the token is rejected with 401 "Session was expired due to inactivity", - * long before `exp`. + * The JWT's `exp` claim says 30 days, but the session dies far sooner, and the + * mechanism is not what the upstream source suggests. Both the current + * (`JwtWhitelistAdapter`) and legacy (Feathers `ensureTokenIsWhitelisted`) + * implementations re-set a Valkey TTL on every authenticated request, which + * would make the window slide with ordinary use. Measured against the live + * instance, it does not: * - * So an idle server loses its token overnight. Pinging any authenticated - * endpoint is enough to hold it: `POST /authentication/refresh-session` is - * what the web UI's "Sitzung verlängern" button calls, but it extends the - * session through the very same guard as every other route, and additionally - * reports the remaining TTL. We use a plain `GET /api/v3/me` instead, so that - * every call this server makes upstream remains a GET. + * A keepalive doing only `GET /api/v3/me` every 30 min was pinged + * successfully at t+0/30/60/90 and was nonetheless rejected by t+120 — + * almost exactly two hours after *login*, not two hours after the last + * request, which would have been t+210. + * + * So the binding clock is anchored at login and ordinary reads do not move it. + * We therefore extend explicitly, via the endpoint the web UI's "Sitzung + * verlängern" button uses, which also reports the remaining budget so the log + * shows whether the extension actually took. + * + * Whether that can carry a session past login+2h at all is the open question; + * `npm run session-diagnose` is the instrument for settling it. If it cannot, + * no keepalive will help and the auth approach itself needs revisiting — see + * docs/AUTH.md. */ export class SessionKeepalive { private timer: NodeJS.Timeout | undefined; @@ -62,7 +69,14 @@ export class SessionKeepalive { private async tick(): Promise { if (this.stopped) return; try { - await this.client.me(); + const { expiresInSeconds } = await this.client.extendSession(); + // 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. + this.log( + `[schulcloud-mcp] keepalive: session extended, ${expiresInSeconds}s ` + + `(${Math.round(expiresInSeconds / 60)} min) of budget left`, + ); this.schedule(this.intervalMs); } catch (error) { if (error instanceof SchulcloudApiError && error.isAuthFailure) { @@ -70,10 +84,10 @@ export class SessionKeepalive { // ceiling. Pinging harder cannot revive it — a human must paste a new // token — so stop and say so loudly rather than logging every 30 min. this.log( - '[schulcloud-mcp] keepalive: token rejected (401). The session has expired — ' + - 'either more than 2h elapsed without a successful request, or the JWT reached ' + - 'its 30-day limit. Put a fresh jwt cookie in TSC_JWT_COOKIE and restart. ' + - 'Keepalive stopped.', + '[schulcloud-mcp] keepalive: token rejected (401). The session is gone — ' + + 'measured behaviour is that it ends roughly 2h after login regardless of ' + + 'activity, and the JWT also has a 30-day hard limit. Put a fresh jwt cookie ' + + 'in TSC_JWT_COOKIE and restart. Keepalive stopped.', ); this.stop(); return; diff --git a/src/schulcloud/client.ts b/src/schulcloud/client.ts index dac8717..538f821 100644 --- a/src/schulcloud/client.ts +++ b/src/schulcloud/client.ts @@ -48,7 +48,8 @@ export interface DownloadedFile { } /** - * Read-only HTTP client for a Schulcloud instance. + * HTTP client for a Schulcloud instance. Read-only apart from `extendSession`, + * which touches only the caller's own session — see its doc comment. * * Two services sit behind the same origin and both accept the same bearer * token: the main server under `/api/v3/*`, and the files-storage service @@ -56,9 +57,11 @@ export interface DownloadedFile { * verbatim as `Authorization: Bearer` — no cookie jar or session refresh is * involved, and the token is valid for 30 days (see docs/AUTH.md). * - * Every method here is a GET. Keeping the client incapable of writing is the - * main safety property of this server: whoever reaches the MCP endpoint can - * read this account's data but cannot act as the user inside Schulcloud. + * Every method that touches user data is a GET. Keeping the client incapable of + * writing is the main safety property of this server: whoever reaches the MCP + * endpoint can read this account's data but cannot act as the user inside + * Schulcloud. `extendSession` is the single exception and is not exposed as a + * tool, so no model-driven call can ever be a POST. */ export class SchulcloudClient { private readonly config: Config; @@ -154,6 +157,39 @@ export class SchulcloudClient { return this.getJson('/api/v3/me'); } + // --- session --------------------------------------------------------- + + /** + * Extends the current session and reports its remaining budget. + * + * **The only non-GET request in this server, and deliberately so.** It is + * what the web UI's "Sitzung verlängern" button calls. It takes no body, + * touches nothing but the caller's own session, and cannot read or change + * any user data — so it does not weaken the property that matters: nobody + * reaching this server can act as the user inside Schulcloud. No MCP tool + * exposes it, so Claude can never cause a POST; only the keepalive calls it. + * + * Using a plain GET here was tried and does not work — see docs/AUTH.md for + * the endurance test that ruled it out. + */ + async extendSession(): Promise<{ expiresInSeconds: number }> { + const url = this.url('/api/v3/authentication/refresh-session'); + const response = await fetch(url, { + method: 'POST', + headers: { + Authorization: `Bearer ${this.config.jwt}`, + Accept: 'application/json', + 'Content-Length': '0', + }, + signal: AbortSignal.timeout(this.config.requestTimeoutMs), + }); + if (!response.ok) { + const body = await response.text().catch(() => ''); + throw new SchulcloudApiError(response.status, url.pathname, body); + } + return (await response.json()) as { expiresInSeconds: number }; + } + // --- courses and the classic course board ---------------------------- listCourses(params: { skip?: number; limit?: number } = {}): Promise> { diff --git a/test/keepalive.test.ts b/test/keepalive.test.ts index 7c3804a..c3d2ee8 100644 --- a/test/keepalive.test.ts +++ b/test/keepalive.test.ts @@ -9,11 +9,11 @@ function fakeClient(outcomes: (Error | 'ok')[]) { return { calls, client: { - me: async () => { + extendSession: async () => { const outcome = outcomes[calls.length] ?? 'ok'; calls.push(Date.now()); if (outcome !== 'ok') throw outcome; - return {} as never; + return { expiresInSeconds: 7200 }; }, } as never, }; @@ -41,7 +41,7 @@ describe('SessionKeepalive', () => { }); it('stops permanently on 401 — a dead session cannot be revived by retrying', async () => { - const unauthorized = new SchulcloudApiError(401, '/api/v3/me', ''); + const unauthorized = new SchulcloudApiError(401, '/api/v3/authentication/refresh-session', ''); const { client, calls } = fakeClient([unauthorized]); const messages: string[] = []; const keepalive = new SessionKeepalive(client, 15, 15, (message) => messages.push(message)); @@ -75,3 +75,16 @@ describe('SessionKeepalive', () => { assert.equal(calls.length, seen, 'no pings after stop()'); }); }); + +describe('SessionKeepalive logging', () => { + it('reports the remaining session budget on a successful extension', async () => { + const messages: string[] = []; + const client = { extendSession: async () => ({ expiresInSeconds: 7200 }) } as never; + const keepalive = new SessionKeepalive(client, 60_000, 1000, (m) => messages.push(m)); + keepalive.start(); + await new Promise((resolve) => setTimeout(resolve, 30)); + keepalive.stop(); + assert.equal(messages.length, 1); + assert.match(messages[0]!, /session extended, 7200s \(120 min\)/); + }); +});