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