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

@@ -4,7 +4,8 @@ import { mkdir } from 'node:fs/promises';
import { basename, dirname, resolve } from 'node:path';
import { Readable } from 'node:stream';
import { pipeline } from 'node:stream/promises';
import { ApiClient, ApiError } from '../cli/client.ts';
import { ApiClient, ApiError, type TokenInfo } from '../cli/client.ts';
import { readHidden, readPiped } from '../cli/prompt.ts';
import { defaultSyncDir, loadCliConfig, saveCliConfig, configPath } from '../cli/config.ts';
import { formatBytes } from '../core/extract.ts';
import { fsFind, fsGet, fsList, fsTree } from '../cli/fs.ts';
@@ -26,6 +27,8 @@ const USAGE = `schulcloud — browse and mirror your Schulcloud files
schulcloud get <fileId> [--out <path>]
schulcloud sync [--dry-run] [--full] [--prune] [--dir <path>] [--jobs <n>]
schulcloud refresh [--course <id>] [--force]
schulcloud token when the server's Schulcloud token expires
schulcloud token set hand the server a fresh one (paste, or pipe it in)
The file manager ("Dateien") — /my, /courses/<course>, /teams/<team>, /shared:
@@ -71,6 +74,8 @@ async function main(argv: string[]): Promise<number> {
return refresh(flags);
case 'fs':
return fileManager(flags);
case 'token':
return token(flags);
default:
process.stderr.write(`Unknown command "${command}".\n\n${USAGE}`);
return 2;
@@ -256,6 +261,57 @@ async function refresh(flags: Flags): Promise<number> {
return 0;
}
/**
* The monthly chore: log in to Schulcloud in a private window, copy the `jwt`
* cookie, paste it here, close the window. The server checks the token with
* Schulcloud before swapping it in, so a bad paste changes nothing.
*/
async function token(flags: Flags): Promise<number> {
const api = new ApiClient(await loadCliConfig());
const sub = flags._[0];
if (sub === undefined || sub === 'status') {
process.stdout.write(`${describeToken(await api.token())}\n`);
return 0;
}
if (sub !== 'set') {
process.stderr.write(`Unknown token command "${sub}". Use "schulcloud token" or "schulcloud token set".\n`);
return 2;
}
const pasted = process.stdin.isTTY
? await readHidden('Paste the value of the "jwt" cookie (input hidden): ')
: await readPiped();
if (!pasted.trim()) {
process.stderr.write('No token given.\n');
return 2;
}
process.stderr.write('Checking it with Schulcloud…\n');
const result = await api.replaceToken(pasted);
process.stdout.write(`${result.changed ? 'Replaced' : 'Already in use'}: ${describeToken(result)}\n`);
if (result.changed && !result.persisted) {
process.stderr.write('Not saved on the server (STATE_DIR is unset): a restart falls back to TSC_JWT_COOKIE.\n');
}
process.stdout.write('Now close the private window — left open, it logs this token out about two hours after login.\n');
return 0;
}
function describeToken(info: TokenInfo): string {
const expiry = info.expiresAt
? `expires ${info.expiresAt.slice(0, 16).replace('T', ' ')} UTC (${info.daysLeft} day(s) left)`
: 'expiry unknown';
const keepalive = info.keepalive;
const session = !keepalive
? 'keepalive off'
: keepalive.running
? `session alive${keepalive.budgetSeconds === undefined ? '' : `, ${Math.round(keepalive.budgetSeconds / 60)} min budget`}`
: 'session ENDED — Schulcloud rejected the token; run: schulcloud token set';
const source =
info.source === 'environment' ? 'from TSC_JWT_COOKIE' : info.source === 'state file' ? 'saved from an earlier replacement' : info.source;
const warning = info.daysLeft !== undefined && info.daysLeft <= 7 ? '\nRenew it soon: schulcloud token set' : '';
return `${expiry}; ${session}; ${source}${warning}`;
}
function describe(event: SyncEvent, dryRun: boolean): string {
switch (event.type) {
case 'download':

View File

@@ -20,7 +20,25 @@ async function main(): Promise<void> {
console.log(message),
)
: undefined;
services.keepalive = keepalive;
keepalive?.start();
// A replaced token is a new session to hold, and very likely the fix for a
// keepalive that stopped on a 401.
services.session.onReplaced(() => keepalive?.restart());
// The token's 30 days end on a date nothing else announces, and the only fix
// needs a person at a browser — so warn a week ahead, twice a day.
const warnIfExpiring = () => {
const { daysLeft } = services.session.status();
if (daysLeft === undefined || daysLeft > 7) return;
console.log(
daysLeft < 0
? '[schulcloud-mcp] session token: EXPIRED. Replace it with `schulcloud token set` or on the /token page.'
: `[schulcloud-mcp] session token: expires in ${daysLeft} day(s). Replace it with \`schulcloud token set\` or on the /token page.`,
);
};
warnIfExpiring();
setInterval(warnIfExpiring, 12 * 60 * 60_000).unref();
// Periodic re-crawl so the index does not drift. Each run only downloads
// files it has never seen, so a steady state costs a few hundred cheap GETs.
@@ -49,9 +67,12 @@ async function main(): Promise<void> {
}
const server = app.listen(config.port, config.bindHost, () => {
const token = services.session.status();
console.log(
`[schulcloud-mcp] listening on ${config.bindHost}:${config.port} — instance ${config.baseUrl}, ` +
`auth ${config.authToken ? 'enabled' : 'DISABLED'}, ` +
`token from ${token.source}${token.daysLeft === undefined ? '' : `, ${token.daysLeft} day(s) left`}` +
`${token.persistent ? '' : ' (replacements not saved: STATE_DIR unset)'}, ` +
`keepalive ${keepalive ? `every ${Math.round(config.keepaliveIntervalMs / 60_000)}min` : 'off'}, ` +
`index ${services.store ? (config.crawlIntervalMs > 0 ? `every ${Math.round(config.crawlIntervalMs / 3_600_000)}h` : 'on demand') : 'off'}`,
);

View File

@@ -59,6 +59,15 @@ export interface FsWalk {
failures?: { path: string; reason: string }[];
}
/** The server's Schulcloud token, as `/api/token` reports it — never the token itself. */
export interface TokenInfo {
expiresAt?: string;
daysLeft?: number;
source: string;
persistent: boolean;
keepalive: { running: boolean; budgetSeconds?: number; lastExtendedAt?: string; rejectedAt?: string } | null;
}
export class ApiError extends Error {
readonly status: number;
@@ -98,6 +107,20 @@ export class ApiClient {
return (await (await this.request('/api/status')).json()) as Record<string, unknown>;
}
async token(): Promise<TokenInfo> {
return (await (await this.request('/api/token')).json()) as TokenInfo;
}
/** Hands the server a fresh Schulcloud token; it checks the token before using it. */
async replaceToken(jwt: string): Promise<TokenInfo & { changed: boolean; persisted: boolean }> {
const response = await this.request('/api/token', {
method: 'PUT',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ jwt }),
});
return (await response.json()) as TokenInfo & { changed: boolean; persisted: boolean };
}
async manifest(since?: string): Promise<Manifest> {
const query = since ? `?since=${encodeURIComponent(since)}` : '';
return (await (await this.request(`/api/manifest${query}`)).json()) as Manifest;

53
src/cli/prompt.ts Normal file
View File

@@ -0,0 +1,53 @@
/**
* Reading a secret the user pastes.
*
* The Schulcloud token grants read access to the whole account, so it is never
* a command-line argument (shell history) and never echoed (scrollback).
*/
/** A line typed or pasted at the terminal, not echoed. */
export function readHidden(prompt: string): Promise<string> {
const input = process.stdin;
return new Promise((resolve, reject) => {
let value = '';
const finish = () => {
input.off('data', onData);
input.setRawMode(false);
input.pause();
process.stderr.write('\n');
};
const onData = (chunk: string) => {
for (const char of chunk) {
if (char === '\r' || char === '\n') {
finish();
// A terminal with bracketed paste on wraps a paste in markers.
resolve(value.replace(/\[20[01]~/g, ''));
return;
}
if (char === '' || char === '') {
finish();
reject(new Error('Cancelled.'));
return;
}
if (char === '' || char === '\b') {
value = value.slice(0, -1);
continue;
}
value += char;
}
};
process.stderr.write(prompt);
input.setRawMode(true);
input.setEncoding('utf8');
input.resume();
input.on('data', onData);
});
}
/** Everything piped in, for `wl-paste | schulcloud token set` and the like. */
export async function readPiped(): Promise<string> {
let data = '';
process.stdin.setEncoding('utf8');
for await (const chunk of process.stdin) data += chunk;
return data;
}

View File

@@ -1,5 +1,6 @@
/**
* Runtime configuration, read once from the environment.
* Runtime configuration, read once from the environment — except `jwt`, which
* can be replaced while the server runs.
*
* The two Schulcloud values are named after the browser artefacts they come
* from (`TSC_URL`, `TSC_JWT_COOKIE`) so that copying a fresh token out of
@@ -11,10 +12,16 @@ import { resolve } from 'node:path';
export interface Config {
/** Instance base URL, no trailing slash, e.g. `https://schulcloud-thueringen.de`. */
baseUrl: string;
/** Raw JWT from the instance's `jwt` cookie. Sent as `Authorization: Bearer`. */
/**
* Raw JWT from the instance's `jwt` cookie. Sent as `Authorization: Bearer`.
* Replaced at runtime by core/session-token.ts, so read it at the moment of
* use and never keep a copy.
*/
jwt: string;
/** Shared secret callers must present to this MCP server. Unused in stdio mode. */
authToken: string | undefined;
/** Where state that must survive a restart is kept: a replaced session token. Unset = memory only. */
stateDir: string | undefined;
port: number;
bindHost: string;
/** Hard ceiling on how many bytes `download_file` will pull from the instance. */
@@ -82,6 +89,7 @@ export function loadConfig(): Config {
baseUrl: required('TSC_URL').replace(/\/+$/, ''),
jwt: required('TSC_JWT_COOKIE'),
authToken: process.env.MCP_AUTH_TOKEN?.trim() || undefined,
stateDir: process.env.STATE_DIR?.trim() ? resolve(process.env.STATE_DIR.trim()) : undefined,
port: int('PORT', 8080),
bindHost: process.env.BIND_HOST?.trim() || '0.0.0.0',
maxDownloadBytes: int('MAX_DOWNLOAD_BYTES', 25 * 1024 * 1024),

View File

@@ -133,12 +133,15 @@ export class SchulcloudClient {
url: URL,
accept: string,
auth: 'bearer' | 'cookie' | 'none' = 'bearer',
options: { idleTimeout?: boolean } = {},
options: { idleTimeout?: boolean; token?: string } = {},
): Promise<Response> {
let lastError: unknown;
const headers: Record<string, string> = { Accept: accept };
if (auth === 'bearer') headers.Authorization = `Bearer ${this.config.jwt}`;
if (auth === 'cookie') headers.Cookie = `jwt=${this.config.jwt}`;
// Read at the moment of use, never earlier: the token can be replaced
// while the server runs (core/session-token.ts).
const jwt = options.token ?? this.config.jwt;
if (auth === 'bearer') headers.Authorization = `Bearer ${jwt}`;
if (auth === 'cookie') headers.Cookie = `jwt=${jwt}`;
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
if (attempt > 0) await delay(backoffMs(attempt));
@@ -253,6 +256,15 @@ export class SchulcloudClient {
return this.getJson<MeResponse>('/api/v3/me');
}
/**
* `/me` as another token sees it, leaving the token in use untouched — how a
* replacement is checked before it is swapped in.
*/
async meAs(token: string): Promise<MeResponse> {
const response = await this.request(this.url('/api/v3/me'), 'application/json', 'bearer', { token });
return (await response.json()) as MeResponse;
}
// --- session ---------------------------------------------------------
/**

View File

@@ -24,9 +24,27 @@ import { SchulcloudApiError } from './client.ts';
* shared key out from under us. See docs/AUTH.md — the fix is to close the tab,
* not to ping harder.
*/
export interface KeepaliveState {
running: boolean;
/** Seconds of session the instance reported at the last successful extension. */
budgetSeconds: number | undefined;
lastExtendedAt: string | undefined;
/** Set once the instance refused the token; cleared by a restart. */
rejectedAt: string | undefined;
}
export class SessionKeepalive {
private timer: NodeJS.Timeout | undefined;
private stopped = false;
/**
* Bumped by every start and stop. A ping still in flight when the token is
* replaced was sent with the old token, and its 401 must not stop the
* keepalive that is already running with the new one.
*/
private generation = 0;
private budgetSeconds: number | undefined;
private lastExtendedAt: Date | undefined;
private rejectedAt: Date | undefined;
private readonly client: SchulcloudClient;
private readonly intervalMs: number;
/** Retry delay after a failed ping — shorter, to use up the remaining budget. */
@@ -47,27 +65,52 @@ export class SessionKeepalive {
/** Pings once now (validating the token at startup), then on the interval. */
start(): void {
if (this.timer) clearTimeout(this.timer);
this.timer = undefined;
this.stopped = false;
void this.tick();
this.rejectedAt = undefined;
void this.tick(++this.generation);
}
/** Starts over with the token now in use — after a replacement, including one that follows a 401. */
restart(): void {
this.start();
}
stop(): void {
this.stopped = true;
this.generation++;
if (this.timer) clearTimeout(this.timer);
this.timer = undefined;
}
private schedule(delayMs: number): void {
if (this.stopped) return;
this.timer = setTimeout(() => void this.tick(), delayMs);
state(): KeepaliveState {
return {
running: !this.stopped,
budgetSeconds: this.budgetSeconds,
lastExtendedAt: this.lastExtendedAt?.toISOString(),
rejectedAt: this.rejectedAt?.toISOString(),
};
}
private current(generation: number): boolean {
return !this.stopped && generation === this.generation;
}
private schedule(delayMs: number, generation: number): void {
if (!this.current(generation)) return;
this.timer = setTimeout(() => void this.tick(generation), delayMs);
// Never hold the process open just for a keepalive.
this.timer.unref();
}
private async tick(): Promise<void> {
if (this.stopped) return;
private async tick(generation: number): Promise<void> {
if (!this.current(generation)) return;
try {
const { expiresInSeconds } = await this.client.extendSession();
if (!this.current(generation)) return;
this.budgetSeconds = expiresInSeconds;
this.lastExtendedAt = new Date();
// A budget well below the instance's JWT_TIMEOUT_SECONDS means the
// extension is not taking effect — worth seeing in the log, because it
// is the early warning that the session is about to be lost.
@@ -75,8 +118,9 @@ export class SessionKeepalive {
`[schulcloud-mcp] keepalive: session extended, ${expiresInSeconds}s ` +
`(${Math.round(expiresInSeconds / 60)} min) of budget left`,
);
this.schedule(this.intervalMs);
this.schedule(this.intervalMs, generation);
} catch (error) {
if (!this.current(generation)) return;
if (error instanceof SchulcloudApiError && error.isAuthFailure) {
// Past saving: the whitelist entry is gone, or the JWT hit its 30-day
// ceiling. Pinging harder cannot revive it — a human must paste a new
@@ -86,16 +130,18 @@ export class SessionKeepalive {
'If this is ~2h after login, the likely cause is a Schulportal tab left open ' +
'on the same token, whose auto-logout revoked it — close the tab. Otherwise ' +
'the server was down past the 2h window, or the JWT hit its 30-day limit. ' +
'Put a fresh jwt cookie in TSC_JWT_COOKIE and restart. Keepalive stopped.',
'Hand the server a fresh jwt cookie with `schulcloud token set` or on its /token page; ' +
'the keepalive resumes by itself. Keepalive stopped.',
);
this.stop();
this.rejectedAt = new Date();
return;
}
this.log(
`[schulcloud-mcp] keepalive: ping failed (${error instanceof Error ? error.message : String(error)}); ` +
`retrying in ${Math.round(this.retryMs / 1000)}s`,
);
this.schedule(this.retryMs);
this.schedule(this.retryMs, generation);
}
}
}

222
src/core/session-token.ts Normal file
View File

@@ -0,0 +1,222 @@
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
import { dirname, join } from 'node:path';
import type { Config } from '../config.ts';
import { SchulcloudApiError, type SchulcloudClient } from './client.ts';
/**
* The Schulcloud session token, replaceable while the server runs.
*
* A token lives 30 days at most, and a new one can only come from a browser:
* the login is federated single sign-on, so this server cannot mint one
* (docs/AUTH.md). What it can do is take a fresh one without a restart — check
* it against the instance, swap it into the config every request reads it
* from, and keep it in a state file, so a later restart does not fall back to
* the older token still sitting in `.env`.
*
* The token is a credential with read access to the whole account. It goes
* into the state file and nowhere else: not into logs, errors or responses.
*/
/** The claims this server reads. Decoded, never verified — Schulcloud does that. */
export interface TokenClaims {
userId?: string;
/** Seconds since the epoch. */
exp?: number;
}
export type TokenSource = 'environment' | 'state file' | 'replaced at runtime';
export interface TokenStatus {
expiresAt: string | undefined;
/** Whole days until expiry; negative once expired. */
daysLeft: number | undefined;
source: TokenSource;
/** Whether a replacement survives a restart, which needs STATE_DIR. */
persistent: boolean;
}
export type TokenProblem = 'malformed' | 'expired' | 'rejected' | 'other_account';
/** A replacement that was refused, with a message saying what to do instead. */
export class TokenRejected extends Error {
readonly problem: TokenProblem;
constructor(problem: TokenProblem, message: string) {
super(message);
this.name = 'TokenRejected';
this.problem = problem;
}
}
const STATE_FILE = 'schulcloud-jwt';
const DAY_MS = 86_400_000;
/**
* The token inside whatever was pasted.
*
* DevTools copies the bare value, but a cookie line (`jwt=…; Path=/`), quotes
* and a trailing newline all happen on the way from a browser to a terminal,
* and none of them is worth a refused replacement.
*/
export function normalizeToken(input: string): string {
const unquote = (value: string) => value.trim().replace(/^(["'])(.*)\1$/s, '$2').trim();
return unquote(
unquote(input)
.replace(/^jwt\s*=\s*/i, '')
.replace(/;.*$/s, ''),
);
}
export function decodeClaims(token: string): TokenClaims | undefined {
const parts = token.split('.');
if (parts.length !== 3 || parts.some((part) => !/^[A-Za-z0-9_-]+$/.test(part))) return undefined;
try {
const payload: unknown = JSON.parse(Buffer.from(parts[1]!, 'base64url').toString('utf8'));
if (!payload || typeof payload !== 'object') return undefined;
const { userId, exp } = payload as Record<string, unknown>;
return {
userId: typeof userId === 'string' ? userId : undefined,
exp: typeof exp === 'number' ? exp : undefined,
};
} catch {
return undefined;
}
}
export class SessionToken {
private readonly config: Config;
private readonly client: Pick<SchulcloudClient, 'meAs'>;
private readonly stateFile: string | undefined;
private readonly listeners = new Set<() => void>();
private source: TokenSource = 'environment';
/** One replacement at a time, so two pastes cannot interleave a swap and a write. */
private queue: Promise<unknown> = Promise.resolve();
constructor(config: Config, client: Pick<SchulcloudClient, 'meAs'>, stateDir?: string) {
this.config = config;
this.client = client;
this.stateFile = stateDir ? join(stateDir, STATE_FILE) : undefined;
}
/**
* Picks the token to start with: the saved one when it is the newer of the
* two, since that is what a runtime replacement leaves behind — but never one
* for a different account than `TSC_JWT_COOKIE`, because changing that
* variable is how accounts are switched.
*/
async load(log: (message: string) => void = (message) => console.error(message)): Promise<void> {
if (!this.stateFile) return;
let saved: string;
try {
saved = normalizeToken(await readFile(this.stateFile, 'utf8'));
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (code !== 'ENOENT') log(`[schulcloud-mcp] session token: could not read the saved one (${code}); using TSC_JWT_COOKIE`);
return;
}
if (!saved || saved === this.config.jwt) return;
const fromState = decodeClaims(saved);
const fromEnvironment = decodeClaims(this.config.jwt);
if (!fromState) {
log('[schulcloud-mcp] session token: the saved one is unreadable; using TSC_JWT_COOKIE');
return;
}
if (fromEnvironment?.userId && fromState.userId !== fromEnvironment.userId) {
log('[schulcloud-mcp] session token: the saved one belongs to another account than TSC_JWT_COOKIE; using TSC_JWT_COOKIE');
return;
}
if ((fromState.exp ?? 0) > (fromEnvironment?.exp ?? 0)) {
this.config.jwt = saved;
this.source = 'state file';
log('[schulcloud-mcp] session token: using the one replaced at runtime, which is newer than TSC_JWT_COOKIE');
}
}
status(): TokenStatus {
const exp = decodeClaims(this.config.jwt)?.exp;
return {
expiresAt: exp === undefined ? undefined : new Date(exp * 1000).toISOString(),
daysLeft: exp === undefined ? undefined : Math.floor((exp * 1000 - Date.now()) / DAY_MS),
source: this.source,
persistent: this.stateFile !== undefined,
};
}
/** Called after every successful replacement — the keepalive restarts on it. */
onReplaced(listener: () => void): void {
this.listeners.add(listener);
}
replace(input: string): Promise<{ changed: boolean; persisted: boolean; status: TokenStatus }> {
const run = this.queue.then(() => this.swap(input));
this.queue = run.catch(() => {});
return run;
}
private async swap(input: string): Promise<{ changed: boolean; persisted: boolean; status: TokenStatus }> {
const token = normalizeToken(input);
const claims = decodeClaims(token);
if (!claims) {
throw new TokenRejected(
'malformed',
'That is not a jwt cookie value: it should be three parts separated by dots, starting with "eyJ". ' +
'Copy the Value column of the cookie named "jwt".',
);
}
if (claims.exp !== undefined && claims.exp * 1000 <= Date.now()) {
throw new TokenRejected(
'expired',
`That token expired on ${new Date(claims.exp * 1000).toISOString().slice(0, 10)}. Log in again and copy the new cookie.`,
);
}
if (token === this.config.jwt) return { changed: false, persisted: false, status: this.status() };
let userId: string;
try {
userId = (await this.client.meAs(token)).user.id;
} catch (error) {
if (error instanceof SchulcloudApiError && error.isAuthFailure) {
throw new TokenRejected(
'rejected',
'Schulcloud rejected that token (401): its session has already ended. Log in again in a private ' +
'window and copy the cookie — then close the window, because left open it logs the token out ' +
'about two hours after login.',
);
}
throw error;
}
const current = decodeClaims(this.config.jwt)?.userId;
if (current && userId !== current) {
throw new TokenRejected(
'other_account',
'That token belongs to a different Schulcloud account than the one this server reads. ' +
'To switch accounts, change TSC_JWT_COOKIE and restart the server.',
);
}
this.config.jwt = token;
this.source = 'replaced at runtime';
const persisted = await this.persist(token);
for (const listener of this.listeners) listener();
return { changed: true, persisted, status: this.status() };
}
/** Written beside itself and renamed into place, so a crash cannot leave half a token. */
private async persist(token: string): Promise<boolean> {
if (!this.stateFile) return false;
try {
await mkdir(dirname(this.stateFile), { recursive: true, mode: 0o700 });
const temporary = `${this.stateFile}.${process.pid}.tmp`;
await writeFile(temporary, `${token}\n`, { mode: 0o600 });
await rename(temporary, this.stateFile);
return true;
} catch (error) {
console.error(
`[schulcloud-mcp] session token: replaced, but not saved (${(error as NodeJS.ErrnoException).code ?? 'error'}); ` +
'a restart will fall back to TSC_JWT_COOKIE',
);
return false;
}
}
}

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);
}

View File

@@ -1,6 +1,7 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { z } from 'zod';
import type { ServerContext } from '../../context.ts';
import { decodeClaims } from '../../core/session-token.ts';
import { dueLabel, formatDate, heading, htmlToText, joinSections } from '../../core/text.ts';
import type { CourseMetadata, TaskContent } from '../../core/types.ts';
import { text, toToolError } from './result.ts';
@@ -31,7 +32,10 @@ export function registerOverviewTools(server: McpServer, context: ServerContext)
`- Roles: ${me.roles.map((role) => role.name).join(', ') || 'none'}`,
`- Instance: ${context.config.baseUrl}`,
`- Permissions: ${me.permissions.length}`,
].join('\n'),
tokenExpiryLine(context.config.jwt),
]
.filter(Boolean)
.join('\n'),
]),
);
} catch (error) {
@@ -184,6 +188,18 @@ export function registerOverviewTools(server: McpServer, context: ServerContext)
);
}
/**
* When the server's Schulcloud token runs out. Only a person can renew it, so
* the week before is worth saying out loud wherever the account is shown.
*/
function tokenExpiryLine(jwt: string): string | undefined {
const exp = decodeClaims(jwt)?.exp;
if (exp === undefined) return undefined;
const days = Math.floor((exp * 1000 - Date.now()) / 86_400_000);
const renew = days <= 7 ? ' — **renew it soon** with `schulcloud token set` or the server\'s /token page' : '';
return `- Server's Schulcloud token: expires ${formatDate(new Date(exp * 1000).toISOString())} (${days} day(s) left)${renew}`;
}
function isCurrentlyRunning(course: CourseMetadata): boolean {
const now = Date.now();
const start = course.startDate ? new Date(course.startDate).getTime() : undefined;

View File

@@ -56,9 +56,9 @@ function describeFailure(error: unknown, action: string): string {
if (error.isAuthFailure) {
return (
`Schulcloud rejected the token while trying to ${action} (HTTP 401).\n\n` +
`The JWT in TSC_JWT_COOKIE has expired or been revoked. Copy a fresh one from ` +
`the browser (DevTools → Application → Cookies → the "jwt" cookie) into the server's ` +
`environment and restart it. See docs/AUTH.md.`
`The server's Schulcloud token has expired or been logged out. The user has to log in in a ` +
`browser, copy the "jwt" cookie (DevTools → Application → Cookies) and hand it to the server ` +
`with \`schulcloud token set\` or on the server's /token page — no restart needed. See docs/AUTH.md.`
);
}
if (error.status === 403) {

View File

@@ -1,6 +1,8 @@
import type { Config } from './config.ts';
import { SchulcloudClient } from './core/client.ts';
import type { SessionKeepalive } from './core/keepalive.ts';
import { FileManager } from './core/legacy-files.ts';
import { SessionToken } from './core/session-token.ts';
import { Indexer } from './indexer/indexer.ts';
import { Store } from './store/store.ts';
@@ -23,10 +25,22 @@ export interface Services {
files: FileManager;
store: Store | undefined;
indexer: Indexer | undefined;
/** The Schulcloud token, which `/api/token` can replace without a restart. */
session: SessionToken;
/**
* Set by the entry point that runs one, for status reports. Created there
* rather than here because each entry point logs to a different stream.
*/
keepalive?: SessionKeepalive;
}
export async function createServices(config: Config): Promise<Services> {
const client = new SchulcloudClient(config);
// Before anything else reads config.jwt: a token replaced at runtime and
// saved may be newer than the one in the environment.
const session = new SessionToken(config, client, config.stateDir);
await session.load();
const files = new FileManager(client);
const store = await Store.open(config.databaseUrl);
const indexer = store ? new Indexer(client, store, config) : undefined;
@@ -37,7 +51,7 @@ export async function createServices(config: Config): Promise<Services> {
'/files, /manifest and refresh_index are unavailable. Set DATABASE_URL to enable them.',
);
}
return { config, client, files, store, indexer };
return { config, client, files, store, indexer, session };
}
export async function closeServices(services: Services): Promise<void> {