feat(sessions): bound how many sessions one user may hold at once - #225
Conversation
issueSessionAndRespond created a session on every successful authentication and never looked at the ones already there, so nothing capped the count. That is NIST 800-53 AC-10, and an operational problem wherever workstations are shared, since an unbounded count leaves sessions alive on machines a user has walked away from. max_concurrent_sessions defaults to no limit, so an instance predating the setting is unaffected. Unlimited is null rather than 0, and 0 is refused by the schema, because zero would otherwise read as "no sessions allowed" and lock every user out of a deployment that meant to remove the cap. MAX_CONCURRENT_SESSIONS also accepts an empty value, null, none or unlimited, since a deployment template cannot easily unset a variable. At the limit the sign-in succeeds and the oldest session is revoked, with revokedReason concurrent_session_limit and a new session_evicted auth event naming the session that ended. Refusing the new session instead would lock a user out of the device in front of them until something they may not have access to expires, which for a shared workstation is the common case rather than the edge one. Enforcement runs before the new row is created, so the limit counts the session about to exist: at a limit of 3 a user holding 3 ends up with 3, not 4. Lowering the limit leaves users above it, and each converges on their next sign-in, which evicts everything above the cap in one pass rather than shedding one session per login forever. It never throws. A session that cannot be revoked is logged and the sign-in continues, because failing an authentication over housekeeping is worse than briefly exceeding the cap. Closes #176
The key shipped in 0.17.0 rather than 0.16.0: 0.16.0 was already published with the WebAuthn error code union, so the release carrying max_concurrent_sessions landed a version later than the pin assumed. Lockfile updated to the published package, and the changeset corrected to name the right version.
|
Unblocked and out of draft. One correction: the key shipped in 0.17.0, not 0.16.0. 0.16.0 was already
Re-verified against the published package rather than the local build I developed |
system_config.value is JSONB NOT NULL, and max_concurrent_sessions defaults to null for "no limit", so bootstrapping seeded that default straight into a column that refuses it: Failed to start server: notNull Violation: SystemConfig.value cannot be null Every unit test around bootstrap mocks the model, so nothing here caught it. The cross-repo verify job did, by starting a real container. Absence is how the store spells null instead. Every nullable key carries a schema default of null, so "no row" and "null" resolve to the same configuration, and a key that was set and is now null has its row removed rather than an update the column would refuse. That also makes turning a limit back off through the environment work, which storing a sentinel would not. Verified against a throwaway PostgreSQL 17 rather than mocks: bootstrap with no value writes no row and resolves null, MAX_CONCURRENT_SESSIONS=3 persists the row, and MAX_CONCURRENT_SESSIONS=unlimited removes it again and resolves null.
Closes #176.
What was wrong
issueSessionAndRespondcreated a session on every successful authentication andnever looked at the ones already there, so nothing bounded the count. NIST 800-53
AC-10 asks for exactly that bound, and it is an operational problem wherever
workstations are shared, since an unbounded count leaves sessions alive on
machines a user has walked away from.
The setting
max_concurrent_sessionsdefaults to no limit, so an instance predating it isunaffected, which is the acceptance this issue asked for.
Unlimited is
nullrather than0, and the schema refuses0. Zero wouldotherwise read as "no sessions allowed", which is a plausible way for an operator
to try to remove a cap and lock every user out instead.
MAX_CONCURRENT_SESSIONSalso accepts an empty value,null,noneorunlimited, because a deployment template cannot easily unset a variable.The behaviour
At the limit the sign-in succeeds and the user's oldest session is revoked
with
revokedReason: 'concurrent_session_limit', recorded as a newsession_evictedauth event naming the session that ended.Refusing the new session was the alternative. It locks a user out of the device in
front of them until something they may not have access to expires, which for the
shared workstations this exists to protect is the common case rather than the edge
one.
Three details worth reviewing:
to exist. At a limit of 3, a user holding 3 ends up with 3, not 4. There is a
test asserting that ordering rather than just the call.
leaves users above it; the next sign-in evicts everything above it rather than
shedding one session per login forever.
continues, because failing an authentication over a housekeeping step is worse
than briefly exceeding the cap. Covered by a test.
An uncapped deployment does not even run the query, so this costs nothing on the
default path.
Ripple
Contract-affecting, as the issue anticipated.
@seamless-auth/typesgains the key(#54 there), which the admin dashboard picks up for free once it takes the
release. No route, status code or token change here.
session_evictedis a newauth event type, categorised under
login, which is where session lifecyclealready lives since
logout_maps there.Checks
npm run typecheck,npm run lint,npm run format:check,npm run build,npm run test:run(1171 passing) andnpm run coverageall pass locally againstthe types branch build.
openapi.jsonandsrc/generated/api.tsregenerated.