Files
Schulcloud-MCP/docs/AUTH.md
MechaCat02 d657ece436 Fix session lifetime: 2h sliding idle timeout, not 30 days
The JWT's exp claim says 30 days, and I took that as the session
lifetime. It is only an outer ceiling. The server also keeps a per-token
whitelist entry in Valkey (jwt:{accountId}:{jti}) whose TTL is
JWT_TIMEOUT_SECONDS — 7200s on this instance — and JwtStrategy.validate
re-sets it on every authenticated request. Two hours idle and the token
is rejected with 29 days still on exp.

Proven, not inferred: the token from yesterday returned 401 at 13.8h old.
The live instance publishes the values unauthenticated at
GET /api/v3/config/public — JWT_TIMEOUT_SECONDS 7200,
JWT_SHOW_TIMEOUT_WARNING_SECONDS 3600, the latter being exactly the
one-hour UI prompt that prompted this investigation.

refresh-session turns out not to be special: it extends through the same
guard as any other route, and uniquely only in returning the remaining
TTL. So the keepalive uses GET /api/v3/me instead, and the server stays
GET-only; the one POST in the repo is in scripts/probe.mjs, where it
reports the idle budget.

JWT_EXTENDED_TIMEOUT_SECONDS (~1 month) exists in the config schema but
is vestigial: privateDevice has no references in the current NestJS
source, and generateJwtAndAddToWhitelist never overrides the TTL.

Also fixes a real breakage this surfaced: TypeScript parameter
properties are rejected by Node's type stripping, so `npm run dev` and
`npm test` both failed on any file reaching them. Rewritten as explicit
fields, and noted in CLAUDE.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 13:07:22 +02:00

7.0 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: two clocks, and the short one is the one that bites

This is the part that is easy to get wrong, because the JWT lies to you by omission. Two independent limits govern the token:

Clock Value Extendable?
Idle timeout — a whitelist entry in the server's Valkey store 7200 s (2 hours) Yes — reset by every authenticated request
Hard expiry — the JWT's own exp claim 30 days No

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

How the idle timeout works

JwtStrategy.validate() runs on every request behind @JwtAuthentication() and calls JwtWhitelistAdapter.isWhitelisted(accountId, jti). That method:

  1. reads Valkey key jwt:{accountId}:{jti}; if it is gone, throws UnauthorizedException('Session was expired due to inactivity - autologout.');
  2. re-sets the key with a fresh JWT_TIMEOUT_SECONDS TTL.

Step 2 is the whole mechanism. The window slides: any successful API call buys another two hours. Two hours of silence and the token is gone, with 29 days still left on exp.

Source: apps/server/src/infra/auth-guard/strategy/jwt.strategy.ts and apps/server/src/infra/jwt-whitelist/adapter/jwt-whitelist.adapter.ts.

The instance publishes these values

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 }

JWT_SHOW_TIMEOUT_WARNING_SECONDS: 3600 is exactly the web UI's behaviour: it warns when 3600 s of the 7200 s budget remain — i.e. after one hour of inactivity — and offers "Sitzung verlängern". Missing that prompt really does log you out; it is not just the frontend discarding the token.

Verified empirically

A token issued 2026-09-11T21:12Z was last used at ~21:47Z. At 2026-09-12T11:00Z — 13.8 hours later, with 29 days left on expGET /api/v3/me returned 401. Consistent with the 2-hour idle timeout, and decisive against the 30-day reading.

refresh-session is not special

POST /api/v3/authentication/refresh-session is what the "Sitzung verlängern" button calls, but it carries @JwtAuthentication() like every other route, so the extension is a side effect of the guard — the same side effect a plain GET /api/v3/me produces. Its handler only calls getJwtTtlFromWhitelist and returns { expiresInSeconds }.

That makes it useful as a measuring instrument rather than a necessity: it is the only way to read how much idle budget is left. npm run probe reports it. It is also the only non-GET call anywhere in this repository, and it lives in that diagnostic script rather than in the server.

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(accountId, jti) with no TTL override, so every token gets jwtTimeoutSeconds. A "remember me" login will not buy a longer idle window.

How this server stays alive

src/keepalive.ts pings GET /api/v3/me every 30 minutes (configurable via KEEPALIVE_INTERVAL_MS; 0 disables it). Started by both entry points, independent of whether any MCP client is connected — the token expires on wall time, not on usage.

Thirty minutes against a 7200 s budget tolerates three consecutive failures before the session is at risk. A transient failure retries in 5 minutes; a 401 stops the keepalive permanently and logs what to do, because a lapsed whitelist entry cannot be revived by retrying — only by pasting a new token.

Operational consequence worth knowing: if the container is down for more than two hours — a long power cut, a Pi left off overnight — the token is dead when it comes back, and restarting will not fix it. The startup log says so immediately:

[schulcloud-mcp] keepalive: token rejected (401). The session has expired …

With the keepalive running, a token survives up to its 30-day hard expiry, at which point it must be replaced by hand regardless.

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 token to its 30-day ceiling, the pasted-JWT approach is still the right trade: one manual step a month against re-implementing an OAuth client we cannot hold the secret for. 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.