Replace the Schulcloud token without a restart
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>
This commit is contained in:
@@ -10,6 +10,8 @@ TSC_URL=https://schulcloud-thueringen.de
|
|||||||
# TTL that the built-in keepalive holds open. IMPORTANT: close the Schulportal
|
# 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
|
# 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.
|
# will revoke this token ~2h after login. See docs/AUTH.md.
|
||||||
|
# Needed for the first start. Later tokens go in with `schulcloud token set` or
|
||||||
|
# the server's /token page, without a restart; a saved newer one wins over this.
|
||||||
TSC_JWT_COOKIE=
|
TSC_JWT_COOKIE=
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -22,6 +24,11 @@ TSC_JWT_COOKIE=
|
|||||||
# openssl rand -hex 32
|
# openssl rand -hex 32
|
||||||
MCP_AUTH_TOKEN=
|
MCP_AUTH_TOKEN=
|
||||||
|
|
||||||
|
# Where a Schulcloud token replaced at runtime (`schulcloud token set`, /token)
|
||||||
|
# is saved, so a restart keeps it. docker-compose.yml sets /data/state; unset =
|
||||||
|
# replacements last until the next restart.
|
||||||
|
# STATE_DIR=/data/state
|
||||||
|
|
||||||
# Listen address inside the container. Leave as-is when running behind Caddy.
|
# Listen address inside the container. Leave as-is when running behind Caddy.
|
||||||
PORT=8080
|
PORT=8080
|
||||||
BIND_HOST=0.0.0.0
|
BIND_HOST=0.0.0.0
|
||||||
|
|||||||
30
CLAUDE.md
30
CLAUDE.md
@@ -39,9 +39,10 @@ index.
|
|||||||
read-only with respect to Schulcloud. Run `smoke` after touching `src/core/`,
|
read-only with respect to Schulcloud. Run `smoke` after touching `src/core/`,
|
||||||
`src/mcp/` or `src/http/` — the unit tests cover only pure functions.
|
`src/mcp/` or `src/http/` — the unit tests cover only pure functions.
|
||||||
|
|
||||||
Run smoke **both ways**: with `DATABASE_URL` set (69 checks, index-backed) and
|
Run smoke **both ways**: with `DATABASE_URL` set (74 checks, index-backed) and
|
||||||
without (67 checks, live-only). The degradation path is a supported mode, not a
|
without (72 checks, live-only). The degradation path is a supported mode, not a
|
||||||
fallback nobody exercises.
|
fallback nobody exercises. Every Schulcloud check fails with 401 when the live
|
||||||
|
session has lapsed — check the container's keepalive log before suspecting code.
|
||||||
|
|
||||||
Store tests need a database and skip without one:
|
Store tests need a database and skip without one:
|
||||||
`TEST_DATABASE_URL=postgresql://… npm test`. They use a real Postgres on
|
`TEST_DATABASE_URL=postgresql://… npm test`. They use a real Postgres on
|
||||||
@@ -74,6 +75,11 @@ bin/cli.ts ──HTTP──────────┘ cli/{config,client,sync
|
|||||||
- `legacy-files.ts` — the file manager ("Dateien": Persönliche, Kurs-, Team-,
|
- `legacy-files.ts` — the file manager ("Dateien": Persönliche, Kurs-, Team-,
|
||||||
Geteilte Dateien) as one path tree, parsed from the legacy client's pages.
|
Geteilte Dateien) as one path tree, parsed from the legacy client's pages.
|
||||||
A separate store from files-storage; the `fs_*` tools and `/api/fs` sit on it.
|
A separate store from files-storage; the `fs_*` tools and `/api/fs` sit on it.
|
||||||
|
- `session-token.ts` — the Schulcloud token, replaceable at runtime: checked
|
||||||
|
with `GET /me` (same `userId`), swapped into `config.jwt`, saved to
|
||||||
|
`STATE_DIR`. **Read `config.jwt` at the moment of use; never keep a copy.**
|
||||||
|
- **`http/`** — besides `/mcp` and `/api`: `/token`, a page that PUTs a fresh
|
||||||
|
token to `/api/token`. docs/AUTH.md says why it exists.
|
||||||
- **`store/`** — crawl generations, identity diffs, `german` + `pg_trgm` FTS.
|
- **`store/`** — crawl generations, identity diffs, `german` + `pg_trgm` FTS.
|
||||||
`Store.open` returns `undefined` when Postgres is down; callers degrade.
|
`Store.open` returns `undefined` when Postgres is down; callers degrade.
|
||||||
- **`indexer/`** — crawl → persist → mirror bytes → extract text → index.
|
- **`indexer/`** — crawl → persist → mirror bytes → extract text → index.
|
||||||
@@ -105,7 +111,8 @@ permission, so `getFileManagerPage` allows only the listing routes, by pattern
|
|||||||
widen that pattern only with a route you have read the handler of. A pre-signed
|
widen that pattern only with a route you have read the handler of. A pre-signed
|
||||||
download URL is fetched with **no** credentials: it names another host, and
|
download URL is fetched with **no** credentials: it names another host, and
|
||||||
neither the bearer nor the `jwt` cookie may go with it. `refresh_index` and `POST /api/refresh` write only to the Pi's
|
neither the bearer nor the `jwt` cookie may go with it. `refresh_index` and `POST /api/refresh` write only to the Pi's
|
||||||
own index and mirror — every upstream call they make is still a GET.
|
own index and mirror, and `PUT /api/token` only to the server's own token — every
|
||||||
|
upstream call they make is still a GET.
|
||||||
|
|
||||||
**Filenames from Schulcloud are untrusted paths.** Course titles, card titles
|
**Filenames from Schulcloud are untrusted paths.** Course titles, card titles
|
||||||
and filenames are all user-supplied upstream, and both the server's mirror and
|
and filenames are all user-supplied upstream, and both the server's mirror and
|
||||||
@@ -121,6 +128,8 @@ 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; `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.
|
||||||
|
One more counts as a secret: a token replaced at runtime, which lives only in
|
||||||
|
`STATE_DIR`, mode 0600.
|
||||||
|
|
||||||
**Live behaviour beats upstream source.** The clones in `vendor/` track `main`
|
**Live behaviour beats upstream source.** The clones in `vendor/` track `main`
|
||||||
and may be ahead of what is deployed. When they disagree with the instance, the
|
and may be ahead of what is deployed. When they disagree with the instance, the
|
||||||
@@ -260,7 +269,10 @@ These cost real time to discover; `docs/API.md` has the full list with evidence.
|
|||||||
- **`exp` (30 days) is not the session lifetime.** The binding limit is a Valkey
|
- **`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
|
whitelist entry with a `JWT_TIMEOUT_SECONDS` TTL (7200s; live value at
|
||||||
`GET /api/v3/config/public`) that every authenticated request re-sets.
|
`GET /api/v3/config/public`) that every authenticated request re-sets.
|
||||||
`src/keepalive.ts` holds it open — don't remove it.
|
`src/keepalive.ts` holds it open — don't remove it. It cannot hold it across
|
||||||
|
downtime: a host off for more than two hours loses the session (seen when a
|
||||||
|
dev machine was off overnight), which is why the deployment is an always-on
|
||||||
|
Pi and why a fresh token can be swapped in without a restart.
|
||||||
- **A Schulportal tab left open revokes our token.** The `jwt` cookie *is* the
|
- **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
|
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
|
(reset only on route change, never from the server TTL) and calls
|
||||||
@@ -316,6 +328,8 @@ bundle (2.1.272), not its docs:
|
|||||||
|
|
||||||
## 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`; docker-compose sets
|
||||||
for the full set and `docs/AUTH.md` for refreshing the JWT. `npm run probe`
|
`STATE_DIR`. See `.env.example` for the
|
||||||
reports both clocks: days until hard expiry and seconds of idle budget left.
|
full set and `docs/AUTH.md` for refreshing the JWT — `schulcloud token set`,
|
||||||
|
no restart. `npm run probe` and `schulcloud token` report the clocks: days until
|
||||||
|
hard expiry and the session budget.
|
||||||
|
|||||||
14
Dockerfile
14
Dockerfile
@@ -30,12 +30,14 @@ COPY --from=deps /app/node_modules ./node_modules
|
|||||||
COPY --from=build /app/dist ./dist
|
COPY --from=build /app/dist ./dist
|
||||||
COPY package.json ./
|
COPY package.json ./
|
||||||
|
|
||||||
# The mirror is the one writable path. Creating it in the image with the right
|
# The mirror and the state directory (a replaced session token) are the only
|
||||||
# owner matters: Docker initialises a new named volume from the image directory,
|
# writable paths. Creating them in the image with the right owner matters: Docker
|
||||||
# including its ownership, so without this the volume lands root-owned and the
|
# initialises a new named volume from the image directory, including its
|
||||||
# unprivileged user gets EACCES on every write — with the failure recorded per
|
# ownership, so without this the volume lands root-owned and the unprivileged
|
||||||
# file rather than crashing, which makes it easy to miss.
|
# user gets EACCES on every write — with the failure recorded rather than
|
||||||
RUN mkdir -p /data/mirror && chown -R node:node /data
|
# crashing, which makes it easy to miss. The state directory holds a credential,
|
||||||
|
# so only its owner may enter it.
|
||||||
|
RUN mkdir -p /data/mirror /data/state && chown -R node:node /data && chmod 700 /data/state
|
||||||
|
|
||||||
# node:alpine ships an unprivileged `node` user.
|
# node:alpine ships an unprivileged `node` user.
|
||||||
USER node
|
USER node
|
||||||
|
|||||||
@@ -77,10 +77,13 @@ schulcloud sync # mirror coursework to ~/Schulcloud
|
|||||||
schulcloud refresh --course <id>
|
schulcloud refresh --course <id>
|
||||||
schulcloud fs tree /courses # browse the file manager ("Dateien")
|
schulcloud fs tree /courses # browse the file manager ("Dateien")
|
||||||
schulcloud fs get "/courses/<course>/<folder>"
|
schulcloud fs get "/courses/<course>/<folder>"
|
||||||
|
schulcloud token set # the monthly chore: hand the Pi a fresh Schulcloud token
|
||||||
```
|
```
|
||||||
|
|
||||||
It talks only to the Pi and holds no Schulcloud credential — see
|
It talks only to the Pi and holds no Schulcloud credential — see
|
||||||
[docs/CLI.md](docs/CLI.md).
|
[docs/CLI.md](docs/CLI.md). A fresh token can also be pasted into the server's
|
||||||
|
`/token` page; either way the server checks it with Schulcloud and swaps it in
|
||||||
|
without a restart.
|
||||||
|
|
||||||
## Quick start
|
## Quick start
|
||||||
|
|
||||||
@@ -174,9 +177,9 @@ npm run typecheck
|
|||||||
```
|
```
|
||||||
|
|
||||||
`npm run smoke` starts the HTTP server, connects a real MCP client over
|
`npm run smoke` starts the HTTP server, connects a real MCP client over
|
||||||
Streamable HTTP and exercises every tool against the live account — 67 checks (69 with the index)
|
Streamable HTTP and exercises every tool against the live account — 72 checks (74 with the index)
|
||||||
covering the auth gate, the protocol handshake, every content chain, file
|
covering the auth gate, the protocol handshake, every content chain, file
|
||||||
extraction, resources and prompts, `api_get`'s guard rails and error handling.
|
extraction, resources and prompts, token replacement, `api_get`'s guard rails and error handling.
|
||||||
|
|
||||||
## Upstream
|
## Upstream
|
||||||
|
|
||||||
|
|||||||
@@ -37,13 +37,16 @@ services:
|
|||||||
PORT: 8080
|
PORT: 8080
|
||||||
BIND_HOST: 0.0.0.0
|
BIND_HOST: 0.0.0.0
|
||||||
MIRROR_DIR: /data/mirror
|
MIRROR_DIR: /data/mirror
|
||||||
|
STATE_DIR: /data/state
|
||||||
depends_on:
|
depends_on:
|
||||||
postgres:
|
postgres:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
volumes:
|
volumes:
|
||||||
# The mirror is the one thing this server writes; everything else stays
|
# The mirror and a replaced Schulcloud token are the only things this
|
||||||
# read-only, so it gets its own volume rather than loosening read_only.
|
# server writes; everything else stays read-only, so each gets its own
|
||||||
|
# volume rather than loosening read_only.
|
||||||
- mirror:/data/mirror
|
- mirror:/data/mirror
|
||||||
|
- state:/data/state
|
||||||
# No ports are published to the host: Caddy reaches the container over the
|
# No ports are published to the host: Caddy reaches the container over the
|
||||||
# shared Docker network, so the only way in from the internet is through
|
# shared Docker network, so the only way in from the internet is through
|
||||||
# Caddy's TLS and this server's bearer check.
|
# Caddy's TLS and this server's bearer check.
|
||||||
@@ -67,6 +70,7 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
pgdata:
|
pgdata:
|
||||||
mirror:
|
mirror:
|
||||||
|
state:
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
caddy:
|
caddy:
|
||||||
|
|||||||
44
docs/AUTH.md
44
docs/AUTH.md
@@ -77,6 +77,37 @@ and this server cannot end each other.
|
|||||||
With no tab attached, the keepalive holds the session to the 30-day hard expiry,
|
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.
|
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
|
### There is no longer window available
|
||||||
|
|
||||||
`config/default.schema.json` documents `JWT_EXTENDED_TIMEOUT_SECONDS`
|
`config/default.schema.json` documents `JWT_EXTENDED_TIMEOUT_SECONDS`
|
||||||
@@ -145,9 +176,9 @@ 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
|
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
|
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
|
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
|
reimplementation of the code exchange. A headless browser would have to hold
|
||||||
account issued by the school's IDM, or a headless browser login — not a
|
the Schulportal password, which unlocks far more than this account's school
|
||||||
reimplementation of the Keycloak exchange.
|
files, so it is not done here.
|
||||||
|
|
||||||
## Protecting this server's own endpoint
|
## Protecting this server's own endpoint
|
||||||
|
|
||||||
@@ -170,5 +201,8 @@ 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
|
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
|
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
|
read this account's Schulcloud data; they could not post, submit, delete, or
|
||||||
otherwise act as the user. Keep it that way — adding a single write tool would
|
otherwise act as the user. With `MCP_AUTH_TOKEN` they
|
||||||
change that property entirely.
|
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.
|
||||||
|
|||||||
27
docs/CLI.md
27
docs/CLI.md
@@ -54,6 +54,33 @@ file-manager folders included — so `refresh` starts it and then polls the
|
|||||||
server's status, printing a note every half minute, rather than holding one
|
server's status, printing a note every half minute, rather than holding one
|
||||||
request open (which Node's fetch abandons after five minutes).
|
request open (which Node's fetch abandons after five minutes).
|
||||||
|
|
||||||
|
### The server's Schulcloud token (`token`)
|
||||||
|
|
||||||
|
```
|
||||||
|
schulcloud token when it expires, and whether the session is alive
|
||||||
|
schulcloud token set hand the server a fresh one
|
||||||
|
```
|
||||||
|
|
||||||
|
The monthly chore, with no restart and no `.env` edit:
|
||||||
|
|
||||||
|
1. Open a **private window** and log in to Schulcloud.
|
||||||
|
2. DevTools → Application (Firefox: Storage) → Cookies → `jwt`: copy the value.
|
||||||
|
3. `schulcloud token set` and paste it at the prompt. The input is hidden.
|
||||||
|
4. **Close the private window.** Left open, it logs the token out about two
|
||||||
|
hours after login (docs/AUTH.md).
|
||||||
|
|
||||||
|
Piping works too — `wl-paste | schulcloud token set` — and a pasted cookie
|
||||||
|
line such as `jwt=…; Path=/` is cleaned up. The token is never a command-line
|
||||||
|
argument, so it cannot end up in shell history.
|
||||||
|
|
||||||
|
The server checks the token with Schulcloud before swapping it in, so a bad
|
||||||
|
paste changes nothing. It refuses a token that is malformed, expired, already
|
||||||
|
logged out, or for a different account. A replacement is saved on the server
|
||||||
|
(`STATE_DIR`), so a restart keeps it, and the keepalive picks it up at once.
|
||||||
|
The same form lives at `https://<server>/token` for when no terminal is at hand.
|
||||||
|
The cookie is HttpOnly, so no bookmarklet can read it for you; the DevTools
|
||||||
|
copy is the step that remains.
|
||||||
|
|
||||||
### The file manager (`fs`)
|
### The file manager (`fs`)
|
||||||
|
|
||||||
The Schulcloud file manager ("Dateien") — Persönliche, Kurs-, Team- and
|
The Schulcloud file manager ("Dateien") — Persönliche, Kurs-, Team- and
|
||||||
|
|||||||
@@ -13,13 +13,19 @@ schulcloud CLI ─────┘ └─ Caddy
|
|||||||
└──▶ schulcloud-thueringen.de
|
└──▶ schulcloud-thueringen.de
|
||||||
```
|
```
|
||||||
|
|
||||||
Both front ends use the same hostname and the same bearer token. `/mcp` speaks
|
Both front ends use the same hostname and the same bearer token. `/mcp` speaks MCP; `/api` serves the
|
||||||
MCP; `/api` serves the CLI's manifest, file bytes and re-crawl requests.
|
CLI's manifest, file bytes, re-crawl requests and token replacement; `/token` is
|
||||||
|
a page for pasting a fresh Schulcloud token.
|
||||||
|
|
||||||
Claude's custom connectors call the endpoint from Anthropic's cloud, so it must
|
Claude's custom connectors call the endpoint from Anthropic's cloud
|
||||||
be publicly reachable over real TLS — a localhost tunnel or self-signed cert
|
(`160.79.104.0/21`), so it must be publicly reachable over real TLS — a
|
||||||
will not do. The VPS provides the public address; Caddy on the Pi terminates
|
localhost tunnel or self-signed cert will not do. The VPS provides the public
|
||||||
TLS and obtains the certificate.
|
address; Caddy on the Pi terminates TLS and obtains the certificate.
|
||||||
|
|
||||||
|
**The Pi must stay up.** More than two hours offline ends the Schulcloud session
|
||||||
|
however long the token has left — a laptop that sleeps overnight loses it every
|
||||||
|
night. A replacement token fixes that without a restart (see *Replacing the
|
||||||
|
Schulcloud token*), but an always-on host is what avoids needing one.
|
||||||
|
|
||||||
The container publishes no host port. Caddy reaches it over the shared Docker
|
The container publishes no host port. Caddy reaches it over the shared Docker
|
||||||
network, so the only way in from the internet is through Caddy and then through
|
network, so the only way in from the internet is through Caddy and then through
|
||||||
@@ -140,6 +146,26 @@ newline from a copy-paste.
|
|||||||
Ask *"which courses am I in?"* as a first check — that exercises auth, the
|
Ask *"which courses am I in?"* as a first check — that exercises auth, the
|
||||||
Schulcloud token and the API in one call.
|
Schulcloud token and the API in one call.
|
||||||
|
|
||||||
|
## Replacing the Schulcloud token
|
||||||
|
|
||||||
|
The token lasts 30 days at most and can only come from a browser login (see
|
||||||
|
docs/AUTH.md), so this is the monthly chore — but it no longer needs a restart
|
||||||
|
or an `.env` edit:
|
||||||
|
|
||||||
|
1. Log in to Schulcloud in a **private window** and copy the `jwt` cookie's
|
||||||
|
value (DevTools → Application → Cookies).
|
||||||
|
2. Either run `schulcloud token set` and paste it, or open
|
||||||
|
`https://mcp.example.org/token` and paste it together with `MCP_AUTH_TOKEN`.
|
||||||
|
3. **Close the private window.**
|
||||||
|
|
||||||
|
The server checks the token with Schulcloud first — right account, not
|
||||||
|
expired, not logged out — then swaps it in, restarts the keepalive and saves it
|
||||||
|
to the `state` volume (`STATE_DIR=/data/state`), so a restart keeps it. At
|
||||||
|
startup the newer of the saved token and `TSC_JWT_COOKIE` wins, unless the two
|
||||||
|
are for different accounts: changing `TSC_JWT_COOKIE` is how you switch
|
||||||
|
accounts. `schulcloud token` shows the days left; from a week before expiry the
|
||||||
|
log, `whoami` and the CLI warn about it.
|
||||||
|
|
||||||
## Running it locally instead
|
## Running it locally instead
|
||||||
|
|
||||||
For Claude Code or Claude Desktop on your own machine, skip all of the above and
|
For Claude Code or Claude Desktop on your own machine, skip all of the above and
|
||||||
@@ -179,9 +205,10 @@ npm run probe # re-verify the API assumptions
|
|||||||
invalidates them; Claude re-initializes transparently.
|
invalidates them; Claude re-initializes transparently.
|
||||||
- **Logs** are capped at 3 × 10 MB. The Authorization header is never logged.
|
- **Logs** are capped at 3 × 10 MB. The Authorization header is never logged.
|
||||||
- **The container is read-only** with `cap_drop: ALL` and `no-new-privileges`,
|
- **The container is read-only** with `cap_drop: ALL` and `no-new-privileges`,
|
||||||
running as the unprivileged `node` user. The one writable path is the mirror
|
running as the unprivileged `node` user. The writable paths are the mirror
|
||||||
volume at `/data/mirror`, which holds downloaded file bytes; everything else
|
volume at `/data/mirror`, which holds downloaded file bytes, and the state
|
||||||
stays read-only.
|
volume at `/data/state`, which holds a replaced Schulcloud token (mode 0600).
|
||||||
|
Both belong in no backup that leaves the Pi unencrypted.
|
||||||
- **The mirror grows.** It holds a copy of every course file under
|
- **The mirror grows.** It holds a copy of every course file under
|
||||||
`MIRROR_MAX_BYTES` (64 MiB default). Larger files — videos, mostly — are
|
`MIRROR_MAX_BYTES` (64 MiB default). Larger files — videos, mostly — are
|
||||||
indexed as metadata and proxied live on request instead. Budget a few GB.
|
indexed as metadata and proxied live on request instead. Budget a few GB.
|
||||||
@@ -196,6 +223,8 @@ npm run probe # re-verify the API assumptions
|
|||||||
the session and its auto-logout will revoke it ~2h after login. Copy the
|
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.
|
cookie in a private window and close it — see docs/AUTH.md.
|
||||||
- **Downtime longer than two hours lapses the session** and restarting does not
|
- **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`.
|
recover it: a long power cut means handing the server a fresh token
|
||||||
- **Monthly chore**: refresh `TSC_JWT_COOKIE` before its 30-day hard expiry.
|
(`schulcloud token set`), no restart needed.
|
||||||
`npm run probe` reports both clocks.
|
- **Monthly chore**: replace the token before its 30-day hard expiry — see
|
||||||
|
*Replacing the Schulcloud token*. `schulcloud token` and `npm run probe`
|
||||||
|
report the clocks.
|
||||||
|
|||||||
@@ -19,13 +19,17 @@ moves the published port when 8080 is taken, and `CRAWL_INTERVAL_MS` restores a
|
|||||||
crawl timer — worth doing against a real account, because `what_changed` can
|
crawl timer — worth doing against a real account, because `what_changed` can
|
||||||
only report what happened between crawls.
|
only report what happened between crawls.
|
||||||
|
|
||||||
After pasting a new `TSC_JWT_COOKIE` into `.env`, **recreate** the container —
|
A fresh Schulcloud token goes in with `schulcloud token set`, or on the page at
|
||||||
|
`http://127.0.0.1:8080/token` — no restart; see docs/CLI.md. Editing
|
||||||
|
`TSC_JWT_COOKIE` in `.env` instead needs the container **recreated**, because
|
||||||
`env_file` is read when the container is created, not on restart:
|
`env_file` is read when the container is created, not on restart:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose up -d --force-recreate schulcloud-mcp
|
docker compose up -d --force-recreate schulcloud-mcp
|
||||||
``` On the Pi neither port is published — Caddy
|
```
|
||||||
reaches the container over the Docker network.
|
|
||||||
|
On the Pi neither port is published — Caddy reaches the container over the
|
||||||
|
Docker network.
|
||||||
|
|
||||||
Loopback binding is deliberate. The bearer token is the only thing in front of
|
Loopback binding is deliberate. The bearer token is the only thing in front of
|
||||||
your account's data, so it should not be listening on your LAN while you test.
|
your account's data, so it should not be listening on your LAN while you test.
|
||||||
@@ -103,9 +107,9 @@ node dist/bin/cli.js sync
|
|||||||
## Run the test suites
|
## Run the test suites
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm test # 135 offline tests
|
npm test # 174 offline tests
|
||||||
npm run smoke # end-to-end against the live instance, live-only mode
|
npm run smoke # end-to-end against the live instance, live-only mode
|
||||||
DATABASE_URL=… npm run smoke # end-to-end with the index (69 checks)
|
DATABASE_URL=… npm run smoke # end-to-end with the index (74 checks)
|
||||||
```
|
```
|
||||||
|
|
||||||
Store tests need a database and skip without one:
|
Store tests need a database and skip without one:
|
||||||
|
|||||||
@@ -217,7 +217,7 @@ be pointed at the instance in between:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
eval "$(./scripts/mcp-env.sh)" # as the demo student
|
eval "$(./scripts/mcp-env.sh)" # as the demo student
|
||||||
cd .. && npm run smoke # 69 checks against the local instance
|
cd .. && npm run smoke # 74 checks against the local instance
|
||||||
```
|
```
|
||||||
|
|
||||||
`mcp-env.sh` points the index at its own database, `schulcloud_local`, and the
|
`mcp-env.sh` points the index at its own database, `schulcloud_local`, and the
|
||||||
|
|||||||
@@ -6,6 +6,9 @@
|
|||||||
* Requires TSC_URL and TSC_JWT_COOKIE in the environment (load .env first).
|
* Requires TSC_URL and TSC_JWT_COOKIE in the environment (load .env first).
|
||||||
* Read-only — it never writes to Schulcloud.
|
* Read-only — it never writes to Schulcloud.
|
||||||
*/
|
*/
|
||||||
|
import { mkdtemp, rm } from 'node:fs/promises';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||||
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
|
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
|
||||||
import { loadConfig } from '../dist/config.js';
|
import { loadConfig } from '../dist/config.js';
|
||||||
@@ -14,6 +17,9 @@ import { closeServices, createServices } from '../dist/services.js';
|
|||||||
|
|
||||||
const TOKEN = 'smoke-test-token-' + Math.random().toString(36).slice(2);
|
const TOKEN = 'smoke-test-token-' + Math.random().toString(36).slice(2);
|
||||||
process.env.MCP_AUTH_TOKEN = TOKEN;
|
process.env.MCP_AUTH_TOKEN = TOKEN;
|
||||||
|
// A state directory of its own, so the run can neither read nor leave a saved token.
|
||||||
|
const STATE_DIR = await mkdtemp(join(tmpdir(), 'schulcloud-smoke-state-'));
|
||||||
|
process.env.STATE_DIR = STATE_DIR;
|
||||||
// The app is bound by this script on an ephemeral port, so config.port is unused.
|
// The app is bound by this script on an ephemeral port, so config.port is unused.
|
||||||
|
|
||||||
const config = loadConfig();
|
const config = loadConfig();
|
||||||
@@ -464,9 +470,61 @@ console.log('\n== error handling ==');
|
|||||||
const bogus = await call('get_course', { courseId: '000000000000000000000000' });
|
const bogus = await call('get_course', { courseId: '000000000000000000000000' });
|
||||||
check('unknown id returns a tool error, not a crash', bogus.isError, bogus.text.split('\n')[0]);
|
check('unknown id returns a tool error, not a crash', bogus.isError, bogus.text.split('\n')[0]);
|
||||||
|
|
||||||
|
console.log('\n== session token ==');
|
||||||
|
// The Schulcloud token can be replaced at runtime. Nothing here replaces the
|
||||||
|
// live token: the one PUT that succeeds sends the token already in use, which
|
||||||
|
// the server answers without a swap.
|
||||||
|
{
|
||||||
|
const root = `http://127.0.0.1:${port}`;
|
||||||
|
|
||||||
|
const anonymous = await fetch(`${root}/api/token`);
|
||||||
|
check('/api/token needs the bearer token', anonymous.status === 401, `got ${anonymous.status}`);
|
||||||
|
|
||||||
|
const bearer = { authorization: `Bearer ${TOKEN}` };
|
||||||
|
const statusResponse = await fetch(`${root}/api/token`, { headers: bearer });
|
||||||
|
const statusText = await statusResponse.text();
|
||||||
|
const tokenStatus = JSON.parse(statusText);
|
||||||
|
check(
|
||||||
|
'/api/token reports the expiry and never the token',
|
||||||
|
statusResponse.ok && typeof tokenStatus.expiresAt === 'string' && Number.isInteger(tokenStatus.daysLeft) && !statusText.includes(config.jwt),
|
||||||
|
`${tokenStatus.daysLeft} day(s) left, from ${tokenStatus.source}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const jwtInUse = config.jwt;
|
||||||
|
const malformed = await fetch(`${root}/api/token`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { ...bearer, 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({ jwt: 'not-a-token' }),
|
||||||
|
});
|
||||||
|
const refusal = await malformed.json();
|
||||||
|
check(
|
||||||
|
'a malformed token is refused and the one in use stays',
|
||||||
|
malformed.status === 422 && refusal.error === 'malformed' && config.jwt === jwtInUse,
|
||||||
|
`${malformed.status} ${refusal.error}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const same = await fetch(`${root}/api/token`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { ...bearer, 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({ jwt: `jwt=${jwtInUse};` }),
|
||||||
|
});
|
||||||
|
const sameResult = await same.json();
|
||||||
|
check('the token already in use is accepted without a swap', same.ok && sameResult.changed === false, `${same.status}`);
|
||||||
|
|
||||||
|
const page = await fetch(`${root}/token`);
|
||||||
|
const script = await fetch(`${root}/token.js`);
|
||||||
|
check(
|
||||||
|
'/token page is served with a strict content security policy',
|
||||||
|
page.ok && /text\/html/.test(page.headers.get('content-type') ?? '') &&
|
||||||
|
/default-src 'none'/.test(page.headers.get('content-security-policy') ?? '') &&
|
||||||
|
script.ok && /javascript/.test(script.headers.get('content-type') ?? ''),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
await client.close();
|
await client.close();
|
||||||
httpServer.close();
|
httpServer.close();
|
||||||
await closeServices(services);
|
await closeServices(services);
|
||||||
|
await rm(STATE_DIR, { recursive: true, force: true });
|
||||||
|
|
||||||
console.log(`\n${results.length - failures}/${results.length} checks passed`);
|
console.log(`\n${results.length - failures}/${results.length} checks passed`);
|
||||||
process.exit(failures === 0 ? 0 : 1);
|
process.exit(failures === 0 ? 0 : 1);
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ import { mkdir } from 'node:fs/promises';
|
|||||||
import { basename, dirname, resolve } from 'node:path';
|
import { basename, dirname, resolve } from 'node:path';
|
||||||
import { Readable } from 'node:stream';
|
import { Readable } from 'node:stream';
|
||||||
import { pipeline } from 'node:stream/promises';
|
import { pipeline } from 'node:stream/promises';
|
||||||
import { ApiClient, ApiError } from '../cli/client.ts';
|
import { ApiClient, ApiError, type TokenInfo } from '../cli/client.ts';
|
||||||
|
import { readHidden, readPiped } from '../cli/prompt.ts';
|
||||||
import { defaultSyncDir, loadCliConfig, saveCliConfig, configPath } from '../cli/config.ts';
|
import { defaultSyncDir, loadCliConfig, saveCliConfig, configPath } from '../cli/config.ts';
|
||||||
import { formatBytes } from '../core/extract.ts';
|
import { formatBytes } from '../core/extract.ts';
|
||||||
import { fsFind, fsGet, fsList, fsTree } from '../cli/fs.ts';
|
import { fsFind, fsGet, fsList, fsTree } from '../cli/fs.ts';
|
||||||
@@ -26,6 +27,8 @@ const USAGE = `schulcloud — browse and mirror your Schulcloud files
|
|||||||
schulcloud get <fileId> [--out <path>]
|
schulcloud get <fileId> [--out <path>]
|
||||||
schulcloud sync [--dry-run] [--full] [--prune] [--dir <path>] [--jobs <n>]
|
schulcloud sync [--dry-run] [--full] [--prune] [--dir <path>] [--jobs <n>]
|
||||||
schulcloud refresh [--course <id>] [--force]
|
schulcloud refresh [--course <id>] [--force]
|
||||||
|
schulcloud token when the server's Schulcloud token expires
|
||||||
|
schulcloud token set hand the server a fresh one (paste, or pipe it in)
|
||||||
|
|
||||||
The file manager ("Dateien") — /my, /courses/<course>, /teams/<team>, /shared:
|
The file manager ("Dateien") — /my, /courses/<course>, /teams/<team>, /shared:
|
||||||
|
|
||||||
@@ -71,6 +74,8 @@ async function main(argv: string[]): Promise<number> {
|
|||||||
return refresh(flags);
|
return refresh(flags);
|
||||||
case 'fs':
|
case 'fs':
|
||||||
return fileManager(flags);
|
return fileManager(flags);
|
||||||
|
case 'token':
|
||||||
|
return token(flags);
|
||||||
default:
|
default:
|
||||||
process.stderr.write(`Unknown command "${command}".\n\n${USAGE}`);
|
process.stderr.write(`Unknown command "${command}".\n\n${USAGE}`);
|
||||||
return 2;
|
return 2;
|
||||||
@@ -256,6 +261,57 @@ async function refresh(flags: Flags): Promise<number> {
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The monthly chore: log in to Schulcloud in a private window, copy the `jwt`
|
||||||
|
* cookie, paste it here, close the window. The server checks the token with
|
||||||
|
* Schulcloud before swapping it in, so a bad paste changes nothing.
|
||||||
|
*/
|
||||||
|
async function token(flags: Flags): Promise<number> {
|
||||||
|
const api = new ApiClient(await loadCliConfig());
|
||||||
|
const sub = flags._[0];
|
||||||
|
|
||||||
|
if (sub === undefined || sub === 'status') {
|
||||||
|
process.stdout.write(`${describeToken(await api.token())}\n`);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if (sub !== 'set') {
|
||||||
|
process.stderr.write(`Unknown token command "${sub}". Use "schulcloud token" or "schulcloud token set".\n`);
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pasted = process.stdin.isTTY
|
||||||
|
? await readHidden('Paste the value of the "jwt" cookie (input hidden): ')
|
||||||
|
: await readPiped();
|
||||||
|
if (!pasted.trim()) {
|
||||||
|
process.stderr.write('No token given.\n');
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
process.stderr.write('Checking it with Schulcloud…\n');
|
||||||
|
const result = await api.replaceToken(pasted);
|
||||||
|
process.stdout.write(`${result.changed ? 'Replaced' : 'Already in use'}: ${describeToken(result)}\n`);
|
||||||
|
if (result.changed && !result.persisted) {
|
||||||
|
process.stderr.write('Not saved on the server (STATE_DIR is unset): a restart falls back to TSC_JWT_COOKIE.\n');
|
||||||
|
}
|
||||||
|
process.stdout.write('Now close the private window — left open, it logs this token out about two hours after login.\n');
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function describeToken(info: TokenInfo): string {
|
||||||
|
const expiry = info.expiresAt
|
||||||
|
? `expires ${info.expiresAt.slice(0, 16).replace('T', ' ')} UTC (${info.daysLeft} day(s) left)`
|
||||||
|
: 'expiry unknown';
|
||||||
|
const keepalive = info.keepalive;
|
||||||
|
const session = !keepalive
|
||||||
|
? 'keepalive off'
|
||||||
|
: keepalive.running
|
||||||
|
? `session alive${keepalive.budgetSeconds === undefined ? '' : `, ${Math.round(keepalive.budgetSeconds / 60)} min budget`}`
|
||||||
|
: 'session ENDED — Schulcloud rejected the token; run: schulcloud token set';
|
||||||
|
const source =
|
||||||
|
info.source === 'environment' ? 'from TSC_JWT_COOKIE' : info.source === 'state file' ? 'saved from an earlier replacement' : info.source;
|
||||||
|
const warning = info.daysLeft !== undefined && info.daysLeft <= 7 ? '\nRenew it soon: schulcloud token set' : '';
|
||||||
|
return `${expiry}; ${session}; ${source}${warning}`;
|
||||||
|
}
|
||||||
|
|
||||||
function describe(event: SyncEvent, dryRun: boolean): string {
|
function describe(event: SyncEvent, dryRun: boolean): string {
|
||||||
switch (event.type) {
|
switch (event.type) {
|
||||||
case 'download':
|
case 'download':
|
||||||
|
|||||||
@@ -20,7 +20,25 @@ async function main(): Promise<void> {
|
|||||||
console.log(message),
|
console.log(message),
|
||||||
)
|
)
|
||||||
: undefined;
|
: undefined;
|
||||||
|
services.keepalive = keepalive;
|
||||||
keepalive?.start();
|
keepalive?.start();
|
||||||
|
// A replaced token is a new session to hold, and very likely the fix for a
|
||||||
|
// keepalive that stopped on a 401.
|
||||||
|
services.session.onReplaced(() => keepalive?.restart());
|
||||||
|
|
||||||
|
// The token's 30 days end on a date nothing else announces, and the only fix
|
||||||
|
// needs a person at a browser — so warn a week ahead, twice a day.
|
||||||
|
const warnIfExpiring = () => {
|
||||||
|
const { daysLeft } = services.session.status();
|
||||||
|
if (daysLeft === undefined || daysLeft > 7) return;
|
||||||
|
console.log(
|
||||||
|
daysLeft < 0
|
||||||
|
? '[schulcloud-mcp] session token: EXPIRED. Replace it with `schulcloud token set` or on the /token page.'
|
||||||
|
: `[schulcloud-mcp] session token: expires in ${daysLeft} day(s). Replace it with \`schulcloud token set\` or on the /token page.`,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
warnIfExpiring();
|
||||||
|
setInterval(warnIfExpiring, 12 * 60 * 60_000).unref();
|
||||||
|
|
||||||
// Periodic re-crawl so the index does not drift. Each run only downloads
|
// Periodic re-crawl so the index does not drift. Each run only downloads
|
||||||
// files it has never seen, so a steady state costs a few hundred cheap GETs.
|
// files it has never seen, so a steady state costs a few hundred cheap GETs.
|
||||||
@@ -49,9 +67,12 @@ async function main(): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const server = app.listen(config.port, config.bindHost, () => {
|
const server = app.listen(config.port, config.bindHost, () => {
|
||||||
|
const token = services.session.status();
|
||||||
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'}, ` +
|
||||||
|
`token from ${token.source}${token.daysLeft === undefined ? '' : `, ${token.daysLeft} day(s) left`}` +
|
||||||
|
`${token.persistent ? '' : ' (replacements not saved: STATE_DIR unset)'}, ` +
|
||||||
`keepalive ${keepalive ? `every ${Math.round(config.keepaliveIntervalMs / 60_000)}min` : 'off'}, ` +
|
`keepalive ${keepalive ? `every ${Math.round(config.keepaliveIntervalMs / 60_000)}min` : 'off'}, ` +
|
||||||
`index ${services.store ? (config.crawlIntervalMs > 0 ? `every ${Math.round(config.crawlIntervalMs / 3_600_000)}h` : 'on demand') : 'off'}`,
|
`index ${services.store ? (config.crawlIntervalMs > 0 ? `every ${Math.round(config.crawlIntervalMs / 3_600_000)}h` : 'on demand') : 'off'}`,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -59,6 +59,15 @@ export interface FsWalk {
|
|||||||
failures?: { path: string; reason: string }[];
|
failures?: { path: string; reason: string }[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The server's Schulcloud token, as `/api/token` reports it — never the token itself. */
|
||||||
|
export interface TokenInfo {
|
||||||
|
expiresAt?: string;
|
||||||
|
daysLeft?: number;
|
||||||
|
source: string;
|
||||||
|
persistent: boolean;
|
||||||
|
keepalive: { running: boolean; budgetSeconds?: number; lastExtendedAt?: string; rejectedAt?: string } | null;
|
||||||
|
}
|
||||||
|
|
||||||
export class ApiError extends Error {
|
export class ApiError extends Error {
|
||||||
readonly status: number;
|
readonly status: number;
|
||||||
|
|
||||||
@@ -98,6 +107,20 @@ export class ApiClient {
|
|||||||
return (await (await this.request('/api/status')).json()) as Record<string, unknown>;
|
return (await (await this.request('/api/status')).json()) as Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async token(): Promise<TokenInfo> {
|
||||||
|
return (await (await this.request('/api/token')).json()) as TokenInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Hands the server a fresh Schulcloud token; it checks the token before using it. */
|
||||||
|
async replaceToken(jwt: string): Promise<TokenInfo & { changed: boolean; persisted: boolean }> {
|
||||||
|
const response = await this.request('/api/token', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({ jwt }),
|
||||||
|
});
|
||||||
|
return (await response.json()) as TokenInfo & { changed: boolean; persisted: boolean };
|
||||||
|
}
|
||||||
|
|
||||||
async manifest(since?: string): Promise<Manifest> {
|
async manifest(since?: string): Promise<Manifest> {
|
||||||
const query = since ? `?since=${encodeURIComponent(since)}` : '';
|
const query = since ? `?since=${encodeURIComponent(since)}` : '';
|
||||||
return (await (await this.request(`/api/manifest${query}`)).json()) as Manifest;
|
return (await (await this.request(`/api/manifest${query}`)).json()) as Manifest;
|
||||||
|
|||||||
53
src/cli/prompt.ts
Normal file
53
src/cli/prompt.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
/**
|
||||||
|
* Reading a secret the user pastes.
|
||||||
|
*
|
||||||
|
* The Schulcloud token grants read access to the whole account, so it is never
|
||||||
|
* a command-line argument (shell history) and never echoed (scrollback).
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** A line typed or pasted at the terminal, not echoed. */
|
||||||
|
export function readHidden(prompt: string): Promise<string> {
|
||||||
|
const input = process.stdin;
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
let value = '';
|
||||||
|
const finish = () => {
|
||||||
|
input.off('data', onData);
|
||||||
|
input.setRawMode(false);
|
||||||
|
input.pause();
|
||||||
|
process.stderr.write('\n');
|
||||||
|
};
|
||||||
|
const onData = (chunk: string) => {
|
||||||
|
for (const char of chunk) {
|
||||||
|
if (char === '\r' || char === '\n') {
|
||||||
|
finish();
|
||||||
|
// A terminal with bracketed paste on wraps a paste in markers.
|
||||||
|
resolve(value.replace(/\[20[01]~/g, ''));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (char === '' || char === '') {
|
||||||
|
finish();
|
||||||
|
reject(new Error('Cancelled.'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (char === '' || char === '\b') {
|
||||||
|
value = value.slice(0, -1);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
value += char;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
process.stderr.write(prompt);
|
||||||
|
input.setRawMode(true);
|
||||||
|
input.setEncoding('utf8');
|
||||||
|
input.resume();
|
||||||
|
input.on('data', onData);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Everything piped in, for `wl-paste | schulcloud token set` and the like. */
|
||||||
|
export async function readPiped(): Promise<string> {
|
||||||
|
let data = '';
|
||||||
|
process.stdin.setEncoding('utf8');
|
||||||
|
for await (const chunk of process.stdin) data += chunk;
|
||||||
|
return data;
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* Runtime configuration, read once from the environment.
|
* Runtime configuration, read once from the environment — except `jwt`, which
|
||||||
|
* can be replaced while the server runs.
|
||||||
*
|
*
|
||||||
* The two Schulcloud values are named after the browser artefacts they come
|
* The two Schulcloud values are named after the browser artefacts they come
|
||||||
* from (`TSC_URL`, `TSC_JWT_COOKIE`) so that copying a fresh token out of
|
* from (`TSC_URL`, `TSC_JWT_COOKIE`) so that copying a fresh token out of
|
||||||
@@ -11,10 +12,16 @@ import { resolve } from 'node:path';
|
|||||||
export interface Config {
|
export interface Config {
|
||||||
/** Instance base URL, no trailing slash, e.g. `https://schulcloud-thueringen.de`. */
|
/** Instance base URL, no trailing slash, e.g. `https://schulcloud-thueringen.de`. */
|
||||||
baseUrl: string;
|
baseUrl: string;
|
||||||
/** Raw JWT from the instance's `jwt` cookie. Sent as `Authorization: Bearer`. */
|
/**
|
||||||
|
* Raw JWT from the instance's `jwt` cookie. Sent as `Authorization: Bearer`.
|
||||||
|
* Replaced at runtime by core/session-token.ts, so read it at the moment of
|
||||||
|
* use and never keep a copy.
|
||||||
|
*/
|
||||||
jwt: string;
|
jwt: string;
|
||||||
/** Shared secret callers must present to this MCP server. Unused in stdio mode. */
|
/** Shared secret callers must present to this MCP server. Unused in stdio mode. */
|
||||||
authToken: string | undefined;
|
authToken: string | undefined;
|
||||||
|
/** Where state that must survive a restart is kept: a replaced session token. Unset = memory only. */
|
||||||
|
stateDir: string | undefined;
|
||||||
port: number;
|
port: number;
|
||||||
bindHost: string;
|
bindHost: string;
|
||||||
/** Hard ceiling on how many bytes `download_file` will pull from the instance. */
|
/** Hard ceiling on how many bytes `download_file` will pull from the instance. */
|
||||||
@@ -82,6 +89,7 @@ export function loadConfig(): Config {
|
|||||||
baseUrl: required('TSC_URL').replace(/\/+$/, ''),
|
baseUrl: required('TSC_URL').replace(/\/+$/, ''),
|
||||||
jwt: required('TSC_JWT_COOKIE'),
|
jwt: required('TSC_JWT_COOKIE'),
|
||||||
authToken: process.env.MCP_AUTH_TOKEN?.trim() || undefined,
|
authToken: process.env.MCP_AUTH_TOKEN?.trim() || undefined,
|
||||||
|
stateDir: process.env.STATE_DIR?.trim() ? resolve(process.env.STATE_DIR.trim()) : undefined,
|
||||||
port: int('PORT', 8080),
|
port: int('PORT', 8080),
|
||||||
bindHost: process.env.BIND_HOST?.trim() || '0.0.0.0',
|
bindHost: process.env.BIND_HOST?.trim() || '0.0.0.0',
|
||||||
maxDownloadBytes: int('MAX_DOWNLOAD_BYTES', 25 * 1024 * 1024),
|
maxDownloadBytes: int('MAX_DOWNLOAD_BYTES', 25 * 1024 * 1024),
|
||||||
|
|||||||
@@ -133,12 +133,15 @@ export class SchulcloudClient {
|
|||||||
url: URL,
|
url: URL,
|
||||||
accept: string,
|
accept: string,
|
||||||
auth: 'bearer' | 'cookie' | 'none' = 'bearer',
|
auth: 'bearer' | 'cookie' | 'none' = 'bearer',
|
||||||
options: { idleTimeout?: boolean } = {},
|
options: { idleTimeout?: boolean; token?: string } = {},
|
||||||
): Promise<Response> {
|
): Promise<Response> {
|
||||||
let lastError: unknown;
|
let lastError: unknown;
|
||||||
const headers: Record<string, string> = { Accept: accept };
|
const headers: Record<string, string> = { Accept: accept };
|
||||||
if (auth === 'bearer') headers.Authorization = `Bearer ${this.config.jwt}`;
|
// Read at the moment of use, never earlier: the token can be replaced
|
||||||
if (auth === 'cookie') headers.Cookie = `jwt=${this.config.jwt}`;
|
// while the server runs (core/session-token.ts).
|
||||||
|
const jwt = options.token ?? this.config.jwt;
|
||||||
|
if (auth === 'bearer') headers.Authorization = `Bearer ${jwt}`;
|
||||||
|
if (auth === 'cookie') headers.Cookie = `jwt=${jwt}`;
|
||||||
|
|
||||||
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
||||||
if (attempt > 0) await delay(backoffMs(attempt));
|
if (attempt > 0) await delay(backoffMs(attempt));
|
||||||
@@ -253,6 +256,15 @@ export class SchulcloudClient {
|
|||||||
return this.getJson<MeResponse>('/api/v3/me');
|
return this.getJson<MeResponse>('/api/v3/me');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `/me` as another token sees it, leaving the token in use untouched — how a
|
||||||
|
* replacement is checked before it is swapped in.
|
||||||
|
*/
|
||||||
|
async meAs(token: string): Promise<MeResponse> {
|
||||||
|
const response = await this.request(this.url('/api/v3/me'), 'application/json', 'bearer', { token });
|
||||||
|
return (await response.json()) as MeResponse;
|
||||||
|
}
|
||||||
|
|
||||||
// --- session ---------------------------------------------------------
|
// --- session ---------------------------------------------------------
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -24,9 +24,27 @@ import { SchulcloudApiError } from './client.ts';
|
|||||||
* shared key out from under us. See docs/AUTH.md — the fix is to close the tab,
|
* shared key out from under us. See docs/AUTH.md — the fix is to close the tab,
|
||||||
* not to ping harder.
|
* not to ping harder.
|
||||||
*/
|
*/
|
||||||
|
export interface KeepaliveState {
|
||||||
|
running: boolean;
|
||||||
|
/** Seconds of session the instance reported at the last successful extension. */
|
||||||
|
budgetSeconds: number | undefined;
|
||||||
|
lastExtendedAt: string | undefined;
|
||||||
|
/** Set once the instance refused the token; cleared by a restart. */
|
||||||
|
rejectedAt: string | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
export class SessionKeepalive {
|
export class SessionKeepalive {
|
||||||
private timer: NodeJS.Timeout | undefined;
|
private timer: NodeJS.Timeout | undefined;
|
||||||
private stopped = false;
|
private stopped = false;
|
||||||
|
/**
|
||||||
|
* Bumped by every start and stop. A ping still in flight when the token is
|
||||||
|
* replaced was sent with the old token, and its 401 must not stop the
|
||||||
|
* keepalive that is already running with the new one.
|
||||||
|
*/
|
||||||
|
private generation = 0;
|
||||||
|
private budgetSeconds: number | undefined;
|
||||||
|
private lastExtendedAt: Date | undefined;
|
||||||
|
private rejectedAt: Date | undefined;
|
||||||
private readonly client: SchulcloudClient;
|
private readonly client: SchulcloudClient;
|
||||||
private readonly intervalMs: number;
|
private readonly intervalMs: number;
|
||||||
/** Retry delay after a failed ping — shorter, to use up the remaining budget. */
|
/** Retry delay after a failed ping — shorter, to use up the remaining budget. */
|
||||||
@@ -47,27 +65,52 @@ export class SessionKeepalive {
|
|||||||
|
|
||||||
/** Pings once now (validating the token at startup), then on the interval. */
|
/** Pings once now (validating the token at startup), then on the interval. */
|
||||||
start(): void {
|
start(): void {
|
||||||
|
if (this.timer) clearTimeout(this.timer);
|
||||||
|
this.timer = undefined;
|
||||||
this.stopped = false;
|
this.stopped = false;
|
||||||
void this.tick();
|
this.rejectedAt = undefined;
|
||||||
|
void this.tick(++this.generation);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Starts over with the token now in use — after a replacement, including one that follows a 401. */
|
||||||
|
restart(): void {
|
||||||
|
this.start();
|
||||||
}
|
}
|
||||||
|
|
||||||
stop(): void {
|
stop(): void {
|
||||||
this.stopped = true;
|
this.stopped = true;
|
||||||
|
this.generation++;
|
||||||
if (this.timer) clearTimeout(this.timer);
|
if (this.timer) clearTimeout(this.timer);
|
||||||
this.timer = undefined;
|
this.timer = undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
private schedule(delayMs: number): void {
|
state(): KeepaliveState {
|
||||||
if (this.stopped) return;
|
return {
|
||||||
this.timer = setTimeout(() => void this.tick(), delayMs);
|
running: !this.stopped,
|
||||||
|
budgetSeconds: this.budgetSeconds,
|
||||||
|
lastExtendedAt: this.lastExtendedAt?.toISOString(),
|
||||||
|
rejectedAt: this.rejectedAt?.toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private current(generation: number): boolean {
|
||||||
|
return !this.stopped && generation === this.generation;
|
||||||
|
}
|
||||||
|
|
||||||
|
private schedule(delayMs: number, generation: number): void {
|
||||||
|
if (!this.current(generation)) return;
|
||||||
|
this.timer = setTimeout(() => void this.tick(generation), delayMs);
|
||||||
// Never hold the process open just for a keepalive.
|
// Never hold the process open just for a keepalive.
|
||||||
this.timer.unref();
|
this.timer.unref();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async tick(): Promise<void> {
|
private async tick(generation: number): Promise<void> {
|
||||||
if (this.stopped) return;
|
if (!this.current(generation)) return;
|
||||||
try {
|
try {
|
||||||
const { expiresInSeconds } = await this.client.extendSession();
|
const { expiresInSeconds } = await this.client.extendSession();
|
||||||
|
if (!this.current(generation)) return;
|
||||||
|
this.budgetSeconds = expiresInSeconds;
|
||||||
|
this.lastExtendedAt = new Date();
|
||||||
// A budget well below the instance's JWT_TIMEOUT_SECONDS means the
|
// A budget well below the instance's JWT_TIMEOUT_SECONDS means the
|
||||||
// extension is not taking effect — worth seeing in the log, because it
|
// extension is not taking effect — worth seeing in the log, because it
|
||||||
// is the early warning that the session is about to be lost.
|
// is the early warning that the session is about to be lost.
|
||||||
@@ -75,8 +118,9 @@ export class SessionKeepalive {
|
|||||||
`[schulcloud-mcp] keepalive: session extended, ${expiresInSeconds}s ` +
|
`[schulcloud-mcp] keepalive: session extended, ${expiresInSeconds}s ` +
|
||||||
`(${Math.round(expiresInSeconds / 60)} min) of budget left`,
|
`(${Math.round(expiresInSeconds / 60)} min) of budget left`,
|
||||||
);
|
);
|
||||||
this.schedule(this.intervalMs);
|
this.schedule(this.intervalMs, generation);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (!this.current(generation)) return;
|
||||||
if (error instanceof SchulcloudApiError && error.isAuthFailure) {
|
if (error instanceof SchulcloudApiError && error.isAuthFailure) {
|
||||||
// Past saving: the whitelist entry is gone, or the JWT hit its 30-day
|
// 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
|
// ceiling. Pinging harder cannot revive it — a human must paste a new
|
||||||
@@ -86,16 +130,18 @@ export class SessionKeepalive {
|
|||||||
'If this is ~2h after login, the likely cause is a Schulportal tab left open ' +
|
'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 ' +
|
'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. ' +
|
'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.',
|
'Hand the server a fresh jwt cookie with `schulcloud token set` or on its /token page; ' +
|
||||||
|
'the keepalive resumes by itself. Keepalive stopped.',
|
||||||
);
|
);
|
||||||
this.stop();
|
this.stop();
|
||||||
|
this.rejectedAt = new Date();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.log(
|
this.log(
|
||||||
`[schulcloud-mcp] keepalive: ping failed (${error instanceof Error ? error.message : String(error)}); ` +
|
`[schulcloud-mcp] keepalive: ping failed (${error instanceof Error ? error.message : String(error)}); ` +
|
||||||
`retrying in ${Math.round(this.retryMs / 1000)}s`,
|
`retrying in ${Math.round(this.retryMs / 1000)}s`,
|
||||||
);
|
);
|
||||||
this.schedule(this.retryMs);
|
this.schedule(this.retryMs, generation);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
222
src/core/session-token.ts
Normal file
222
src/core/session-token.ts
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
|
||||||
|
import { dirname, join } from 'node:path';
|
||||||
|
import type { Config } from '../config.ts';
|
||||||
|
import { SchulcloudApiError, type SchulcloudClient } from './client.ts';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Schulcloud session token, replaceable while the server runs.
|
||||||
|
*
|
||||||
|
* A token lives 30 days at most, and a new one can only come from a browser:
|
||||||
|
* the login is federated single sign-on, so this server cannot mint one
|
||||||
|
* (docs/AUTH.md). What it can do is take a fresh one without a restart — check
|
||||||
|
* it against the instance, swap it into the config every request reads it
|
||||||
|
* from, and keep it in a state file, so a later restart does not fall back to
|
||||||
|
* the older token still sitting in `.env`.
|
||||||
|
*
|
||||||
|
* The token is a credential with read access to the whole account. It goes
|
||||||
|
* into the state file and nowhere else: not into logs, errors or responses.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** The claims this server reads. Decoded, never verified — Schulcloud does that. */
|
||||||
|
export interface TokenClaims {
|
||||||
|
userId?: string;
|
||||||
|
/** Seconds since the epoch. */
|
||||||
|
exp?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TokenSource = 'environment' | 'state file' | 'replaced at runtime';
|
||||||
|
|
||||||
|
export interface TokenStatus {
|
||||||
|
expiresAt: string | undefined;
|
||||||
|
/** Whole days until expiry; negative once expired. */
|
||||||
|
daysLeft: number | undefined;
|
||||||
|
source: TokenSource;
|
||||||
|
/** Whether a replacement survives a restart, which needs STATE_DIR. */
|
||||||
|
persistent: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TokenProblem = 'malformed' | 'expired' | 'rejected' | 'other_account';
|
||||||
|
|
||||||
|
/** A replacement that was refused, with a message saying what to do instead. */
|
||||||
|
export class TokenRejected extends Error {
|
||||||
|
readonly problem: TokenProblem;
|
||||||
|
|
||||||
|
constructor(problem: TokenProblem, message: string) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'TokenRejected';
|
||||||
|
this.problem = problem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATE_FILE = 'schulcloud-jwt';
|
||||||
|
const DAY_MS = 86_400_000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The token inside whatever was pasted.
|
||||||
|
*
|
||||||
|
* DevTools copies the bare value, but a cookie line (`jwt=…; Path=/`), quotes
|
||||||
|
* and a trailing newline all happen on the way from a browser to a terminal,
|
||||||
|
* and none of them is worth a refused replacement.
|
||||||
|
*/
|
||||||
|
export function normalizeToken(input: string): string {
|
||||||
|
const unquote = (value: string) => value.trim().replace(/^(["'])(.*)\1$/s, '$2').trim();
|
||||||
|
return unquote(
|
||||||
|
unquote(input)
|
||||||
|
.replace(/^jwt\s*=\s*/i, '')
|
||||||
|
.replace(/;.*$/s, ''),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decodeClaims(token: string): TokenClaims | undefined {
|
||||||
|
const parts = token.split('.');
|
||||||
|
if (parts.length !== 3 || parts.some((part) => !/^[A-Za-z0-9_-]+$/.test(part))) return undefined;
|
||||||
|
try {
|
||||||
|
const payload: unknown = JSON.parse(Buffer.from(parts[1]!, 'base64url').toString('utf8'));
|
||||||
|
if (!payload || typeof payload !== 'object') return undefined;
|
||||||
|
const { userId, exp } = payload as Record<string, unknown>;
|
||||||
|
return {
|
||||||
|
userId: typeof userId === 'string' ? userId : undefined,
|
||||||
|
exp: typeof exp === 'number' ? exp : undefined,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SessionToken {
|
||||||
|
private readonly config: Config;
|
||||||
|
private readonly client: Pick<SchulcloudClient, 'meAs'>;
|
||||||
|
private readonly stateFile: string | undefined;
|
||||||
|
private readonly listeners = new Set<() => void>();
|
||||||
|
private source: TokenSource = 'environment';
|
||||||
|
/** One replacement at a time, so two pastes cannot interleave a swap and a write. */
|
||||||
|
private queue: Promise<unknown> = Promise.resolve();
|
||||||
|
|
||||||
|
constructor(config: Config, client: Pick<SchulcloudClient, 'meAs'>, stateDir?: string) {
|
||||||
|
this.config = config;
|
||||||
|
this.client = client;
|
||||||
|
this.stateFile = stateDir ? join(stateDir, STATE_FILE) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Picks the token to start with: the saved one when it is the newer of the
|
||||||
|
* two, since that is what a runtime replacement leaves behind — but never one
|
||||||
|
* for a different account than `TSC_JWT_COOKIE`, because changing that
|
||||||
|
* variable is how accounts are switched.
|
||||||
|
*/
|
||||||
|
async load(log: (message: string) => void = (message) => console.error(message)): Promise<void> {
|
||||||
|
if (!this.stateFile) return;
|
||||||
|
let saved: string;
|
||||||
|
try {
|
||||||
|
saved = normalizeToken(await readFile(this.stateFile, 'utf8'));
|
||||||
|
} catch (error) {
|
||||||
|
const code = (error as NodeJS.ErrnoException).code;
|
||||||
|
if (code !== 'ENOENT') log(`[schulcloud-mcp] session token: could not read the saved one (${code}); using TSC_JWT_COOKIE`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!saved || saved === this.config.jwt) return;
|
||||||
|
|
||||||
|
const fromState = decodeClaims(saved);
|
||||||
|
const fromEnvironment = decodeClaims(this.config.jwt);
|
||||||
|
if (!fromState) {
|
||||||
|
log('[schulcloud-mcp] session token: the saved one is unreadable; using TSC_JWT_COOKIE');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (fromEnvironment?.userId && fromState.userId !== fromEnvironment.userId) {
|
||||||
|
log('[schulcloud-mcp] session token: the saved one belongs to another account than TSC_JWT_COOKIE; using TSC_JWT_COOKIE');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ((fromState.exp ?? 0) > (fromEnvironment?.exp ?? 0)) {
|
||||||
|
this.config.jwt = saved;
|
||||||
|
this.source = 'state file';
|
||||||
|
log('[schulcloud-mcp] session token: using the one replaced at runtime, which is newer than TSC_JWT_COOKIE');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
status(): TokenStatus {
|
||||||
|
const exp = decodeClaims(this.config.jwt)?.exp;
|
||||||
|
return {
|
||||||
|
expiresAt: exp === undefined ? undefined : new Date(exp * 1000).toISOString(),
|
||||||
|
daysLeft: exp === undefined ? undefined : Math.floor((exp * 1000 - Date.now()) / DAY_MS),
|
||||||
|
source: this.source,
|
||||||
|
persistent: this.stateFile !== undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Called after every successful replacement — the keepalive restarts on it. */
|
||||||
|
onReplaced(listener: () => void): void {
|
||||||
|
this.listeners.add(listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
replace(input: string): Promise<{ changed: boolean; persisted: boolean; status: TokenStatus }> {
|
||||||
|
const run = this.queue.then(() => this.swap(input));
|
||||||
|
this.queue = run.catch(() => {});
|
||||||
|
return run;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async swap(input: string): Promise<{ changed: boolean; persisted: boolean; status: TokenStatus }> {
|
||||||
|
const token = normalizeToken(input);
|
||||||
|
const claims = decodeClaims(token);
|
||||||
|
if (!claims) {
|
||||||
|
throw new TokenRejected(
|
||||||
|
'malformed',
|
||||||
|
'That is not a jwt cookie value: it should be three parts separated by dots, starting with "eyJ". ' +
|
||||||
|
'Copy the Value column of the cookie named "jwt".',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (claims.exp !== undefined && claims.exp * 1000 <= Date.now()) {
|
||||||
|
throw new TokenRejected(
|
||||||
|
'expired',
|
||||||
|
`That token expired on ${new Date(claims.exp * 1000).toISOString().slice(0, 10)}. Log in again and copy the new cookie.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (token === this.config.jwt) return { changed: false, persisted: false, status: this.status() };
|
||||||
|
|
||||||
|
let userId: string;
|
||||||
|
try {
|
||||||
|
userId = (await this.client.meAs(token)).user.id;
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof SchulcloudApiError && error.isAuthFailure) {
|
||||||
|
throw new TokenRejected(
|
||||||
|
'rejected',
|
||||||
|
'Schulcloud rejected that token (401): its session has already ended. Log in again in a private ' +
|
||||||
|
'window and copy the cookie — then close the window, because left open it logs the token out ' +
|
||||||
|
'about two hours after login.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
const current = decodeClaims(this.config.jwt)?.userId;
|
||||||
|
if (current && userId !== current) {
|
||||||
|
throw new TokenRejected(
|
||||||
|
'other_account',
|
||||||
|
'That token belongs to a different Schulcloud account than the one this server reads. ' +
|
||||||
|
'To switch accounts, change TSC_JWT_COOKIE and restart the server.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.config.jwt = token;
|
||||||
|
this.source = 'replaced at runtime';
|
||||||
|
const persisted = await this.persist(token);
|
||||||
|
for (const listener of this.listeners) listener();
|
||||||
|
return { changed: true, persisted, status: this.status() };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Written beside itself and renamed into place, so a crash cannot leave half a token. */
|
||||||
|
private async persist(token: string): Promise<boolean> {
|
||||||
|
if (!this.stateFile) return false;
|
||||||
|
try {
|
||||||
|
await mkdir(dirname(this.stateFile), { recursive: true, mode: 0o700 });
|
||||||
|
const temporary = `${this.stateFile}.${process.pid}.tmp`;
|
||||||
|
await writeFile(temporary, `${token}\n`, { mode: 0o600 });
|
||||||
|
await rename(temporary, this.stateFile);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(
|
||||||
|
`[schulcloud-mcp] session token: replaced, but not saved (${(error as NodeJS.ErrnoException).code ?? 'error'}); ` +
|
||||||
|
'a restart will fall back to TSC_JWT_COOKIE',
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
type WalkEntry,
|
type WalkEntry,
|
||||||
} from '../core/legacy-files.ts';
|
} from '../core/legacy-files.ts';
|
||||||
import { resolveWithin } from '../core/paths.ts';
|
import { resolveWithin } from '../core/paths.ts';
|
||||||
|
import { TokenRejected } from '../core/session-token.ts';
|
||||||
import type { Services } from '../services.ts';
|
import type { Services } from '../services.ts';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -24,7 +25,8 @@ import type { Services } from '../services.ts';
|
|||||||
* from the mirror does neither, which matters for the video files.
|
* from the mirror does neither, which matters for the video files.
|
||||||
*
|
*
|
||||||
* Nothing here can write to Schulcloud. `/refresh` writes only to the Pi's own
|
* Nothing here can write to Schulcloud. `/refresh` writes only to the Pi's own
|
||||||
* index and mirror, and every upstream call it triggers is a GET.
|
* index and mirror, `/token` only to the server's own token, and every upstream
|
||||||
|
* call either triggers is a GET.
|
||||||
*/
|
*/
|
||||||
export function createApiRouter(services: Services): Router {
|
export function createApiRouter(services: Services): Router {
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
@@ -229,9 +231,57 @@ export function createApiRouter(services: Services): Router {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// --- the Schulcloud session token -------------------------------------------
|
||||||
|
//
|
||||||
|
// A write, but to this server's own state: the token it reads Schulcloud with.
|
||||||
|
// The only upstream call is the GET /me a replacement must pass first. Works
|
||||||
|
// without an index, since a server without one still needs a token.
|
||||||
|
|
||||||
|
router.get('/token', (_req: Request, res: Response) => {
|
||||||
|
res.json(tokenStatus(services));
|
||||||
|
});
|
||||||
|
|
||||||
|
router.put('/token', express.json({ limit: '16kb' }), async (req: Request, res: Response) => {
|
||||||
|
const jwt = (req.body as { jwt?: unknown } | undefined)?.jwt;
|
||||||
|
if (typeof jwt !== 'string' || !jwt.trim()) {
|
||||||
|
return res.status(400).json({ error: 'missing_jwt', message: 'Send {"jwt": "<the value of the jwt cookie>"}.' });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const { changed, persisted } = await services.session.replace(jwt);
|
||||||
|
if (changed) console.log('[schulcloud-mcp] session token replaced at runtime');
|
||||||
|
return res.json({ changed, persisted, ...tokenStatus(services) });
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof TokenRejected) return res.status(422).json({ error: error.problem, message: error.message });
|
||||||
|
// Anything else is the instance failing to answer the check. The error
|
||||||
|
// cannot contain the token — SchulcloudApiError carries only a path — but
|
||||||
|
// the response still says no more than that.
|
||||||
|
const detail = error instanceof SchulcloudApiError ? `HTTP ${error.status}` : error instanceof Error ? error.name : 'error';
|
||||||
|
console.error(`[schulcloud-mcp] token check failed: ${detail}`);
|
||||||
|
return res.status(502).json({
|
||||||
|
error: 'check_failed',
|
||||||
|
message: `Schulcloud did not answer the check (${detail}); the token in use is unchanged. Try again shortly.`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// A body that is not JSON would otherwise reach Express's default handler,
|
||||||
|
// which logs it — and here the body is a credential.
|
||||||
|
router.use((error: unknown, _req: Request, res: Response, next: (error?: unknown) => void) => {
|
||||||
|
const type = (error as { type?: string } | undefined)?.type;
|
||||||
|
if (type === 'entity.parse.failed' || type === 'entity.too.large') {
|
||||||
|
res.status(400).json({ error: 'bad_request' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
next(error);
|
||||||
|
});
|
||||||
|
|
||||||
return router;
|
return router;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function tokenStatus(services: Services) {
|
||||||
|
return { ...services.session.status(), keepalive: services.keepalive?.state() ?? null };
|
||||||
|
}
|
||||||
|
|
||||||
/** Falls back to Schulcloud for anything not in the mirror, streaming through. */
|
/** Falls back to Schulcloud for anything not in the mirror, streaming through. */
|
||||||
/** Streams a file-manager file live, via its pre-signed URL; no credentials leave for the storage host. */
|
/** Streams a file-manager file live, via its pre-signed URL; no credentials leave for the storage host. */
|
||||||
async function proxyFileManager(
|
async function proxyFileManager(
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { createServer } from '../mcp/server.ts';
|
|||||||
import type { Services } from '../services.ts';
|
import type { Services } from '../services.ts';
|
||||||
import { createApiRouter } from './api.ts';
|
import { createApiRouter } from './api.ts';
|
||||||
import { bearerAuth } from './auth.ts';
|
import { bearerAuth } from './auth.ts';
|
||||||
|
import { tokenPage, tokenScript } from './token-page.ts';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Streamable-HTTP front end, for use as a remote MCP connector.
|
* Streamable-HTTP front end, for use as a remote MCP connector.
|
||||||
@@ -71,6 +72,10 @@ export function createHttpApp(config: Config, services?: Services): express.Expr
|
|||||||
|
|
||||||
if (services) {
|
if (services) {
|
||||||
app.use(API_PATH, createApiRouter(services));
|
app.use(API_PATH, createApiRouter(services));
|
||||||
|
// The page to paste a fresh Schulcloud token into. It holds no secret: what
|
||||||
|
// it sends goes to /api/token, behind the bearer check above.
|
||||||
|
app.get('/token', tokenPage);
|
||||||
|
app.get('/token.js', tokenScript);
|
||||||
}
|
}
|
||||||
|
|
||||||
app.use(MCP_PATH, express.json({ limit: '4mb' }));
|
app.use(MCP_PATH, express.json({ limit: '4mb' }));
|
||||||
|
|||||||
142
src/http/token-page.ts
Normal file
142
src/http/token-page.ts
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
import type { Request, Response } from 'express';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `/token`: a page to paste a fresh Schulcloud token into, for when a terminal
|
||||||
|
* is not at hand. `schulcloud token set` does the same from the CLI.
|
||||||
|
*
|
||||||
|
* The page carries no secret and needs no login of its own. It sends what is
|
||||||
|
* typed into it to `PUT /api/token` with the server access token as a bearer,
|
||||||
|
* so it is exactly as protected as the API — and the server checks the pasted
|
||||||
|
* token against Schulcloud before using it.
|
||||||
|
*
|
||||||
|
* The cookie is HttpOnly, so no script on the Schulcloud page can read it and a
|
||||||
|
* one-click bookmarklet is impossible; copying it out of DevTools is the step
|
||||||
|
* that remains.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const SECURITY_HEADERS = {
|
||||||
|
// The page's script is a separate file only because this policy forbids
|
||||||
|
// inline script; nothing it loads comes from anywhere else.
|
||||||
|
'Content-Security-Policy':
|
||||||
|
"default-src 'none'; script-src 'self'; connect-src 'self'; style-src 'unsafe-inline'; " +
|
||||||
|
"base-uri 'none'; form-action 'none'; frame-ancestors 'none'",
|
||||||
|
'Referrer-Policy': 'no-referrer',
|
||||||
|
'X-Content-Type-Options': 'nosniff',
|
||||||
|
'Cache-Control': 'no-store',
|
||||||
|
};
|
||||||
|
|
||||||
|
const PAGE = `<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Schulcloud token</title>
|
||||||
|
<style>
|
||||||
|
:root { color-scheme: light dark; font-family: system-ui, sans-serif; }
|
||||||
|
body { margin: 0; padding: 2rem 1rem; }
|
||||||
|
main { max-width: 34rem; margin: 0 auto; }
|
||||||
|
h1 { font-size: 1.4rem; }
|
||||||
|
ol { padding-left: 1.2rem; line-height: 1.5; }
|
||||||
|
label { display: block; margin: 1rem 0 0.25rem; font-weight: 600; }
|
||||||
|
input { box-sizing: border-box; width: 100%; padding: 0.5rem; font: inherit; }
|
||||||
|
.actions { display: flex; gap: 0.5rem; margin-top: 1rem; flex-wrap: wrap; }
|
||||||
|
button { padding: 0.5rem 1rem; font: inherit; cursor: pointer; }
|
||||||
|
.visually-hidden { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); }
|
||||||
|
#result { margin-top: 1rem; min-height: 1.5em; }
|
||||||
|
.ok { color: #1a7f37; }
|
||||||
|
.error { color: #cf222e; }
|
||||||
|
@media (prefers-color-scheme: dark) { .ok { color: #3fb950; } .error { color: #f85149; } }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main>
|
||||||
|
<h1>Replace the Schulcloud token</h1>
|
||||||
|
<ol>
|
||||||
|
<li>Open a private window and log in to Schulcloud.</li>
|
||||||
|
<li>DevTools → Application (Firefox: Storage) → Cookies → the cookie named <code>jwt</code>: copy its value.</li>
|
||||||
|
<li>Paste it below and press <em>Replace</em>. The server checks it with Schulcloud first.</li>
|
||||||
|
<li><strong>Close the private window.</strong> Left open, it logs the token out about two hours after login.</li>
|
||||||
|
</ol>
|
||||||
|
<form id="form">
|
||||||
|
<input class="visually-hidden" type="text" name="username" value="schulcloud-mcp" autocomplete="username" tabindex="-1" aria-hidden="true">
|
||||||
|
<label for="access">Server access token (MCP_AUTH_TOKEN)</label>
|
||||||
|
<input id="access" name="password" type="password" autocomplete="current-password" required>
|
||||||
|
<label for="jwt">jwt cookie</label>
|
||||||
|
<input id="jwt" type="password" autocomplete="off" spellcheck="false">
|
||||||
|
<div class="actions">
|
||||||
|
<button type="submit">Replace</button>
|
||||||
|
<button type="button" id="check">Check current token</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<p id="result" role="status" aria-live="polite"></p>
|
||||||
|
</main>
|
||||||
|
<script src="token.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const SCRIPT = `'use strict';
|
||||||
|
const form = document.getElementById('form');
|
||||||
|
const access = document.getElementById('access');
|
||||||
|
const jwt = document.getElementById('jwt');
|
||||||
|
const result = document.getElementById('result');
|
||||||
|
|
||||||
|
function show(text, ok) {
|
||||||
|
result.textContent = text;
|
||||||
|
result.className = ok ? 'ok' : 'error';
|
||||||
|
}
|
||||||
|
|
||||||
|
function describe(status) {
|
||||||
|
const expiry = status.expiresAt
|
||||||
|
? 'expires ' + status.expiresAt.slice(0, 10) + ' (' + status.daysLeft + ' days left)'
|
||||||
|
: 'expiry unknown';
|
||||||
|
const keepalive = status.keepalive;
|
||||||
|
const session = !keepalive ? '' : keepalive.running ? 'session alive' : 'session ended — replace the token';
|
||||||
|
return [expiry, session].filter(Boolean).join('; ');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function call(method, body) {
|
||||||
|
const headers = { authorization: 'Bearer ' + access.value.trim() };
|
||||||
|
if (body) headers['content-type'] = 'application/json';
|
||||||
|
const response = await fetch('api/token', {
|
||||||
|
method,
|
||||||
|
headers,
|
||||||
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
|
cache: 'no-store',
|
||||||
|
});
|
||||||
|
const data = await response.json().catch(() => ({}));
|
||||||
|
if (response.status === 401) throw new Error('The server access token is wrong.');
|
||||||
|
if (!response.ok) throw new Error(data.message || 'HTTP ' + response.status);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
form.addEventListener('submit', async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!jwt.value.trim()) return show('Paste the jwt cookie first.', false);
|
||||||
|
show('Checking it with Schulcloud…', true);
|
||||||
|
try {
|
||||||
|
const data = await call('PUT', { jwt: jwt.value });
|
||||||
|
jwt.value = '';
|
||||||
|
const saved = data.changed && !data.persisted ? ' Not saved on the server: a restart falls back to TSC_JWT_COOKIE.' : '';
|
||||||
|
show((data.changed ? 'Replaced — ' : 'Already in use — ') + describe(data) + '.' + saved + ' Now close the private window.', true);
|
||||||
|
} catch (error) {
|
||||||
|
show(error.message, false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('check').addEventListener('click', async () => {
|
||||||
|
try {
|
||||||
|
show('Current token ' + describe(await call('GET')) + '.', true);
|
||||||
|
} catch (error) {
|
||||||
|
show(error.message, false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
`;
|
||||||
|
|
||||||
|
export function tokenPage(_req: Request, res: Response): void {
|
||||||
|
res.set(SECURITY_HEADERS).type('html').send(PAGE);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function tokenScript(_req: Request, res: Response): void {
|
||||||
|
res.set(SECURITY_HEADERS).type('application/javascript').send(SCRIPT);
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import type { ServerContext } from '../../context.ts';
|
import type { ServerContext } from '../../context.ts';
|
||||||
|
import { decodeClaims } from '../../core/session-token.ts';
|
||||||
import { dueLabel, formatDate, heading, htmlToText, joinSections } from '../../core/text.ts';
|
import { dueLabel, formatDate, heading, htmlToText, joinSections } from '../../core/text.ts';
|
||||||
import type { CourseMetadata, TaskContent } from '../../core/types.ts';
|
import type { CourseMetadata, TaskContent } from '../../core/types.ts';
|
||||||
import { text, toToolError } from './result.ts';
|
import { text, toToolError } from './result.ts';
|
||||||
@@ -31,7 +32,10 @@ export function registerOverviewTools(server: McpServer, context: ServerContext)
|
|||||||
`- Roles: ${me.roles.map((role) => role.name).join(', ') || 'none'}`,
|
`- Roles: ${me.roles.map((role) => role.name).join(', ') || 'none'}`,
|
||||||
`- Instance: ${context.config.baseUrl}`,
|
`- Instance: ${context.config.baseUrl}`,
|
||||||
`- Permissions: ${me.permissions.length}`,
|
`- Permissions: ${me.permissions.length}`,
|
||||||
].join('\n'),
|
tokenExpiryLine(context.config.jwt),
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join('\n'),
|
||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -184,6 +188,18 @@ export function registerOverviewTools(server: McpServer, context: ServerContext)
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When the server's Schulcloud token runs out. Only a person can renew it, so
|
||||||
|
* the week before is worth saying out loud wherever the account is shown.
|
||||||
|
*/
|
||||||
|
function tokenExpiryLine(jwt: string): string | undefined {
|
||||||
|
const exp = decodeClaims(jwt)?.exp;
|
||||||
|
if (exp === undefined) return undefined;
|
||||||
|
const days = Math.floor((exp * 1000 - Date.now()) / 86_400_000);
|
||||||
|
const renew = days <= 7 ? ' — **renew it soon** with `schulcloud token set` or the server\'s /token page' : '';
|
||||||
|
return `- Server's Schulcloud token: expires ${formatDate(new Date(exp * 1000).toISOString())} (${days} day(s) left)${renew}`;
|
||||||
|
}
|
||||||
|
|
||||||
function isCurrentlyRunning(course: CourseMetadata): boolean {
|
function isCurrentlyRunning(course: CourseMetadata): boolean {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const start = course.startDate ? new Date(course.startDate).getTime() : undefined;
|
const start = course.startDate ? new Date(course.startDate).getTime() : undefined;
|
||||||
|
|||||||
@@ -56,9 +56,9 @@ function describeFailure(error: unknown, action: string): string {
|
|||||||
if (error.isAuthFailure) {
|
if (error.isAuthFailure) {
|
||||||
return (
|
return (
|
||||||
`Schulcloud rejected the token while trying to ${action} (HTTP 401).\n\n` +
|
`Schulcloud rejected the token while trying to ${action} (HTTP 401).\n\n` +
|
||||||
`The JWT in TSC_JWT_COOKIE has expired or been revoked. Copy a fresh one from ` +
|
`The server's Schulcloud token has expired or been logged out. The user has to log in in a ` +
|
||||||
`the browser (DevTools → Application → Cookies → the "jwt" cookie) into the server's ` +
|
`browser, copy the "jwt" cookie (DevTools → Application → Cookies) and hand it to the server ` +
|
||||||
`environment and restart it. See docs/AUTH.md.`
|
`with \`schulcloud token set\` or on the server's /token page — no restart needed. See docs/AUTH.md.`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (error.status === 403) {
|
if (error.status === 403) {
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import type { Config } from './config.ts';
|
import type { Config } from './config.ts';
|
||||||
import { SchulcloudClient } from './core/client.ts';
|
import { SchulcloudClient } from './core/client.ts';
|
||||||
|
import type { SessionKeepalive } from './core/keepalive.ts';
|
||||||
import { FileManager } from './core/legacy-files.ts';
|
import { FileManager } from './core/legacy-files.ts';
|
||||||
|
import { SessionToken } from './core/session-token.ts';
|
||||||
import { Indexer } from './indexer/indexer.ts';
|
import { Indexer } from './indexer/indexer.ts';
|
||||||
import { Store } from './store/store.ts';
|
import { Store } from './store/store.ts';
|
||||||
|
|
||||||
@@ -23,10 +25,22 @@ export interface Services {
|
|||||||
files: FileManager;
|
files: FileManager;
|
||||||
store: Store | undefined;
|
store: Store | undefined;
|
||||||
indexer: Indexer | undefined;
|
indexer: Indexer | undefined;
|
||||||
|
/** The Schulcloud token, which `/api/token` can replace without a restart. */
|
||||||
|
session: SessionToken;
|
||||||
|
/**
|
||||||
|
* Set by the entry point that runs one, for status reports. Created there
|
||||||
|
* rather than here because each entry point logs to a different stream.
|
||||||
|
*/
|
||||||
|
keepalive?: SessionKeepalive;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createServices(config: Config): Promise<Services> {
|
export async function createServices(config: Config): Promise<Services> {
|
||||||
const client = new SchulcloudClient(config);
|
const client = new SchulcloudClient(config);
|
||||||
|
// Before anything else reads config.jwt: a token replaced at runtime and
|
||||||
|
// saved may be newer than the one in the environment.
|
||||||
|
const session = new SessionToken(config, client, config.stateDir);
|
||||||
|
await session.load();
|
||||||
|
|
||||||
const files = new FileManager(client);
|
const files = new FileManager(client);
|
||||||
const store = await Store.open(config.databaseUrl);
|
const store = await Store.open(config.databaseUrl);
|
||||||
const indexer = store ? new Indexer(client, store, config) : undefined;
|
const indexer = store ? new Indexer(client, store, config) : undefined;
|
||||||
@@ -37,7 +51,7 @@ export async function createServices(config: Config): Promise<Services> {
|
|||||||
'/files, /manifest and refresh_index are unavailable. Set DATABASE_URL to enable them.',
|
'/files, /manifest and refresh_index are unavailable. Set DATABASE_URL to enable them.',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return { config, client, files, store, indexer };
|
return { config, client, files, store, indexer, session };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function closeServices(services: Services): Promise<void> {
|
export async function closeServices(services: Services): Promise<void> {
|
||||||
|
|||||||
@@ -34,3 +34,12 @@ describe('loadConfig', () => {
|
|||||||
assert.equal(loadConfig().authToken, undefined);
|
assert.equal(loadConfig().authToken, undefined);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('loadConfig: state directory', () => {
|
||||||
|
it('resolves the state directory to an absolute path', () => {
|
||||||
|
process.env.TSC_URL = 'https://example.org';
|
||||||
|
process.env.TSC_JWT_COOKIE = 'x';
|
||||||
|
process.env.STATE_DIR = 'tmp/state';
|
||||||
|
assert.match(loadConfig().stateDir ?? '', /^\/.*\/tmp\/state$/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -88,3 +88,52 @@ describe('SessionKeepalive logging', () => {
|
|||||||
assert.match(messages[0]!, /session extended, 7200s \(120 min\)/);
|
assert.match(messages[0]!, /session extended, 7200s \(120 min\)/);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('SessionKeepalive after a token replacement', () => {
|
||||||
|
it('resumes when restarted after a 401', async () => {
|
||||||
|
const unauthorized = new SchulcloudApiError(401, '/api/v3/authentication/refresh-session', '');
|
||||||
|
const { client, calls } = fakeClient([unauthorized]);
|
||||||
|
const keepalive = new SessionKeepalive(client, 15, 15, () => {});
|
||||||
|
keepalive.start();
|
||||||
|
await settle();
|
||||||
|
assert.equal(keepalive.state().running, false);
|
||||||
|
assert.ok(keepalive.state().rejectedAt);
|
||||||
|
|
||||||
|
keepalive.restart();
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||||
|
keepalive.stop();
|
||||||
|
assert.ok(calls.length >= 3, `expected pings to resume, got ${calls.length}`);
|
||||||
|
assert.equal(keepalive.state().rejectedAt, undefined);
|
||||||
|
assert.equal(keepalive.state().budgetSeconds, 7200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores a 401 for the old token that arrives after the restart', async () => {
|
||||||
|
// The first ping is still in flight, with the old token, when the token is
|
||||||
|
// replaced; its 401 must not stop the keepalive now holding the new one.
|
||||||
|
let release: (() => void) | undefined;
|
||||||
|
let first = true;
|
||||||
|
const client = {
|
||||||
|
extendSession: async () => {
|
||||||
|
if (first) {
|
||||||
|
first = false;
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
release = resolve;
|
||||||
|
});
|
||||||
|
throw new SchulcloudApiError(401, '/api/v3/authentication/refresh-session', '');
|
||||||
|
}
|
||||||
|
return { expiresInSeconds: 7200 };
|
||||||
|
},
|
||||||
|
} as never;
|
||||||
|
const messages: string[] = [];
|
||||||
|
const keepalive = new SessionKeepalive(client, 60_000, 60_000, (message) => messages.push(message));
|
||||||
|
keepalive.start();
|
||||||
|
await settle();
|
||||||
|
keepalive.restart();
|
||||||
|
await settle();
|
||||||
|
release?.();
|
||||||
|
await settle();
|
||||||
|
assert.equal(keepalive.state().running, true);
|
||||||
|
assert.equal(messages.filter((message) => /401/.test(message)).length, 0);
|
||||||
|
keepalive.stop();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
199
test/session-token.test.ts
Normal file
199
test/session-token.test.ts
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { afterEach, beforeEach, describe, it } from 'node:test';
|
||||||
|
import type { Config } from '../src/config.ts';
|
||||||
|
import { SchulcloudApiError } from '../src/core/client.ts';
|
||||||
|
import { decodeClaims, normalizeToken, SessionToken, TokenRejected } from '../src/core/session-token.ts';
|
||||||
|
|
||||||
|
const DAY = 86_400;
|
||||||
|
const now = () => Math.floor(Date.now() / 1000);
|
||||||
|
|
||||||
|
/** An unsigned JWT with the given claims — the server only decodes them. */
|
||||||
|
function jwt(claims: Record<string, unknown>): string {
|
||||||
|
const part = (value: object) => Buffer.from(JSON.stringify(value)).toString('base64url');
|
||||||
|
return `${part({ alg: 'HS256', typ: 'JWT' })}.${part(claims)}.c2lnbmF0dXJl`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function config(token: string): Config {
|
||||||
|
return { jwt: token } as Config;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A client whose /me answers for the user a token names, or refuses it. */
|
||||||
|
function client(options: { refuse?: boolean; error?: Error } = {}) {
|
||||||
|
const seen: string[] = [];
|
||||||
|
return {
|
||||||
|
seen,
|
||||||
|
client: {
|
||||||
|
meAs: async (token: string) => {
|
||||||
|
seen.push(token);
|
||||||
|
if (options.error) throw options.error;
|
||||||
|
if (options.refuse) throw new SchulcloudApiError(401, '/api/v3/me', '');
|
||||||
|
return { user: { id: decodeClaims(token)?.userId } } as never;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function rejection(promise: Promise<unknown>): Promise<TokenRejected> {
|
||||||
|
try {
|
||||||
|
await promise;
|
||||||
|
} catch (error) {
|
||||||
|
assert.ok(error instanceof TokenRejected, `expected TokenRejected, got ${error}`);
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
assert.fail('the replacement should have been refused');
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('normalizeToken', () => {
|
||||||
|
it('takes the token out of whatever the copy produced', () => {
|
||||||
|
const token = jwt({ userId: 'u1' });
|
||||||
|
for (const pasted of [token, ` ${token}\n`, `jwt=${token}`, `jwt=${token}; Path=/; Secure`, `"${token}"`, `'jwt=${token};'`]) {
|
||||||
|
assert.equal(normalizeToken(pasted), token, JSON.stringify(pasted));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('decodeClaims', () => {
|
||||||
|
it('reads the user and the expiry', () => {
|
||||||
|
assert.deepEqual(decodeClaims(jwt({ userId: 'u1', exp: 123, roles: ['x'] })), { userId: 'u1', exp: 123 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gives up on anything that is not three base64url parts of JSON', () => {
|
||||||
|
for (const value of ['', 'abc', 'a.b', 'a.b.c.d', 'a.!!.c', `x.${Buffer.from('not json').toString('base64url')}.y`]) {
|
||||||
|
assert.equal(decodeClaims(value), undefined, value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('SessionToken.replace', () => {
|
||||||
|
const current = jwt({ userId: 'u1', exp: now() + 2 * DAY });
|
||||||
|
const fresh = jwt({ userId: 'u1', exp: now() + 30 * DAY });
|
||||||
|
|
||||||
|
it('checks a new token with Schulcloud, swaps it in and tells the listeners', async () => {
|
||||||
|
const cfg = config(current);
|
||||||
|
const { client: fake, seen } = client();
|
||||||
|
const session = new SessionToken(cfg, fake);
|
||||||
|
let notified = 0;
|
||||||
|
session.onReplaced(() => notified++);
|
||||||
|
|
||||||
|
const result = await session.replace(`jwt=${fresh};`);
|
||||||
|
assert.equal(result.changed, true);
|
||||||
|
assert.equal(result.persisted, false, 'nothing to persist to without a state directory');
|
||||||
|
assert.equal(cfg.jwt, fresh);
|
||||||
|
assert.deepEqual(seen, [fresh], 'the check uses the new token, not the one in use');
|
||||||
|
assert.equal(notified, 1);
|
||||||
|
assert.equal(result.status.source, 'replaced at runtime');
|
||||||
|
assert.equal(result.status.daysLeft, 29);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses a paste that is not a token, without asking Schulcloud', async () => {
|
||||||
|
const cfg = config(current);
|
||||||
|
const { client: fake, seen } = client();
|
||||||
|
const error = await rejection(new SessionToken(cfg, fake).replace('hello'));
|
||||||
|
assert.equal(error.problem, 'malformed');
|
||||||
|
assert.equal(cfg.jwt, current);
|
||||||
|
assert.equal(seen.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses an expired token', async () => {
|
||||||
|
const cfg = config(current);
|
||||||
|
const { client: fake } = client();
|
||||||
|
const error = await rejection(new SessionToken(cfg, fake).replace(jwt({ userId: 'u1', exp: now() - 60 })));
|
||||||
|
assert.equal(error.problem, 'expired');
|
||||||
|
assert.equal(cfg.jwt, current);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses a token Schulcloud no longer accepts, and keeps the one in use', async () => {
|
||||||
|
const cfg = config(current);
|
||||||
|
const { client: fake } = client({ refuse: true });
|
||||||
|
const error = await rejection(new SessionToken(cfg, fake).replace(fresh));
|
||||||
|
assert.equal(error.problem, 'rejected');
|
||||||
|
assert.match(error.message, /close the window/);
|
||||||
|
assert.equal(cfg.jwt, current);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses another account\'s token: switching accounts is a restart', async () => {
|
||||||
|
const cfg = config(current);
|
||||||
|
const { client: fake } = client();
|
||||||
|
const error = await rejection(new SessionToken(cfg, fake).replace(jwt({ userId: 'someone-else', exp: now() + 30 * DAY })));
|
||||||
|
assert.equal(error.problem, 'other_account');
|
||||||
|
assert.equal(cfg.jwt, current);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes other failures through untouched, leaving the token in use', async () => {
|
||||||
|
const cfg = config(current);
|
||||||
|
const { client: fake } = client({ error: new SchulcloudApiError(503, '/api/v3/me', '') });
|
||||||
|
await assert.rejects(new SessionToken(cfg, fake).replace(fresh), SchulcloudApiError);
|
||||||
|
assert.equal(cfg.jwt, current);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats the token already in use as nothing to do', async () => {
|
||||||
|
const cfg = config(current);
|
||||||
|
const { client: fake, seen } = client();
|
||||||
|
const session = new SessionToken(cfg, fake);
|
||||||
|
let notified = 0;
|
||||||
|
session.onReplaced(() => notified++);
|
||||||
|
const result = await session.replace(current);
|
||||||
|
assert.equal(result.changed, false);
|
||||||
|
assert.equal(seen.length, 0);
|
||||||
|
assert.equal(notified, 0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('SessionToken state file', () => {
|
||||||
|
let dir: string;
|
||||||
|
beforeEach(async () => {
|
||||||
|
dir = await mkdtemp(join(tmpdir(), 'session-token-'));
|
||||||
|
});
|
||||||
|
afterEach(async () => {
|
||||||
|
await rm(dir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
const older = jwt({ userId: 'u1', exp: now() + 2 * DAY });
|
||||||
|
const newer = jwt({ userId: 'u1', exp: now() + 30 * DAY });
|
||||||
|
const quiet = () => {};
|
||||||
|
|
||||||
|
it('saves a replacement readable by its owner only, and a restart picks it up', async () => {
|
||||||
|
const { client: fake } = client();
|
||||||
|
await new SessionToken(config(older), fake, join(dir, 'state')).replace(newer);
|
||||||
|
|
||||||
|
const file = join(dir, 'state', 'schulcloud-jwt');
|
||||||
|
assert.equal((await readFile(file, 'utf8')).trim(), newer);
|
||||||
|
assert.equal((await stat(file)).mode & 0o777, 0o600);
|
||||||
|
|
||||||
|
// A restart: the environment still holds the older token.
|
||||||
|
const restarted = config(older);
|
||||||
|
const session = new SessionToken(restarted, fake, join(dir, 'state'));
|
||||||
|
await session.load(quiet);
|
||||||
|
assert.equal(restarted.jwt, newer);
|
||||||
|
assert.equal(session.status().source, 'state file');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prefers the environment once it holds the newer token', async () => {
|
||||||
|
await writeFile(join(dir, 'schulcloud-jwt'), `${older}\n`);
|
||||||
|
const cfg = config(newer);
|
||||||
|
const session = new SessionToken(cfg, client().client, dir);
|
||||||
|
await session.load(quiet);
|
||||||
|
assert.equal(cfg.jwt, newer);
|
||||||
|
assert.equal(session.status().source, 'environment');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never lets a saved token for another account override the environment', async () => {
|
||||||
|
await writeFile(join(dir, 'schulcloud-jwt'), jwt({ userId: 'previous-account', exp: now() + 30 * DAY }));
|
||||||
|
const cfg = config(older);
|
||||||
|
const messages: string[] = [];
|
||||||
|
await new SessionToken(cfg, client().client, dir).load((message) => messages.push(message));
|
||||||
|
assert.equal(cfg.jwt, older);
|
||||||
|
assert.match(messages.join('\n'), /another account/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('starts from the environment when nothing was saved', async () => {
|
||||||
|
const cfg = config(older);
|
||||||
|
const messages: string[] = [];
|
||||||
|
await new SessionToken(cfg, client().client, dir).load((message) => messages.push(message));
|
||||||
|
assert.equal(cfg.jwt, older);
|
||||||
|
assert.deepEqual(messages, [], 'a missing state file is the normal first start');
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user