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:
@@ -34,3 +34,12 @@ describe('loadConfig', () => {
|
||||
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\)/);
|
||||
});
|
||||
});
|
||||
|
||||
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