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

156 lines
7.0 KiB
Markdown

# 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:
```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.