Files
Schulcloud-MCP/docs/AUTH.md
MechaCat02 60ca4d3eba Keepalive via refresh-session; GET pings measured insufficient
The endurance test refuted the sliding-window model I committed earlier.
A keepalive doing only GET /api/v3/me succeeded at t+0/30/60/90 and was
still rejected by t+120 — consistent with the session ending ~2h after
LOGIN (t+107), and inconsistent with 2h after the last request, which
would have been t+210.

This is a live-vs-source divergence, not a misreading: both the current
JwtWhitelistAdapter and the legacy Feathers ensureTokenIsWhitelisted
re-set the Valkey TTL on every authenticated request, so the source
reads as a sliding window. The instance does not behave that way.

So the keepalive now calls POST /authentication/refresh-session, the
endpoint behind the UI's "Sitzung verlängern" button, which a separate
100s test showed does hold the reported budget at 7200s. It is the only
non-GET request in the server: no body, touches only our 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. It logs the returned budget, which
makes a failing extension visible before the session is lost.

Whether this is sufficient is NOT established. Two mechanisms still fit:
an idle TTL that reads fail to refresh (keepalive works), or an absolute
cap/revocation anchored at login — e.g. the IDP's back-channel logout,
which clears every token for the account rather than one. Added
scripts/session-diagnose.mjs to settle it: it logs the budget every 10
min, so a decaying series indicates the former and an abrupt 401 at
7200s the latter. Docs state the open question rather than asserting a
mechanism.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 15:46:54 +02:00

8.8 KiB
Raw Blame History

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: what the source says, and what the instance does

Two clocks govern the token, and only one of them is in the JWT:

Clock Value Moves with use?
Session — a whitelist entry in the server's Valkey store ends ~2 h after login No (measured)
Hard expiry — the JWT's own exp claim 30 days No (cannot be extended)

The token dies at whichever comes first. Decoding the JWT shows only the second, which is how "valid for 30 days" becomes a plausible and wrong conclusion — it was the first conclusion drawn here, and it is wrong.

What the source says

JwtStrategy.validate() runs on every route behind @JwtAuthentication() and calls JwtWhitelistAdapter.isWhitelisted(accountId, jti), which reads Valkey key jwt:{accountId}:{jti}, throws 'Session was expired due to inactivity - autologout.' if it is gone, and then re-sets the key with a fresh JWT_TIMEOUT_SECONDS TTL. The legacy Feathers implementation it replaced (ensureTokenIsWhitelisted) does the same thing, with the comment "extend token expiration if token is already whitelisted".

Read on its own, that says the window slides with ordinary use.

What the instance actually does

It does not slide. A keepalive doing nothing but GET /api/v3/me every 30 minutes was measured against the live instance:

[t+0.0min]   ping #1 -> 200 OK
[t+30.0min]  ping #2 -> 200 OK
[t+60.0min]  ping #3 -> 200 OK
[t+90.0min]  ping #4 -> 200 OK
[t+120.0min] 401 Unauthorized          ← token gone

The timing is what makes this conclusive:

If the limit were… Predicted death Observed?
2 h after the last request t+210 min no — died by t+120
2 h after login t+107 min (login was ~t13) yes — inside the (t+90, t+120] window

Four requests succeeded inside the window and bought nothing. The binding clock is anchored at login, and ordinary reads do not move it.

A separate 100-second test does show POST /authentication/refresh-session holding the reported budget at 7200 s rather than letting it decay to ~7100, so that endpoint does something the reads do not.

Values the instance publishes

Unauthenticated, so you can check them any time:

curl -s "$TSC_URL/api/v3/config/public" | jq '{JWT_TIMEOUT_SECONDS, JWT_SHOW_TIMEOUT_WARNING_SECONDS}'
# { "JWT_TIMEOUT_SECONDS": 7200, "JWT_SHOW_TIMEOUT_WARNING_SECONDS": 3600 }

7200 s matches the observed ~2 h, and JWT_SHOW_TIMEOUT_WARNING_SECONDS: 3600 is exactly the web UI's prompt one hour in, warning of expiry an hour later, with a "Sitzung verlängern" button. That the prompt appears at all is itself evidence: if ordinary activity extended the session, an actively used portal would never show it.

The open question

Two mechanisms fit the evidence, and they differ in what they mean for this server:

  1. An idle TTL that ordinary reads fail to refresh. Then explicitly calling refresh-session on a timer holds the session indefinitely, up to the 30-day hard expiry, and the keepalive below is the whole fix.
  2. An absolute cap at login + 2 h, or revocation from outside — for instance the identity provider ending its SSO session, whose back-channel logout (POST /api/v3/logout/oidcremoveUserFromWhitelist(account)) clears every token for the account, not just one. Then no keepalive can help, and a pasted JWT is only ever good for ~2 h from login.

npm run session-diagnose settles it. It calls refresh-session every 10 minutes and logs the reported budget, so the shape of the log at death is the answer: a budget decaying 7200 → 0 means (1); a budget sitting at 7200 until an abrupt 401 means (2).

Note that plain POST /api/v3/logout removes only its own jti, so logging out in a browser does not kill this server's token. Only the OIDC back-channel variant clears all of them.

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 will not buy a longer window.

How this server tries to stay alive

src/keepalive.ts calls POST /api/v3/authentication/refresh-session every 30 minutes (KEEPALIVE_INTERVAL_MS; 0 disables it), started by both entry points, independent of whether any MCP client is connected — the session expires on wall time, not on usage. It logs the budget each time, so the log shows whether the extension is taking:

[schulcloud-mcp] keepalive: session extended, 7200s (120 min) of budget left

A budget well under 7200 s is the early warning that the session is being lost. A transient failure retries in 5 minutes; a 401 stops the keepalive permanently and says what to do, because a cleared whitelist entry cannot be revived by retrying.

This is the one non-GET request in the server. It takes no body, touches only the caller's own session, and cannot read or modify user data, so it does not weaken the property that matters — nobody reaching this endpoint can act as the user inside Schulcloud. No MCP tool exposes it, so a model-driven call can never be a POST. A plain GET was tried first and is what the endurance test above ruled out.

Whether this is sufficient is not yet established. If npm run session-diagnose reports mechanism (2), the keepalive cannot work and the auth approach itself needs revisiting.

Operational consequence either way: if the container is down for more than two hours, the token is dead when it returns and restarting will not fix 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.

Whether the pasted-JWT approach remains the right trade depends on the open question above. If a keepalive can hold a session to its 30-day ceiling, it is: one manual step a month against re-implementing an OAuth client whose secret we cannot hold. If the session is instead capped at ~2 h from login, a pasted JWT is not viable for an unattended server, and the realistic options become a service account issued by the school's IDM, or driving the Keycloak login with a headless browser. 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.