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 <noreply@anthropic.com>
92 lines
4.1 KiB
JavaScript
92 lines
4.1 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* Watches a Schulcloud session to confirm it is actually being held.
|
|
*
|
|
* 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.
|
|
*
|
|
* 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:
|
|
*
|
|
* budget steady at 7200, then an abrupt 401 → revoked (open tab, or logout)
|
|
* budget decaying 7200 → 0 across pings → extension not taking effect
|
|
*
|
|
* 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';
|
|
|
|
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('=> 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('=> 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.');
|
|
}
|
|
}
|
|
}
|