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

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