From 9ce869f3fb3b7dae276f84f968eb799b9179ef52 Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Sat, 12 Sep 2026 16:32:42 +0200 Subject: [PATCH] Root cause: an open Schulportal tab revokes the shared token MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Neither of my two hypotheses was right, and the upstream source was correct all along. The jwt cookie copied from the browser IS the browser's session token — same jti — so this server and the tab share one session, and the tab ends it: 1. nuxt-client sets a purely client-side timer, sessionTimeoutTimestamp = now + JWT_TIMEOUT_SECONDS, reset only on route change (watch(router.currentRoute, startTimer)) — never by API activity and never read back from the server's TTL. 2. AutoLogoutWarning.vue warns at JWT_SHOW_TIMEOUT_WARNING_SECONDS. 3. At zero, autoLogout() -> location.replace('/logout?auto-logout=true'). 4. schulcloud-client controllers/login.js:439 -> POST /api/v3/logout -> removeJwtFromWhitelist(jwt) -> the shared key is deleted. That explains the endurance failure exactly: the GET pings at t+0/30/60/90 were sliding the Valkey TTL correctly, and then the tab deleted the key. It also explains the ~1h warning dialog appearing in a tab the user considers in use — the timer only resets on navigation. So the sliding TTL is real and a keepalive does hold a session to the 30-day ceiling. The operational fix is not to ping harder but to close the Schulportal window after copying the cookie; a private window is the tidy way. This is now the loudest caveat in the token-copying steps, because it is the single easiest way to break the setup. Keeping refresh-session rather than reverting to GET, now for a reason that stands on its own: it states the intent contractually instead of relying on extend-on-check as a side effect of an unrelated read (that whitelist has been refactored twice in 2026, and a GET keepalive would fail silently if it went away), and its budget readout makes session health visible in the log. Co-Authored-By: Claude Opus 5 --- .env.example | 7 +- CLAUDE.md | 20 ++-- README.md | 23 ++--- docs/AUTH.md | 180 +++++++++++++++-------------------- docs/DEPLOYMENT.md | 15 +-- scripts/session-diagnose.mjs | 46 ++++----- src/keepalive.ts | 45 +++++---- 7 files changed, 155 insertions(+), 181 deletions(-) diff --git a/.env.example b/.env.example index f48e25e..86d9577 100644 --- a/.env.example +++ b/.env.example @@ -6,9 +6,10 @@ TSC_URL=https://schulcloud-thueringen.de # The value of the `jwt` cookie from a logged-in browser session. -# 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. +# The 30-day `exp` is only a ceiling; the real limit is a 2-hour sliding session +# 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. TSC_JWT_COOKIE= # --------------------------------------------------------------------------- diff --git a/CLAUDE.md b/CLAUDE.md index c73e206..64b6491 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -88,15 +88,17 @@ 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 ~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`. +- **`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. +- **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 + `/logout?auto-logout=true` ~2h after login, which issues `POST /api/v3/logout` + and deletes the shared key. No keepalive can prevent it; the fix is to close + the tab. This produced two false conclusions before being found — if a token + dies ~2h after login, suspect an open tab first. `docs/AUTH.md` has the chain. ## Conventions diff --git a/README.md b/README.md index bf9ea02..3a2d419 100644 --- a/README.md +++ b/README.md @@ -50,21 +50,22 @@ 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. 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. +Getting `TSC_JWT_COOKIE` takes four clicks in DevTools and then lasts 30 days — +provided you close the Schulportal window afterwards. See +[docs/AUTH.md](docs/AUTH.md); that caveat is not optional. ## 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'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. +as `Authorization: Bearer` — no cookie jar, no `connect.sid`. Its `exp` claim +(30 days) is only a ceiling: the real limit is a 2-hour server-side session TTL +that any request slides, so the server calls `refresh-session` every 30 minutes +(the one non-GET request here, and not exposed as a tool). + +The sharp edge is subtler and cost two endurance tests to find: **the cookie you +copy is the browser's own session token**, so a Schulportal tab left open will +auto-logout after ~2 hours and revoke this server's token with it. Copy the +token in a private window and close it. See [docs/AUTH.md](docs/AUTH.md). **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/AUTH.md b/docs/AUTH.md index 25b3a0e..489ebd8 100644 --- a/docs/AUTH.md +++ b/docs/AUTH.md @@ -18,95 +18,64 @@ 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: what the source says, and what the instance does +## Token lifetime, and the thing that actually kills it -Two clocks govern the token, and only one of them is in the JWT: +Two clocks govern the token: -| Clock | Value | Moves with use? | +| Clock | Value | Notes | |---|---|---| -| **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) | +| **Session** — a whitelist entry in Valkey, `jwt:{accountId}:{jti}` | `JWT_TIMEOUT_SECONDS` = **7200 s** | **Sliding**: every authenticated request re-sets it | +| **Hard expiry** — the JWT's own `exp` claim | **30 days** | Cannot be extended | -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. - -### What the source says - -`JwtStrategy.validate()` runs on every route behind `@JwtAuthentication()` and -calls `JwtWhitelistAdapter.isWhitelisted(accountId, jti)`, which reads Valkey -key `jwt:{accountId}:{jti}`, throws +`JwtStrategy.validate()` calls `JwtWhitelistAdapter.isWhitelisted()`, which +reads the key, 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". +**re-sets it** with a fresh TTL. The legacy Feathers implementation does the +same. So the window really does slide with use, and a keepalive holds a session +up to the 30-day ceiling. -Read on its own, that says the window slides with ordinary use. +### But a browser tab will log this server out -### What the instance actually does +The trap is not a clock at all. **The `jwt` cookie you copy is the browser's own +session token — same `jti`.** This server and that tab share one session, and +the tab will end it: -It does not slide. A keepalive doing nothing but `GET /api/v3/me` every 30 -minutes was measured against the live instance: +1. On login the front end starts a **purely client-side** timer: + `sessionTimeoutTimestamp = Date.now() + JWT_TIMEOUT_SECONDS`. It is reset + only on *route change* — `watch(router.currentRoute, startTimer)` — never by + API activity, and never read back from the server's real TTL. + (`nuxt-client/src/modules/data/application/application.store.ts`) +2. After `JWT_SHOW_TIMEOUT_WARNING_SECONDS` (3600 s) remain it shows the + "Sitzung verlängern" dialog. (`AutoLogoutWarning.vue`) +3. At zero it calls `autoLogout()` → + `location.replace('/logout?auto-logout=true')`. +4. That route, in the legacy client, issues `POST /api/v3/logout` + (`schulcloud-client/controllers/login.js:439`) → + `removeJwtFromWhitelist(jwt)` → **the shared key is deleted.** -``` -[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 -``` +An idle tab therefore revokes this server's token about two hours after login, +no matter how diligently the server refreshes it. Measured exactly that way: a +keepalive pinged successfully at t+0/30/60/90 and was dead by t+120, ~2 h after +login, while `exp` still had 29 days left. -The timing is what makes this conclusive: +Note the timer is client-side and reset only by navigation. That is why the +dialog appears after an idle hour even in a tab you consider "in use", and why +no amount of API traffic prevents it. -| 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 | +### So: close the tab -Four requests succeeded inside the window and bought nothing. The binding clock -is anchored at **login**, and ordinary reads do not move it. +**After copying the `jwt` cookie, close the Schulportal tab** — all of them; the +front end also syncs logout across tabs over a `BroadcastChannel`. Closing a tab +runs no logout itself (`autoLogout` fires only from the expiry timer), so the +session survives on the server with nothing left to revoke 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. +The tidiest recipe is a **private/incognito window**: log in there, copy the +`jwt`, close the window. That session is separate from your normal browsing, and +`POST /api/v3/logout` only ever removes its own `jti`, so your everyday session +and this server cannot end each other. -### Values the instance publishes - -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 } -``` - -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. - -### The open question - -Two mechanisms fit the evidence, and they differ in what they mean for this -server: - -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. - -`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). - -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. +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. ### There is no longer window available @@ -114,40 +83,42 @@ back-channel variant clears all of them. (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` with no TTL override. A "remember me" login will not buy a -longer window. +`addToWhitelist` with no TTL override. A "remember me" login buys nothing. -## How this server tries to stay alive +## How this server stays alive `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: +minutes (`KEEPALIVE_INTERVAL_MS`; `0` disables it), from both entry points and +independently of whether any MCP client is connected — the session expires on +wall time, not on usage. It logs the budget each time: ``` [schulcloud-mcp] keepalive: session extended, 7200s (120 min) of budget left ``` -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. +A plain `GET` would also slide the TTL today. `refresh-session` is used anyway +because it states the intent contractually rather than relying on a side effect +of an unrelated read — upstream has refactored this whitelist twice in 2026, and +a GET-based keepalive would fail *silently* if extend-on-check ever went away. +The budget readout also turns "is the session healthy" into something the log +answers directly. -**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. +**It is the one non-GET request in the server.** No body, touches only the +caller's own session, cannot read or modify user data, and is not exposed as a +tool — so no model-driven call can ever be a POST, and the property that matters +is intact: nobody reaching this endpoint can act as the user in Schulcloud. -**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. +A transient failure retries in 5 minutes. A `401` stops the keepalive +permanently and says what to do, because a deleted whitelist entry cannot be +revived by retrying — and if it happens roughly two hours after login, suspect +an open tab before anything else. -**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: +`npm run session-diagnose` logs the budget every 10 minutes for ~2.5 h, which is +the direct way to confirm a token is holding. + +**Operational consequence:** if the container is down for over two hours the +session lapses on its own, and restarting will not recover it. The startup log +says so immediately: ``` [schulcloud-mcp] keepalive: token rejected (401). The session is gone … @@ -162,13 +133,12 @@ cannot replicate that flow. `POST /api/v3/authentication/local` exists but is for accounts with local credentials, which federated school accounts do not have. -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 +Given a keepalive that holds a session to its 30-day ceiling, the pasted-JWT +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. diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index cd74ae0..3df6c91 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -155,12 +155,13 @@ 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 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`. Whether the keepalive suffices at all is still being - measured; see docs/AUTH.md. +- **The Schulcloud session has a 2-hour sliding TTL**, so the server calls + `refresh-session` every 30 minutes. Watch for + `keepalive: session extended, 7200s` in the logs. +- **Never leave a Schulportal tab open on the token you deployed.** It shares + 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. diff --git a/scripts/session-diagnose.mjs b/scripts/session-diagnose.mjs index 7426e80..57e8a17 100644 --- a/scripts/session-diagnose.mjs +++ b/scripts/session-diagnose.mjs @@ -1,27 +1,22 @@ #!/usr/bin/env node /** - * Instruments the Schulcloud session to find out what actually ends it. + * Watches a Schulcloud session to confirm it is actually being held. * - * 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: + * The session is a Valkey whitelist entry with a 7200 s TTL that every + * authenticated request re-sets, so the keepalive should hold it to the JWT's + * 30-day ceiling. The thing that breaks it is not a clock: a Schulportal tab + * left open shares the same token and its client-side auto-logout issues + * `POST /api/v3/logout` ~2 h after login, deleting the shared key. See + * docs/AUTH.md. * - * (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 calls refresh-session every 10 minutes and logs the reported budget, so + * the shape of the log at death tells you which it was: * - * 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 steady at 7200, then an abrupt 401 → revoked (open tab, or logout) + * budget decaying 7200 → 0 across pings → extension not taking effect * - * 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). + * Read-only with respect to user data. Usage: `npm run session-diagnose [minutes]` + * (default 150 — enough to pass the ~2 h mark where an open tab would strike). */ import { loadConfig } from '../dist/config.js'; import { SchulcloudClient, SchulcloudApiError } from '../dist/schulcloud/client.js'; @@ -80,12 +75,17 @@ function verdict(series, died) { 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.'); + console.log('=> the extension is not taking effect. Shorten KEEPALIVE_INTERVAL_MS; the'); + console.log(' series above 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).'); + console.log('=> REVOKED from outside, not expired. The budget was healthy right up to the 401.'); + if (minutesAlive > 100 && minutesAlive < 160) { + console.log(' ~2h after login is the signature of a Schulportal tab left open on this'); + console.log(' token: its auto-logout calls POST /api/v3/logout and deletes the shared'); + console.log(' key. Close every Schulportal window and use a fresh token (docs/AUTH.md).'); + } else { + console.log(' Check for an explicit logout, or an IDP back-channel logout.'); + } } } diff --git a/src/keepalive.ts b/src/keepalive.ts index 6251021..9f9965c 100644 --- a/src/keepalive.ts +++ b/src/keepalive.ts @@ -4,27 +4,25 @@ import { SchulcloudApiError } from './schulcloud/client.ts'; /** * Keeps the Schulcloud session alive. * - * 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: + * The JWT's `exp` claim (30 days) is only an outer ceiling. The binding limit + * is a Valkey whitelist entry, `jwt:{accountId}:{jti}`, with a + * `JWT_TIMEOUT_SECONDS` TTL — 7200s on this instance, readable from + * `GET /api/v3/config/public`. Every authenticated request re-sets it, so the + * window slides and periodic traffic holds a session to the 30-day ceiling. * - * 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. + * A plain GET would therefore do. We call `refresh-session` instead, the + * endpoint behind the web UI's "Sitzung verlängern" button, for two reasons: + * it states the intent contractually rather than depending on a side effect of + * an unrelated read (upstream has refactored this whitelist twice in 2026, and + * a GET-based keepalive would fail *silently* if extend-on-check went away), + * and it returns the remaining budget, so the log answers "is the session + * healthy" directly. * - * 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. + * What this CANNOT protect against: a Schulportal tab left open on the same + * token. The browser's `jwt` cookie is the same session, and the front end's + * client-side timer calls logout roughly two hours after login, deleting the + * shared key out from under us. See docs/AUTH.md — the fix is to close the tab, + * not to ping harder. */ export class SessionKeepalive { private timer: NodeJS.Timeout | undefined; @@ -84,10 +82,11 @@ 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 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.', + '[schulcloud-mcp] keepalive: token rejected (401). The session is gone. ' + + '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.', ); this.stop(); return;