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:
23
README.md
23
README.md
@@ -50,21 +50,22 @@ npm run probe # verifies the token and API against the live instan
|
||||
Then either deploy it as a remote connector, or point Claude Code at
|
||||
`dist/bin/stdio.js`. Both paths are in [docs/DEPLOYMENT.md](docs/DEPLOYMENT.md).
|
||||
|
||||
Getting `TSC_JWT_COOKIE` takes four clicks in DevTools. Expect to redo it
|
||||
often until the session question in [docs/AUTH.md](docs/AUTH.md) is settled —
|
||||
sessions have been observed ending ~2 h after login.
|
||||
Getting `TSC_JWT_COOKIE` takes four clicks in DevTools and then lasts 30 days —
|
||||
provided you close the Schulportal window afterwards. See
|
||||
[docs/AUTH.md](docs/AUTH.md); that caveat is not optional.
|
||||
|
||||
## Design decisions
|
||||
|
||||
**Bearer token plus a keepalive.** The instance's `jwt` cookie works verbatim
|
||||
as `Authorization: Bearer` — no cookie jar, no `connect.sid`. But the token's
|
||||
`exp` claim (30 days) is not its lifetime: the session ends about **two hours
|
||||
after login**, and measurement showed that ordinary API reads do *not* extend
|
||||
it, despite the upstream source saying they should. So the server calls
|
||||
`refresh-session` every 30 minutes — the one non-GET request here, and not
|
||||
exposed as a tool. Whether that is enough is still being measured; see
|
||||
[docs/AUTH.md](docs/AUTH.md), which has the endurance test and the open
|
||||
question.
|
||||
as `Authorization: Bearer` — no cookie jar, no `connect.sid`. Its `exp` claim
|
||||
(30 days) is only a ceiling: the real limit is a 2-hour server-side session TTL
|
||||
that any request slides, so the server calls `refresh-session` every 30 minutes
|
||||
(the one non-GET request here, and not exposed as a tool).
|
||||
|
||||
The sharp edge is subtler and cost two endurance tests to find: **the cookie you
|
||||
copy is the browser's own session token**, so a Schulportal tab left open will
|
||||
auto-logout after ~2 hours and revoke this server's token with it. Copy the
|
||||
token in a private window and close it. See [docs/AUTH.md](docs/AUTH.md).
|
||||
|
||||
**Read-only by construction.** Every method on the API client is a `GET`,
|
||||
including `api_get`. The endpoint is internet-facing by necessity (Claude's
|
||||
|
||||
Reference in New Issue
Block a user