Fix session lifetime: 2h sliding idle timeout, not 30 days

The JWT's exp claim says 30 days, and I took that as the session
lifetime. It is only an outer ceiling. The server also keeps a per-token
whitelist entry in Valkey (jwt:{accountId}:{jti}) whose TTL is
JWT_TIMEOUT_SECONDS — 7200s on this instance — and JwtStrategy.validate
re-sets it on every authenticated request. Two hours idle and the token
is rejected with 29 days still on exp.

Proven, not inferred: the token from yesterday returned 401 at 13.8h old.
The live instance publishes the values unauthenticated at
GET /api/v3/config/public — JWT_TIMEOUT_SECONDS 7200,
JWT_SHOW_TIMEOUT_WARNING_SECONDS 3600, the latter being exactly the
one-hour UI prompt that prompted this investigation.

refresh-session turns out not to be special: it extends through the same
guard as any other route, and uniquely only in returning the remaining
TTL. So the keepalive uses GET /api/v3/me instead, and the server stays
GET-only; the one POST in the repo is in scripts/probe.mjs, where it
reports the idle budget.

JWT_EXTENDED_TIMEOUT_SECONDS (~1 month) exists in the config schema but
is vestigial: privateDevice has no references in the current NestJS
source, and generateJwtAndAddToWhitelist never overrides the TTL.

Also fixes a real breakage this surfaced: TypeScript parameter
properties are rejected by Node's type stripping, so `npm run dev` and
`npm test` both failed on any file reaching them. Rewritten as explicit
fields, and noted in CLAUDE.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-12 13:07:22 +02:00
parent 35125b7683
commit d657ece436
14 changed files with 408 additions and 57 deletions

View File

@@ -1,6 +1,8 @@
#!/usr/bin/env node
import { loadConfig } from '../config.ts';
import { createHttpApp } from '../http/server.ts';
import { SessionKeepalive } from '../keepalive.ts';
import { SchulcloudClient } from '../schulcloud/client.ts';
/**
* HTTP entry point — the deployed form of this server, sitting behind Caddy.
@@ -9,10 +11,21 @@ async function main(): Promise<void> {
const config = loadConfig();
const app = createHttpApp(config);
// One process-wide keepalive, independent of MCP sessions: the Schulcloud
// token dies after 2h of inactivity regardless of whether anyone is connected.
const keepalive =
config.keepaliveIntervalMs > 0
? new SessionKeepalive(new SchulcloudClient(config), config.keepaliveIntervalMs, undefined, (message) =>
console.log(message),
)
: undefined;
keepalive?.start();
const server = app.listen(config.port, config.bindHost, () => {
console.log(
`[schulcloud-mcp] listening on ${config.bindHost}:${config.port} — instance ${config.baseUrl}, ` +
`auth ${config.authToken ? 'enabled' : 'DISABLED'}`,
`auth ${config.authToken ? 'enabled' : 'DISABLED'}, ` +
`keepalive ${keepalive ? `every ${Math.round(config.keepaliveIntervalMs / 60_000)}min` : 'off'}`,
);
});
@@ -20,6 +33,7 @@ async function main(): Promise<void> {
for (const signal of ['SIGTERM', 'SIGINT'] as const) {
process.on(signal, () => {
console.log(`[schulcloud-mcp] ${signal} received, shutting down`);
keepalive?.stop();
server.close(() => process.exit(0));
setTimeout(() => process.exit(0), 10_000).unref();
});

View File

@@ -1,6 +1,8 @@
#!/usr/bin/env node
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { loadConfig } from '../config.ts';
import { SessionKeepalive } from '../keepalive.ts';
import { SchulcloudClient } from '../schulcloud/client.ts';
import { createServer } from '../server.ts';
/**
@@ -13,6 +15,14 @@ async function main(): Promise<void> {
const config = loadConfig();
const { server } = createServer(config);
await server.connect(new StdioServerTransport());
// A desktop client left open overnight idles far past the instance's 2h
// session timeout, so stdio needs the keepalive just as much as HTTP does.
// It logs to stderr; stdout carries protocol frames only.
if (config.keepaliveIntervalMs > 0) {
new SessionKeepalive(new SchulcloudClient(config), config.keepaliveIntervalMs).start();
}
console.error(`[schulcloud-mcp] stdio transport ready for ${config.baseUrl}`);
}

View File

@@ -20,6 +20,12 @@ export interface Config {
/** Characters of extracted text returned before truncation kicks in. */
maxExtractedChars: number;
requestTimeoutMs: number;
/**
* How often to ping the instance to hold the session open. Must stay well
* under the instance's `JWT_TIMEOUT_SECONDS` (7200s here) — see
* src/keepalive.ts. Zero disables the keepalive.
*/
keepaliveIntervalMs: number;
}
function required(name: string): string {
@@ -38,6 +44,17 @@ function int(name: string, fallback: number): number {
return parsed;
}
/** Like `int`, but 0 is meaningful (it disables the feature) rather than invalid. */
function intAllowingZero(name: string, fallback: number): number {
const raw = process.env[name]?.trim();
if (!raw) return fallback;
const parsed = Number.parseInt(raw, 10);
if (!Number.isFinite(parsed) || parsed < 0) {
throw new Error(`Environment variable ${name} must be a non-negative integer, got ${raw}`);
}
return parsed;
}
export function loadConfig(): Config {
return {
baseUrl: required('TSC_URL').replace(/\/+$/, ''),
@@ -48,5 +65,6 @@ export function loadConfig(): Config {
maxDownloadBytes: int('MAX_DOWNLOAD_BYTES', 25 * 1024 * 1024),
maxExtractedChars: int('MAX_EXTRACTED_CHARS', 120_000),
requestTimeoutMs: int('REQUEST_TIMEOUT_MS', 30_000),
keepaliveIntervalMs: intAllowingZero('KEEPALIVE_INTERVAL_MS', 30 * 60_000),
};
}

View File

@@ -10,10 +10,12 @@ import type { MeResponse } from './schulcloud/types.ts';
* cannot change for a given JWT. Everything else is fetched live.
*/
export class ServerContext {
readonly config: Config;
readonly client: SchulcloudClient;
private identity: Promise<MeResponse> | undefined;
constructor(readonly config: Config) {
constructor(config: Config) {
this.config = config;
this.client = new SchulcloudClient(config);
}

88
src/keepalive.ts Normal file
View File

@@ -0,0 +1,88 @@
import type { SchulcloudClient } from './schulcloud/client.ts';
import { SchulcloudApiError } from './schulcloud/client.ts';
/**
* Keeps the Schulcloud session alive.
*
* The JWT's `exp` claim says 30 days, but that is only an outer ceiling. The
* server also keeps a whitelist entry per token in Valkey, keyed
* `jwt:{accountId}:{jti}`, whose TTL is `JWT_TIMEOUT_SECONDS` — 7200s (2h) on
* this instance, readable from `GET /api/v3/config/public`. Every request that
* passes the JWT guard re-sets that key, so the window slides; let it lapse
* and the token is rejected with 401 "Session was expired due to inactivity",
* long before `exp`.
*
* So an idle server loses its token overnight. Pinging any authenticated
* endpoint is enough to hold it: `POST /authentication/refresh-session` is
* what the web UI's "Sitzung verlängern" button calls, but it extends the
* session through the very same guard as every other route, and additionally
* reports the remaining TTL. We use a plain `GET /api/v3/me` instead, so that
* every call this server makes upstream remains a GET.
*/
export class SessionKeepalive {
private timer: NodeJS.Timeout | undefined;
private stopped = false;
private readonly client: SchulcloudClient;
private readonly intervalMs: number;
/** Retry delay after a failed ping — shorter, to use up the remaining budget. */
private readonly retryMs: number;
private readonly log: (message: string) => void;
constructor(
client: SchulcloudClient,
intervalMs: number,
retryMs: number = Math.min(5 * 60_000, intervalMs),
log: (message: string) => void = (message) => console.error(message),
) {
this.client = client;
this.intervalMs = intervalMs;
this.retryMs = retryMs;
this.log = log;
}
/** Pings once now (validating the token at startup), then on the interval. */
start(): void {
this.stopped = false;
void this.tick();
}
stop(): void {
this.stopped = true;
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);
// Never hold the process open just for a keepalive.
this.timer.unref();
}
private async tick(): Promise<void> {
if (this.stopped) return;
try {
await this.client.me();
this.schedule(this.intervalMs);
} catch (error) {
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
// token — so stop and say so loudly rather than logging every 30 min.
this.log(
'[schulcloud-mcp] keepalive: token rejected (401). The session has expired — ' +
'either more than 2h elapsed without a successful request, or the JWT reached ' +
'its 30-day limit. Put a fresh jwt cookie in TSC_JWT_COOKIE and restart. ' +
'Keepalive stopped.',
);
this.stop();
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);
}
}
}

View File

@@ -17,13 +17,16 @@ import type {
/** An API response outside the 2xx range, carrying the status for callers to branch on. */
export class SchulcloudApiError extends Error {
constructor(
readonly status: number,
readonly path: string,
readonly body: string,
) {
readonly status: number;
readonly path: string;
readonly body: string;
constructor(status: number, path: string, body: string) {
super(`Schulcloud API ${status} for ${path}${body ? `: ${truncate(body, 400)}` : ''}`);
this.name = 'SchulcloudApiError';
this.status = status;
this.path = path;
this.body = body;
}
/** True when the instance rejected our JWT — the one error the user must act on. */
@@ -58,7 +61,11 @@ export interface DownloadedFile {
* read this account's data but cannot act as the user inside Schulcloud.
*/
export class SchulcloudClient {
constructor(private readonly config: Config) {}
private readonly config: Config;
constructor(config: Config) {
this.config = config;
}
// --- transport -------------------------------------------------------