Session expiry: sliding idle timeout with an absolute ceiling (#108) #127
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "feat/session-expiry"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Closes #108. Closes #49.
Now targets
main— #123 has merged andorigin/mainis merged in (dde880d). Mergeable, no conflicts.The symptom
Leave Nexus open a while and it stops accepting operations. Log out, log back in, everything works again. Two independent defects compound.
The TTL was absolute, not sliding.
SessionStore.create()stampedexpiresAtonce andget()never moved it. Withsession_ttl_hoursdefaulting to 12, an operator actively using the app was signed out 12 hours after unlocking regardless of activity.Nothing acted on the resulting 401. The gate returned
401 {"error":"locked"},call()threw, and no caller distinguished it. The redirect that would rescue you lives in+layout.server.ts, which only runs on a navigation — a polling SPA never triggers one. So the UI just went quiet.Two details made it look patchy rather than total, which is why it read as mysterious: an already-open terminal keeps streaming after expiry (it authenticates only at handshake), and the quota poll used raw
fetchwithif (!res.ok) return, so the header kept displaying its last value while the session was dead.What changed
Two deadlines, one comparison.
SessionDatagainsabsoluteExpiresAt(a ceiling, fixed atcreate()) besideexpiresAt(idle, slides). BecauseexpiresAtis clamped to the ceiling at every write, the singleexpiresAt < nowcheck inget()enforces both — there is no second comparison to add and no way for a later edit to check one and forget the other.get()stays a pure read; onlytouch()slides. This is the crux. The refcountedsessionStatsstore polls/api/quotaevery 10s while anything is subscribed — the header badge is, on every page the operator can browse — so if any authenticated request slid the deadline, "idle" would degenerate into "no tab open" and the timeout would be decorative. The hook slides only when the method is neither GET nor HEAD — HEAD excluded because SvelteKit serves it from the same GET handler, so admitting it would reopen the hole through any route's HEAD.POST /api/auth/touchhas a deliberately empty handler. It is a non-GET/api/*request, so the hook slides it like any other; asessions.touch()in the route would be a second, divergeable slide site. It is not inPUBLIC_API— the 401 it earns from a dead session is the client's expiry detector on a page issuing no other authenticated request.The keepalive has no timer.
$lib/keepalivesends on interaction (pointerdown,keydown,focus), throttled to once a minute. An unattended tab sends nothing at all — a periodic ping would measure "tab open" instead of "operator present". It also means the 401 arrives the moment you reach for the app, not minutes later.The cookie carries the ceiling, not the idle TTL (
auth/cookie.ts, now the single place bothunlockandrotate-passphraseread). The cookie has to outlive every session it could name: one that expires first sends nosidat all, so a session the server had been happily sliding looks gone to the browser. That drift is the bug.Every 401 from
call()bounces once to/unlock?next=…, skipped on/unlockand/setupso a wrong passphrase doesn't reload the page mid-typo.nextis sanitized —//evil.comis protocol-relative, so a browser reads it as absolute while a naive "starts with/" check calls it local, and browsers normalise\to/in an authority. That closes #49.Settings:
session_ttl_hoursis reused as the idle timeout (relabelled in the UI, not renamed in the DB — zero migration);session_max_lifetime_hoursis new, default 168.Three things worth a second look
The merge with #122 moved the fix, and the conflict was semantic. This branch originally routed
QuotaBadge's ownfetchthroughcall(). Onmain, #122 had refactored the badge to stop fetching entirely — the poll moved into the refcountedsessionStatsstore, which took the rawfetchand itsif (!res.ok) returnwith it. So the 401-swallowing bug moved too. Resolution takes main's badge verbatim and puts the fix inSessionStatsStore.refresh()instead. That is strictly better than the original: one poll for the whole app rather than N+1, and still the only authenticated poll that runs wherever the operator can browse — which is what makes it the detector.The ceiling constructor argument is required, with no default. It started optional, defaulting to
Infinity. Review pointed out that's a silent runtime regression waiting to happen: omit the argument and you get a store that slides without bound — strictly worse than the fixed deadline this replaces, with no symptom. ANaNguard was considered and rejected as the wrong defence, sincecoercethrows on a non-numeric setting rather than yieldingNaN; the reachable mistake was always the forgotten argument, andInfinitypasses every finiteness check. Required makes it a compile error. Cost: 13 mechanical test edits. Same reasoning as the exhaustiveswitchinmounts/(fact #22).A pre-existing env-vs-setting split is closed.
singletons.tsbuilt the store fromconfig.SESSION_TTL_HOURS(env) while the cookie read the instance setting. Nearly harmless while the deadline was absolute; under sliding the in-memory value becomes authoritative, so loweringsession_ttl_hoursin the UI would have done nothing. There's now acachedSessionTtlspair on the singletons object that the store's lazy readers read through, refreshed at boot and on the settings PUT — same cross-bundle pattern ascachedHosts, for the same reason.Verification
1366 tests across 131 files, typecheck (4922 files) and lint clean — post-merge, on the tree as it stands.
Driven in a real browser against a built server, since
.sveltefiles have no test coverage by construction (vitest.config.tsisenvironment: 'node'):/unlock?next=%2Fsettings%3Ftab%3Dinstance— no click needed/settings?tab=instancenext=%2Fclaude-session?next=//evil.com/The restart case is the real #108 scenario — the cookie survives, the in-memory session doesn't.
Negative controls, run and reverted: pointing
sessionCookieOptionsat the idle setting turns 3 tests red across 2 files; folding the slide intoget()turns exactly 1 test red, the one whose comment says so.Re-verified after the merge. The browser pass above originally ran against the badge's own poll, which no longer exists. Re-driven on the post-merge build: the
sessionStatspoll confirmed live on/settings(2 requests in 25s), then a server restart with no interaction at all bounced to/unlock?next=%2Fsettings%3Ftab%3Dinstance, and unlocking returned to/settings?tab=instance.Notes for review
SessionData.keyis the Argon2id-derived vault key; persisting a session without it yields one that is authenticated-but-locked — the same failure with more code. Where key material could live across a restart is #110 item 2. The 401 handling is what makes a restart graceful rather than mysterious.get()and therefore does not slide. Deliberate:SessionTerminal.sveltereconnects on a backoff timer, and a retry loop is not a present human./unlockbefore an operator reads it.session_max_lifetime_hoursdeliberately has no env var: a new setting would inherit the trap (an env value silently freezes the field in the UI) without the backwards-compatibility that justifies it onsession_ttl_hours.AGENTS.mdfact 25 records the model. Threedocs:commits amend the plan mid-flight — each records a defect review found in the plan text itself, so the committed plan matches what was built rather than what was first specified.https://claude.ai/code/session_01VY9PGWAqaA9Gf7fLeXPLjW