Skip to content

feat(sessions): bound how many sessions one user may hold at once - #225

Merged
Bccorb merged 3 commits into
mainfrom
feat/concurrent-session-limit
Aug 31, 2026
Merged

feat(sessions): bound how many sessions one user may hold at once#225
Bccorb merged 3 commits into
mainfrom
feat/concurrent-session-limit

Conversation

@Bccorb

@Bccorb Bccorb commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Closes #176.

Blocked on a release. This needs @seamless-auth/types@0.16.0, which is
fells-code/seamless-auth-types#54. package.json asks for ^0.16.0 but the
lockfile still pins 0.15.0, so CI will fail on npm ci until that publishes.
Run npm install here once it does. I verified locally against the types build
from that branch: typecheck, lint, format, build, 1171 tests and coverage all
pass.

What was wrong

issueSessionAndRespond created a session on every successful authentication and
never 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_sessions defaults to no limit, so an instance predating it is
unaffected, which is the acceptance this issue asked for.

Unlimited is null rather than 0, and the schema refuses 0. Zero would
otherwise 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_SESSIONS also accepts an empty value, null, none or
unlimited, 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 new
session_evicted auth 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:

  • It 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. There is a
    test asserting that ordering rather than just the call.
  • Lowering the limit converges in one pass. A deployment that drops the cap
    leaves users above it; the next sign-in evicts everything above it 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 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/types gains 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_evicted is a new
auth event type, categorised under login, which is where session lifecycle
already 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) and npm run coverage all pass locally against
the types branch build. openapi.json and src/generated/api.ts regenerated.

Bccorb added 2 commits August 30, 2026 20:11
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.
@Bccorb
Bccorb marked this pull request as ready for review August 31, 2026 00:25
@Bccorb

Bccorb commented Aug 31, 2026

Copy link
Copy Markdown
Contributor Author

Unblocked and out of draft.

One correction: the key shipped in 0.17.0, not 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 my pin assumed. I checked
both rather than guessing:

0.16.0 occurrences of max_concurrent_sessions: 0
0.17.0 occurrences of max_concurrent_sessions: 4

package.json and the lockfile now take ^0.17.0 from npm, and the changeset
names the right version.

Re-verified against the published package rather than the local build I developed
against: npm run typecheck, npm run lint, npm run format:check,
npm run build, npm run test:run (1171 passing) and npm run coverage (98.78%
statements, above every threshold) all pass. openapi.json and
src/generated/api.ts regenerate byte identical, so the published contract is
unchanged by the dependency move.

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.
@Bccorb
Bccorb merged commit 7356602 into main Aug 31, 2026
4 checks passed
@Bccorb
Bccorb deleted the feat/concurrent-session-limit branch August 31, 2026 00:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

No concurrent session limit per user

1 participant