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:
@@ -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;
|
||||
|
||||
@@ -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>> {
|
||||
|
||||
Reference in New Issue
Block a user