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

@@ -13,6 +13,7 @@ import {
type WalkEntry,
} from '../core/legacy-files.ts';
import { resolveWithin } from '../core/paths.ts';
import { TokenRejected } from '../core/session-token.ts';
import type { Services } from '../services.ts';
/**
@@ -24,7 +25,8 @@ import type { Services } from '../services.ts';
* from the mirror does neither, which matters for the video files.
*
* Nothing here can write to Schulcloud. `/refresh` writes only to the Pi's own
* index and mirror, and every upstream call it triggers is a GET.
* index and mirror, `/token` only to the server's own token, and every upstream
* call either triggers is a GET.
*/
export function createApiRouter(services: Services): Router {
const router = express.Router();
@@ -229,9 +231,57 @@ export function createApiRouter(services: Services): Router {
}
});
// --- the Schulcloud session token -------------------------------------------
//
// A write, but to this server's own state: the token it reads Schulcloud with.
// The only upstream call is the GET /me a replacement must pass first. Works
// without an index, since a server without one still needs a token.
router.get('/token', (_req: Request, res: Response) => {
res.json(tokenStatus(services));
});
router.put('/token', express.json({ limit: '16kb' }), async (req: Request, res: Response) => {
const jwt = (req.body as { jwt?: unknown } | undefined)?.jwt;
if (typeof jwt !== 'string' || !jwt.trim()) {
return res.status(400).json({ error: 'missing_jwt', message: 'Send {"jwt": "<the value of the jwt cookie>"}.' });
}
try {
const { changed, persisted } = await services.session.replace(jwt);
if (changed) console.log('[schulcloud-mcp] session token replaced at runtime');
return res.json({ changed, persisted, ...tokenStatus(services) });
} catch (error) {
if (error instanceof TokenRejected) return res.status(422).json({ error: error.problem, message: error.message });
// Anything else is the instance failing to answer the check. The error
// cannot contain the token — SchulcloudApiError carries only a path — but
// the response still says no more than that.
const detail = error instanceof SchulcloudApiError ? `HTTP ${error.status}` : error instanceof Error ? error.name : 'error';
console.error(`[schulcloud-mcp] token check failed: ${detail}`);
return res.status(502).json({
error: 'check_failed',
message: `Schulcloud did not answer the check (${detail}); the token in use is unchanged. Try again shortly.`,
});
}
});
// A body that is not JSON would otherwise reach Express's default handler,
// which logs it — and here the body is a credential.
router.use((error: unknown, _req: Request, res: Response, next: (error?: unknown) => void) => {
const type = (error as { type?: string } | undefined)?.type;
if (type === 'entity.parse.failed' || type === 'entity.too.large') {
res.status(400).json({ error: 'bad_request' });
return;
}
next(error);
});
return router;
}
function tokenStatus(services: Services) {
return { ...services.session.status(), keepalive: services.keepalive?.state() ?? null };
}
/** Falls back to Schulcloud for anything not in the mirror, streaming through. */
/** Streams a file-manager file live, via its pre-signed URL; no credentials leave for the storage host. */
async function proxyFileManager(

View File

@@ -7,6 +7,7 @@ import { createServer } from '../mcp/server.ts';
import type { Services } from '../services.ts';
import { createApiRouter } from './api.ts';
import { bearerAuth } from './auth.ts';
import { tokenPage, tokenScript } from './token-page.ts';
/**
* Streamable-HTTP front end, for use as a remote MCP connector.
@@ -71,6 +72,10 @@ export function createHttpApp(config: Config, services?: Services): express.Expr
if (services) {
app.use(API_PATH, createApiRouter(services));
// The page to paste a fresh Schulcloud token into. It holds no secret: what
// it sends goes to /api/token, behind the bearer check above.
app.get('/token', tokenPage);
app.get('/token.js', tokenScript);
}
app.use(MCP_PATH, express.json({ limit: '4mb' }));

142
src/http/token-page.ts Normal file
View File

@@ -0,0 +1,142 @@
import type { Request, Response } from 'express';
/**
* `/token`: a page to paste a fresh Schulcloud token into, for when a terminal
* is not at hand. `schulcloud token set` does the same from the CLI.
*
* The page carries no secret and needs no login of its own. It sends what is
* typed into it to `PUT /api/token` with the server access token as a bearer,
* so it is exactly as protected as the API — and the server checks the pasted
* token against Schulcloud before using it.
*
* The cookie is HttpOnly, so no script on the Schulcloud page can read it and a
* one-click bookmarklet is impossible; copying it out of DevTools is the step
* that remains.
*/
const SECURITY_HEADERS = {
// The page's script is a separate file only because this policy forbids
// inline script; nothing it loads comes from anywhere else.
'Content-Security-Policy':
"default-src 'none'; script-src 'self'; connect-src 'self'; style-src 'unsafe-inline'; " +
"base-uri 'none'; form-action 'none'; frame-ancestors 'none'",
'Referrer-Policy': 'no-referrer',
'X-Content-Type-Options': 'nosniff',
'Cache-Control': 'no-store',
};
const PAGE = `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Schulcloud token</title>
<style>
:root { color-scheme: light dark; font-family: system-ui, sans-serif; }
body { margin: 0; padding: 2rem 1rem; }
main { max-width: 34rem; margin: 0 auto; }
h1 { font-size: 1.4rem; }
ol { padding-left: 1.2rem; line-height: 1.5; }
label { display: block; margin: 1rem 0 0.25rem; font-weight: 600; }
input { box-sizing: border-box; width: 100%; padding: 0.5rem; font: inherit; }
.actions { display: flex; gap: 0.5rem; margin-top: 1rem; flex-wrap: wrap; }
button { padding: 0.5rem 1rem; font: inherit; cursor: pointer; }
.visually-hidden { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); }
#result { margin-top: 1rem; min-height: 1.5em; }
.ok { color: #1a7f37; }
.error { color: #cf222e; }
@media (prefers-color-scheme: dark) { .ok { color: #3fb950; } .error { color: #f85149; } }
</style>
</head>
<body>
<main>
<h1>Replace the Schulcloud token</h1>
<ol>
<li>Open a private window and log in to Schulcloud.</li>
<li>DevTools → Application (Firefox: Storage) → Cookies → the cookie named <code>jwt</code>: copy its value.</li>
<li>Paste it below and press <em>Replace</em>. The server checks it with Schulcloud first.</li>
<li><strong>Close the private window.</strong> Left open, it logs the token out about two hours after login.</li>
</ol>
<form id="form">
<input class="visually-hidden" type="text" name="username" value="schulcloud-mcp" autocomplete="username" tabindex="-1" aria-hidden="true">
<label for="access">Server access token (MCP_AUTH_TOKEN)</label>
<input id="access" name="password" type="password" autocomplete="current-password" required>
<label for="jwt">jwt cookie</label>
<input id="jwt" type="password" autocomplete="off" spellcheck="false">
<div class="actions">
<button type="submit">Replace</button>
<button type="button" id="check">Check current token</button>
</div>
</form>
<p id="result" role="status" aria-live="polite"></p>
</main>
<script src="token.js"></script>
</body>
</html>
`;
const SCRIPT = `'use strict';
const form = document.getElementById('form');
const access = document.getElementById('access');
const jwt = document.getElementById('jwt');
const result = document.getElementById('result');
function show(text, ok) {
result.textContent = text;
result.className = ok ? 'ok' : 'error';
}
function describe(status) {
const expiry = status.expiresAt
? 'expires ' + status.expiresAt.slice(0, 10) + ' (' + status.daysLeft + ' days left)'
: 'expiry unknown';
const keepalive = status.keepalive;
const session = !keepalive ? '' : keepalive.running ? 'session alive' : 'session ended — replace the token';
return [expiry, session].filter(Boolean).join('; ');
}
async function call(method, body) {
const headers = { authorization: 'Bearer ' + access.value.trim() };
if (body) headers['content-type'] = 'application/json';
const response = await fetch('api/token', {
method,
headers,
body: body ? JSON.stringify(body) : undefined,
cache: 'no-store',
});
const data = await response.json().catch(() => ({}));
if (response.status === 401) throw new Error('The server access token is wrong.');
if (!response.ok) throw new Error(data.message || 'HTTP ' + response.status);
return data;
}
form.addEventListener('submit', async (event) => {
event.preventDefault();
if (!jwt.value.trim()) return show('Paste the jwt cookie first.', false);
show('Checking it with Schulcloud…', true);
try {
const data = await call('PUT', { jwt: jwt.value });
jwt.value = '';
const saved = data.changed && !data.persisted ? ' Not saved on the server: a restart falls back to TSC_JWT_COOKIE.' : '';
show((data.changed ? 'Replaced — ' : 'Already in use — ') + describe(data) + '.' + saved + ' Now close the private window.', true);
} catch (error) {
show(error.message, false);
}
});
document.getElementById('check').addEventListener('click', async () => {
try {
show('Current token ' + describe(await call('GET')) + '.', true);
} catch (error) {
show(error.message, false);
}
});
`;
export function tokenPage(_req: Request, res: Response): void {
res.set(SECURITY_HEADERS).type('html').send(PAGE);
}
export function tokenScript(_req: Request, res: Response): void {
res.set(SECURITY_HEADERS).type('application/javascript').send(SCRIPT);
}