# Authentication ## What this server uses The `jwt` cookie from a logged-in browser session, sent verbatim as `Authorization: Bearer `. 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 → 200 # what this server does Cookie: 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: ```bash 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 `exp` — `GET /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.