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>
156 lines
7.0 KiB
Markdown
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.
|