Root cause: an open Schulportal tab revokes the shared token

Neither of my two hypotheses was right, and the upstream source was
correct all along. The jwt cookie copied from the browser IS the
browser's session token — same jti — so this server and the tab share
one session, and the tab ends it:

  1. nuxt-client sets a purely client-side timer, sessionTimeoutTimestamp
     = now + JWT_TIMEOUT_SECONDS, reset only on route change
     (watch(router.currentRoute, startTimer)) — never by API activity and
     never read back from the server's TTL.
  2. AutoLogoutWarning.vue warns at JWT_SHOW_TIMEOUT_WARNING_SECONDS.
  3. At zero, autoLogout() -> location.replace('/logout?auto-logout=true').
  4. schulcloud-client controllers/login.js:439 -> POST /api/v3/logout
     -> removeJwtFromWhitelist(jwt) -> the shared key is deleted.

That explains the endurance failure exactly: the GET pings at t+0/30/60/90
were sliding the Valkey TTL correctly, and then the tab deleted the key.
It also explains the ~1h warning dialog appearing in a tab the user
considers in use — the timer only resets on navigation.

So the sliding TTL is real and a keepalive does hold a session to the
30-day ceiling. The operational fix is not to ping harder but to close
the Schulportal window after copying the cookie; a private window is the
tidy way. This is now the loudest caveat in the token-copying steps,
because it is the single easiest way to break the setup.

Keeping refresh-session rather than reverting to GET, now for a reason
that stands on its own: it states the intent contractually instead of
relying on extend-on-check as a side effect of an unrelated read (that
whitelist has been refactored twice in 2026, and a GET keepalive would
fail silently if it went away), and its budget readout makes session
health visible in the log.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-12 16:32:42 +02:00
parent 60ca4d3eba
commit 9ce869f3fb
7 changed files with 155 additions and 181 deletions

View File

@@ -6,9 +6,10 @@
TSC_URL=https://schulcloud-thueringen.de
# The value of the `jwt` cookie from a logged-in browser session.
# 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.
# The 30-day `exp` is only a ceiling; the real limit is a 2-hour sliding session
# TTL that the built-in keepalive holds open. IMPORTANT: close the Schulportal
# window after copying this — an open tab shares the session and its auto-logout
# will revoke this token ~2h after login. See docs/AUTH.md.
TSC_JWT_COOKIE=
# ---------------------------------------------------------------------------

View File

@@ -88,15 +88,17 @@ 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 ~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`.
- **`exp` (30 days) is not the session lifetime.** The binding limit is a Valkey
whitelist entry with a `JWT_TIMEOUT_SECONDS` TTL (7200s; live value at
`GET /api/v3/config/public`) that every authenticated request re-sets.
`src/keepalive.ts` holds it open — don't remove it.
- **A Schulportal tab left open revokes our token.** The `jwt` cookie *is* the
browser's session token, same `jti`. The front end runs a client-side timer
(reset only on route change, never from the server TTL) and calls
`/logout?auto-logout=true` ~2h after login, which issues `POST /api/v3/logout`
and deletes the shared key. No keepalive can prevent it; the fix is to close
the tab. This produced two false conclusions before being found — if a token
dies ~2h after login, suspect an open tab first. `docs/AUTH.md` has the chain.
## Conventions

View File

@@ -50,21 +50,22 @@ 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. 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.
Getting `TSC_JWT_COOKIE` takes four clicks in DevTools and then lasts 30 days —
provided you close the Schulportal window afterwards. See
[docs/AUTH.md](docs/AUTH.md); that caveat is not optional.
## 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'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.
as `Authorization: Bearer` — no cookie jar, no `connect.sid`. Its `exp` claim
(30 days) is only a ceiling: the real limit is a 2-hour server-side session TTL
that any request slides, so the server calls `refresh-session` every 30 minutes
(the one non-GET request here, and not exposed as a tool).
The sharp edge is subtler and cost two endurance tests to find: **the cookie you
copy is the browser's own session token**, so a Schulportal tab left open will
auto-logout after ~2 hours and revoke this server's token with it. Copy the
token in a private window and close it. See [docs/AUTH.md](docs/AUTH.md).
**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

View File

@@ -18,95 +18,64 @@ 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: what the source says, and what the instance does
## Token lifetime, and the thing that actually kills it
Two clocks govern the token, and only one of them is in the JWT:
Two clocks govern the token:
| Clock | Value | Moves with use? |
| Clock | Value | Notes |
|---|---|---|
| **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) |
| **Session** — a whitelist entry in Valkey, `jwt:{accountId}:{jti}` | `JWT_TIMEOUT_SECONDS` = **7200 s** | **Sliding**: every authenticated request re-sets it |
| **Hard expiry** — the JWT's own `exp` claim | **30 days** | Cannot be extended |
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.
### What the source says
`JwtStrategy.validate()` runs on every route behind `@JwtAuthentication()` and
calls `JwtWhitelistAdapter.isWhitelisted(accountId, jti)`, which reads Valkey
key `jwt:{accountId}:{jti}`, throws
`JwtStrategy.validate()` calls `JwtWhitelistAdapter.isWhitelisted()`, which
reads the key, 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".
**re-sets it** with a fresh TTL. The legacy Feathers implementation does the
same. So the window really does slide with use, and a keepalive holds a session
up to the 30-day ceiling.
Read on its own, that says the window slides with ordinary use.
### But a browser tab will log this server out
### What the instance actually does
The trap is not a clock at all. **The `jwt` cookie you copy is the browser's own
session token — same `jti`.** This server and that tab share one session, and
the tab will end it:
It does not slide. A keepalive doing nothing but `GET /api/v3/me` every 30
minutes was measured against the live instance:
1. On login the front end starts a **purely client-side** timer:
`sessionTimeoutTimestamp = Date.now() + JWT_TIMEOUT_SECONDS`. It is reset
only on *route change*`watch(router.currentRoute, startTimer)` — never by
API activity, and never read back from the server's real TTL.
(`nuxt-client/src/modules/data/application/application.store.ts`)
2. After `JWT_SHOW_TIMEOUT_WARNING_SECONDS` (3600 s) remain it shows the
"Sitzung verlängern" dialog. (`AutoLogoutWarning.vue`)
3. At zero it calls `autoLogout()`
`location.replace('/logout?auto-logout=true')`.
4. That route, in the legacy client, issues `POST /api/v3/logout`
(`schulcloud-client/controllers/login.js:439`) →
`removeJwtFromWhitelist(jwt)`**the shared key is deleted.**
```
[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
```
An idle tab therefore revokes this server's token about two hours after login,
no matter how diligently the server refreshes it. Measured exactly that way: a
keepalive pinged successfully at t+0/30/60/90 and was dead by t+120, ~2 h after
login, while `exp` still had 29 days left.
The timing is what makes this conclusive:
Note the timer is client-side and reset only by navigation. That is why the
dialog appears after an idle hour even in a tab you consider "in use", and why
no amount of API traffic prevents it.
| 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 ~t13) | **yes** — inside the (t+90, t+120] window |
### So: close the tab
Four requests succeeded inside the window and bought nothing. The binding clock
is anchored at **login**, and ordinary reads do not move it.
**After copying the `jwt` cookie, close the Schulportal tab** — all of them; the
front end also syncs logout across tabs over a `BroadcastChannel`. Closing a tab
runs no logout itself (`autoLogout` fires only from the expiry timer), so the
session survives on the server with nothing left to revoke 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.
The tidiest recipe is a **private/incognito window**: log in there, copy the
`jwt`, close the window. That session is separate from your normal browsing, and
`POST /api/v3/logout` only ever removes its own `jti`, so your everyday session
and this server cannot end each other.
### Values the instance publishes
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 }
```
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.
### The open question
Two mechanisms fit the evidence, and they differ in what they mean for this
server:
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.
`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).
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.
With no tab attached, the keepalive holds the session to the 30-day hard expiry,
and replacing the token becomes the monthly chore it looked like at first.
### There is no longer window available
@@ -114,40 +83,42 @@ back-channel variant clears all of them.
(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` with no TTL override. A "remember me" login will not buy a
longer window.
`addToWhitelist` with no TTL override. A "remember me" login buys nothing.
## How this server tries to stay alive
## How this server stays alive
`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:
minutes (`KEEPALIVE_INTERVAL_MS`; `0` disables it), from both entry points and
independently of whether any MCP client is connected — the session expires on
wall time, not on usage. It logs the budget each time:
```
[schulcloud-mcp] keepalive: session extended, 7200s (120 min) of budget left
```
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.
A plain `GET` would also slide the TTL today. `refresh-session` is used anyway
because it states the intent contractually rather than relying on a side effect
of an unrelated read — upstream has refactored this whitelist twice in 2026, and
a GET-based keepalive would fail *silently* if extend-on-check ever went away.
The budget readout also turns "is the session healthy" into something the log
answers directly.
**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.
**It is the one non-GET request in the server.** No body, touches only the
caller's 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, and the property that matters
is intact: nobody reaching this endpoint can act as the user in Schulcloud.
**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.
A transient failure retries in 5 minutes. A `401` stops the keepalive
permanently and says what to do, because a deleted whitelist entry cannot be
revived by retrying — and if it happens roughly two hours after login, suspect
an open tab before anything else.
**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:
`npm run session-diagnose` logs the budget every 10 minutes for ~2.5 h, which is
the direct way to confirm a token is holding.
**Operational consequence:** if the container is down for over two hours the
session lapses on its own, and restarting will not recover it. The startup log
says so immediately:
```
[schulcloud-mcp] keepalive: token rejected (401). The session is gone …
@@ -162,13 +133,12 @@ cannot replicate that flow. `POST /api/v3/authentication/local` exists but is
for accounts with local credentials, which federated school accounts do not
have.
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
Given a keepalive that holds a session to its 30-day ceiling, the pasted-JWT
approach is the right trade: one manual step a month against re-implementing an
OAuth client whose secret we cannot hold. If that monthly step ever becomes
unacceptable, the honest options are a service account issued by the school's
IDM, or driving the Keycloak login with a headless browser — not a
reimplementation of the code exchange. If this ever needs to be unattended, the honest options are a service
account issued by the school's IDM, or a headless browser login — not a
reimplementation of the Keycloak exchange.

View File

@@ -155,12 +155,13 @@ 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 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`. Whether the keepalive suffices at all is still being
measured; see docs/AUTH.md.
- **The Schulcloud session has a 2-hour sliding TTL**, so the server calls
`refresh-session` every 30 minutes. Watch for
`keepalive: session extended, 7200s` in the logs.
- **Never leave a Schulportal tab open on the token you deployed.** It shares
the session and its auto-logout will revoke it ~2h after login. Copy the
cookie in a private window and close it — see docs/AUTH.md.
- **Downtime longer than two hours lapses the session** and restarting does not
recover it: a long power cut means pasting a fresh `TSC_JWT_COOKIE`.
- **Monthly chore**: refresh `TSC_JWT_COOKIE` before its 30-day hard expiry.
`npm run probe` reports both clocks.

View File

@@ -1,27 +1,22 @@
#!/usr/bin/env node
/**
* Instruments the Schulcloud session to find out what actually ends it.
* Watches a Schulcloud session to confirm it is actually being held.
*
* 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:
* The session is a Valkey whitelist entry with a 7200 s TTL that every
* authenticated request re-sets, so the keepalive should hold it to the JWT's
* 30-day ceiling. The thing that breaks it is not a clock: a Schulportal tab
* left open shares the same token and its client-side auto-logout issues
* `POST /api/v3/logout` ~2 h after login, deleting the shared key. See
* docs/AUTH.md.
*
* (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 calls refresh-session every 10 minutes and logs the reported budget, so
* the shape of the log at death tells you which it was:
*
* 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 steady at 7200, then an abrupt 401 → revoked (open tab, or logout)
* budget decaying 7200 → 0 across pings → extension not taking effect
*
* 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).
* Read-only with respect to user data. Usage: `npm run session-diagnose [minutes]`
* (default 150 — enough to pass the ~2 h mark where an open tab would strike).
*/
import { loadConfig } from '../dist/config.js';
import { SchulcloudClient, SchulcloudApiError } from '../dist/schulcloud/client.js';
@@ -80,12 +75,17 @@ function verdict(series, died) {
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.');
console.log('=> the extension is not taking effect. Shorten KEEPALIVE_INTERVAL_MS; the');
console.log(' series above 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).');
console.log('=> REVOKED from outside, not expired. The budget was healthy right up to the 401.');
if (minutesAlive > 100 && minutesAlive < 160) {
console.log(' ~2h after login is the signature of a Schulportal tab left open on this');
console.log(' token: its auto-logout calls POST /api/v3/logout and deletes the shared');
console.log(' key. Close every Schulportal window and use a fresh token (docs/AUTH.md).');
} else {
console.log(' Check for an explicit logout, or an IDP back-channel logout.');
}
}
}

View File

@@ -4,27 +4,25 @@ import { SchulcloudApiError } from './schulcloud/client.ts';
/**
* Keeps the Schulcloud session alive.
*
* 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:
* The JWT's `exp` claim (30 days) is only an outer ceiling. The binding limit
* is a Valkey whitelist entry, `jwt:{accountId}:{jti}`, with a
* `JWT_TIMEOUT_SECONDS` TTL — 7200s on this instance, readable from
* `GET /api/v3/config/public`. Every authenticated request re-sets it, so the
* window slides and periodic traffic holds a session to the 30-day ceiling.
*
* 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.
* A plain GET would therefore do. We call `refresh-session` instead, the
* endpoint behind the web UI's "Sitzung verlängern" button, for two reasons:
* it states the intent contractually rather than depending on a side effect of
* an unrelated read (upstream has refactored this whitelist twice in 2026, and
* a GET-based keepalive would fail *silently* if extend-on-check went away),
* and it returns the remaining budget, so the log answers "is the session
* healthy" directly.
*
* 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.
* What this CANNOT protect against: a Schulportal tab left open on the same
* token. The browser's `jwt` cookie is the same session, and the front end's
* client-side timer calls logout roughly two hours after login, deleting the
* shared key out from under us. See docs/AUTH.md — the fix is to close the tab,
* not to ping harder.
*/
export class SessionKeepalive {
private timer: NodeJS.Timeout | undefined;
@@ -84,10 +82,11 @@ 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 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.',
'[schulcloud-mcp] keepalive: token rejected (401). The session is gone. ' +
'If this is ~2h after login, the likely cause is a Schulportal tab left open ' +
'on the same token, whose auto-logout revoked it — close the tab. Otherwise ' +
'the server was down past the 2h window, or the JWT hit its 30-day limit. ' +
'Put a fresh jwt cookie in TSC_JWT_COOKIE and restart. Keepalive stopped.',
);
this.stop();
return;