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:
77
test/keepalive.test.ts
Normal file
77
test/keepalive.test.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { describe, it } from 'node:test';
|
||||
import { SchulcloudApiError } from '../src/schulcloud/client.ts';
|
||||
import { SessionKeepalive } from '../src/keepalive.ts';
|
||||
|
||||
/** A stand-in for SchulcloudClient that records calls and replays scripted outcomes. */
|
||||
function fakeClient(outcomes: (Error | 'ok')[]) {
|
||||
const calls: number[] = [];
|
||||
return {
|
||||
calls,
|
||||
client: {
|
||||
me: async () => {
|
||||
const outcome = outcomes[calls.length] ?? 'ok';
|
||||
calls.push(Date.now());
|
||||
if (outcome !== 'ok') throw outcome;
|
||||
return {} as never;
|
||||
},
|
||||
} as never,
|
||||
};
|
||||
}
|
||||
|
||||
const settle = () => new Promise((resolve) => setTimeout(resolve, 30));
|
||||
|
||||
describe('SessionKeepalive', () => {
|
||||
it('pings immediately on start, so a bad token is noticed at boot', async () => {
|
||||
const { client, calls } = fakeClient(['ok']);
|
||||
const keepalive = new SessionKeepalive(client, 60_000, 1000, () => {});
|
||||
keepalive.start();
|
||||
await settle();
|
||||
assert.equal(calls.length, 1);
|
||||
keepalive.stop();
|
||||
});
|
||||
|
||||
it('keeps pinging on the interval', async () => {
|
||||
const { client, calls } = fakeClient([]);
|
||||
const keepalive = new SessionKeepalive(client, 15, 15, () => {});
|
||||
keepalive.start();
|
||||
await new Promise((resolve) => setTimeout(resolve, 120));
|
||||
keepalive.stop();
|
||||
assert.ok(calls.length >= 3, `expected repeated pings, got ${calls.length}`);
|
||||
});
|
||||
|
||||
it('stops permanently on 401 — a dead session cannot be revived by retrying', async () => {
|
||||
const unauthorized = new SchulcloudApiError(401, '/api/v3/me', '');
|
||||
const { client, calls } = fakeClient([unauthorized]);
|
||||
const messages: string[] = [];
|
||||
const keepalive = new SessionKeepalive(client, 15, 15, (message) => messages.push(message));
|
||||
keepalive.start();
|
||||
await new Promise((resolve) => setTimeout(resolve, 120));
|
||||
assert.equal(calls.length, 1, 'must not keep hammering a dead token');
|
||||
assert.equal(messages.length, 1);
|
||||
assert.match(messages[0]!, /fresh jwt cookie/);
|
||||
keepalive.stop();
|
||||
});
|
||||
|
||||
it('retries a transient failure instead of giving up', async () => {
|
||||
const { client, calls } = fakeClient([new Error('ECONNRESET')]);
|
||||
const messages: string[] = [];
|
||||
const keepalive = new SessionKeepalive(client, 10_000, 15, (message) => messages.push(message));
|
||||
keepalive.start();
|
||||
await new Promise((resolve) => setTimeout(resolve, 120));
|
||||
keepalive.stop();
|
||||
assert.ok(calls.length >= 2, `expected a retry, got ${calls.length} call(s)`);
|
||||
assert.match(messages[0]!, /retrying in/);
|
||||
});
|
||||
|
||||
it('stop() prevents any further pings', async () => {
|
||||
const { client, calls } = fakeClient([]);
|
||||
const keepalive = new SessionKeepalive(client, 15, 15, () => {});
|
||||
keepalive.start();
|
||||
await settle();
|
||||
keepalive.stop();
|
||||
const seen = calls.length;
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
assert.equal(calls.length, seen, 'no pings after stop()');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user