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

@@ -6,6 +6,9 @@
* Requires TSC_URL and TSC_JWT_COOKIE in the environment (load .env first).
* Read-only — it never writes to Schulcloud.
*/
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
import { loadConfig } from '../dist/config.js';
@@ -14,6 +17,9 @@ import { closeServices, createServices } from '../dist/services.js';
const TOKEN = 'smoke-test-token-' + Math.random().toString(36).slice(2);
process.env.MCP_AUTH_TOKEN = TOKEN;
// A state directory of its own, so the run can neither read nor leave a saved token.
const STATE_DIR = await mkdtemp(join(tmpdir(), 'schulcloud-smoke-state-'));
process.env.STATE_DIR = STATE_DIR;
// The app is bound by this script on an ephemeral port, so config.port is unused.
const config = loadConfig();
@@ -464,9 +470,61 @@ console.log('\n== error handling ==');
const bogus = await call('get_course', { courseId: '000000000000000000000000' });
check('unknown id returns a tool error, not a crash', bogus.isError, bogus.text.split('\n')[0]);
console.log('\n== session token ==');
// The Schulcloud token can be replaced at runtime. Nothing here replaces the
// live token: the one PUT that succeeds sends the token already in use, which
// the server answers without a swap.
{
const root = `http://127.0.0.1:${port}`;
const anonymous = await fetch(`${root}/api/token`);
check('/api/token needs the bearer token', anonymous.status === 401, `got ${anonymous.status}`);
const bearer = { authorization: `Bearer ${TOKEN}` };
const statusResponse = await fetch(`${root}/api/token`, { headers: bearer });
const statusText = await statusResponse.text();
const tokenStatus = JSON.parse(statusText);
check(
'/api/token reports the expiry and never the token',
statusResponse.ok && typeof tokenStatus.expiresAt === 'string' && Number.isInteger(tokenStatus.daysLeft) && !statusText.includes(config.jwt),
`${tokenStatus.daysLeft} day(s) left, from ${tokenStatus.source}`,
);
const jwtInUse = config.jwt;
const malformed = await fetch(`${root}/api/token`, {
method: 'PUT',
headers: { ...bearer, 'content-type': 'application/json' },
body: JSON.stringify({ jwt: 'not-a-token' }),
});
const refusal = await malformed.json();
check(
'a malformed token is refused and the one in use stays',
malformed.status === 422 && refusal.error === 'malformed' && config.jwt === jwtInUse,
`${malformed.status} ${refusal.error}`,
);
const same = await fetch(`${root}/api/token`, {
method: 'PUT',
headers: { ...bearer, 'content-type': 'application/json' },
body: JSON.stringify({ jwt: `jwt=${jwtInUse};` }),
});
const sameResult = await same.json();
check('the token already in use is accepted without a swap', same.ok && sameResult.changed === false, `${same.status}`);
const page = await fetch(`${root}/token`);
const script = await fetch(`${root}/token.js`);
check(
'/token page is served with a strict content security policy',
page.ok && /text\/html/.test(page.headers.get('content-type') ?? '') &&
/default-src 'none'/.test(page.headers.get('content-security-policy') ?? '') &&
script.ok && /javascript/.test(script.headers.get('content-type') ?? ''),
);
}
await client.close();
httpServer.close();
await closeServices(services);
await rm(STATE_DIR, { recursive: true, force: true });
console.log(`\n${results.length - failures}/${results.length} checks passed`);
process.exit(failures === 0 ? 0 : 1);