#!/usr/bin/env node /** * Watches a Schulcloud session to confirm it is actually being held. * * The session is a Valkey whitelist entry with a 7200 s TTL that every * authenticated request re-sets, so the keepalive should hold it to the JWT's * 30-day ceiling. The thing that breaks it is not a clock: a Schulportal tab * left open shares the same token and its client-side auto-logout issues * `POST /api/v3/logout` ~2 h after login, deleting the shared key. See * docs/AUTH.md. * * This calls refresh-session every 10 minutes and logs the reported budget, so * the shape of the log at death tells you which it was: * * budget steady at 7200, then an abrupt 401 → revoked (open tab, or logout) * budget decaying 7200 → 0 across pings → extension not taking effect * * Read-only with respect to user data. Usage: `npm run session-diagnose [minutes]` * (default 150 — enough to pass the ~2 h mark where an open tab would strike). */ import { loadConfig } from '../dist/config.js'; import { SchulcloudClient, SchulcloudApiError } from '../dist/schulcloud/client.js'; const totalMinutes = Number(process.argv[2] ?? 150); const INTERVAL_MS = 10 * 60_000; const config = loadConfig(); const client = new SchulcloudClient(config); const payload = JSON.parse(Buffer.from(config.jwt.split('.')[1], 'base64url').toString('utf8')); const login = new Date(payload.iat * 1000); const start = Date.now(); const stamp = () => { const now = new Date(); const sinceStart = ((Date.now() - start) / 60000).toFixed(1); const sinceLogin = ((Date.now() - login.getTime()) / 60000).toFixed(1); return `${now.toISOString().slice(11, 19)}Z t+${sinceStart.padStart(6)}min login+${sinceLogin.padStart(6)}min`; }; console.log(`session diagnosis — instance ${config.baseUrl}`); console.log(`token jti ${payload.jti}`); console.log(`login (iat) ${login.toISOString()} → login+2h = ${new Date(login.getTime() + 7200_000).toISOString()}`); console.log(`pinging refresh-session every ${INTERVAL_MS / 60000} min for ${totalMinutes} min\n`); const samples = []; const deadline = start + totalMinutes * 60_000; while (Date.now() < deadline) { try { const { expiresInSeconds } = await client.extendSession(); samples.push(expiresInSeconds); console.log(`${stamp()} budget ${expiresInSeconds}s (${Math.round(expiresInSeconds / 60)} min)`); } catch (error) { const status = error instanceof SchulcloudApiError ? error.status : '—'; console.log(`${stamp()} FAILED ${status} — session is gone`); verdict(samples, true); process.exit(0); } await new Promise((resolve) => setTimeout(resolve, Math.min(INTERVAL_MS, deadline - Date.now()))); } console.log(`\n${stamp()} reached the end of the window with the session still alive`); verdict(samples, false); function verdict(series, died) { const minutesAlive = (Date.now() - login.getTime()) / 60000; console.log('\n--- verdict ---'); console.log(`budget series: ${series.join(', ')}`); if (!died) { console.log(`PASS: session survived ${minutesAlive.toFixed(0)} min since login with refresh-session pings.`); console.log('=> refresh-session DOES hold the session. The keepalive works; ship it.'); return; } const decayed = series.length >= 2 && series.at(-1) < series[0] - 60; if (decayed) { console.log(`Session ended ${minutesAlive.toFixed(0)} min after login, and the budget was DECAYING.`); console.log('=> the extension is not taking effect. Shorten KEEPALIVE_INTERVAL_MS; the'); console.log(' series above shows the real decay rate.'); } else { console.log(`Session ended ${minutesAlive.toFixed(0)} min after login while the budget still read ${series.at(-1)}s.`); console.log('=> REVOKED from outside, not expired. The budget was healthy right up to the 401.'); if (minutesAlive > 100 && minutesAlive < 160) { console.log(' ~2h after login is the signature of a Schulportal tab left open on this'); console.log(' token: its auto-logout calls POST /api/v3/logout and deletes the shared'); console.log(' key. Close every Schulportal window and use a fresh token (docs/AUTH.md).'); } else { console.log(' Check for an explicit logout, or an IDP back-channel logout.'); } } }