Keepalive via refresh-session; GET pings measured insufficient
The endurance test refuted the sliding-window model I committed earlier. A keepalive doing only GET /api/v3/me succeeded at t+0/30/60/90 and was still rejected by t+120 — consistent with the session ending ~2h after LOGIN (t+107), and inconsistent with 2h after the last request, which would have been t+210. This is a live-vs-source divergence, not a misreading: both the current JwtWhitelistAdapter and the legacy Feathers ensureTokenIsWhitelisted re-set the Valkey TTL on every authenticated request, so the source reads as a sliding window. The instance does not behave that way. So the keepalive now calls POST /authentication/refresh-session, the endpoint behind the UI's "Sitzung verlängern" button, which a separate 100s test showed does hold the reported budget at 7200s. It is the only non-GET request in the server: no body, touches only our 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. It logs the returned budget, which makes a failing extension visible before the session is lost. Whether this is sufficient is NOT established. Two mechanisms still fit: an idle TTL that reads fail to refresh (keepalive works), or an absolute cap/revocation anchored at login — e.g. the IDP's back-channel logout, which clears every token for the account rather than one. Added scripts/session-diagnose.mjs to settle it: it logs the budget every 10 min, so a decaying series indicates the former and an abrupt 401 at 7200s the latter. Docs state the open question rather than asserting a mechanism. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
172
docs/AUTH.md
172
docs/AUTH.md
@@ -18,36 +18,59 @@ Cookie: jwt=<jwt> → 200 # also works
|
||||
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
|
||||
## Token lifetime: what the source says, and what the instance does
|
||||
|
||||
This is the part that is easy to get wrong, because the JWT lies to you by
|
||||
omission. Two independent limits govern the token:
|
||||
Two clocks govern the token, and only one of them is in the JWT:
|
||||
|
||||
| Clock | Value | Extendable? |
|
||||
| Clock | Value | Moves with use? |
|
||||
|---|---|---|
|
||||
| **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 |
|
||||
| **Session** — a whitelist entry in the server's Valkey store | ends **~2 h after login** | **No** (measured) |
|
||||
| **Hard expiry** — the JWT's own `exp` claim | 30 days | No (cannot be extended) |
|
||||
|
||||
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.
|
||||
The token dies at whichever comes first. Decoding the JWT shows only the
|
||||
second, which is how "valid for 30 days" becomes a plausible and wrong
|
||||
conclusion — it was the first conclusion drawn here, and it is wrong.
|
||||
|
||||
### How the idle timeout works
|
||||
### What the source says
|
||||
|
||||
`JwtStrategy.validate()` runs on every request behind `@JwtAuthentication()`
|
||||
and calls `JwtWhitelistAdapter.isWhitelisted(accountId, jti)`. That method:
|
||||
`JwtStrategy.validate()` runs on every route behind `@JwtAuthentication()` and
|
||||
calls `JwtWhitelistAdapter.isWhitelisted(accountId, jti)`, which reads Valkey
|
||||
key `jwt:{accountId}:{jti}`, throws
|
||||
`'Session was expired due to inactivity - autologout.'` if it is gone, and then
|
||||
**re-sets the key with a fresh `JWT_TIMEOUT_SECONDS` TTL**. The legacy Feathers
|
||||
implementation it replaced (`ensureTokenIsWhitelisted`) does the same thing,
|
||||
with the comment "extend token expiration if token is already whitelisted".
|
||||
|
||||
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.**
|
||||
Read on its own, that says the window slides with ordinary use.
|
||||
|
||||
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`.
|
||||
### What the instance actually does
|
||||
|
||||
Source: `apps/server/src/infra/auth-guard/strategy/jwt.strategy.ts` and
|
||||
`apps/server/src/infra/jwt-whitelist/adapter/jwt-whitelist.adapter.ts`.
|
||||
It does not slide. A keepalive doing nothing but `GET /api/v3/me` every 30
|
||||
minutes was measured against the live instance:
|
||||
|
||||
### The instance publishes these values
|
||||
```
|
||||
[t+0.0min] ping #1 -> 200 OK
|
||||
[t+30.0min] ping #2 -> 200 OK
|
||||
[t+60.0min] ping #3 -> 200 OK
|
||||
[t+90.0min] ping #4 -> 200 OK
|
||||
[t+120.0min] 401 Unauthorized ← token gone
|
||||
```
|
||||
|
||||
The timing is what makes this conclusive:
|
||||
|
||||
| If the limit were… | Predicted death | Observed? |
|
||||
|---|---|---|
|
||||
| 2 h after the **last request** | t+210 min | **no** — died by t+120 |
|
||||
| 2 h after **login** | t+107 min (login was ~t−13) | **yes** — inside the (t+90, t+120] window |
|
||||
|
||||
Four requests succeeded inside the window and bought nothing. The binding clock
|
||||
is anchored at **login**, and ordinary reads do not move it.
|
||||
|
||||
A separate 100-second test does show `POST /authentication/refresh-session`
|
||||
holding the reported budget at 7200 s rather than letting it decay to ~7100, so
|
||||
that endpoint does *something* the reads do not.
|
||||
|
||||
### Values the instance publishes
|
||||
|
||||
Unauthenticated, so you can check them any time:
|
||||
|
||||
@@ -56,64 +79,79 @@ curl -s "$TSC_URL/api/v3/config/public" | jq '{JWT_TIMEOUT_SECONDS, JWT_SHOW_TIM
|
||||
# { "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.
|
||||
7200 s matches the observed ~2 h, and `JWT_SHOW_TIMEOUT_WARNING_SECONDS: 3600`
|
||||
is exactly the web UI's prompt one hour in, warning of expiry an hour later,
|
||||
with a "Sitzung verlängern" button. That the prompt appears at all is itself
|
||||
evidence: if ordinary activity extended the session, an actively used portal
|
||||
would never show it.
|
||||
|
||||
### Verified empirically
|
||||
### The open question
|
||||
|
||||
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.
|
||||
Two mechanisms fit the evidence, and they differ in what they mean for this
|
||||
server:
|
||||
|
||||
### `refresh-session` is not special
|
||||
1. **An idle TTL that ordinary reads fail to refresh.** Then explicitly calling
|
||||
`refresh-session` on a timer holds the session indefinitely, up to the
|
||||
30-day hard expiry, and the keepalive below is the whole fix.
|
||||
2. **An absolute cap at login + 2 h, or revocation from outside** — for
|
||||
instance the identity provider ending its SSO session, whose back-channel
|
||||
logout (`POST /api/v3/logout/oidc` → `removeUserFromWhitelist(account)`)
|
||||
clears *every* token for the account, not just one. Then no keepalive can
|
||||
help, and a pasted JWT is only ever good for ~2 h from login.
|
||||
|
||||
`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 }`.
|
||||
`npm run session-diagnose` settles it. It calls `refresh-session` every 10
|
||||
minutes and logs the reported budget, so the shape of the log at death is the
|
||||
answer: a budget decaying 7200 → 0 means (1); a budget sitting at 7200 until an
|
||||
abrupt 401 means (2).
|
||||
|
||||
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.
|
||||
Note that plain `POST /api/v3/logout` removes only its own `jti`, so logging
|
||||
out in a browser does **not** kill this server's token. Only the OIDC
|
||||
back-channel variant clears all of them.
|
||||
|
||||
### 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.
|
||||
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 will not buy a
|
||||
longer window.
|
||||
|
||||
## How this server stays alive
|
||||
## How this server tries to stay 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:
|
||||
`src/keepalive.ts` calls `POST /api/v3/authentication/refresh-session` every 30
|
||||
minutes (`KEEPALIVE_INTERVAL_MS`; `0` disables it), started by both entry
|
||||
points, independent of whether any MCP client is connected — the session
|
||||
expires on wall time, not on usage. It logs the budget each time, so the log
|
||||
shows whether the extension is taking:
|
||||
|
||||
```
|
||||
[schulcloud-mcp] keepalive: token rejected (401). The session has expired …
|
||||
[schulcloud-mcp] keepalive: session extended, 7200s (120 min) of budget left
|
||||
```
|
||||
|
||||
With the keepalive running, a token survives up to its 30-day hard expiry, at
|
||||
which point it must be replaced by hand regardless.
|
||||
A budget well under 7200 s is the early warning that the session is being lost.
|
||||
A transient failure retries in 5 minutes; a `401` stops the keepalive
|
||||
permanently and says what to do, because a cleared whitelist entry cannot be
|
||||
revived by retrying.
|
||||
|
||||
**This is the one non-GET request in the server.** It takes no body, touches
|
||||
only the caller's own session, and cannot read or modify user data, so it does
|
||||
not weaken the property that matters — nobody reaching this endpoint can act as
|
||||
the user inside Schulcloud. No MCP tool exposes it, so a model-driven call can
|
||||
never be a POST. A plain `GET` was tried first and is what the endurance test
|
||||
above ruled out.
|
||||
|
||||
**Whether this is sufficient is not yet established.** If
|
||||
`npm run session-diagnose` reports mechanism (2), the keepalive cannot work and
|
||||
the auth approach itself needs revisiting.
|
||||
|
||||
**Operational consequence either way:** if the container is down for more than
|
||||
two hours, the token is dead when it returns and restarting will not fix it.
|
||||
The startup log says so immediately:
|
||||
|
||||
```
|
||||
[schulcloud-mcp] keepalive: token rejected (401). The session is gone …
|
||||
```
|
||||
|
||||
## Why not username + password
|
||||
|
||||
@@ -124,9 +162,13 @@ 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
|
||||
Whether the pasted-JWT approach remains the right trade depends on the open
|
||||
question above. If a keepalive can hold a session to its 30-day ceiling, it is:
|
||||
one manual step a month against re-implementing an OAuth client whose secret we
|
||||
cannot hold. If the session is instead capped at ~2 h from login, a pasted JWT
|
||||
is not viable for an unattended server, and the realistic options become a
|
||||
service account issued by the school's IDM, or driving the Keycloak login with
|
||||
a headless browser. 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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user