Files
Schulcloud-MCP/scripts/probe.mjs
MechaCat02 d657ece436 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>
2026-09-12 13:07:22 +02:00

147 lines
6.0 KiB
JavaScript

#!/usr/bin/env node
/**
* Verifies this server's assumptions against the live instance and prints what
* it finds. Run it after a Schulcloud release, or when a tool starts failing,
* to tell "the token expired" apart from "the API moved".
*
* Read-only. Usage: `node --env-file=.env scripts/probe.mjs`
*/
import { loadConfig } from '../dist/config.js';
import { SchulcloudClient, SchulcloudApiError } from '../dist/schulcloud/client.js';
const config = loadConfig();
const client = new SchulcloudClient(config);
console.log(`instance: ${config.baseUrl}\n`);
// --- token ---------------------------------------------------------------
// Two independent clocks govern the token, and only one of them is in the JWT:
// exp — a hard 30-day ceiling, cannot be extended.
// whitelist TTL — JWT_TIMEOUT_SECONDS (2h here), reset by every request.
// The second one is what actually kills idle sessions, so report both.
const payload = decodeJwt(config.jwt);
if (payload) {
const daysLeft = (payload.exp * 1000 - Date.now()) / 86_400_000;
console.log(`token: issued ${new Date(payload.iat * 1000).toISOString().slice(0, 16)}Z, ` +
`hard expiry ${new Date(payload.exp * 1000).toISOString().slice(0, 10)} (${daysLeft.toFixed(1)} days left)`);
if (daysLeft < 0) console.log(' *** PAST HARD EXPIRY — copy a fresh jwt cookie, see docs/AUTH.md');
else if (daysLeft < 5) console.log(' *** hard expiry approaching — plan to copy a fresh jwt cookie');
} else {
console.log('token: could not decode (not a JWT?)');
}
// The instance publishes its own session settings, unauthenticated.
try {
const publicConfig = await client.getJson('/api/v3/config/public');
console.log(`instance: JWT_TIMEOUT_SECONDS=${publicConfig.JWT_TIMEOUT_SECONDS} ` +
`(idle timeout), warning shown at ${publicConfig.JWT_SHOW_TIMEOUT_WARNING_SECONDS}s remaining`);
} catch {
console.log('instance: could not read /api/v3/config/public');
}
// refresh-session reports the whitelist TTL. It is the one non-GET call in this
// repo, and it lives here in a diagnostic rather than in the server, whose every
// upstream call is a GET. It extends the session no more than any GET does.
try {
const response = await fetch(`${config.baseUrl}/api/v3/authentication/refresh-session`, {
method: 'POST',
headers: { Authorization: `Bearer ${config.jwt}`, 'Content-Length': '0' },
});
if (response.ok) {
const { expiresInSeconds } = await response.json();
console.log(`session: ${expiresInSeconds}s of idle budget left ` +
`(${(expiresInSeconds / 60).toFixed(0)} min) — any request resets this`);
} else {
console.log(`session: refresh-session returned ${response.status}` +
(response.status === 401 ? ' *** session expired through inactivity or hard expiry' : ''));
}
} catch (error) {
console.log(`session: could not read TTL (${error.message})`);
}
// --- endpoints this server depends on ------------------------------------
const checks = [
['GET /api/v3/me', () => client.me()],
['GET /api/v3/courses', () => client.listCourses({ limit: 1 })],
['GET /api/v3/dashboard', () => client.getDashboard()],
['GET /api/v3/tasks', () => client.listTasks({ limit: 1 })],
['GET /api/v3/tasks/finished', () => client.listFinishedTasks({ limit: 1 })],
['GET /api/v3/news', () => client.listNews({ limit: 1 })],
['GET /api/v3/docs-json', () => client.getJson('/api/v3/docs-json')],
['GET /api/v3/file/docs-json', () => client.getJson('/api/v3/file/docs-json')],
];
console.log('\ncore endpoints:');
let me;
for (const [label, run] of checks) {
try {
const result = await run();
if (label.endsWith('/me')) me = result;
console.log(` ok ${label}${summarize(result)}`);
} catch (error) {
const status = error instanceof SchulcloudApiError ? error.status : '—';
console.log(` FAIL ${label}${status} ${error.message.slice(0, 120)}`);
}
}
// --- the chains that make the content tools work -------------------------
if (me) {
console.log('\ncontent chain:');
const courses = await client.listAllCourses();
console.log(` ${courses.length} course(s) visible`);
let boardId, lessonId;
for (const course of courses) {
const page = await client.getCourseBoard(course.id).catch(() => null);
if (!page) continue;
boardId ??= page.elements.find((e) => e.type === 'column-board')?.content.id;
lessonId ??= page.elements.find((e) => e.type === 'lesson')?.content.id;
if (boardId && lessonId) break;
}
if (boardId) {
const skeleton = await client.getBoardSkeleton(boardId);
const cardIds = skeleton.columns.flatMap((c) => c.cards.map((x) => x.cardId));
const cards = await client.getCards(cardIds.slice(0, 5));
console.log(` ok board → columns → cards (board ${boardId}: ${skeleton.columns.length} col, ${cardIds.length} cards)`);
const fileElement = cards.flatMap((c) => c.elements).find((e) => e.type === 'file');
if (fileElement) {
const files = await client.listFiles({
storageLocationId: me.school.id,
parentType: 'boardnodes',
parentId: fileElement.id,
});
console.log(` ok file element → files-storage (${files.total} file(s) on element ${fileElement.id})`);
} else {
console.log(' — no file element among the sampled cards');
}
} else {
console.log(' — no column board found to test with');
}
if (lessonId) {
const lesson = await client.getLesson(lessonId);
console.log(` ok lesson ${lessonId} ("${lesson.name}", ${lesson.contents?.length ?? 0} section(s))`);
}
}
function summarize(result) {
if (result && typeof result === 'object') {
if ('total' in result) return ` (total ${result.total})`;
if ('paths' in result) return ` (${Object.keys(result.paths).length} paths)`;
if ('school' in result) return ` (${result.school.name})`;
if ('gridElements' in result) return ` (${result.gridElements.length} tiles)`;
}
return '';
}
function decodeJwt(token) {
try {
const part = token.split('.')[1];
return JSON.parse(Buffer.from(part, 'base64url').toString('utf8'));
} catch {
return null;
}
}