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>
103 lines
3.9 KiB
TypeScript
103 lines
3.9 KiB
TypeScript
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 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:
|
|
*
|
|
* 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;
|
|
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<void> {
|
|
if (this.stopped) return;
|
|
try {
|
|
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) {
|
|
// 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 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;
|
|
}
|
|
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);
|
|
}
|
|
}
|
|
}
|