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:
10
.env.example
10
.env.example
@@ -6,7 +6,9 @@
|
|||||||
TSC_URL=https://schulcloud-thueringen.de
|
TSC_URL=https://schulcloud-thueringen.de
|
||||||
|
|
||||||
# The value of the `jwt` cookie from a logged-in browser session.
|
# The value of the `jwt` cookie from a logged-in browser session.
|
||||||
# Valid for 30 days from issue; see docs/AUTH.md for how to copy a fresh one.
|
# 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.
|
||||||
TSC_JWT_COOKIE=
|
TSC_JWT_COOKIE=
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -36,3 +38,9 @@ BIND_HOST=0.0.0.0
|
|||||||
|
|
||||||
# Per-request timeout against the Schulcloud API, in ms. Default 30000.
|
# Per-request timeout against the Schulcloud API, in ms. Default 30000.
|
||||||
# REQUEST_TIMEOUT_MS=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.
|
||||||
|
# KEEPALIVE_INTERVAL_MS=1800000
|
||||||
|
|||||||
13
CLAUDE.md
13
CLAUDE.md
@@ -59,7 +59,7 @@ the property that makes that acceptable. Do not add a write tool without the
|
|||||||
user explicitly asking for one and understanding this.
|
user explicitly asking for one and understanding this.
|
||||||
|
|
||||||
**Never log or echo secrets.** `TSC_JWT_COOKIE` grants full read access to the
|
**Never log or echo secrets.** `TSC_JWT_COOKIE` grants full read access to the
|
||||||
account for 30 days; `MCP_AUTH_TOKEN` guards the endpoint. Neither belongs in
|
account; `MCP_AUTH_TOKEN` guards the endpoint. Neither belongs in
|
||||||
logs, error messages, or tool output. `.env` is git-ignored — keep it that way.
|
logs, error messages, or tool output. `.env` is git-ignored — keep it that way.
|
||||||
|
|
||||||
**Live behaviour beats upstream source.** The clones in `vendor/` track `main`
|
**Live behaviour beats upstream source.** The clones in `vendor/` track `main`
|
||||||
@@ -84,11 +84,18 @@ 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`.
|
its own OpenAPI document. It is not in the main `docs-json`.
|
||||||
- Legacy lesson responses return ids as `{buffer:{data:[...]}}`; use
|
- Legacy lesson responses return ids as `{buffer:{data:[...]}}`; use
|
||||||
`normalizeObjectId`.
|
`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.
|
||||||
|
|
||||||
## Conventions
|
## Conventions
|
||||||
|
|
||||||
- Imports use `.ts` extensions; `rewriteRelativeImportExtensions` makes `tsc`
|
- Imports use `.ts` extensions; `rewriteRelativeImportExtensions` makes `tsc`
|
||||||
emit `.js`. This lets `node --watch src/bin/http.ts` run the tree directly.
|
emit `.js`. This lets `node --watch src/bin/http.ts` run the tree directly.
|
||||||
|
- **No TypeScript parameter properties** (`constructor(private readonly x: T)`).
|
||||||
|
Node's type stripping rejects them, which breaks `npm run dev` and `npm test`.
|
||||||
|
Declare the field and assign it in the constructor body instead.
|
||||||
- Tabs for indentation, single quotes, trailing commas.
|
- Tabs for indentation, single quotes, trailing commas.
|
||||||
- Comments explain *why* — an API quirk, a security property, a trade-off — not
|
- Comments explain *why* — an API quirk, a security property, a trade-off — not
|
||||||
what the line does. Several such comments encode findings that are expensive
|
what the line does. Several such comments encode findings that are expensive
|
||||||
@@ -108,5 +115,5 @@ These cost real time to discover; `docs/API.md` has the full list with evidence.
|
|||||||
## Environment
|
## Environment
|
||||||
|
|
||||||
`.env` holds `TSC_URL`, `TSC_JWT_COOKIE`, `MCP_AUTH_TOKEN`. See `.env.example`
|
`.env` holds `TSC_URL`, `TSC_JWT_COOKIE`, `MCP_AUTH_TOKEN`. See `.env.example`
|
||||||
for the full set and `docs/AUTH.md` for refreshing the JWT — it expires every 30
|
for the full set and `docs/AUTH.md` for refreshing the JWT. `npm run probe`
|
||||||
days, and `npm run probe` reports the days remaining.
|
reports both clocks: days until hard expiry and seconds of idle budget left.
|
||||||
|
|||||||
17
README.md
17
README.md
@@ -50,15 +50,20 @@ 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
|
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).
|
`dist/bin/stdio.js`. Both paths are in [docs/DEPLOYMENT.md](docs/DEPLOYMENT.md).
|
||||||
|
|
||||||
Getting `TSC_JWT_COOKIE` takes four clicks in DevTools and lasts 30 days —
|
Getting `TSC_JWT_COOKIE` takes four clicks in DevTools. With the server running
|
||||||
see [docs/AUTH.md](docs/AUTH.md).
|
it lasts up to 30 days; left idle for two hours it dies regardless — see
|
||||||
|
[docs/AUTH.md](docs/AUTH.md).
|
||||||
|
|
||||||
## Design decisions
|
## Design decisions
|
||||||
|
|
||||||
**Bearer token, not a cookie jar.** The instance's `jwt` cookie works verbatim
|
**Bearer token plus a keepalive.** The instance's `jwt` cookie works verbatim
|
||||||
as `Authorization: Bearer`, and is valid for 30 days. There is no session to
|
as `Authorization: Bearer` — no cookie jar, no `connect.sid`. But the token has
|
||||||
keep alive and no `refresh-session` timer — a simplification that only became
|
two clocks: a 30-day `exp` claim, and a **2-hour idle timeout** held in a
|
||||||
apparent by testing against the live instance.
|
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.
|
||||||
|
|
||||||
**Read-only by construction.** Every method on the API client is a `GET`,
|
**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
|
including `api_get`. The endpoint is internet-facing by necessity (Claude's
|
||||||
|
|||||||
15
docs/API.md
15
docs/API.md
@@ -79,6 +79,8 @@ current material**; lessons are the older format.
|
|||||||
| One file's metadata | `GET /api/v3/file/{fileRecordId}` |
|
| One file's metadata | `GET /api/v3/file/{fileRecordId}` |
|
||||||
| File bytes | `GET /api/v3/file/download/{fileRecordId}/{fileName}` |
|
| File bytes | `GET /api/v3/file/download/{fileRecordId}/{fileName}` |
|
||||||
| News | `GET /api/v3/news` |
|
| 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
|
### 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`
|
from the legacy Mongo serialisation. `normalizeObjectId` in `src/render.ts`
|
||||||
converts them.
|
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
|
**`Content-Disposition` on downloads is malformed.** It comes back as
|
||||||
`attachment;; filename="…"` — note the doubled semicolon — and the filename is
|
`attachment;; filename="…"` — note the doubled semicolon — and the filename is
|
||||||
percent-encoded inside the quotes. Parse defensively.
|
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
|
## Re-verifying after an upstream release
|
||||||
|
|
||||||
`npm run probe` re-checks every assumption above against the live instance and
|
`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.
|
||||||
|
|||||||
120
docs/AUTH.md
120
docs/AUTH.md
@@ -14,44 +14,106 @@ Cookie: jwt=<jwt> → 200 # also works
|
|||||||
(no auth) → 401
|
(no auth) → 401
|
||||||
```
|
```
|
||||||
|
|
||||||
`connect.sid`, `SERVERID` and `isLoggedIn` are **not** needed. There is no
|
`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.
|
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:
|
||||||
|
|
||||||
```
|
| Clock | Value | Extendable? |
|
||||||
iss / aud : schulcloud-thueringen.de
|
|---|---|---|
|
||||||
iat → exp : 720 hours (exactly 30 days)
|
| **Idle timeout** — a whitelist entry in the server's Valkey store | **7200 s (2 hours)** | Yes — reset by *every* authenticated request |
|
||||||
claims : accountId, userId, schoolId, roles, systemId, jti,
|
| **Hard expiry** — the JWT's own `exp` claim | **30 days** | No |
|
||||||
isExternalUser, isServiceAccount, support
|
|
||||||
|
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
|
`JWT_SHOW_TIMEOUT_WARNING_SECONDS: 3600` is exactly the web UI's behaviour: it
|
||||||
chore rather than an engineering problem. `npm run probe` prints the days
|
warns when 3600 s of the 7200 s budget remain — i.e. after one hour of
|
||||||
remaining.
|
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.
|
A token issued 2026-09-11T21:12Z was last used at ~21:47Z. At 2026-09-12T11:00Z
|
||||||
2. DevTools → **Application** → **Cookies** → the instance's origin.
|
— 13.8 hours later, with 29 days left on `exp` — `GET /api/v3/me` returned
|
||||||
3. Copy the value of the **`jwt`** cookie.
|
`401`. Consistent with the 2-hour idle timeout, and decisive against the
|
||||||
4. Put it in `TSC_JWT_COOKIE` in `.env` and restart the server
|
30-day reading.
|
||||||
(`docker compose restart schulcloud-mcp`).
|
|
||||||
|
|
||||||
There is no need to log out afterwards; the token stays valid independently of
|
### `refresh-session` is not special
|
||||||
the browser session.
|
|
||||||
|
|
||||||
## 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
|
### There is no longer window available
|
||||||
> revoked.
|
|
||||||
|
|
||||||
That message is the signal to redo the four steps above. A `403` means the
|
`config/default.schema.json` documents `JWT_EXTENDED_TIMEOUT_SECONDS`
|
||||||
account genuinely lacks access to that resource and is *not* a token problem.
|
(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
|
## 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
|
for accounts with local credentials, which federated school accounts do not
|
||||||
have.
|
have.
|
||||||
|
|
||||||
Given a 30-day token, the pasted-JWT approach is the right trade: one manual
|
Given a keepalive that holds a token to its 30-day ceiling, the pasted-JWT
|
||||||
step a month against re-implementing an OAuth client we cannot hold the secret
|
approach is still the right trade: one manual step a month against
|
||||||
for. If this ever needs to be unattended, the honest options are a service
|
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
|
account issued by the school's IDM, or a headless browser login — not a
|
||||||
reimplementation of the Keycloak exchange.
|
reimplementation of the Keycloak exchange.
|
||||||
|
|
||||||
|
|||||||
@@ -35,11 +35,13 @@ docker compose logs -f schulcloud-mcp
|
|||||||
Expect:
|
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
|
`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
|
## 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
|
`no-new-privileges`, running as the unprivileged `node` user. It writes
|
||||||
nothing to disk — downloads are streamed through memory, capped at
|
nothing to disk — downloads are streamed through memory, capped at
|
||||||
`MAX_DOWNLOAD_BYTES` (25 MiB default).
|
`MAX_DOWNLOAD_BYTES` (25 MiB default).
|
||||||
- **Monthly chore**: refresh `TSC_JWT_COOKIE`. `npm run probe` tells you how
|
- **The Schulcloud session dies after 2 hours of inactivity**, so the server
|
||||||
many days are left.
|
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.
|
||||||
|
|||||||
@@ -15,18 +15,50 @@ const client = new SchulcloudClient(config);
|
|||||||
console.log(`instance: ${config.baseUrl}\n`);
|
console.log(`instance: ${config.baseUrl}\n`);
|
||||||
|
|
||||||
// --- token ---------------------------------------------------------------
|
// --- token ---------------------------------------------------------------
|
||||||
|
// Two independent clocks govern the token, and only one of them is in the JWT:
|
||||||
|
// exp — a hard 30-day ceiling, cannot be extended.
|
||||||
|
// whitelist TTL — JWT_TIMEOUT_SECONDS (2h here), reset by every request.
|
||||||
|
// The second one is what actually kills idle sessions, so report both.
|
||||||
const payload = decodeJwt(config.jwt);
|
const payload = decodeJwt(config.jwt);
|
||||||
if (payload) {
|
if (payload) {
|
||||||
const expires = new Date(payload.exp * 1000);
|
|
||||||
const daysLeft = (payload.exp * 1000 - Date.now()) / 86_400_000;
|
const daysLeft = (payload.exp * 1000 - Date.now()) / 86_400_000;
|
||||||
console.log(`token: issued ${new Date(payload.iat * 1000).toISOString().slice(0, 10)}, ` +
|
console.log(`token: issued ${new Date(payload.iat * 1000).toISOString().slice(0, 16)}Z, ` +
|
||||||
`expires ${expires.toISOString().slice(0, 10)} (${daysLeft.toFixed(1)} days left)`);
|
`hard expiry ${new Date(payload.exp * 1000).toISOString().slice(0, 10)} (${daysLeft.toFixed(1)} days left)`);
|
||||||
if (daysLeft < 0) console.log(' *** EXPIRED — copy a fresh jwt cookie, see docs/AUTH.md');
|
if (daysLeft < 0) console.log(' *** PAST HARD EXPIRY — copy a fresh jwt cookie, see docs/AUTH.md');
|
||||||
else if (daysLeft < 5) console.log(' *** expiring soon — plan to copy a fresh jwt cookie');
|
else if (daysLeft < 5) console.log(' *** hard expiry approaching — plan to copy a fresh jwt cookie');
|
||||||
} else {
|
} else {
|
||||||
console.log('token: could not decode (not a JWT?)');
|
console.log('token: could not decode (not a JWT?)');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The instance publishes its own session settings, unauthenticated.
|
||||||
|
try {
|
||||||
|
const publicConfig = await client.getJson('/api/v3/config/public');
|
||||||
|
console.log(`instance: JWT_TIMEOUT_SECONDS=${publicConfig.JWT_TIMEOUT_SECONDS} ` +
|
||||||
|
`(idle timeout), warning shown at ${publicConfig.JWT_SHOW_TIMEOUT_WARNING_SECONDS}s remaining`);
|
||||||
|
} catch {
|
||||||
|
console.log('instance: could not read /api/v3/config/public');
|
||||||
|
}
|
||||||
|
|
||||||
|
// refresh-session reports the whitelist TTL. It is the one non-GET call in this
|
||||||
|
// repo, and it lives here in a diagnostic rather than in the server, whose every
|
||||||
|
// upstream call is a GET. It extends the session no more than any GET does.
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${config.baseUrl}/api/v3/authentication/refresh-session`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { Authorization: `Bearer ${config.jwt}`, 'Content-Length': '0' },
|
||||||
|
});
|
||||||
|
if (response.ok) {
|
||||||
|
const { expiresInSeconds } = await response.json();
|
||||||
|
console.log(`session: ${expiresInSeconds}s of idle budget left ` +
|
||||||
|
`(${(expiresInSeconds / 60).toFixed(0)} min) — any request resets this`);
|
||||||
|
} else {
|
||||||
|
console.log(`session: refresh-session returned ${response.status}` +
|
||||||
|
(response.status === 401 ? ' *** session expired through inactivity or hard expiry' : ''));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.log(`session: could not read TTL (${error.message})`);
|
||||||
|
}
|
||||||
|
|
||||||
// --- endpoints this server depends on ------------------------------------
|
// --- endpoints this server depends on ------------------------------------
|
||||||
const checks = [
|
const checks = [
|
||||||
['GET /api/v3/me', () => client.me()],
|
['GET /api/v3/me', () => client.me()],
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
import { loadConfig } from '../config.ts';
|
import { loadConfig } from '../config.ts';
|
||||||
import { createHttpApp } from '../http/server.ts';
|
import { createHttpApp } from '../http/server.ts';
|
||||||
|
import { SessionKeepalive } from '../keepalive.ts';
|
||||||
|
import { SchulcloudClient } from '../schulcloud/client.ts';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* HTTP entry point — the deployed form of this server, sitting behind Caddy.
|
* HTTP entry point — the deployed form of this server, sitting behind Caddy.
|
||||||
@@ -9,10 +11,21 @@ async function main(): Promise<void> {
|
|||||||
const config = loadConfig();
|
const config = loadConfig();
|
||||||
const app = createHttpApp(config);
|
const app = createHttpApp(config);
|
||||||
|
|
||||||
|
// One process-wide keepalive, independent of MCP sessions: the Schulcloud
|
||||||
|
// token dies after 2h of inactivity regardless of whether anyone is connected.
|
||||||
|
const keepalive =
|
||||||
|
config.keepaliveIntervalMs > 0
|
||||||
|
? new SessionKeepalive(new SchulcloudClient(config), config.keepaliveIntervalMs, undefined, (message) =>
|
||||||
|
console.log(message),
|
||||||
|
)
|
||||||
|
: undefined;
|
||||||
|
keepalive?.start();
|
||||||
|
|
||||||
const server = app.listen(config.port, config.bindHost, () => {
|
const server = app.listen(config.port, config.bindHost, () => {
|
||||||
console.log(
|
console.log(
|
||||||
`[schulcloud-mcp] listening on ${config.bindHost}:${config.port} — instance ${config.baseUrl}, ` +
|
`[schulcloud-mcp] listening on ${config.bindHost}:${config.port} — instance ${config.baseUrl}, ` +
|
||||||
`auth ${config.authToken ? 'enabled' : 'DISABLED'}`,
|
`auth ${config.authToken ? 'enabled' : 'DISABLED'}, ` +
|
||||||
|
`keepalive ${keepalive ? `every ${Math.round(config.keepaliveIntervalMs / 60_000)}min` : 'off'}`,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -20,6 +33,7 @@ async function main(): Promise<void> {
|
|||||||
for (const signal of ['SIGTERM', 'SIGINT'] as const) {
|
for (const signal of ['SIGTERM', 'SIGINT'] as const) {
|
||||||
process.on(signal, () => {
|
process.on(signal, () => {
|
||||||
console.log(`[schulcloud-mcp] ${signal} received, shutting down`);
|
console.log(`[schulcloud-mcp] ${signal} received, shutting down`);
|
||||||
|
keepalive?.stop();
|
||||||
server.close(() => process.exit(0));
|
server.close(() => process.exit(0));
|
||||||
setTimeout(() => process.exit(0), 10_000).unref();
|
setTimeout(() => process.exit(0), 10_000).unref();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
||||||
import { loadConfig } from '../config.ts';
|
import { loadConfig } from '../config.ts';
|
||||||
|
import { SessionKeepalive } from '../keepalive.ts';
|
||||||
|
import { SchulcloudClient } from '../schulcloud/client.ts';
|
||||||
import { createServer } from '../server.ts';
|
import { createServer } from '../server.ts';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -13,6 +15,14 @@ async function main(): Promise<void> {
|
|||||||
const config = loadConfig();
|
const config = loadConfig();
|
||||||
const { server } = createServer(config);
|
const { server } = createServer(config);
|
||||||
await server.connect(new StdioServerTransport());
|
await server.connect(new StdioServerTransport());
|
||||||
|
|
||||||
|
// A desktop client left open overnight idles far past the instance's 2h
|
||||||
|
// session timeout, so stdio needs the keepalive just as much as HTTP does.
|
||||||
|
// It logs to stderr; stdout carries protocol frames only.
|
||||||
|
if (config.keepaliveIntervalMs > 0) {
|
||||||
|
new SessionKeepalive(new SchulcloudClient(config), config.keepaliveIntervalMs).start();
|
||||||
|
}
|
||||||
|
|
||||||
console.error(`[schulcloud-mcp] stdio transport ready for ${config.baseUrl}`);
|
console.error(`[schulcloud-mcp] stdio transport ready for ${config.baseUrl}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,12 @@ export interface Config {
|
|||||||
/** Characters of extracted text returned before truncation kicks in. */
|
/** Characters of extracted text returned before truncation kicks in. */
|
||||||
maxExtractedChars: number;
|
maxExtractedChars: number;
|
||||||
requestTimeoutMs: number;
|
requestTimeoutMs: number;
|
||||||
|
/**
|
||||||
|
* How often to ping the instance to hold the session open. Must stay well
|
||||||
|
* under the instance's `JWT_TIMEOUT_SECONDS` (7200s here) — see
|
||||||
|
* src/keepalive.ts. Zero disables the keepalive.
|
||||||
|
*/
|
||||||
|
keepaliveIntervalMs: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
function required(name: string): string {
|
function required(name: string): string {
|
||||||
@@ -38,6 +44,17 @@ function int(name: string, fallback: number): number {
|
|||||||
return parsed;
|
return parsed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Like `int`, but 0 is meaningful (it disables the feature) rather than invalid. */
|
||||||
|
function intAllowingZero(name: string, fallback: number): number {
|
||||||
|
const raw = process.env[name]?.trim();
|
||||||
|
if (!raw) return fallback;
|
||||||
|
const parsed = Number.parseInt(raw, 10);
|
||||||
|
if (!Number.isFinite(parsed) || parsed < 0) {
|
||||||
|
throw new Error(`Environment variable ${name} must be a non-negative integer, got ${raw}`);
|
||||||
|
}
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
export function loadConfig(): Config {
|
export function loadConfig(): Config {
|
||||||
return {
|
return {
|
||||||
baseUrl: required('TSC_URL').replace(/\/+$/, ''),
|
baseUrl: required('TSC_URL').replace(/\/+$/, ''),
|
||||||
@@ -48,5 +65,6 @@ export function loadConfig(): Config {
|
|||||||
maxDownloadBytes: int('MAX_DOWNLOAD_BYTES', 25 * 1024 * 1024),
|
maxDownloadBytes: int('MAX_DOWNLOAD_BYTES', 25 * 1024 * 1024),
|
||||||
maxExtractedChars: int('MAX_EXTRACTED_CHARS', 120_000),
|
maxExtractedChars: int('MAX_EXTRACTED_CHARS', 120_000),
|
||||||
requestTimeoutMs: int('REQUEST_TIMEOUT_MS', 30_000),
|
requestTimeoutMs: int('REQUEST_TIMEOUT_MS', 30_000),
|
||||||
|
keepaliveIntervalMs: intAllowingZero('KEEPALIVE_INTERVAL_MS', 30 * 60_000),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,10 +10,12 @@ import type { MeResponse } from './schulcloud/types.ts';
|
|||||||
* cannot change for a given JWT. Everything else is fetched live.
|
* cannot change for a given JWT. Everything else is fetched live.
|
||||||
*/
|
*/
|
||||||
export class ServerContext {
|
export class ServerContext {
|
||||||
|
readonly config: Config;
|
||||||
readonly client: SchulcloudClient;
|
readonly client: SchulcloudClient;
|
||||||
private identity: Promise<MeResponse> | undefined;
|
private identity: Promise<MeResponse> | undefined;
|
||||||
|
|
||||||
constructor(readonly config: Config) {
|
constructor(config: Config) {
|
||||||
|
this.config = config;
|
||||||
this.client = new SchulcloudClient(config);
|
this.client = new SchulcloudClient(config);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
88
src/keepalive.ts
Normal file
88
src/keepalive.ts
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
import type { SchulcloudClient } from './schulcloud/client.ts';
|
||||||
|
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`.
|
||||||
|
*
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
export class SessionKeepalive {
|
||||||
|
private timer: NodeJS.Timeout | undefined;
|
||||||
|
private stopped = false;
|
||||||
|
private readonly client: SchulcloudClient;
|
||||||
|
private readonly intervalMs: number;
|
||||||
|
/** Retry delay after a failed ping — shorter, to use up the remaining budget. */
|
||||||
|
private readonly retryMs: number;
|
||||||
|
private readonly log: (message: string) => void;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
client: SchulcloudClient,
|
||||||
|
intervalMs: number,
|
||||||
|
retryMs: number = Math.min(5 * 60_000, intervalMs),
|
||||||
|
log: (message: string) => void = (message) => console.error(message),
|
||||||
|
) {
|
||||||
|
this.client = client;
|
||||||
|
this.intervalMs = intervalMs;
|
||||||
|
this.retryMs = retryMs;
|
||||||
|
this.log = log;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pings once now (validating the token at startup), then on the interval. */
|
||||||
|
start(): void {
|
||||||
|
this.stopped = false;
|
||||||
|
void this.tick();
|
||||||
|
}
|
||||||
|
|
||||||
|
stop(): void {
|
||||||
|
this.stopped = true;
|
||||||
|
if (this.timer) clearTimeout(this.timer);
|
||||||
|
this.timer = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private schedule(delayMs: number): void {
|
||||||
|
if (this.stopped) return;
|
||||||
|
this.timer = setTimeout(() => void this.tick(), delayMs);
|
||||||
|
// Never hold the process open just for a keepalive.
|
||||||
|
this.timer.unref();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async tick(): Promise<void> {
|
||||||
|
if (this.stopped) return;
|
||||||
|
try {
|
||||||
|
await this.client.me();
|
||||||
|
this.schedule(this.intervalMs);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof SchulcloudApiError && error.isAuthFailure) {
|
||||||
|
// Past saving: the whitelist entry is gone, or the JWT hit its 30-day
|
||||||
|
// 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.',
|
||||||
|
);
|
||||||
|
this.stop();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.log(
|
||||||
|
`[schulcloud-mcp] keepalive: ping failed (${error instanceof Error ? error.message : String(error)}); ` +
|
||||||
|
`retrying in ${Math.round(this.retryMs / 1000)}s`,
|
||||||
|
);
|
||||||
|
this.schedule(this.retryMs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,13 +17,16 @@ import type {
|
|||||||
|
|
||||||
/** An API response outside the 2xx range, carrying the status for callers to branch on. */
|
/** An API response outside the 2xx range, carrying the status for callers to branch on. */
|
||||||
export class SchulcloudApiError extends Error {
|
export class SchulcloudApiError extends Error {
|
||||||
constructor(
|
readonly status: number;
|
||||||
readonly status: number,
|
readonly path: string;
|
||||||
readonly path: string,
|
readonly body: string;
|
||||||
readonly body: string,
|
|
||||||
) {
|
constructor(status: number, path: string, body: string) {
|
||||||
super(`Schulcloud API ${status} for ${path}${body ? `: ${truncate(body, 400)}` : ''}`);
|
super(`Schulcloud API ${status} for ${path}${body ? `: ${truncate(body, 400)}` : ''}`);
|
||||||
this.name = 'SchulcloudApiError';
|
this.name = 'SchulcloudApiError';
|
||||||
|
this.status = status;
|
||||||
|
this.path = path;
|
||||||
|
this.body = body;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** True when the instance rejected our JWT — the one error the user must act on. */
|
/** True when the instance rejected our JWT — the one error the user must act on. */
|
||||||
@@ -58,7 +61,11 @@ export interface DownloadedFile {
|
|||||||
* read this account's data but cannot act as the user inside Schulcloud.
|
* read this account's data but cannot act as the user inside Schulcloud.
|
||||||
*/
|
*/
|
||||||
export class SchulcloudClient {
|
export class SchulcloudClient {
|
||||||
constructor(private readonly config: Config) {}
|
private readonly config: Config;
|
||||||
|
|
||||||
|
constructor(config: Config) {
|
||||||
|
this.config = config;
|
||||||
|
}
|
||||||
|
|
||||||
// --- transport -------------------------------------------------------
|
// --- transport -------------------------------------------------------
|
||||||
|
|
||||||
|
|||||||
77
test/keepalive.test.ts
Normal file
77
test/keepalive.test.ts
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { describe, it } from 'node:test';
|
||||||
|
import { SchulcloudApiError } from '../src/schulcloud/client.ts';
|
||||||
|
import { SessionKeepalive } from '../src/keepalive.ts';
|
||||||
|
|
||||||
|
/** A stand-in for SchulcloudClient that records calls and replays scripted outcomes. */
|
||||||
|
function fakeClient(outcomes: (Error | 'ok')[]) {
|
||||||
|
const calls: number[] = [];
|
||||||
|
return {
|
||||||
|
calls,
|
||||||
|
client: {
|
||||||
|
me: async () => {
|
||||||
|
const outcome = outcomes[calls.length] ?? 'ok';
|
||||||
|
calls.push(Date.now());
|
||||||
|
if (outcome !== 'ok') throw outcome;
|
||||||
|
return {} as never;
|
||||||
|
},
|
||||||
|
} as never,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const settle = () => new Promise((resolve) => setTimeout(resolve, 30));
|
||||||
|
|
||||||
|
describe('SessionKeepalive', () => {
|
||||||
|
it('pings immediately on start, so a bad token is noticed at boot', async () => {
|
||||||
|
const { client, calls } = fakeClient(['ok']);
|
||||||
|
const keepalive = new SessionKeepalive(client, 60_000, 1000, () => {});
|
||||||
|
keepalive.start();
|
||||||
|
await settle();
|
||||||
|
assert.equal(calls.length, 1);
|
||||||
|
keepalive.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps pinging on the interval', async () => {
|
||||||
|
const { client, calls } = fakeClient([]);
|
||||||
|
const keepalive = new SessionKeepalive(client, 15, 15, () => {});
|
||||||
|
keepalive.start();
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 120));
|
||||||
|
keepalive.stop();
|
||||||
|
assert.ok(calls.length >= 3, `expected repeated pings, got ${calls.length}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stops permanently on 401 — a dead session cannot be revived by retrying', async () => {
|
||||||
|
const unauthorized = new SchulcloudApiError(401, '/api/v3/me', '');
|
||||||
|
const { client, calls } = fakeClient([unauthorized]);
|
||||||
|
const messages: string[] = [];
|
||||||
|
const keepalive = new SessionKeepalive(client, 15, 15, (message) => messages.push(message));
|
||||||
|
keepalive.start();
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 120));
|
||||||
|
assert.equal(calls.length, 1, 'must not keep hammering a dead token');
|
||||||
|
assert.equal(messages.length, 1);
|
||||||
|
assert.match(messages[0]!, /fresh jwt cookie/);
|
||||||
|
keepalive.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('retries a transient failure instead of giving up', async () => {
|
||||||
|
const { client, calls } = fakeClient([new Error('ECONNRESET')]);
|
||||||
|
const messages: string[] = [];
|
||||||
|
const keepalive = new SessionKeepalive(client, 10_000, 15, (message) => messages.push(message));
|
||||||
|
keepalive.start();
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 120));
|
||||||
|
keepalive.stop();
|
||||||
|
assert.ok(calls.length >= 2, `expected a retry, got ${calls.length} call(s)`);
|
||||||
|
assert.match(messages[0]!, /retrying in/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stop() prevents any further pings', async () => {
|
||||||
|
const { client, calls } = fakeClient([]);
|
||||||
|
const keepalive = new SessionKeepalive(client, 15, 15, () => {});
|
||||||
|
keepalive.start();
|
||||||
|
await settle();
|
||||||
|
keepalive.stop();
|
||||||
|
const seen = calls.length;
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||||
|
assert.equal(calls.length, seen, 'no pings after stop()');
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user