A token lasts 30 days and only a browser login yields one — the account is federated, so the server cannot mint it. Replacing it meant editing .env and recreating the container, every month. `schulcloud token set` (a hidden prompt, or piped input) and a /token page both send it to PUT /api/token. The server checks it with Schulcloud first — well-formed, unexpired, still logged in, the same account — then swaps it into the config every request reads, restarts the keepalive and saves it in STATE_DIR, a new volume, with mode 0600. At startup the newer of the saved token and TSC_JWT_COOKIE wins, unless they belong to different accounts. A refused paste changes nothing, and the token is never logged. The keepalive's pings carry a generation, so a 401 for the old token that arrives after a swap cannot stop the new cycle. `schulcloud token`, whoami and the log report the expiry and warn a week ahead. Found on the way: a host that is off for more than two hours loses the session however long the token has left — this machine lost it overnight — which is what the always-on Pi is for. 174 tests. Smoke 72/72 on the local instance, and a real swap verified end to end there. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
209 lines
10 KiB
Markdown
209 lines
10 KiB
Markdown
# Authentication
|
|
|
|
## What this server uses
|
|
|
|
The `jwt` cookie from a logged-in browser session, sent verbatim as
|
|
`Authorization: Bearer <token>`. That is the whole mechanism.
|
|
|
|
This was worth confirming rather than assuming, because the obvious reading of
|
|
"it's a cookie" leads somewhere much more complicated. Verified live:
|
|
|
|
```
|
|
Authorization: Bearer <jwt> → 200 # what this server does
|
|
Cookie: jwt=<jwt> → 200 # also works
|
|
(no auth) → 401
|
|
```
|
|
|
|
`connect.sid`, `SERVERID` and `isLoggedIn` are **not** needed — there is no
|
|
cookie jar. There *is* a session to keep alive, though not in the way the
|
|
cookies suggest; see the next section.
|
|
|
|
## Token lifetime, and the thing that actually kills it
|
|
|
|
Two clocks govern the token:
|
|
|
|
| Clock | Value | Notes |
|
|
|---|---|---|
|
|
| **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 |
|
|
|
|
`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 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.
|
|
|
|
### But a browser tab will log this server out
|
|
|
|
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:
|
|
|
|
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.**
|
|
|
|
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.
|
|
|
|
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.
|
|
|
|
### So: close the tab
|
|
|
|
**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.
|
|
|
|
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.
|
|
|
|
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.
|
|
|
|
## Replacing the token without a restart
|
|
|
|
`schulcloud token set` (paste at a hidden prompt, or pipe it in) and the
|
|
server's `/token` page both send a fresh token to `PUT /api/token`, behind the
|
|
bearer check. `core/session-token.ts` then:
|
|
|
|
1. **Cleans the paste.** A bare value, `jwt=…; Path=/`, quotes and newlines all
|
|
work.
|
|
2. **Checks before it swaps.** A malformed or expired token is refused without
|
|
asking Schulcloud; otherwise `GET /api/v3/me` must succeed *with the new
|
|
token*, and for the same `userId` the current one carries. A refused token
|
|
changes nothing. Switching accounts stays a deliberate act — change
|
|
`TSC_JWT_COOKIE` and restart.
|
|
3. **Swaps it in place.** Every request reads `config.jwt` at the moment it is
|
|
sent, so the next one uses the new token; nothing caches a copy.
|
|
4. **Restarts the keepalive**, which stopped for good on a 401. Its pings carry
|
|
a generation number, so one still in flight with the old token cannot stop
|
|
the new cycle when its 401 arrives.
|
|
5. **Saves it** to `STATE_DIR` (0600, written beside itself and renamed). At
|
|
startup the newer of the saved token and `TSC_JWT_COOKIE` wins, by `exp` —
|
|
unless they belong to different accounts, when the environment does.
|
|
|
|
The token appears in no log line and no response; `/api/token` reports only
|
|
the expiry, where the token came from, and the keepalive's state. The claims
|
|
are decoded, never verified — Schulcloud verifies, this only reads dates.
|
|
|
|
The cookie is **HttpOnly**, so no script — no bookmarklet, no page on another
|
|
origin — can read it out of the browser. The DevTools copy is the one manual
|
|
step, and it cannot be automated away short of a headless browser holding the
|
|
Schulportal password (see below).
|
|
|
|
### There is no longer window available
|
|
|
|
`config/default.schema.json` documents `JWT_EXTENDED_TIMEOUT_SECONDS`
|
|
(2629746 s ≈ 1 month), reachable in the legacy stack via a `privateDevice`
|
|
login flag. On the current NestJS server that is vestigial: `privateDevice` has
|
|
no references in `apps/server/src`, and `generateJwtAndAddToWhitelist` calls
|
|
`addToWhitelist` with no TTL override. A "remember me" login buys nothing.
|
|
|
|
## How this server stays alive
|
|
|
|
`src/keepalive.ts` calls `POST /api/v3/authentication/refresh-session` every 30
|
|
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 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.
|
|
|
|
**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.
|
|
|
|
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.
|
|
|
|
Two ways to check that a token is holding:
|
|
|
|
- `npm run keepalive-status` reads the running container's logs and summarises
|
|
its extensions. It makes **no** API call on purpose — any authenticated
|
|
request slides the TTL, so a checker that talked to Schulcloud would be
|
|
keeping the session alive itself and could not tell you whether the keepalive
|
|
works.
|
|
- `npm run session-diagnose` calls refresh-session every 10 minutes for ~2.5 h
|
|
and logs the budget, for when there is no container to read logs from.
|
|
|
|
**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 …
|
|
```
|
|
|
|
## Why not username + password
|
|
|
|
The instance's login redirects to Keycloak (realm `TIS`) with a `redirect_uri`
|
|
pointing back at Schulcloud's own server, so the authorization-code exchange
|
|
happens server-side with a client secret only Schulcloud holds. A third party
|
|
cannot replicate that flow. `POST /api/v3/authentication/local` exists but is
|
|
for accounts with local credentials, which federated school accounts do not
|
|
have.
|
|
|
|
Given a keepalive that holds a 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. A headless browser would have to hold
|
|
the Schulportal password, which unlocks far more than this account's school
|
|
files, so it is not done here.
|
|
|
|
## Protecting this server's own endpoint
|
|
|
|
Distinct from the above, and just as important. The MCP endpoint is reachable
|
|
from the public internet by construction: Claude's connectors call it from
|
|
Anthropic's cloud, not from your machine. It is protected by `MCP_AUTH_TOKEN`,
|
|
a shared secret checked in constant time on every `/mcp` request
|
|
(`src/http/auth.ts`), accepted as either `Authorization: Bearer …` or
|
|
`X-Api-Key`. `/healthz` is deliberately open and reveals nothing.
|
|
|
|
Generate one with `openssl rand -hex 32`. If it is unset the server logs a loud
|
|
warning and serves unauthenticated — only acceptable bound to localhost.
|
|
|
|
Rotating it: change `MCP_AUTH_TOKEN` in `.env`, restart the container, update
|
|
the connector in Claude. Nothing else stores it.
|
|
|
|
## Blast radius
|
|
|
|
Every path in this server is a `GET`, including the `api_get` escape hatch,
|
|
which rejects anything not starting with `/api/` and anything carrying a scheme
|
|
or host. Someone who obtained both the endpoint URL and `MCP_AUTH_TOKEN` could
|
|
read this account's Schulcloud data; they could not post, submit, delete, or
|
|
otherwise act as the user. With `MCP_AUTH_TOKEN` they
|
|
could also call `PUT /api/token`, but it accepts only a live token for the same
|
|
account, so the most it can do is hand the server a session the owner already
|
|
has. Keep it that way — adding a single write tool would change that property
|
|
entirely.
|