# 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: 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 ~t−13) | **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: ```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 } ``` 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/oidc` → `removeUserFromWhitelist(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.