Root cause: an open Schulportal tab revokes the shared token
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>
This commit is contained in:
@@ -1,27 +1,22 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Instruments the Schulcloud session to find out what actually ends it.
|
||||
* Watches a Schulcloud session to confirm it is actually being held.
|
||||
*
|
||||
* 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:
|
||||
* 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.
|
||||
*
|
||||
* (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 calls refresh-session every 10 minutes and logs the reported budget, so
|
||||
* the shape of the log at death tells you which it was:
|
||||
*
|
||||
* 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 steady at 7200, then an abrupt 401 → revoked (open tab, or logout)
|
||||
* budget decaying 7200 → 0 across pings → extension not taking effect
|
||||
*
|
||||
* 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).
|
||||
* 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';
|
||||
@@ -80,12 +75,17 @@ function verdict(series, died) {
|
||||
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.');
|
||||
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('=> 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).');
|
||||
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.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user