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:
2026-09-12 16:32:42 +02:00
parent 60ca4d3eba
commit 9ce869f3fb
7 changed files with 155 additions and 181 deletions

View File

@@ -4,27 +4,25 @@ 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:
* The JWT's `exp` claim (30 days) is only an outer ceiling. The binding limit
* is a Valkey whitelist entry, `jwt:{accountId}:{jti}`, with a
* `JWT_TIMEOUT_SECONDS` TTL — 7200s on this instance, readable from
* `GET /api/v3/config/public`. Every authenticated request re-sets it, so the
* window slides and periodic traffic holds a session to the 30-day ceiling.
*
* 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.
* A plain GET would therefore do. We call `refresh-session` instead, the
* endpoint behind the web UI's "Sitzung verlängern" button, for two reasons:
* it states the intent contractually rather than depending on a side effect of
* an unrelated read (upstream has refactored this whitelist twice in 2026, and
* a GET-based keepalive would fail *silently* if extend-on-check went away),
* and it returns the remaining budget, so the log answers "is the session
* healthy" directly.
*
* 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.
* What this CANNOT protect against: a Schulportal tab left open on the same
* token. The browser's `jwt` cookie is the same session, and the front end's
* client-side timer calls logout roughly two hours after login, deleting the
* shared key out from under us. See docs/AUTH.md — the fix is to close the tab,
* not to ping harder.
*/
export class SessionKeepalive {
private timer: NodeJS.Timeout | undefined;
@@ -84,10 +82,11 @@ 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 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.',
'[schulcloud-mcp] keepalive: token rejected (401). The session is gone. ' +
'If this is ~2h after login, the likely cause is a Schulportal tab left open ' +
'on the same token, whose auto-logout revoked it — close the tab. Otherwise ' +
'the server was down past the 2h window, or the JWT hit its 30-day limit. ' +
'Put a fresh jwt cookie in TSC_JWT_COOKIE and restart. Keepalive stopped.',
);
this.stop();
return;