# 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, 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: 1. 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`) 2. After `JWT_SHOW_TIMEOUT_WARNING_SECONDS` (3600 s) remain it shows the "Sitzung verlängern" dialog. (`AutoLogoutWarning.vue`) 3. At zero it calls `autoLogout()` → `location.replace('/logout?auto-logout=true')`. 4. 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. ## Replacing the token without a restart `schulcloud token set` (paste at a hidden prompt, or pipe it in) and the server's `/token` page both send a fresh token to `PUT /api/token`, behind the bearer check. `core/session-token.ts` then: 1. **Cleans the paste.** A bare value, `jwt=…; Path=/`, quotes and newlines all work. 2. **Checks before it swaps.** A malformed or expired token is refused without asking Schulcloud; otherwise `GET /api/v3/me` must succeed *with the new token*, and for the same `userId` the current one carries. A refused token changes nothing. Switching accounts stays a deliberate act — change `TSC_JWT_COOKIE` and restart. 3. **Swaps it in place.** Every request reads `config.jwt` at the moment it is sent, so the next one uses the new token; nothing caches a copy. 4. **Restarts the keepalive**, which stopped for good on a 401. Its pings carry a generation number, so one still in flight with the old token cannot stop the new cycle when its 401 arrives. 5. **Saves it** to `STATE_DIR` (0600, written beside itself and renamed). At startup the newer of the saved token and `TSC_JWT_COOKIE` wins, by `exp` — unless they belong to different accounts, when the environment does. The token appears in no log line and no response; `/api/token` reports only the expiry, where the token came from, and the keepalive's state. The claims are decoded, never verified — Schulcloud verifies, this only reads dates. The cookie is **HttpOnly**, so no script — no bookmarklet, no page on another origin — can read it out of the browser. The DevTools copy is the one manual step, and it cannot be automated away short of a headless browser holding the Schulportal password (see below). ### 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. Two ways to check that a token is holding: - `npm run keepalive-status` reads the running container's logs and summarises its extensions. It makes **no** API call on purpose — any authenticated request slides the TTL, so a checker that talked to Schulcloud would be keeping the session alive itself and could not tell you whether the keepalive works. - `npm run session-diagnose` calls refresh-session every 10 minutes for ~2.5 h and logs the budget, for when there is no container to read logs from. **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. A headless browser would have to hold the Schulportal password, which unlocks far more than this account's school files, so it is not done here. ## 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`, recreate the container, and update Claude Code and the CLI (`schulcloud login`). Nothing else stores it. ### The secret path, for claude.ai claude.ai's connector dialog takes a URL and no header, so `MCP_PATH_SECRET` opens a second way in: `//mcp`, with no bearer token. The path is the credential there. `http/auth.ts` compares it in constant time and answers a wrong one with the same 404 as any unknown path; `config.ts` insists on at least 32 URL-safe characters and never echoes the value; the server never logs request paths; and `deploy/Caddyfile.snippet` rewrites the segment before an access log entry is written. Rotating it means a new value, a recreated container, and re-adding the connector. It is a stopgap: OAuth is how connectors are meant to authenticate, and it would make the URL a plain address again. ## 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` — or the secret MCP path — could read this account's Schulcloud data; they could not post, submit, delete, or otherwise act as the user. With `MCP_AUTH_TOKEN` they could also call `PUT /api/token`, but it accepts only a live token for the same account, so the most it can do is hand the server a session the owner already has. Keep it that way — adding a single write tool would change that property entirely.