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:
MechaCat02
2026-09-16 20:19:16 +02:00
parent 9d0272c622
commit 973b82ebf5
28 changed files with 1170 additions and 63 deletions

View File

@@ -30,12 +30,14 @@ COPY --from=deps /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
COPY package.json ./
# The mirror is the one writable path. Creating it in the image with the right
# owner matters: Docker initialises a new named volume from the image directory,
# including its ownership, so without this the volume lands root-owned and the
# unprivileged user gets EACCES on every write — with the failure recorded per
# file rather than crashing, which makes it easy to miss.
RUN mkdir -p /data/mirror && chown -R node:node /data
# The mirror and the state directory (a replaced session token) are the only
# writable paths. Creating them in the image with the right owner matters: Docker
# initialises a new named volume from the image directory, including its
# ownership, so without this the volume lands root-owned and the unprivileged
# user gets EACCES on every write — with the failure recorded rather than
# 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.
USER node