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:
@@ -88,3 +88,52 @@ describe('SessionKeepalive logging', () => {
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user