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>
7.9 KiB
Authentication
What this server uses
The jwt cookie from a logged-in browser session, sent verbatim as
Authorization: Bearer <token>. That is the whole mechanism.
This was worth confirming rather than assuming, because the obvious reading of "it's a cookie" leads somewhere much more complicated. Verified live:
Authorization: Bearer <jwt> → 200 # what this server does
Cookie: jwt=<jwt> → 200 # also works
(no auth) → 401
connect.sid, SERVERID and isLoggedIn are not needed — there is no
cookie jar. There is a session to keep alive, though not in the way the
cookies suggest; see the next section.
Token lifetime, and the thing that actually kills it
Two clocks govern the token:
| Clock | Value | Notes |
|---|---|---|
Session — a whitelist entry in Valkey, jwt:{accountId}:{jti} |
JWT_TIMEOUT_SECONDS = 7200 s |
Sliding: every authenticated request re-sets it |
Hard expiry — the JWT's own exp claim |
30 days | Cannot be extended |
JwtStrategy.validate() calls JwtWhitelistAdapter.isWhitelisted(), which
reads the key, throws
'Session was expired due to inactivity - autologout.' if it is gone, and then
re-sets it with a fresh TTL. The legacy Feathers implementation does the
same. So the window really does slide with use, and a keepalive holds a session
up to the 30-day ceiling.
But a browser tab will log this server out
The trap is not a clock at all. The jwt cookie you copy is the browser's own
session token — same jti. This server and that tab share one session, and
the tab will end it:
- On login the front end starts a purely client-side timer:
sessionTimeoutTimestamp = Date.now() + JWT_TIMEOUT_SECONDS. It is reset only on route change —watch(router.currentRoute, startTimer)— never by API activity, and never read back from the server's real TTL. (nuxt-client/src/modules/data/application/application.store.ts) - After
JWT_SHOW_TIMEOUT_WARNING_SECONDS(3600 s) remain it shows the "Sitzung verlängern" dialog. (AutoLogoutWarning.vue) - At zero it calls
autoLogout()→location.replace('/logout?auto-logout=true'). - That route, in the legacy client, issues
POST /api/v3/logout(schulcloud-client/controllers/login.js:439) →removeJwtFromWhitelist(jwt)→ the shared key is deleted.
An idle tab therefore revokes this server's token about two hours after login,
no matter how diligently the server refreshes it. Measured exactly that way: a
keepalive pinged successfully at t+0/30/60/90 and was dead by t+120, ~2 h after
login, while exp still had 29 days left.
Note the timer is client-side and reset only by navigation. That is why the dialog appears after an idle hour even in a tab you consider "in use", and why no amount of API traffic prevents it.
So: close the tab
After copying the jwt cookie, close the Schulportal tab — all of them; the
front end also syncs logout across tabs over a BroadcastChannel. Closing a tab
runs no logout itself (autoLogout fires only from the expiry timer), so the
session survives on the server with nothing left to revoke it.
The tidiest recipe is a private/incognito window: log in there, copy the
jwt, close the window. That session is separate from your normal browsing, and
POST /api/v3/logout only ever removes its own jti, so your everyday session
and this server cannot end each other.
With no tab attached, the keepalive holds the session to the 30-day hard expiry, and replacing the token becomes the monthly chore it looked like at first.
There is no longer window available
config/default.schema.json documents JWT_EXTENDED_TIMEOUT_SECONDS
(2629746 s ≈ 1 month), reachable in the legacy stack via a privateDevice
login flag. On the current NestJS server that is vestigial: privateDevice has
no references in apps/server/src, and generateJwtAndAddToWhitelist calls
addToWhitelist with no TTL override. A "remember me" login buys nothing.
How this server stays alive
src/keepalive.ts calls POST /api/v3/authentication/refresh-session every 30
minutes (KEEPALIVE_INTERVAL_MS; 0 disables it), from both entry points and
independently of whether any MCP client is connected — the session expires on
wall time, not on usage. It logs the budget each time:
[schulcloud-mcp] keepalive: session extended, 7200s (120 min) of budget left
A plain GET would also slide the TTL today. refresh-session is used anyway
because it states the intent contractually rather than relying 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 ever went away.
The budget readout also turns "is the session healthy" into something the log
answers directly.
It is the one non-GET request in the server. No body, touches only the caller's 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, and the property that matters is intact: nobody reaching this endpoint can act as the user in Schulcloud.
A transient failure retries in 5 minutes. A 401 stops the keepalive
permanently and says what to do, because a deleted whitelist entry cannot be
revived by retrying — and if it happens roughly two hours after login, suspect
an open tab before anything else.
npm run session-diagnose logs the budget every 10 minutes for ~2.5 h, which is
the direct way to confirm a token is holding.
Operational consequence: if the container is down for over two hours the session lapses on its own, and restarting will not recover it. The startup log says so immediately:
[schulcloud-mcp] keepalive: token rejected (401). The session is gone …
Why not username + password
The instance's login redirects to Keycloak (realm TIS) with a redirect_uri
pointing back at Schulcloud's own server, so the authorization-code exchange
happens server-side with a client secret only Schulcloud holds. A third party
cannot replicate that flow. POST /api/v3/authentication/local exists but is
for accounts with local credentials, which federated school accounts do not
have.
Given a keepalive that holds a session to its 30-day ceiling, the pasted-JWT approach is the right trade: one manual step a month against re-implementing an OAuth client whose secret we cannot hold. If that monthly step ever becomes unacceptable, the honest options are a service account issued by the school's IDM, or driving the Keycloak login with a headless browser — not a reimplementation of the code exchange. If this ever needs to be unattended, the honest options are a service account issued by the school's IDM, or a headless browser login — not a reimplementation of the Keycloak exchange.
Protecting this server's own endpoint
Distinct from the above, and just as important. The MCP endpoint is reachable
from the public internet by construction: Claude's connectors call it from
Anthropic's cloud, not from your machine. It is protected by MCP_AUTH_TOKEN,
a shared secret checked in constant time on every /mcp request
(src/http/auth.ts), accepted as either Authorization: Bearer … or
X-Api-Key. /healthz is deliberately open and reveals nothing.
Generate one with openssl rand -hex 32. If it is unset the server logs a loud
warning and serves unauthenticated — only acceptable bound to localhost.
Rotating it: change MCP_AUTH_TOKEN in .env, restart the container, update
the connector in Claude. Nothing else stores it.
Blast radius
Every path in this server is a GET, including the api_get escape hatch,
which rejects anything not starting with /api/ and anything carrying a scheme
or host. Someone who obtained both the endpoint URL and MCP_AUTH_TOKEN could
read this account's Schulcloud data; they could not post, submit, delete, or
otherwise act as the user. Keep it that way — adding a single write tool would
change that property entirely.