A session clock with two deadlines that do not contaminate each other: a sliding idle timeout, and an absolute cap anchored to the instant the subject actually authenticated.
import { SessionClock, SessionRegistry } from 'absolute-cap-drift';
const clock = new SessionClock({
idleTtlMs: 15 * 60 * 1000,
absoluteCapMs: 12 * 60 * 60 * 1000,
extendingActivities: ['navigate', 'submit'], // a person did something
observingActivities: ['poll', 'telemetry'], // a timer did something
});
const sessions = new SessionRegistry(clock);
sessions.authenticate({ sessionId: 's1', subject: 'u1', credentialId: 'c1', now: Date.now() });
const decision = sessions.access('s1', { now: Date.now(), activity: 'navigate' });
if (decision.outcome === 'refuse') {
// decision.reason is 'idleTimeout' | 'absoluteCap' | 'terminated'
// | 'credentialMismatch' | 'clockRegression'
throw new Error(decision.detail);
}
decision.remainingMs; // until the earlier of the two deadlines
decision.limitedBy; // 'idle' | 'absolute' | 'both'The obvious implementation stores an expiry on the session, resets it to now plus the idle TTL on every request, and computes the absolute cap from an issuedAt that lives on the credential. Rotation issues a new credential, so rotation rewrites issuedAt.
That last sentence is the whole bug. The twelve hour cap is now twelve hours measured from a moment that is never more than one rotation old. A session that began at 08:00 and rotates every five minutes is still alive at 03:00 the following morning, and at 03:00 the next night, and so on until the user closes the browser. Nothing in the code looks wrong. The line reads expiresAt = issuedAt + CAP_MS and that is exactly what the design document says.
The test written for this passes too, because the natural test rotates once and asserts that the session dies twelve hours after authentication. With one rotation early on, it does.
Here the cap is anchored to authenticatedAt, which nothing in the module rewrites, and absoluteDeadline is derived from it once and stored rather than recomputed. rotate() copies both through untouched and increments a generation counter, which is the only thing rotation changes besides the credential itself. The test in test/cap-anchor.test.ts runs 288 rotations across a simulated day and asserts the refusal lands on rotation 144, at exactly twelve hours.
The only path to a new anchor is reauthenticate(), which is a separate named operation. It requires a new session identifier (an identifier that survives a re-authentication is still valid for anything that learned it beforehand), terminates the previous record with the reason superseded, and returns both records so the caller cannot store one without the other.
The second defect is ordering. A middleware calls touch(session) and then isExpired(session). Or the touch and the check live in the same transaction and the write commits first. Either way the request arriving three milliseconds after the idle deadline moves the deadline out of its own way before being compared against it.
This is not a race that shows up under load. It is deterministic, and it fires on precisely the requests the idle timeout exists to refuse, which is why the timeout appears to work in every test and never in production.
Three things make it structurally impossible here:
- The instant is captured once.
evaluate()readsrequest.nowinto a local at the top and every comparison below uses that local. Re-reading a clock between the idle check and the cap check produces a decision that was never true of any single moment. - The touched record does not exist on the refusal branch. Both comparisons run first, and only after both pass does
applyActivity()construct a new record. A refusal cannot write a touched record because there is no touched record to write. What a refusal returns instead is a tombstone: the same record marked terminated, withlastActivityAtandlastSeenAtexactly where the previous request left them. - A decision knows which record it came from. Every decision carries a
basis(session id, generation, both high water marks, credential, terminated flag).SessionRegistry.commit()refuses a decision whose basis no longer matches the stored record, which closes the same hole one level up: two handlers read the same snapshot, one lands inside the window and one outside, and whichever writes last would otherwise win.
Expiry is inclusive. At the deadline instant exactly, the full window has elapsed and the session is over. The alternative gives away a millisecond at every boundary and makes the boundary itself untestable, since the equal case would belong to neither branch.
When both deadlines have passed, the refusal reports whichever came first in time. A session that went idle at 08:15 and was hit at 04:00 the next morning reports idleTimeout with expiredAt at 08:15, not absoluteCap. Reporting the cap would describe a user who walked away as a user who ran out of session. A genuine tie, where the idle deadline lands exactly on the cap, is reported as absoluteCap.
The third defect is that every request counts as activity. A background tab sends analytics beacons, notification polls, service worker prefetches, and silent token refreshes. Fifteen minutes of inactivity never arrives for any real browser, so the idle policy is decorative while the dashboard reports it as enforced.
So activity classification is part of the policy, and there is no rule that infers it:
extendingActivitiesnames what pushes the idle deadline forward. It must be non-empty, because an empty list would make the idle window a fixed countdown from authentication, which is whatabsoluteCapMsalready is.observingActivitiesnames what is allowed through while the session is live but never moves the deadline.- An undeclared name throws by default, listing every declared name. Set
unlistedActivity: 'observe'to let unknown traffic through without refreshing the window. There is deliberately no'extend'option: an undeclared name is one nobody has classified, and a default that slides the window enrols every newly added endpoint into keeping sessions alive on the day it ships. rotate()is permanently classified as observing, and no parameter changes that. Silent token refresh is the largest single source of apparent activity in a real deployment, and a rotation that refreshed the idle window would defeat the idle timeout for every user on every browser that keeps a tab open.
The record keeps lastActivityAt (moved only by extending activity, the sole input to the idle deadline) apart from lastSeenAt (moved by every admitted request, feeding nothing). Collapsing them into one field is the defect. Keeping them apart also makes it measurable: observedOnlyMs(record) is exactly how much traffic arrived with nobody behind it.
A clock that steps backwards cannot revive a session. Deadlines here are absolute instants, so an NTP correction of an hour would pull an hour of expired sessions back inside their windows. A request whose instant is behind the record's own high water mark by more than clockSkewToleranceMs is refused with clockRegression and the session is left untouched. The tolerance is capped below idleTtlMs, because a tolerance that large would let one backward step carry an instant from past the idle deadline back to before it.
A policy change never extends a session already running. adopt() reconciles a stored record against the current policy by taking the earlier of the stored deadline and the deadline the current policy would produce. Tightening the cap applies immediately to sessions in flight. Loosening it does not reach back: a subject who authenticated under the old rules never presented anything that justifies the longer window.
now is trusted input. Every deadline is compared against the instant the caller supplies. The clock regression check catches a wall clock that moves backwards, but nothing here can defend against a caller that supplies an attacker controlled instant. Capture now once at the edge of your request pipeline, from a source the request cannot influence.
SessionRegistry is a single process store. Its compare and set is atomic because JavaScript does not yield inside access(), which is true on one node and says nothing about four. The DecisionBasis fields are meant to be ported directly into a conditional update: they are exactly the WHERE clause you need. Without that conditional, a distributed deployment reintroduces the read to write window that commit() closes here.
Wall clock instants, not monotonic ones. Absolute deadlines have to survive a process restart and be comparable across nodes, which rules out performance.now(). The cost is that suspend and resume, virtual machine migration, and daylight saving transitions all move the clock under you. Only the backwards direction is checked, because a forward jump expires sessions early, which is the safe direction.
Prune is a scan. SessionRegistry.prune() walks every stored record. That is fine for the thousands, not for the millions. A real store should index on the effective deadline instead.
The idle deadline is not clamped in storage. A record whose lastActivityAt + idleTtlMs runs past the cap keeps that value; the clamp is applied when the deadlines are compared and when they are reported. So record.lastActivityAt alone does not tell you when the session ends. Use clock.describe(record, now), which returns both deadlines, the effective one, and which is limiting.
npm install
npm test # 92 tests: cap anchoring across 288 rotations, boundary ordering,
# background polling, clock regression, policy validationMIT