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:
13
.env.example
13
.env.example
@@ -6,9 +6,9 @@
|
||||
TSC_URL=https://schulcloud-thueringen.de
|
||||
|
||||
# The value of the `jwt` cookie from a logged-in browser session.
|
||||
# Two clocks apply: a 30-day hard expiry, and a 2-hour idle timeout that every
|
||||
# API call resets. The built-in keepalive handles the second one, so in practice
|
||||
# this needs replacing monthly. See docs/AUTH.md.
|
||||
# The 30-day `exp` in the token is NOT its lifetime: sessions have been measured
|
||||
# ending ~2h after login, and ordinary API reads do not extend them. The
|
||||
# keepalive calls refresh-session to try to hold it. See docs/AUTH.md.
|
||||
TSC_JWT_COOKIE=
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -39,8 +39,7 @@ BIND_HOST=0.0.0.0
|
||||
# Per-request timeout against the Schulcloud API, in ms. Default 30000.
|
||||
# REQUEST_TIMEOUT_MS=30000
|
||||
|
||||
# How often to ping Schulcloud to hold the session open, in ms. Default 1800000
|
||||
# (30 min). Must stay well under the instance's JWT_TIMEOUT_SECONDS — 7200s
|
||||
# here, readable from GET /api/v3/config/public. Set to 0 to disable, which
|
||||
# will let the token die after two hours of inactivity.
|
||||
# How often to call refresh-session to hold the session open, in ms. Default
|
||||
# 1800000 (30 min). Must stay well under the instance's JWT_TIMEOUT_SECONDS —
|
||||
# 7200s here, readable from GET /api/v3/config/public. Set to 0 to disable.
|
||||
# KEEPALIVE_INTERVAL_MS=1800000
|
||||
|
||||
21
CLAUDE.md
21
CLAUDE.md
@@ -21,6 +21,7 @@ npm test # unit tests (node:test), no network
|
||||
npm run typecheck
|
||||
npm run probe # verify token + API assumptions against the LIVE instance
|
||||
npm run smoke # full end-to-end: real server + real MCP client + real data
|
||||
npm run session-diagnose # ~2.5h: measure what actually ends the session
|
||||
```
|
||||
|
||||
`probe` and `smoke` hit the live Schulcloud and need a valid `.env`. Both are
|
||||
@@ -52,8 +53,11 @@ bin/{http,stdio}.ts → server.ts (createServer)
|
||||
|
||||
## Invariants
|
||||
|
||||
**Everything is read-only.** Every client method is a `GET`, and `api_get`
|
||||
rejects non-`/api/` paths and anything carrying a scheme or host. The endpoint
|
||||
**Everything that touches user data is read-only.** Every client method is a
|
||||
`GET` except `extendSession` (the keepalive's `refresh-session` call, which
|
||||
touches only our own session and is not exposed as a tool, so no model-driven
|
||||
call can be a POST). `api_get` rejects non-`/api/` paths and anything carrying
|
||||
a scheme or host. The endpoint
|
||||
is internet-facing by necessity, so "a leaked token cannot act as the user" is
|
||||
the property that makes that acceptable. Do not add a write tool without the
|
||||
user explicitly asking for one and understanding this.
|
||||
@@ -84,10 +88,15 @@ These cost real time to discover; `docs/API.md` has the full list with evidence.
|
||||
its own OpenAPI document. It is not in the main `docs-json`.
|
||||
- Legacy lesson responses return ids as `{buffer:{data:[...]}}`; use
|
||||
`normalizeObjectId`.
|
||||
- **The JWT dies after 2h idle, not 30 days.** `exp` is a hard ceiling; the
|
||||
real limit is a Valkey whitelist entry (`JWT_TIMEOUT_SECONDS`, live value at
|
||||
`GET /api/v3/config/public`) that every authenticated request re-sets.
|
||||
`src/keepalive.ts` holds it open. Do not "simplify" it away.
|
||||
- **The JWT dies ~2h after login, not after 30 days, and reads do not extend
|
||||
it.** `exp` is only a hard ceiling. Both the current and legacy upstream
|
||||
implementations re-set a Valkey TTL on every authenticated request, so the
|
||||
source reads as if the window slides — measured against the live instance, it
|
||||
does not: four successful `GET /me` pings at 30-min intervals did not prevent
|
||||
a 401 by t+120. `src/keepalive.ts` therefore calls `refresh-session`. Do not
|
||||
"simplify" it back to a GET, and do not trust the upstream source here.
|
||||
Open question and the instrument to settle it: `docs/AUTH.md`,
|
||||
`npm run session-diagnose`.
|
||||
|
||||
## Conventions
|
||||
|
||||
|
||||
22
README.md
22
README.md
@@ -50,20 +50,21 @@ npm run probe # verifies the token and API against the live instan
|
||||
Then either deploy it as a remote connector, or point Claude Code at
|
||||
`dist/bin/stdio.js`. Both paths are in [docs/DEPLOYMENT.md](docs/DEPLOYMENT.md).
|
||||
|
||||
Getting `TSC_JWT_COOKIE` takes four clicks in DevTools. With the server running
|
||||
it lasts up to 30 days; left idle for two hours it dies regardless — see
|
||||
[docs/AUTH.md](docs/AUTH.md).
|
||||
Getting `TSC_JWT_COOKIE` takes four clicks in DevTools. Expect to redo it
|
||||
often until the session question in [docs/AUTH.md](docs/AUTH.md) is settled —
|
||||
sessions have been observed ending ~2 h after login.
|
||||
|
||||
## Design decisions
|
||||
|
||||
**Bearer token plus a keepalive.** The instance's `jwt` cookie works verbatim
|
||||
as `Authorization: Bearer` — no cookie jar, no `connect.sid`. But the token has
|
||||
two clocks: a 30-day `exp` claim, and a **2-hour idle timeout** held in a
|
||||
server-side whitelist that *every* authenticated request resets. Only the first
|
||||
is visible in the token, which makes "valid for 30 days" an easy and wrong
|
||||
conclusion. So the server pings `GET /api/v3/me` every 30 minutes to hold the
|
||||
window open. See [docs/AUTH.md](docs/AUTH.md) — this one cost a day-old token to
|
||||
pin down.
|
||||
as `Authorization: Bearer` — no cookie jar, no `connect.sid`. But the token's
|
||||
`exp` claim (30 days) is not its lifetime: the session ends about **two hours
|
||||
after login**, and measurement showed that ordinary API reads do *not* extend
|
||||
it, despite the upstream source saying they should. So the server calls
|
||||
`refresh-session` every 30 minutes — the one non-GET request here, and not
|
||||
exposed as a tool. Whether that is enough is still being measured; see
|
||||
[docs/AUTH.md](docs/AUTH.md), which has the endurance test and the open
|
||||
question.
|
||||
|
||||
**Read-only by construction.** Every method on the API client is a `GET`,
|
||||
including `api_get`. The endpoint is internet-facing by necessity (Claude's
|
||||
@@ -104,6 +105,7 @@ npm run dev # watch mode, runs src/ directly
|
||||
npm test # unit tests, no network
|
||||
npm run probe # check assumptions against the live instance
|
||||
npm run smoke # full end-to-end: real server, real client, real data
|
||||
npm run session-diagnose # instrument what actually ends the session (~2.5h)
|
||||
npm run typecheck
|
||||
```
|
||||
|
||||
|
||||
11
docs/API.md
11
docs/API.md
@@ -117,11 +117,12 @@ 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`.
|
||||
**The JWT's `exp` is not the session lifetime, and the source misleads here.**
|
||||
Both the current and legacy whitelist implementations re-set a Valkey TTL on
|
||||
every authenticated request, which reads as a sliding window. The live instance
|
||||
does not behave that way: a session ends ~2 h after **login**, and successful
|
||||
reads in between do not extend it (measured — see `docs/AUTH.md`). This is the
|
||||
clearest case in this API of live behaviour diverging from upstream source.
|
||||
|
||||
**`GET /api/v3/config/public` is unauthenticated and useful.** 78 keys of
|
||||
instance configuration, including the session timeouts and feature flags. Handy
|
||||
|
||||
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.
|
||||
|
||||
|
||||
@@ -155,11 +155,12 @@ 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).
|
||||
- **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
|
||||
- **The Schulcloud session ends ~2 hours after login**, and API reads do not
|
||||
extend it, so the server calls `refresh-session` every 30 minutes. Watch for
|
||||
`keepalive: session extended, 7200s` in the logs; a falling budget is the
|
||||
early warning. **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.
|
||||
`TSC_JWT_COOKIE`. Whether the keepalive suffices at all is still being
|
||||
measured; see docs/AUTH.md.
|
||||
- **Monthly chore**: refresh `TSC_JWT_COOKIE` before its 30-day hard expiry.
|
||||
`npm run probe` reports both clocks.
|
||||
|
||||
@@ -18,7 +18,8 @@
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||
"test": "node --test test/*.test.ts",
|
||||
"probe": "node --env-file=.env scripts/probe.mjs",
|
||||
"smoke": "node --env-file=.env scripts/smoke.mjs"
|
||||
"smoke": "node --env-file=.env scripts/smoke.mjs",
|
||||
"session-diagnose": "node --env-file=.env scripts/session-diagnose.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.20.0",
|
||||
|
||||
91
scripts/session-diagnose.mjs
Normal file
91
scripts/session-diagnose.mjs
Normal file
@@ -0,0 +1,91 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Instruments the Schulcloud session to find out what actually ends it.
|
||||
*
|
||||
* Measured behaviour so far: a token survives ~2h from *login* and no amount
|
||||
* of `GET` traffic extends that. Two mechanisms could produce it, and they
|
||||
* imply very different things for this server:
|
||||
*
|
||||
* (1) an idle TTL that ordinary reads fail to refresh — a keepalive calling
|
||||
* refresh-session would hold the session indefinitely;
|
||||
* (2) an absolute cap anchored at login, or outright revocation (e.g. the
|
||||
* identity provider ending its SSO session and back-channel logout
|
||||
* clearing every token for the account) — in which case no keepalive
|
||||
* can help and the pasted-JWT approach caps out at ~2h.
|
||||
*
|
||||
* This script distinguishes them. It calls refresh-session on a short interval
|
||||
* and records the reported budget each time. The shape of the log at death is
|
||||
* the answer:
|
||||
*
|
||||
* budget decays 7200 → 0 across pings → (1), and pinging more often fixes it
|
||||
* budget sits at 7200, then 401 abruptly → (2), revocation from outside
|
||||
*
|
||||
* Read-only with respect to user data; refresh-session touches only this
|
||||
* session. Usage: `npm run session-diagnose [minutes]` (default 150).
|
||||
*/
|
||||
import { loadConfig } from '../dist/config.js';
|
||||
import { SchulcloudClient, SchulcloudApiError } from '../dist/schulcloud/client.js';
|
||||
|
||||
const totalMinutes = Number(process.argv[2] ?? 150);
|
||||
const INTERVAL_MS = 10 * 60_000;
|
||||
|
||||
const config = loadConfig();
|
||||
const client = new SchulcloudClient(config);
|
||||
|
||||
const payload = JSON.parse(Buffer.from(config.jwt.split('.')[1], 'base64url').toString('utf8'));
|
||||
const login = new Date(payload.iat * 1000);
|
||||
const start = Date.now();
|
||||
|
||||
const stamp = () => {
|
||||
const now = new Date();
|
||||
const sinceStart = ((Date.now() - start) / 60000).toFixed(1);
|
||||
const sinceLogin = ((Date.now() - login.getTime()) / 60000).toFixed(1);
|
||||
return `${now.toISOString().slice(11, 19)}Z t+${sinceStart.padStart(6)}min login+${sinceLogin.padStart(6)}min`;
|
||||
};
|
||||
|
||||
console.log(`session diagnosis — instance ${config.baseUrl}`);
|
||||
console.log(`token jti ${payload.jti}`);
|
||||
console.log(`login (iat) ${login.toISOString()} → login+2h = ${new Date(login.getTime() + 7200_000).toISOString()}`);
|
||||
console.log(`pinging refresh-session every ${INTERVAL_MS / 60000} min for ${totalMinutes} min\n`);
|
||||
|
||||
const samples = [];
|
||||
const deadline = start + totalMinutes * 60_000;
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
const { expiresInSeconds } = await client.extendSession();
|
||||
samples.push(expiresInSeconds);
|
||||
console.log(`${stamp()} budget ${expiresInSeconds}s (${Math.round(expiresInSeconds / 60)} min)`);
|
||||
} catch (error) {
|
||||
const status = error instanceof SchulcloudApiError ? error.status : '—';
|
||||
console.log(`${stamp()} FAILED ${status} — session is gone`);
|
||||
verdict(samples, true);
|
||||
process.exit(0);
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, Math.min(INTERVAL_MS, deadline - Date.now())));
|
||||
}
|
||||
|
||||
console.log(`\n${stamp()} reached the end of the window with the session still alive`);
|
||||
verdict(samples, false);
|
||||
|
||||
function verdict(series, died) {
|
||||
const minutesAlive = (Date.now() - login.getTime()) / 60000;
|
||||
console.log('\n--- verdict ---');
|
||||
console.log(`budget series: ${series.join(', ')}`);
|
||||
if (!died) {
|
||||
console.log(`PASS: session survived ${minutesAlive.toFixed(0)} min since login with refresh-session pings.`);
|
||||
console.log('=> refresh-session DOES hold the session. The keepalive works; ship it.');
|
||||
return;
|
||||
}
|
||||
const decayed = series.length >= 2 && series.at(-1) < series[0] - 60;
|
||||
if (decayed) {
|
||||
console.log(`Session ended ${minutesAlive.toFixed(0)} min after login, and the budget was DECAYING.`);
|
||||
console.log('=> mechanism (1): an idle TTL that these pings did not fully refresh.');
|
||||
console.log(' Try a shorter KEEPALIVE_INTERVAL_MS; the budget series shows the real decay rate.');
|
||||
} else {
|
||||
console.log(`Session ended ${minutesAlive.toFixed(0)} min after login while the budget still read ${series.at(-1)}s.`);
|
||||
console.log('=> mechanism (2): revoked from outside, not expired by inactivity.');
|
||||
console.log(' No keepalive can prevent this. The pasted-JWT approach is capped at ~2h from');
|
||||
console.log(' login, and the auth strategy needs revisiting (see docs/AUTH.md).');
|
||||
}
|
||||
}
|
||||
@@ -4,20 +4,27 @@ import { SchulcloudApiError } from './schulcloud/client.ts';
|
||||
/**
|
||||
* Keeps the Schulcloud session alive.
|
||||
*
|
||||
* The JWT's `exp` claim says 30 days, but that is only an outer ceiling. The
|
||||
* server also keeps a whitelist entry per token in Valkey, keyed
|
||||
* `jwt:{accountId}:{jti}`, whose TTL is `JWT_TIMEOUT_SECONDS` — 7200s (2h) on
|
||||
* this instance, readable from `GET /api/v3/config/public`. Every request that
|
||||
* passes the JWT guard re-sets that key, so the window slides; let it lapse
|
||||
* and the token is rejected with 401 "Session was expired due to inactivity",
|
||||
* long before `exp`.
|
||||
* The JWT's `exp` claim says 30 days, but the session dies far sooner, and the
|
||||
* mechanism is not what the upstream source suggests. Both the current
|
||||
* (`JwtWhitelistAdapter`) and legacy (Feathers `ensureTokenIsWhitelisted`)
|
||||
* implementations re-set a Valkey TTL on every authenticated request, which
|
||||
* would make the window slide with ordinary use. Measured against the live
|
||||
* instance, it does not:
|
||||
*
|
||||
* So an idle server loses its token overnight. Pinging any authenticated
|
||||
* endpoint is enough to hold it: `POST /authentication/refresh-session` is
|
||||
* what the web UI's "Sitzung verlängern" button calls, but it extends the
|
||||
* session through the very same guard as every other route, and additionally
|
||||
* reports the remaining TTL. We use a plain `GET /api/v3/me` instead, so that
|
||||
* every call this server makes upstream remains a GET.
|
||||
* A keepalive doing only `GET /api/v3/me` every 30 min was pinged
|
||||
* successfully at t+0/30/60/90 and was nonetheless rejected by t+120 —
|
||||
* almost exactly two hours after *login*, not two hours after the last
|
||||
* request, which would have been t+210.
|
||||
*
|
||||
* So the binding clock is anchored at login and ordinary reads do not move it.
|
||||
* We therefore extend explicitly, via the endpoint the web UI's "Sitzung
|
||||
* verlängern" button uses, which also reports the remaining budget so the log
|
||||
* shows whether the extension actually took.
|
||||
*
|
||||
* Whether that can carry a session past login+2h at all is the open question;
|
||||
* `npm run session-diagnose` is the instrument for settling it. If it cannot,
|
||||
* no keepalive will help and the auth approach itself needs revisiting — see
|
||||
* docs/AUTH.md.
|
||||
*/
|
||||
export class SessionKeepalive {
|
||||
private timer: NodeJS.Timeout | undefined;
|
||||
@@ -62,7 +69,14 @@ export class SessionKeepalive {
|
||||
private async tick(): Promise<void> {
|
||||
if (this.stopped) return;
|
||||
try {
|
||||
await this.client.me();
|
||||
const { expiresInSeconds } = await this.client.extendSession();
|
||||
// A budget well below the instance's JWT_TIMEOUT_SECONDS means the
|
||||
// extension is not taking effect — worth seeing in the log, because it
|
||||
// is the early warning that the session is about to be lost.
|
||||
this.log(
|
||||
`[schulcloud-mcp] keepalive: session extended, ${expiresInSeconds}s ` +
|
||||
`(${Math.round(expiresInSeconds / 60)} min) of budget left`,
|
||||
);
|
||||
this.schedule(this.intervalMs);
|
||||
} catch (error) {
|
||||
if (error instanceof SchulcloudApiError && error.isAuthFailure) {
|
||||
@@ -70,10 +84,10 @@ export class SessionKeepalive {
|
||||
// ceiling. Pinging harder cannot revive it — a human must paste a new
|
||||
// token — so stop and say so loudly rather than logging every 30 min.
|
||||
this.log(
|
||||
'[schulcloud-mcp] keepalive: token rejected (401). The session has expired — ' +
|
||||
'either more than 2h elapsed without a successful request, or the JWT reached ' +
|
||||
'its 30-day limit. Put a fresh jwt cookie in TSC_JWT_COOKIE and restart. ' +
|
||||
'Keepalive stopped.',
|
||||
'[schulcloud-mcp] keepalive: token rejected (401). The session is gone — ' +
|
||||
'measured behaviour is that it ends roughly 2h after login regardless of ' +
|
||||
'activity, and the JWT also has a 30-day hard limit. Put a fresh jwt cookie ' +
|
||||
'in TSC_JWT_COOKIE and restart. Keepalive stopped.',
|
||||
);
|
||||
this.stop();
|
||||
return;
|
||||
|
||||
@@ -48,7 +48,8 @@ export interface DownloadedFile {
|
||||
}
|
||||
|
||||
/**
|
||||
* Read-only HTTP client for a Schulcloud instance.
|
||||
* HTTP client for a Schulcloud instance. Read-only apart from `extendSession`,
|
||||
* which touches only the caller's own session — see its doc comment.
|
||||
*
|
||||
* Two services sit behind the same origin and both accept the same bearer
|
||||
* token: the main server under `/api/v3/*`, and the files-storage service
|
||||
@@ -56,9 +57,11 @@ export interface DownloadedFile {
|
||||
* verbatim as `Authorization: Bearer` — no cookie jar or session refresh is
|
||||
* involved, and the token is valid for 30 days (see docs/AUTH.md).
|
||||
*
|
||||
* Every method here is a GET. Keeping the client incapable of writing is the
|
||||
* main safety property of this server: whoever reaches the MCP endpoint can
|
||||
* read this account's data but cannot act as the user inside Schulcloud.
|
||||
* Every method that touches user data is a GET. Keeping the client incapable of
|
||||
* writing is the main safety property of this server: whoever reaches the MCP
|
||||
* endpoint can read this account's data but cannot act as the user inside
|
||||
* Schulcloud. `extendSession` is the single exception and is not exposed as a
|
||||
* tool, so no model-driven call can ever be a POST.
|
||||
*/
|
||||
export class SchulcloudClient {
|
||||
private readonly config: Config;
|
||||
@@ -154,6 +157,39 @@ export class SchulcloudClient {
|
||||
return this.getJson<MeResponse>('/api/v3/me');
|
||||
}
|
||||
|
||||
// --- session ---------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Extends the current session and reports its remaining budget.
|
||||
*
|
||||
* **The only non-GET request in this server, and deliberately so.** It is
|
||||
* what the web UI's "Sitzung verlängern" button calls. It takes no body,
|
||||
* touches nothing but the caller's own session, and cannot read or change
|
||||
* any user data — so it does not weaken the property that matters: nobody
|
||||
* reaching this server can act as the user inside Schulcloud. No MCP tool
|
||||
* exposes it, so Claude can never cause a POST; only the keepalive calls it.
|
||||
*
|
||||
* Using a plain GET here was tried and does not work — see docs/AUTH.md for
|
||||
* the endurance test that ruled it out.
|
||||
*/
|
||||
async extendSession(): Promise<{ expiresInSeconds: number }> {
|
||||
const url = this.url('/api/v3/authentication/refresh-session');
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.config.jwt}`,
|
||||
Accept: 'application/json',
|
||||
'Content-Length': '0',
|
||||
},
|
||||
signal: AbortSignal.timeout(this.config.requestTimeoutMs),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const body = await response.text().catch(() => '');
|
||||
throw new SchulcloudApiError(response.status, url.pathname, body);
|
||||
}
|
||||
return (await response.json()) as { expiresInSeconds: number };
|
||||
}
|
||||
|
||||
// --- courses and the classic course board ----------------------------
|
||||
|
||||
listCourses(params: { skip?: number; limit?: number } = {}): Promise<Paginated<CourseMetadata>> {
|
||||
|
||||
@@ -9,11 +9,11 @@ function fakeClient(outcomes: (Error | 'ok')[]) {
|
||||
return {
|
||||
calls,
|
||||
client: {
|
||||
me: async () => {
|
||||
extendSession: async () => {
|
||||
const outcome = outcomes[calls.length] ?? 'ok';
|
||||
calls.push(Date.now());
|
||||
if (outcome !== 'ok') throw outcome;
|
||||
return {} as never;
|
||||
return { expiresInSeconds: 7200 };
|
||||
},
|
||||
} as never,
|
||||
};
|
||||
@@ -41,7 +41,7 @@ describe('SessionKeepalive', () => {
|
||||
});
|
||||
|
||||
it('stops permanently on 401 — a dead session cannot be revived by retrying', async () => {
|
||||
const unauthorized = new SchulcloudApiError(401, '/api/v3/me', '');
|
||||
const unauthorized = new SchulcloudApiError(401, '/api/v3/authentication/refresh-session', '');
|
||||
const { client, calls } = fakeClient([unauthorized]);
|
||||
const messages: string[] = [];
|
||||
const keepalive = new SessionKeepalive(client, 15, 15, (message) => messages.push(message));
|
||||
@@ -75,3 +75,16 @@ describe('SessionKeepalive', () => {
|
||||
assert.equal(calls.length, seen, 'no pings after stop()');
|
||||
});
|
||||
});
|
||||
|
||||
describe('SessionKeepalive logging', () => {
|
||||
it('reports the remaining session budget on a successful extension', async () => {
|
||||
const messages: string[] = [];
|
||||
const client = { extendSession: async () => ({ expiresInSeconds: 7200 }) } as never;
|
||||
const keepalive = new SessionKeepalive(client, 60_000, 1000, (m) => messages.push(m));
|
||||
keepalive.start();
|
||||
await new Promise((resolve) => setTimeout(resolve, 30));
|
||||
keepalive.stop();
|
||||
assert.equal(messages.length, 1);
|
||||
assert.match(messages[0]!, /session extended, 7200s \(120 min\)/);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user