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:
91
scripts/session-diagnose.mjs
Normal file
91
scripts/session-diagnose.mjs
Normal 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).');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user