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:
@@ -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 ---------------------------------------------------------
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
222
src/core/session-token.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user