Keepalive via refresh-session; GET pings measured insufficient

The endurance test refuted the sliding-window model I committed earlier.
A keepalive doing only GET /api/v3/me succeeded at t+0/30/60/90 and was
still rejected by t+120 — consistent with the session ending ~2h after
LOGIN (t+107), and inconsistent with 2h after the last request, which
would have been t+210.

This is a live-vs-source divergence, not a misreading: both the current
JwtWhitelistAdapter and the legacy Feathers ensureTokenIsWhitelisted
re-set the Valkey TTL on every authenticated request, so the source
reads as a sliding window. The instance does not behave that way.

So the keepalive now calls POST /authentication/refresh-session, the
endpoint behind the UI's "Sitzung verlängern" button, which a separate
100s test showed does hold the reported budget at 7200s. It is the only
non-GET request in the server: no body, touches only our 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. It logs the returned budget, which
makes a failing extension visible before the session is lost.

Whether this is sufficient is NOT established. Two mechanisms still fit:
an idle TTL that reads fail to refresh (keepalive works), or an absolute
cap/revocation anchored at login — e.g. the IDP's back-channel logout,
which clears every token for the account rather than one. Added
scripts/session-diagnose.mjs to settle it: it logs the budget every 10
min, so a decaying series indicates the former and an abrupt 401 at
7200s the latter. Docs state the open question rather than asserting a
mechanism.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-12 15:46:54 +02:00
parent d657ece436
commit 60ca4d3eba
11 changed files with 333 additions and 124 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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
```

View File

@@ -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

View File

@@ -18,36 +18,59 @@ Cookie: jwt=<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 ~t13) | **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.

View File

@@ -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.

View File

@@ -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",

View File

@@ -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).');
}
}

View File

@@ -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<void> {
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;

View File

@@ -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<MeResponse>('/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<Paginated<CourseMetadata>> {

View File

@@ -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\)/);
});
});