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

@@ -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.