Skip to content

Do not let a session outlive the account it belongs to - #834

Merged
blaipr merged 1 commit into
mainfrom
fix/a-session-must-not-outlive-the-account
Aug 22, 2026
Merged

blaipr merged 1 commit into
mainfrom
fix/a-session-must-not-outlive-the-account

Conversation

@blaipr

@blaipr blaipr commented Aug 22, 2026

Copy link
Copy Markdown
Member

The defect

Disabling an account did not end the session it was already using.

checks isDisabled
API, every request (Api::setUserData()) yes — the token stops working immediately
Web login (LoginUser) yes — the account cannot sign in again
Web, every request after that no

The web trusted what login had put in the session, and nothing ever revisited it. Nor does the
session lapse on its own: the timeout is measured from the last request, so the session that is
being actively used is precisely the one that never expires — and an account is normally disabled
because of what is being done with it right now.

So the administrator disables the account, watches the API token stop working, and the browser
session carries on.

The fix

Init re-reads the account on each request of a signed-in session and ends the session if it has
been disabled, using SessionLifecycleHandler::restart() — the same thing the timeout already does,
so the user lands on the login page exactly as after any other expiry. It sits beside the timeout
check, because it is the same question (may this session continue) with the same answer, and costs
one primary-key read per authenticated request, which is what the API has always paid.

Only a positive answer ends a session. A read that fails — the database briefly unreachable, a
row that does not come back — says nothing about the account, and this runs on every request of
every session: answering "disabled" to a hiccup would sign out every user at once and turn a blip
into an outage. An account that has been deleted rather than disabled is left to the ordinary
expiry for the same reason. That trade is deliberate and has its own test.

Test

Three tests in InitSessionTest, driven through the full Init::initialize() with a genuinely
returning session — signed in, written out, then picked up the way a real second request does,
rather than handed a session that was never stored:

  • a disabled account's session is ended;
  • an enabled account keeps its session — without this the first is satisfied by ending every
    session, which is not a check on the account at all;
  • a read that fails leaves the session alone, pinning the conservative failure mode above.

Mutation-checked: removing the guard fails exactly the first and leaves both controls passing.

OK (4010 tests, 36826 assertions)   unit
OK (994 tests, 2979 assertions)     integration

PHPStan level 6 and PHPCS clean.

One thing worth flagging

The first version of this guard declared : bool and returned User::isDisabled(), which is
?bool. Under strict_types a row whose flag is null is then a TypeError — out of a method that
runs on every request of every session. The integration suite caught it as
"Init::isUserDisabled(): Return value must be of type bool, null returned" on unrelated tests. It
is === true now, with a comment, because "the flag was never set" is not "the account is disabled".

The integration suite also caught the guard ending sessions in tests where nothing was disabled: the
harness answers one User row for both the signed-in user and the user under test, and an empty
result read as "account gone". That is what settled the failure mode above — and the suite now runs
with exactly the baseline's warning count rather than three extra.

Also

CLAUDE.md gains a Known gap section recording something found while looking at this and
deliberately not fixed: POST /api/v1/auth-tokens answers 500 for every action whose token carries
a vault — ACCOUNT_VIEW and ACCOUNT_CREATE among them — because the API can only load the master
password from the calling token's own vault, and AUTHTOKEN_CREATE has none. Making it work means
deciding that a token which can mint tokens also carries the master password, which is a product
decision rather than a bug fix, so it is written down with the diagnosis instead of patched.

Disabling an account did not end the session it was already using. The API
re-reads the user on every request and refuses a disabled one
(Api::setUserData()), and the web login refuses one too, but nothing revisited
it on any later web request: the session held whatever had been true at login.

Nor does such a session lapse on its own. The timeout is measured from the last
request, so the session being actively used is precisely the one that never
expires — and an account is normally disabled because of what is being done with
it right now. The administrator disables the account, watches the API token stop
working, and the browser session carries on.

Init now re-reads the account on each request of a signed-in session and ends it
with SessionLifecycleHandler::restart(), the same thing the timeout already does,
so the user lands on the login page as after any other expiry. It sits beside the
timeout check because it is the same question with the same answer, and costs one
primary-key read per authenticated request, which is what the API has always
paid.

Only a positive answer ends a session. A read that fails — the database briefly
unreachable, a row that does not come back — says nothing about the account, and
this runs on every request of every session: answering "disabled" to a hiccup
would sign out every user at once and turn a blip into an outage. An account
deleted rather than disabled is left to the ordinary expiry for the same reason.

The check reads isDisabled() === true rather than the value: the getter is
?bool, and under strict_types a row whose flag was never set is a TypeError out
of a method declared bool, on every request of every session.

Three tests in InitSessionTest drive the full initialize() with a genuinely
returning session — signed in, written out, then picked up the way a second
request does: a disabled account's session ends, an enabled account's survives,
and a failed read leaves the session alone. Removing the guard fails only the
first.

CLAUDE.md records a gap found while looking at this and deliberately not fixed:
POST /api/v1/auth-tokens answers 500 for every action whose token carries a
vault, because the API can only load the master password from the calling
token's own vault and AUTHTOKEN_CREATE has none. Making it work means deciding
that a token which can mint tokens also carries the master password, which is a
product decision rather than a bug fix.
@blaipr
blaipr merged commit a5e4d11 into main Aug 22, 2026
8 checks passed
@blaipr
blaipr deleted the fix/a-session-must-not-outlive-the-account branch August 22, 2026 15:26
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.

1 participant