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>
This commit is contained in:
2026-09-12 13:07:22 +02:00
parent 35125b7683
commit d657ece436
14 changed files with 408 additions and 57 deletions

View File

@@ -79,6 +79,8 @@ current material**; lessons are the older format.
| One file's metadata | `GET /api/v3/file/{fileRecordId}` |
| File bytes | `GET /api/v3/file/download/{fileRecordId}/{fileName}` |
| News | `GET /api/v3/news` |
| Instance settings (no auth) | `GET /api/v3/config/public` |
| Remaining idle budget | `POST /api/v3/authentication/refresh-session``{expiresInSeconds}` |
### Gotchas that cost real time
@@ -115,6 +117,16 @@ ids as `{buffer:{type:'Buffer',data:[...]}}` rather than hex strings — a leak
from the legacy Mongo serialisation. `normalizeObjectId` in `src/render.ts`
converts them.
**The JWT's `exp` is not the session lifetime.** A server-side whitelist entry
in Valkey (`jwt:{accountId}:{jti}`) expires after `JWT_TIMEOUT_SECONDS` — 7200 s
on this instance — and every authenticated request re-sets it. Two hours idle
and the token is rejected with 29 days still on `exp`. The live values are
public at `GET /api/v3/config/public`. Full write-up in `docs/AUTH.md`.
**`GET /api/v3/config/public` is unauthenticated and useful.** 78 keys of
instance configuration, including the session timeouts and feature flags. Handy
for checking deployed settings without a token.
**`Content-Disposition` on downloads is malformed.** It comes back as
`attachment;; filename="…"` — note the doubled semicolon — and the filename is
percent-encoded inside the quotes. Parse defensively.
@@ -132,4 +144,5 @@ the Etherpad-style editor, not the document text.
## Re-verifying after an upstream release
`npm run probe` re-checks every assumption above against the live instance and
prints what it finds, including days left on the token.
prints what it finds — both token clocks included: days until hard expiry, and
seconds of idle budget remaining.

View File

@@ -14,44 +14,106 @@ Cookie: jwt=<jwt> → 200 # also works
(no auth) → 401
```
`connect.sid`, `SERVERID` and `isLoggedIn` are **not** needed. There is no
cookie jar, no session to keep alive, and no `refresh-session` call on a timer.
`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: 30 days
## Token lifetime: two clocks, and the short one is the one that bites
The token is a standard JWT. Decoded from the live instance:
This is the part that is easy to get wrong, because the JWT lies to you by
omission. Two independent limits govern the token:
```
iss / aud : schulcloud-thueringen.de
iat → exp : 720 hours (exactly 30 days)
claims : accountId, userId, schoolId, roles, systemId, jti,
isExternalUser, isServiceAccount, support
| 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 }
```
So a token copied today works for a month, and refreshing it is a calendar
chore rather than an engineering problem. `npm run probe` prints the days
remaining.
`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.
## Getting a fresh token
### Verified empirically
1. Log in to the instance in a normal browser.
2. DevTools → **Application****Cookies** → the instance's origin.
3. Copy the value of the **`jwt`** cookie.
4. Put it in `TSC_JWT_COOKIE` in `.env` and restart the server
(`docker compose restart schulcloud-mcp`).
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.
There is no need to log out afterwards; the token stays valid independently of
the browser session.
### `refresh-session` is not special
## How you will know it expired
`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 }`.
Every tool returns a specific message on `401` rather than a generic failure:
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.
> Schulcloud rejected the token … The JWT in TSC_JWT_COOKIE has expired or been
> revoked.
### There is no longer window available
That message is the signal to redo the four steps above. A `403` means the
account genuinely lacks access to that resource and is *not* a token problem.
`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
@@ -62,9 +124,9 @@ 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 30-day token, the pasted-JWT approach is 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
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.

View File

@@ -35,11 +35,13 @@ docker compose logs -f schulcloud-mcp
Expect:
```
[schulcloud-mcp] listening on 0.0.0.0:8080 — instance https://… , auth enabled
[schulcloud-mcp] listening on 0.0.0.0:8080 — instance https://… , auth enabled, keepalive every 30min
```
`auth DISABLED` there means `MCP_AUTH_TOKEN` is empty — fix it before exposing
the service.
the service. A `keepalive: token rejected (401)` line right after startup means
the Schulcloud token is dead and needs replacing (see docs/AUTH.md); the server
will run but every tool will fail.
## Joining the existing Caddy
@@ -153,5 +155,11 @@ npm run probe # re-verify the API assumptions
`no-new-privileges`, running as the unprivileged `node` user. It writes
nothing to disk — downloads are streamed through memory, capped at
`MAX_DOWNLOAD_BYTES` (25 MiB default).
- **Monthly chore**: refresh `TSC_JWT_COOKIE`. `npm run probe` tells you how
many days are left.
- **The Schulcloud session dies after 2 hours of inactivity**, so the server
pings `/api/v3/me` every 30 minutes to hold it open. This has a consequence
worth planning for: **downtime longer than two hours kills the token**, and
restarting does not recover it — a long power cut means pasting a fresh
`TSC_JWT_COOKIE`. The startup log says so immediately. Background in
docs/AUTH.md.
- **Monthly chore**: refresh `TSC_JWT_COOKIE` before its 30-day hard expiry.
`npm run probe` reports both clocks.