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