From d657ece4365b42332b168885e55a966b598afdef Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Sat, 12 Sep 2026 13:07:22 +0200 Subject: [PATCH] Fix session lifetime: 2h sliding idle timeout, not 30 days MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The JWT's exp claim says 30 days, and I took that as the session lifetime. It is only an outer ceiling. The server also keeps a per-token whitelist entry in Valkey (jwt:{accountId}:{jti}) whose TTL is JWT_TIMEOUT_SECONDS — 7200s on this instance — and JwtStrategy.validate re-sets it on every authenticated request. Two hours idle and the token is rejected with 29 days still on exp. Proven, not inferred: the token from yesterday returned 401 at 13.8h old. The live instance publishes the values unauthenticated at GET /api/v3/config/public — JWT_TIMEOUT_SECONDS 7200, JWT_SHOW_TIMEOUT_WARNING_SECONDS 3600, the latter being exactly the one-hour UI prompt that prompted this investigation. refresh-session turns out not to be special: it extends through the same guard as any other route, and uniquely only in returning the remaining TTL. So the keepalive uses GET /api/v3/me instead, and the server stays GET-only; the one POST in the repo is in scripts/probe.mjs, where it reports the idle budget. JWT_EXTENDED_TIMEOUT_SECONDS (~1 month) exists in the config schema but is vestigial: privateDevice has no references in the current NestJS source, and generateJwtAndAddToWhitelist never overrides the TTL. Also fixes a real breakage this surfaced: TypeScript parameter properties are rejected by Node's type stripping, so `npm run dev` and `npm test` both failed on any file reaching them. Rewritten as explicit fields, and noted in CLAUDE.md. Co-Authored-By: Claude Opus 5 --- .env.example | 10 +++- CLAUDE.md | 13 ++++- README.md | 17 ++++-- docs/API.md | 15 ++++- docs/AUTH.md | 120 +++++++++++++++++++++++++++++---------- docs/DEPLOYMENT.md | 16 ++++-- scripts/probe.mjs | 42 ++++++++++++-- src/bin/http.ts | 16 +++++- src/bin/stdio.ts | 10 ++++ src/config.ts | 18 ++++++ src/context.ts | 4 +- src/keepalive.ts | 88 ++++++++++++++++++++++++++++ src/schulcloud/client.ts | 19 +++++-- test/keepalive.test.ts | 77 +++++++++++++++++++++++++ 14 files changed, 408 insertions(+), 57 deletions(-) create mode 100644 src/keepalive.ts create mode 100644 test/keepalive.test.ts diff --git a/.env.example b/.env.example index 4d0c072..3495d05 100644 --- a/.env.example +++ b/.env.example @@ -6,7 +6,9 @@ TSC_URL=https://schulcloud-thueringen.de # The value of the `jwt` cookie from a logged-in browser session. -# Valid for 30 days from issue; see docs/AUTH.md for how to copy a fresh one. +# 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. TSC_JWT_COOKIE= # --------------------------------------------------------------------------- @@ -36,3 +38,9 @@ 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. +# KEEPALIVE_INTERVAL_MS=1800000 diff --git a/CLAUDE.md b/CLAUDE.md index 4a67035..aac41ac 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -59,7 +59,7 @@ the property that makes that acceptable. Do not add a write tool without the user explicitly asking for one and understanding this. **Never log or echo secrets.** `TSC_JWT_COOKIE` grants full read access to the -account for 30 days; `MCP_AUTH_TOKEN` guards the endpoint. Neither belongs in +account; `MCP_AUTH_TOKEN` guards the endpoint. Neither belongs in logs, error messages, or tool output. `.env` is git-ignored — keep it that way. **Live behaviour beats upstream source.** The clones in `vendor/` track `main` @@ -84,11 +84,18 @@ 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. ## Conventions - Imports use `.ts` extensions; `rewriteRelativeImportExtensions` makes `tsc` emit `.js`. This lets `node --watch src/bin/http.ts` run the tree directly. +- **No TypeScript parameter properties** (`constructor(private readonly x: T)`). + Node's type stripping rejects them, which breaks `npm run dev` and `npm test`. + Declare the field and assign it in the constructor body instead. - Tabs for indentation, single quotes, trailing commas. - Comments explain *why* — an API quirk, a security property, a trade-off — not what the line does. Several such comments encode findings that are expensive @@ -108,5 +115,5 @@ These cost real time to discover; `docs/API.md` has the full list with evidence. ## 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 — it expires every 30 -days, and `npm run probe` reports the days remaining. +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. diff --git a/README.md b/README.md index 9697cc6..ca82984 100644 --- a/README.md +++ b/README.md @@ -50,15 +50,20 @@ 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 and lasts 30 days — -see [docs/AUTH.md](docs/AUTH.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). ## Design decisions -**Bearer token, not a cookie jar.** The instance's `jwt` cookie works verbatim -as `Authorization: Bearer`, and is valid for 30 days. There is no session to -keep alive and no `refresh-session` timer — a simplification that only became -apparent by testing against the live instance. +**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. **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 diff --git a/docs/API.md b/docs/API.md index c4eac88..aef2e76 100644 --- a/docs/API.md +++ b/docs/API.md @@ -79,6 +79,8 @@ current material**; lessons are the older format. | One file's metadata | `GET /api/v3/file/{fileRecordId}` | | File bytes | `GET /api/v3/file/download/{fileRecordId}/{fileName}` | | News | `GET /api/v3/news` | +| Instance settings (no auth) | `GET /api/v3/config/public` | +| Remaining idle budget | `POST /api/v3/authentication/refresh-session` → `{expiresInSeconds}` | ### Gotchas that cost real time @@ -115,6 +117,16 @@ 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`. + +**`GET /api/v3/config/public` is unauthenticated and useful.** 78 keys of +instance configuration, including the session timeouts and feature flags. Handy +for checking deployed settings without a token. + **`Content-Disposition` on downloads is malformed.** It comes back as `attachment;; filename="…"` — note the doubled semicolon — and the filename is percent-encoded inside the quotes. Parse defensively. @@ -132,4 +144,5 @@ the Etherpad-style editor, not the document text. ## Re-verifying after an upstream release `npm run probe` re-checks every assumption above against the live instance and -prints what it finds, including days left on the token. +prints what it finds — both token clocks included: days until hard expiry, and +seconds of idle budget remaining. diff --git a/docs/AUTH.md b/docs/AUTH.md index 52d9292..87f6028 100644 --- a/docs/AUTH.md +++ b/docs/AUTH.md @@ -14,44 +14,106 @@ Cookie: jwt= → 200 # also works (no auth) → 401 ``` -`connect.sid`, `SERVERID` and `isLoggedIn` are **not** needed. There is no -cookie jar, no session to keep alive, and no `refresh-session` call on a timer. +`connect.sid`, `SERVERID` and `isLoggedIn` are **not** needed — there is no +cookie jar. There *is* a session to keep alive, though not in the way the +cookies suggest; see the next section. -## Token lifetime: 30 days +## Token lifetime: two clocks, and the short one is the one that bites -The token is a standard JWT. Decoded from the live instance: +This is the part that is easy to get wrong, because the JWT lies to you by +omission. Two independent limits govern the token: -``` -iss / aud : schulcloud-thueringen.de -iat → exp : 720 hours (exactly 30 days) -claims : accountId, userId, schoolId, roles, systemId, jti, - isExternalUser, isServiceAccount, support +| Clock | Value | Extendable? | +|---|---|---| +| **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 | + +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. + +### How the idle timeout works + +`JwtStrategy.validate()` runs on every request behind `@JwtAuthentication()` +and calls `JwtWhitelistAdapter.isWhitelisted(accountId, jti)`. That method: + +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.** + +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`. + +Source: `apps/server/src/infra/auth-guard/strategy/jwt.strategy.ts` and +`apps/server/src/infra/jwt-whitelist/adapter/jwt-whitelist.adapter.ts`. + +### The instance publishes these values + +Unauthenticated, so you can check them any time: + +```bash +curl -s "$TSC_URL/api/v3/config/public" | jq '{JWT_TIMEOUT_SECONDS, JWT_SHOW_TIMEOUT_WARNING_SECONDS}' +# { "JWT_TIMEOUT_SECONDS": 7200, "JWT_SHOW_TIMEOUT_WARNING_SECONDS": 3600 } ``` -So a token copied today works for a month, and refreshing it is a calendar -chore rather than an engineering problem. `npm run probe` prints the days -remaining. +`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. -## Getting a fresh token +### Verified empirically -1. Log in to the instance in a normal browser. -2. DevTools → **Application** → **Cookies** → the instance's origin. -3. Copy the value of the **`jwt`** cookie. -4. Put it in `TSC_JWT_COOKIE` in `.env` and restart the server - (`docker compose restart schulcloud-mcp`). +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. -There is no need to log out afterwards; the token stays valid independently of -the browser session. +### `refresh-session` is not special -## How you will know it expired +`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 }`. -Every tool returns a specific message on `401` rather than a generic failure: +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. -> Schulcloud rejected the token … The JWT in TSC_JWT_COOKIE has expired or been -> revoked. +### There is no longer window available -That message is the signal to redo the four steps above. A `403` means the -account genuinely lacks access to that resource and is *not* a token problem. +`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. + +## How this server stays 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: + +``` +[schulcloud-mcp] keepalive: token rejected (401). The session has expired … +``` + +With the keepalive running, a token survives up to its 30-day hard expiry, at +which point it must be replaced by hand regardless. ## Why not username + password @@ -62,9 +124,9 @@ 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 30-day token, the pasted-JWT approach is 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 +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 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 6484dd6..352a3c9 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -35,11 +35,13 @@ docker compose logs -f schulcloud-mcp Expect: ``` -[schulcloud-mcp] listening on 0.0.0.0:8080 — instance https://… , auth enabled +[schulcloud-mcp] listening on 0.0.0.0:8080 — instance https://… , auth enabled, keepalive every 30min ``` `auth DISABLED` there means `MCP_AUTH_TOKEN` is empty — fix it before exposing -the service. +the service. A `keepalive: token rejected (401)` line right after startup means +the Schulcloud token is dead and needs replacing (see docs/AUTH.md); the server +will run but every tool will fail. ## Joining the existing Caddy @@ -153,5 +155,11 @@ 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). -- **Monthly chore**: refresh `TSC_JWT_COOKIE`. `npm run probe` tells you how - many days are left. +- **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 + 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. +- **Monthly chore**: refresh `TSC_JWT_COOKIE` before its 30-day hard expiry. + `npm run probe` reports both clocks. diff --git a/scripts/probe.mjs b/scripts/probe.mjs index 4a4356e..b336486 100644 --- a/scripts/probe.mjs +++ b/scripts/probe.mjs @@ -15,18 +15,50 @@ const client = new SchulcloudClient(config); console.log(`instance: ${config.baseUrl}\n`); // --- token --------------------------------------------------------------- +// Two independent clocks govern the token, and only one of them is in the JWT: +// exp — a hard 30-day ceiling, cannot be extended. +// whitelist TTL — JWT_TIMEOUT_SECONDS (2h here), reset by every request. +// The second one is what actually kills idle sessions, so report both. const payload = decodeJwt(config.jwt); if (payload) { - const expires = new Date(payload.exp * 1000); const daysLeft = (payload.exp * 1000 - Date.now()) / 86_400_000; - console.log(`token: issued ${new Date(payload.iat * 1000).toISOString().slice(0, 10)}, ` + - `expires ${expires.toISOString().slice(0, 10)} (${daysLeft.toFixed(1)} days left)`); - if (daysLeft < 0) console.log(' *** EXPIRED — copy a fresh jwt cookie, see docs/AUTH.md'); - else if (daysLeft < 5) console.log(' *** expiring soon — plan to copy a fresh jwt cookie'); + console.log(`token: issued ${new Date(payload.iat * 1000).toISOString().slice(0, 16)}Z, ` + + `hard expiry ${new Date(payload.exp * 1000).toISOString().slice(0, 10)} (${daysLeft.toFixed(1)} days left)`); + if (daysLeft < 0) console.log(' *** PAST HARD EXPIRY — copy a fresh jwt cookie, see docs/AUTH.md'); + else if (daysLeft < 5) console.log(' *** hard expiry approaching — plan to copy a fresh jwt cookie'); } else { console.log('token: could not decode (not a JWT?)'); } +// The instance publishes its own session settings, unauthenticated. +try { + const publicConfig = await client.getJson('/api/v3/config/public'); + console.log(`instance: JWT_TIMEOUT_SECONDS=${publicConfig.JWT_TIMEOUT_SECONDS} ` + + `(idle timeout), warning shown at ${publicConfig.JWT_SHOW_TIMEOUT_WARNING_SECONDS}s remaining`); +} catch { + console.log('instance: could not read /api/v3/config/public'); +} + +// refresh-session reports the whitelist TTL. It is the one non-GET call in this +// repo, and it lives here in a diagnostic rather than in the server, whose every +// upstream call is a GET. It extends the session no more than any GET does. +try { + const response = await fetch(`${config.baseUrl}/api/v3/authentication/refresh-session`, { + method: 'POST', + headers: { Authorization: `Bearer ${config.jwt}`, 'Content-Length': '0' }, + }); + if (response.ok) { + const { expiresInSeconds } = await response.json(); + console.log(`session: ${expiresInSeconds}s of idle budget left ` + + `(${(expiresInSeconds / 60).toFixed(0)} min) — any request resets this`); + } else { + console.log(`session: refresh-session returned ${response.status}` + + (response.status === 401 ? ' *** session expired through inactivity or hard expiry' : '')); + } +} catch (error) { + console.log(`session: could not read TTL (${error.message})`); +} + // --- endpoints this server depends on ------------------------------------ const checks = [ ['GET /api/v3/me', () => client.me()], diff --git a/src/bin/http.ts b/src/bin/http.ts index cd46a65..ba6f560 100644 --- a/src/bin/http.ts +++ b/src/bin/http.ts @@ -1,6 +1,8 @@ #!/usr/bin/env node import { loadConfig } from '../config.ts'; import { createHttpApp } from '../http/server.ts'; +import { SessionKeepalive } from '../keepalive.ts'; +import { SchulcloudClient } from '../schulcloud/client.ts'; /** * HTTP entry point — the deployed form of this server, sitting behind Caddy. @@ -9,10 +11,21 @@ async function main(): Promise { const config = loadConfig(); const app = createHttpApp(config); + // One process-wide keepalive, independent of MCP sessions: the Schulcloud + // token dies after 2h of inactivity regardless of whether anyone is connected. + const keepalive = + config.keepaliveIntervalMs > 0 + ? new SessionKeepalive(new SchulcloudClient(config), config.keepaliveIntervalMs, undefined, (message) => + console.log(message), + ) + : undefined; + keepalive?.start(); + const server = app.listen(config.port, config.bindHost, () => { console.log( `[schulcloud-mcp] listening on ${config.bindHost}:${config.port} — instance ${config.baseUrl}, ` + - `auth ${config.authToken ? 'enabled' : 'DISABLED'}`, + `auth ${config.authToken ? 'enabled' : 'DISABLED'}, ` + + `keepalive ${keepalive ? `every ${Math.round(config.keepaliveIntervalMs / 60_000)}min` : 'off'}`, ); }); @@ -20,6 +33,7 @@ async function main(): Promise { for (const signal of ['SIGTERM', 'SIGINT'] as const) { process.on(signal, () => { console.log(`[schulcloud-mcp] ${signal} received, shutting down`); + keepalive?.stop(); server.close(() => process.exit(0)); setTimeout(() => process.exit(0), 10_000).unref(); }); diff --git a/src/bin/stdio.ts b/src/bin/stdio.ts index 3009471..fccd944 100644 --- a/src/bin/stdio.ts +++ b/src/bin/stdio.ts @@ -1,6 +1,8 @@ #!/usr/bin/env node import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; import { loadConfig } from '../config.ts'; +import { SessionKeepalive } from '../keepalive.ts'; +import { SchulcloudClient } from '../schulcloud/client.ts'; import { createServer } from '../server.ts'; /** @@ -13,6 +15,14 @@ async function main(): Promise { const config = loadConfig(); const { server } = createServer(config); await server.connect(new StdioServerTransport()); + + // A desktop client left open overnight idles far past the instance's 2h + // session timeout, so stdio needs the keepalive just as much as HTTP does. + // It logs to stderr; stdout carries protocol frames only. + if (config.keepaliveIntervalMs > 0) { + new SessionKeepalive(new SchulcloudClient(config), config.keepaliveIntervalMs).start(); + } + console.error(`[schulcloud-mcp] stdio transport ready for ${config.baseUrl}`); } diff --git a/src/config.ts b/src/config.ts index db7689d..80c12f4 100644 --- a/src/config.ts +++ b/src/config.ts @@ -20,6 +20,12 @@ export interface Config { /** Characters of extracted text returned before truncation kicks in. */ maxExtractedChars: number; requestTimeoutMs: number; + /** + * How often to ping the instance to hold the session open. Must stay well + * under the instance's `JWT_TIMEOUT_SECONDS` (7200s here) — see + * src/keepalive.ts. Zero disables the keepalive. + */ + keepaliveIntervalMs: number; } function required(name: string): string { @@ -38,6 +44,17 @@ function int(name: string, fallback: number): number { return parsed; } +/** Like `int`, but 0 is meaningful (it disables the feature) rather than invalid. */ +function intAllowingZero(name: string, fallback: number): number { + const raw = process.env[name]?.trim(); + if (!raw) return fallback; + const parsed = Number.parseInt(raw, 10); + if (!Number.isFinite(parsed) || parsed < 0) { + throw new Error(`Environment variable ${name} must be a non-negative integer, got ${raw}`); + } + return parsed; +} + export function loadConfig(): Config { return { baseUrl: required('TSC_URL').replace(/\/+$/, ''), @@ -48,5 +65,6 @@ export function loadConfig(): Config { maxDownloadBytes: int('MAX_DOWNLOAD_BYTES', 25 * 1024 * 1024), maxExtractedChars: int('MAX_EXTRACTED_CHARS', 120_000), requestTimeoutMs: int('REQUEST_TIMEOUT_MS', 30_000), + keepaliveIntervalMs: intAllowingZero('KEEPALIVE_INTERVAL_MS', 30 * 60_000), }; } diff --git a/src/context.ts b/src/context.ts index 6bf801d..b06b1fb 100644 --- a/src/context.ts +++ b/src/context.ts @@ -10,10 +10,12 @@ import type { MeResponse } from './schulcloud/types.ts'; * cannot change for a given JWT. Everything else is fetched live. */ export class ServerContext { + readonly config: Config; readonly client: SchulcloudClient; private identity: Promise | undefined; - constructor(readonly config: Config) { + constructor(config: Config) { + this.config = config; this.client = new SchulcloudClient(config); } diff --git a/src/keepalive.ts b/src/keepalive.ts new file mode 100644 index 0000000..1931a68 --- /dev/null +++ b/src/keepalive.ts @@ -0,0 +1,88 @@ +import type { SchulcloudClient } from './schulcloud/client.ts'; +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`. + * + * 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. + */ +export class SessionKeepalive { + private timer: NodeJS.Timeout | undefined; + private stopped = false; + private readonly client: SchulcloudClient; + private readonly intervalMs: number; + /** Retry delay after a failed ping — shorter, to use up the remaining budget. */ + private readonly retryMs: number; + private readonly log: (message: string) => void; + + constructor( + client: SchulcloudClient, + intervalMs: number, + retryMs: number = Math.min(5 * 60_000, intervalMs), + log: (message: string) => void = (message) => console.error(message), + ) { + this.client = client; + this.intervalMs = intervalMs; + this.retryMs = retryMs; + this.log = log; + } + + /** Pings once now (validating the token at startup), then on the interval. */ + start(): void { + this.stopped = false; + void this.tick(); + } + + stop(): void { + this.stopped = true; + 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); + // Never hold the process open just for a keepalive. + this.timer.unref(); + } + + private async tick(): Promise { + if (this.stopped) return; + try { + await this.client.me(); + this.schedule(this.intervalMs); + } catch (error) { + 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 + // 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.', + ); + this.stop(); + 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); + } + } +} diff --git a/src/schulcloud/client.ts b/src/schulcloud/client.ts index e1f3e3a..dac8717 100644 --- a/src/schulcloud/client.ts +++ b/src/schulcloud/client.ts @@ -17,13 +17,16 @@ import type { /** An API response outside the 2xx range, carrying the status for callers to branch on. */ export class SchulcloudApiError extends Error { - constructor( - readonly status: number, - readonly path: string, - readonly body: string, - ) { + readonly status: number; + readonly path: string; + readonly body: string; + + constructor(status: number, path: string, body: string) { super(`Schulcloud API ${status} for ${path}${body ? `: ${truncate(body, 400)}` : ''}`); this.name = 'SchulcloudApiError'; + this.status = status; + this.path = path; + this.body = body; } /** True when the instance rejected our JWT — the one error the user must act on. */ @@ -58,7 +61,11 @@ export interface DownloadedFile { * read this account's data but cannot act as the user inside Schulcloud. */ export class SchulcloudClient { - constructor(private readonly config: Config) {} + private readonly config: Config; + + constructor(config: Config) { + this.config = config; + } // --- transport ------------------------------------------------------- diff --git a/test/keepalive.test.ts b/test/keepalive.test.ts new file mode 100644 index 0000000..7c3804a --- /dev/null +++ b/test/keepalive.test.ts @@ -0,0 +1,77 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { SchulcloudApiError } from '../src/schulcloud/client.ts'; +import { SessionKeepalive } from '../src/keepalive.ts'; + +/** A stand-in for SchulcloudClient that records calls and replays scripted outcomes. */ +function fakeClient(outcomes: (Error | 'ok')[]) { + const calls: number[] = []; + return { + calls, + client: { + me: async () => { + const outcome = outcomes[calls.length] ?? 'ok'; + calls.push(Date.now()); + if (outcome !== 'ok') throw outcome; + return {} as never; + }, + } as never, + }; +} + +const settle = () => new Promise((resolve) => setTimeout(resolve, 30)); + +describe('SessionKeepalive', () => { + it('pings immediately on start, so a bad token is noticed at boot', async () => { + const { client, calls } = fakeClient(['ok']); + const keepalive = new SessionKeepalive(client, 60_000, 1000, () => {}); + keepalive.start(); + await settle(); + assert.equal(calls.length, 1); + keepalive.stop(); + }); + + it('keeps pinging on the interval', async () => { + const { client, calls } = fakeClient([]); + const keepalive = new SessionKeepalive(client, 15, 15, () => {}); + keepalive.start(); + await new Promise((resolve) => setTimeout(resolve, 120)); + keepalive.stop(); + assert.ok(calls.length >= 3, `expected repeated pings, got ${calls.length}`); + }); + + it('stops permanently on 401 — a dead session cannot be revived by retrying', async () => { + const unauthorized = new SchulcloudApiError(401, '/api/v3/me', ''); + const { client, calls } = fakeClient([unauthorized]); + const messages: string[] = []; + const keepalive = new SessionKeepalive(client, 15, 15, (message) => messages.push(message)); + keepalive.start(); + await new Promise((resolve) => setTimeout(resolve, 120)); + assert.equal(calls.length, 1, 'must not keep hammering a dead token'); + assert.equal(messages.length, 1); + assert.match(messages[0]!, /fresh jwt cookie/); + keepalive.stop(); + }); + + it('retries a transient failure instead of giving up', async () => { + const { client, calls } = fakeClient([new Error('ECONNRESET')]); + const messages: string[] = []; + const keepalive = new SessionKeepalive(client, 10_000, 15, (message) => messages.push(message)); + keepalive.start(); + await new Promise((resolve) => setTimeout(resolve, 120)); + keepalive.stop(); + assert.ok(calls.length >= 2, `expected a retry, got ${calls.length} call(s)`); + assert.match(messages[0]!, /retrying in/); + }); + + it('stop() prevents any further pings', async () => { + const { client, calls } = fakeClient([]); + const keepalive = new SessionKeepalive(client, 15, 15, () => {}); + keepalive.start(); + await settle(); + keepalive.stop(); + const seen = calls.length; + await new Promise((resolve) => setTimeout(resolve, 100)); + assert.equal(calls.length, seen, 'no pings after stop()'); + }); +});