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

@@ -4,7 +4,8 @@ import { mkdir } from 'node:fs/promises';
import { basename, dirname, resolve } from 'node:path';
import { Readable } from 'node:stream';
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 { formatBytes } from '../core/extract.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 sync [--dry-run] [--full] [--prune] [--dir <path>] [--jobs <n>]
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:
@@ -71,6 +74,8 @@ async function main(argv: string[]): Promise<number> {
return refresh(flags);
case 'fs':
return fileManager(flags);
case 'token':
return token(flags);
default:
process.stderr.write(`Unknown command "${command}".\n\n${USAGE}`);
return 2;
@@ -256,6 +261,57 @@ async function refresh(flags: Flags): Promise<number> {
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 {
switch (event.type) {
case 'download':