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:
@@ -6,9 +6,10 @@
|
|||||||
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.
|
||||||
# The 30-day `exp` in the token is NOT its lifetime: sessions have been measured
|
# The 30-day `exp` is only a ceiling; the real limit is a 2-hour sliding session
|
||||||
# ending ~2h after login, and ordinary API reads do not extend them. The
|
# TTL that the built-in keepalive holds open. IMPORTANT: close the Schulportal
|
||||||
# keepalive calls refresh-session to try to hold it. See docs/AUTH.md.
|
# 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=
|
TSC_JWT_COOKIE=
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
20
CLAUDE.md
20
CLAUDE.md
@@ -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`.
|
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 ~2h after login, not after 30 days, and reads do not extend
|
- **`exp` (30 days) is not the session lifetime.** The binding limit is a Valkey
|
||||||
it.** `exp` is only a hard ceiling. Both the current and legacy upstream
|
whitelist entry with a `JWT_TIMEOUT_SECONDS` TTL (7200s; live value at
|
||||||
implementations re-set a Valkey TTL on every authenticated request, so the
|
`GET /api/v3/config/public`) that every authenticated request re-sets.
|
||||||
source reads as if the window slides — measured against the live instance, it
|
`src/keepalive.ts` holds it open — don't remove it.
|
||||||
does not: four successful `GET /me` pings at 30-min intervals did not prevent
|
- **A Schulportal tab left open revokes our token.** The `jwt` cookie *is* the
|
||||||
a 401 by t+120. `src/keepalive.ts` therefore calls `refresh-session`. Do not
|
browser's session token, same `jti`. The front end runs a client-side timer
|
||||||
"simplify" it back to a GET, and do not trust the upstream source here.
|
(reset only on route change, never from the server TTL) and calls
|
||||||
Open question and the instrument to settle it: `docs/AUTH.md`,
|
`/logout?auto-logout=true` ~2h after login, which issues `POST /api/v3/logout`
|
||||||
`npm run session-diagnose`.
|
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
|
## Conventions
|
||||||
|
|
||||||
|
|||||||
23
README.md
23
README.md
@@ -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
|
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. Expect to redo it
|
Getting `TSC_JWT_COOKIE` takes four clicks in DevTools and then lasts 30 days —
|
||||||
often until the session question in [docs/AUTH.md](docs/AUTH.md) is settled —
|
provided you close the Schulportal window afterwards. See
|
||||||
sessions have been observed ending ~2 h after login.
|
[docs/AUTH.md](docs/AUTH.md); that caveat is not optional.
|
||||||
|
|
||||||
## Design decisions
|
## Design decisions
|
||||||
|
|
||||||
**Bearer token plus a keepalive.** The instance's `jwt` cookie works verbatim
|
**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
|
as `Authorization: Bearer` — no cookie jar, no `connect.sid`. Its `exp` claim
|
||||||
`exp` claim (30 days) is not its lifetime: the session ends about **two hours
|
(30 days) is only a ceiling: the real limit is a 2-hour server-side session TTL
|
||||||
after login**, and measurement showed that ordinary API reads do *not* extend
|
that any request slides, so the server calls `refresh-session` every 30 minutes
|
||||||
it, despite the upstream source saying they should. So the server calls
|
(the one non-GET request here, and not exposed as a tool).
|
||||||
`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
|
The sharp edge is subtler and cost two endurance tests to find: **the cookie you
|
||||||
[docs/AUTH.md](docs/AUTH.md), which has the endurance test and the open
|
copy is the browser's own session token**, so a Schulportal tab left open will
|
||||||
question.
|
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`,
|
**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
|
||||||
|
|||||||
180
docs/AUTH.md
180
docs/AUTH.md
@@ -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
|
cookie jar. There *is* a session to keep alive, though not in the way the
|
||||||
cookies suggest; see the next section.
|
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) |
|
| **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 | No (cannot be extended) |
|
| **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
|
`JwtStrategy.validate()` calls `JwtWhitelistAdapter.isWhitelisted()`, which
|
||||||
second, which is how "valid for 30 days" becomes a plausible and wrong
|
reads the key, throws
|
||||||
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
|
|
||||||
`'Session was expired due to inactivity - autologout.'` if it is gone, and then
|
`'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
|
**re-sets it** with a fresh TTL. The legacy Feathers implementation does the
|
||||||
implementation it replaced (`ensureTokenIsWhitelisted`) does the same thing,
|
same. So the window really does slide with use, and a keepalive holds a session
|
||||||
with the comment "extend token expiration if token is already whitelisted".
|
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
|
1. On login the front end starts a **purely client-side** timer:
|
||||||
minutes was measured against the live instance:
|
`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.**
|
||||||
|
|
||||||
```
|
An idle tab therefore revokes this server's token about two hours after login,
|
||||||
[t+0.0min] ping #1 -> 200 OK
|
no matter how diligently the server refreshes it. Measured exactly that way: a
|
||||||
[t+30.0min] ping #2 -> 200 OK
|
keepalive pinged successfully at t+0/30/60/90 and was dead by t+120, ~2 h after
|
||||||
[t+60.0min] ping #3 -> 200 OK
|
login, while `exp` still had 29 days left.
|
||||||
[t+90.0min] ping #4 -> 200 OK
|
|
||||||
[t+120.0min] 401 Unauthorized ← token gone
|
|
||||||
```
|
|
||||||
|
|
||||||
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? |
|
### So: close the tab
|
||||||
|---|---|---|
|
|
||||||
| 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
|
**After copying the `jwt` cookie, close the Schulportal tab** — all of them; the
|
||||||
is anchored at **login**, and ordinary reads do not move it.
|
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`
|
The tidiest recipe is a **private/incognito window**: log in there, copy the
|
||||||
holding the reported budget at 7200 s rather than letting it decay to ~7100, so
|
`jwt`, close the window. That session is separate from your normal browsing, and
|
||||||
that endpoint does *something* the reads do not.
|
`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
|
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.
|
||||||
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.
|
|
||||||
|
|
||||||
### There is no longer window available
|
### 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`
|
(2629746 s ≈ 1 month), reachable in the legacy stack via a `privateDevice`
|
||||||
login flag. On the current NestJS server that is vestigial: `privateDevice` has
|
login flag. On the current NestJS server that is vestigial: `privateDevice` has
|
||||||
no references in `apps/server/src`, and `generateJwtAndAddToWhitelist` calls
|
no references in `apps/server/src`, and `generateJwtAndAddToWhitelist` calls
|
||||||
`addToWhitelist` with no TTL override. A "remember me" login will not buy a
|
`addToWhitelist` with no TTL override. A "remember me" login buys nothing.
|
||||||
longer window.
|
|
||||||
|
|
||||||
## How this server tries to stay alive
|
## How this server stays alive
|
||||||
|
|
||||||
`src/keepalive.ts` calls `POST /api/v3/authentication/refresh-session` every 30
|
`src/keepalive.ts` calls `POST /api/v3/authentication/refresh-session` every 30
|
||||||
minutes (`KEEPALIVE_INTERVAL_MS`; `0` disables it), started by both entry
|
minutes (`KEEPALIVE_INTERVAL_MS`; `0` disables it), from both entry points and
|
||||||
points, independent of whether any MCP client is connected — the session
|
independently of whether any MCP client is connected — the session expires on
|
||||||
expires on wall time, not on usage. It logs the budget each time, so the log
|
wall time, not on usage. It logs the budget each time:
|
||||||
shows whether the extension is taking:
|
|
||||||
|
|
||||||
```
|
```
|
||||||
[schulcloud-mcp] keepalive: session extended, 7200s (120 min) of budget left
|
[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 plain `GET` would also slide the TTL today. `refresh-session` is used anyway
|
||||||
A transient failure retries in 5 minutes; a `401` stops the keepalive
|
because it states the intent contractually rather than relying on a side effect
|
||||||
permanently and says what to do, because a cleared whitelist entry cannot be
|
of an unrelated read — upstream has refactored this whitelist twice in 2026, and
|
||||||
revived by retrying.
|
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
|
**It is the one non-GET request in the server.** No body, touches only the
|
||||||
only the caller's own session, and cannot read or modify user data, so it does
|
caller's own session, cannot read or modify user data, and is not exposed as a
|
||||||
not weaken the property that matters — nobody reaching this endpoint can act as
|
tool — so no model-driven call can ever be a POST, and the property that matters
|
||||||
the user inside Schulcloud. No MCP tool exposes it, so a model-driven call can
|
is intact: nobody reaching this endpoint can act as the user in Schulcloud.
|
||||||
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
|
A transient failure retries in 5 minutes. A `401` stops the keepalive
|
||||||
`npm run session-diagnose` reports mechanism (2), the keepalive cannot work and
|
permanently and says what to do, because a deleted whitelist entry cannot be
|
||||||
the auth approach itself needs revisiting.
|
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
|
`npm run session-diagnose` logs the budget every 10 minutes for ~2.5 h, which is
|
||||||
two hours, the token is dead when it returns and restarting will not fix it.
|
the direct way to confirm a token is holding.
|
||||||
The startup log says so immediately:
|
|
||||||
|
**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 …
|
[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
|
for accounts with local credentials, which federated school accounts do not
|
||||||
have.
|
have.
|
||||||
|
|
||||||
Whether the pasted-JWT approach remains the right trade depends on the open
|
Given a keepalive that holds a session to its 30-day ceiling, the pasted-JWT
|
||||||
question above. If a keepalive can hold a session to its 30-day ceiling, it is:
|
approach is the right trade: one manual step a month against re-implementing an
|
||||||
one manual step a month against re-implementing an OAuth client whose secret we
|
OAuth client whose secret we cannot hold. If that monthly step ever becomes
|
||||||
cannot hold. If the session is instead capped at ~2 h from login, a pasted JWT
|
unacceptable, the honest options are a service account issued by the school's
|
||||||
is not viable for an unattended server, and the realistic options become a
|
IDM, or driving the Keycloak login with a headless browser — not a
|
||||||
service account issued by the school's IDM, or driving the Keycloak login with
|
reimplementation of the code exchange. If this ever needs to be unattended, the honest options are a service
|
||||||
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
|
account issued by the school's IDM, or a headless browser login — not a
|
||||||
reimplementation of the Keycloak exchange.
|
reimplementation of the Keycloak exchange.
|
||||||
|
|
||||||
|
|||||||
@@ -155,12 +155,13 @@ 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).
|
||||||
- **The Schulcloud session ends ~2 hours after login**, and API reads do not
|
- **The Schulcloud session has a 2-hour sliding TTL**, so the server calls
|
||||||
extend it, so the server calls `refresh-session` every 30 minutes. Watch for
|
`refresh-session` every 30 minutes. Watch for
|
||||||
`keepalive: session extended, 7200s` in the logs; a falling budget is the
|
`keepalive: session extended, 7200s` in the logs.
|
||||||
early warning. **Downtime longer than two hours kills the token** and
|
- **Never leave a Schulportal tab open on the token you deployed.** It shares
|
||||||
restarting does not recover it — a long power cut means pasting a fresh
|
the session and its auto-logout will revoke it ~2h after login. Copy the
|
||||||
`TSC_JWT_COOKIE`. Whether the keepalive suffices at all is still being
|
cookie in a private window and close it — see docs/AUTH.md.
|
||||||
measured; 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.
|
- **Monthly chore**: refresh `TSC_JWT_COOKIE` before its 30-day hard expiry.
|
||||||
`npm run probe` reports both clocks.
|
`npm run probe` reports both clocks.
|
||||||
|
|||||||
@@ -1,27 +1,22 @@
|
|||||||
#!/usr/bin/env node
|
#!/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
|
* The session is a Valkey whitelist entry with a 7200 s TTL that every
|
||||||
* of `GET` traffic extends that. Two mechanisms could produce it, and they
|
* authenticated request re-sets, so the keepalive should hold it to the JWT's
|
||||||
* imply very different things for this server:
|
* 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
|
* This calls refresh-session every 10 minutes and logs the reported budget, so
|
||||||
* refresh-session would hold the session indefinitely;
|
* the shape of the log at death tells you which it was:
|
||||||
* (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
|
* budget steady at 7200, then an abrupt 401 → revoked (open tab, or logout)
|
||||||
* and records the reported budget each time. The shape of the log at death is
|
* budget decaying 7200 → 0 across pings → extension not taking effect
|
||||||
* the answer:
|
|
||||||
*
|
*
|
||||||
* budget decays 7200 → 0 across pings → (1), and pinging more often fixes it
|
* Read-only with respect to user data. Usage: `npm run session-diagnose [minutes]`
|
||||||
* budget sits at 7200, then 401 abruptly → (2), revocation from outside
|
* (default 150 — enough to pass the ~2 h mark where an open tab would strike).
|
||||||
*
|
|
||||||
* 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 { loadConfig } from '../dist/config.js';
|
||||||
import { SchulcloudClient, SchulcloudApiError } from '../dist/schulcloud/client.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;
|
const decayed = series.length >= 2 && series.at(-1) < series[0] - 60;
|
||||||
if (decayed) {
|
if (decayed) {
|
||||||
console.log(`Session ended ${minutesAlive.toFixed(0)} min after login, and the budget was DECAYING.`);
|
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('=> the extension is not taking effect. Shorten KEEPALIVE_INTERVAL_MS; the');
|
||||||
console.log(' Try a shorter KEEPALIVE_INTERVAL_MS; the budget series shows the real decay rate.');
|
console.log(' series above shows the real decay rate.');
|
||||||
} else {
|
} else {
|
||||||
console.log(`Session ended ${minutesAlive.toFixed(0)} min after login while the budget still read ${series.at(-1)}s.`);
|
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('=> REVOKED from outside, not expired. The budget was healthy right up to the 401.');
|
||||||
console.log(' No keepalive can prevent this. The pasted-JWT approach is capped at ~2h from');
|
if (minutesAlive > 100 && minutesAlive < 160) {
|
||||||
console.log(' login, and the auth strategy needs revisiting (see docs/AUTH.md).');
|
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.');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,27 +4,25 @@ import { SchulcloudApiError } from './schulcloud/client.ts';
|
|||||||
/**
|
/**
|
||||||
* Keeps the Schulcloud session alive.
|
* Keeps the Schulcloud session alive.
|
||||||
*
|
*
|
||||||
* The JWT's `exp` claim says 30 days, but the session dies far sooner, and the
|
* The JWT's `exp` claim (30 days) is only an outer ceiling. The binding limit
|
||||||
* mechanism is not what the upstream source suggests. Both the current
|
* is a Valkey whitelist entry, `jwt:{accountId}:{jti}`, with a
|
||||||
* (`JwtWhitelistAdapter`) and legacy (Feathers `ensureTokenIsWhitelisted`)
|
* `JWT_TIMEOUT_SECONDS` TTL — 7200s on this instance, readable from
|
||||||
* implementations re-set a Valkey TTL on every authenticated request, which
|
* `GET /api/v3/config/public`. Every authenticated request re-sets it, so the
|
||||||
* would make the window slide with ordinary use. Measured against the live
|
* window slides and periodic traffic holds a session to the 30-day ceiling.
|
||||||
* instance, it does not:
|
|
||||||
*
|
*
|
||||||
* A keepalive doing only `GET /api/v3/me` every 30 min was pinged
|
* A plain GET would therefore do. We call `refresh-session` instead, the
|
||||||
* successfully at t+0/30/60/90 and was nonetheless rejected by t+120 —
|
* endpoint behind the web UI's "Sitzung verlängern" button, for two reasons:
|
||||||
* almost exactly two hours after *login*, not two hours after the last
|
* it states the intent contractually rather than depending on a side effect of
|
||||||
* request, which would have been t+210.
|
* 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.
|
* What this CANNOT protect against: a Schulportal tab left open on the same
|
||||||
* We therefore extend explicitly, via the endpoint the web UI's "Sitzung
|
* token. The browser's `jwt` cookie is the same session, and the front end's
|
||||||
* verlängern" button uses, which also reports the remaining budget so the log
|
* client-side timer calls logout roughly two hours after login, deleting the
|
||||||
* shows whether the extension actually took.
|
* shared key out from under us. See docs/AUTH.md — the fix is to close the tab,
|
||||||
*
|
* not to ping harder.
|
||||||
* 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 {
|
export class SessionKeepalive {
|
||||||
private timer: NodeJS.Timeout | undefined;
|
private timer: NodeJS.Timeout | undefined;
|
||||||
@@ -84,10 +82,11 @@ export class SessionKeepalive {
|
|||||||
// ceiling. Pinging harder cannot revive it — a human must paste a new
|
// 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.
|
// token — so stop and say so loudly rather than logging every 30 min.
|
||||||
this.log(
|
this.log(
|
||||||
'[schulcloud-mcp] keepalive: token rejected (401). The session is gone — ' +
|
'[schulcloud-mcp] keepalive: token rejected (401). The session is gone. ' +
|
||||||
'measured behaviour is that it ends roughly 2h after login regardless of ' +
|
'If this is ~2h after login, the likely cause is a Schulportal tab left open ' +
|
||||||
'activity, and the JWT also has a 30-day hard limit. Put a fresh jwt cookie ' +
|
'on the same token, whose auto-logout revoked it — close the tab. Otherwise ' +
|
||||||
'in TSC_JWT_COOKIE and restart. Keepalive stopped.',
|
'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();
|
this.stop();
|
||||||
return;
|
return;
|
||||||
|
|||||||
Reference in New Issue
Block a user