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:
@@ -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':
|
||||
|
||||
Reference in New Issue
Block a user