Skip to content

fix(auth): logout ends the bearer session and blocks all its access tokens (#699) - #704

Merged
omattsson merged 2 commits into
mainfrom
fix/699-logout-session-gaps
Oct 25, 2026
Merged

omattsson merged 2 commits into
mainfrom
fix/699-logout-session-gaps

Conversation

@omattsson

Copy link
Copy Markdown
Owner

Closes #699

Summary

Logout could leave a session usable:

  • Without the cookie: a logout with only an access token ended nothing on the server.
  • Other access tokens: other access tokens of a logged-out session (another tab, a refresh in flight) kept working until they expired.
  • Logout-all: the user's earlier access tokens kept working over HTTP.

Session blocks

  • sessionstore gets BlockSession(sid, until) and IsSessionBlocked(sid): a new entry kind session_block in session_entries (MySQL), a map in the memory store.
  • No migration: Cleanup already deletes every kind by expires_at.
  • No shortening: a re-block keeps the later expiry (GREATEST on MySQL).
  • Where it's checked: middleware.RevocationStatus checks the jti, then the session, then the user, so JWT auth, the /ws upgrade and the socket revalidation reject tokens of a blocked session with 401 "Session revoked". The revalidation now knows each socket's session.
  • Refresh: refresh and its grace path refuse a blocked session and revoke its family.
  • Store errors: every check fails open, as the existing ones do.

Logout

  • Which session: it revokes the family of the cookie token, and of the bearer's sid when the cookie is missing or unknown.
  • Cookie and bearer of different sessions or users: both end. Either credential could end its own session alone, so ending both gives no new power.
  • Order: revoke, then block the session until now + access-token lifetime + 1 minute, then block the jti and close its sockets. Refresh signs before its session re-check, so any token that passed the re-check predates the block.
  • Failed revoke: a long block (now + SESSION_MAX_LIFETIME + 1 minute), and the answer is 500.

Logout-all and others

  • Logout-all now calls BlockUser, like disable and password reset.
  • Admin revokes (delete, disable, password reset, role change) now revoke before they block, which closes the same race.
  • Refresh-token reuse (replay) also blocks the family.

Upgrade notes

For the release text:

Logout now ends the session of the access token even without the refresh cookie, and every other access token of that session stops working. Logout-all stops all earlier access tokens of the user. During a rolling update, older replicas do not check the new session block, and a logout they serve without a cookie does not end the session. No migration.

Tests

  • Covered: every acceptance criterion of fix(auth): logout leaves the session usable without the cookie, and through other access tokens #699.
    • bearer-only logout;
    • other tokens of the session on HTTP and /ws, and the socket revalidation;
    • other sessions unaffected;
    • logout-all;
    • cookie and bearer of different users;
    • races after the refresh re-check (logout, grace refresh, logout-all, password reset, role change);
    • replay, and failed revokes;
    • the revoke-before-block order. A mutation test confirmed that the tests fail when the order is swapped or the session block is missing.
  • Results:
    • go vet ./... and go vet -tags integration for database and sessionstore: clean.
    • go test ./... -short: pass. go test -race for handlers, middleware, sessionstore and websocket: pass.
    • make gosec: 0 issues.
    • The sessionstore MySQL integration tests (19, including 6 new) pass on MySQL 8.4.4.
  • Review: two rounds of security-focused review.
    • Two medium findings were fixed: the failed-revoke block, and refresh reading the block. A third, the missing clock-skew margin on the user block, is documented.
    • The re-review found nothing critical, high or medium.

Not in this PR

  • Logout-all, failed revoke: the refresh tokens stay valid, so a refresh after the user block still works. The 500 tells the client. Blocking each active family would close it.
  • Clock skew: the user block has no clock-skew margin. It's documented in Swagger and auth-sessions.md.
  • Test gap: no test covers the 499 branch of the refresh block check.
  • Queries: one query for the jti, session and user checks, instead of three.

🤖 Generated with Claude Code

omattsson and others added 2 commits October 25, 2026 08:22
…okens (#699)

Logout found the session only through the refresh cookie and blocked only
the presented access token. A logout without the cookie ended nothing on
the server, and other access tokens of a logged-out session (another tab,
a refresh in flight) kept working until they expired. Logout-all did not
block the user, so earlier access tokens kept working over HTTP.

Session blocks (sessionstore):
- BlockSession(sid, until) and IsSessionBlocked(sid), a new entry kind
  `session_block` in session_entries (MySQL) and a map in the memory
  store. No migration: Cleanup already deletes every kind by expires_at.
- A re-block keeps the later expiry (GREATEST on MySQL, max in memory).

Checks: middleware.RevocationStatus checks the jti, then the sid, then
the user, so JWT auth, the /ws upgrade and Hub.StartRevalidation reject
tokens of a blocked session (401 "Session revoked"). The revalidation now
knows the sid of each socket. Refresh and its grace path refuse a blocked
family and revoke it. All checks fail open on a store error, as before.

Logout:
- Revokes the family of the cookie token, and of the bearer's sid when
  the cookie is missing or unknown. Cookie and bearer of different
  sessions or users end both: either credential could end its own
  session alone.
- Order: revoke, then BlockSession until now + access-token lifetime +
  1 minute, then block the jti and close its sockets. Refresh signs before
  its session recheck, so any token that passed the recheck predates the
  block.
- A failed revoke writes a long block (now + SESSION_MAX_LIFETIME +
  1 minute) and answers 500.

Logout-all now calls BlockUser. The admin revokes (delete, disable,
password reset, role change) revoke before they block, which closes the
same race. Refresh-token replay also blocks the family.

During a rolling update, older replicas do not read session blocks, and
a logout they serve without a cookie does not revoke the sid family.

Tests cover the acceptance criteria of #699: bearer-only logout, other
tokens of the session on HTTP and /ws, socket revalidation, other
sessions unaffected, logout-all, cookie/bearer of different users, races
after the refresh recheck, replay, failed revokes, the revoke-before-block
order and the MySQL store (MySQL 8.4.4). Swagger is regenerated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
docs/claude/auth-sessions.md and CLAUDE.md (Security Rules) describe
the session block, what logout and logout-all revoke and block, the
order of the steps, the user block's reliance on synchronised clocks,
and the rolling-update behaviour. backend/CLAUDE.md (Sessions and
Tokens) changed in the previous commit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@omattsson omattsson added the security Security vulnerability or hardening label Oct 25, 2026
@coderabbitai

coderabbitai Bot commented Oct 25, 2026

Copy link
Copy Markdown

Important

  • 🔍 Trigger review

This repository does not receive automatic reviews because it has fewer than 10 stars.

Run configuration
  • Source mode: Auto
  • Repository: Unavailable
  • Branch: Unavailable
  • File: No configuration file from this repository applied
  • Commit: Unavailable
  • Layers: defaults
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 58635a44-b513-441f-b1fa-00476b6591a8
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@omattsson
omattsson merged commit a43c0b8 into main Oct 25, 2026
8 checks passed
omattsson added a commit that referenced this pull request Oct 25, 2026
… variables on every connection (#706) (#707)

* fix(auth): logout-all blocks every recent session; follow-ups to #699 (#706)

Follow-ups to the logout session blocks of #699 (PR #704).

Logout-all (item 1):
- After RevokeAllForUser, logout-all lists the user's recent session
  families (RefreshTokenRepository.ListRecentFamiliesForUser): every
  family with a refresh-token row that expires after now minus (access
  token lifetime + 1 minute + REFRESH_REUSE_GRACE), revoked or not, so a
  family revoked by idle timeout or the token cap is included. Newest
  first, at most 1000, with a warning at the limit. A legacy row is its
  own family.
- Each listed family and the bearer sid get a session block: the long
  block (until SESSION_MAX_LIFETIME) when the revoke failed, so no
  listed session can refresh; the normal block otherwise.
- A listing failure is logged; the revoke and the user block still run.
  When the listing and the revoke both fail, only the presented session
  is blocked and the answer is 500.

User block (item 2): no clock-skew margin. A margin would also reject a
login right after logout-all, disable or password reset. Logout-all
blocks each session instead, which does not compare issue times. A
session without a refresh-token row (a login whose refresh-token create
failed, rows already deleted by the cleanup) and the SSO CLI token rely
on the user block.

Also:
- Docs (item 3): the rolling-update note names sessions whose revoke
  failed; the skew direction in the comments is corrected.
- Test (item 4): the 499 branch of the refresh session-block check.
- Cleanups (item 6): logout revokes with RevokeFamilyForUser (only the
  family of the user that the credential names; RevokeFamily stays as
  the fallback without a user ID), and refreshConsumedToken's grace part
  moved to tryGraceRefresh, with identical behaviour.

Tests cover the listing window (rows that expired just inside and just
outside it), revoked and legacy families, other users, the failure
matrix, and a refresh after a failed logout-all on the normal and the
grace path. The repository query is tested on SQLite (order, revoked
rows, the window, the 1000 limit) and on MySQL 8.4.4.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(sessionstore): benchmark the revocation lookups (#706)

Item 5 of #706: measure whether one query for the jti, session and user
checks would pay off.

On MySQL 8.4.4 on localhost (Apple M2 Pro), with 10k token blocks, 1k
session blocks and 1k user blocks, a miss on all three keys takes about
1.3-1.6 ms as three lookups and about 0.5 ms as one query: a saving of
about 0.8 ms per JWT request. The memory store does all three checks in
about 38 ns.

Not implemented: with the API limit of 100 requests per minute per IP,
0.8 ms is small next to the other database work of a request, and a
combined method would touch the store interface, both stores and every
mock, and would have to repeat the user-block issue-time rule.

Run with:
  go test -tags integration -run '^$' -bench MySQLRevocation -benchmem ./internal/sessionstore/
  (with TEST_MYSQL_DSN set)
  go test -run '^$' -bench MemoryRevocation -benchmem ./internal/sessionstore/

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* perf(sessionstore): batch the session blocks of logout-all (#706)

Logout-all can block up to models.MaxRecentFamilies (1000) sessions, and
wrote one BlockSession upsert per session on the request path: up to
1000 round trips to MySQL for a user with many sessions.

- New SessionStore.BlockSessions(ctx, ids, until). The MySQL store writes
  one multi-row upsert per batch of 500 rows, in a loop of
  single-statement Create calls, with the same GREATEST rule, so a short
  block never shortens a long one. A duplicate ID is written once.
- The batches are separate statements, not one transaction. When a later
  batch fails, the earlier batches stay written and the error says how
  many distinct sessions were blocked ("blocked 500 of 502 sessions").
  That is intended: a block only ever blocks more, a second write is
  harmless, and a rollback would leave every session unblocked.
- The memory store blocks all IDs under one lock. BlockSession is now
  BlockSessions with one ID.
- The handler's blockSessions groups the sessions by block lifetime
  (normal and long) and makes at most two BlockSessions calls. Its error
  log names the block kind.

Tests:
- the memory store (several IDs, duplicates, no shortening, empty list);
- MySQL 8.4.4 with 1007 IDs across three batches plus a duplicate, with
  one longer block pre-written in each batch;
- a MySQL partial write: an over-long ID fails batch 2, batch 1 stays
  written and the error gives the count;
- the sessionstore integration setup opens MySQL with
  SkipDefaultTransaction, as production does;
- the handler: logout-all writes its session blocks in one call, and a
  mix of normal and long blocks makes exactly two calls with the right
  end times.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(auth): keep expired refresh-token rows while their session can hold a live token (#706)

The refresh-token cleanup deleted rows at expires_at, with no margin. A
session whose last row expired (for example at SESSION_MAX_LIFETIME,
right after a refresh) can still hold an access token for its whole
lifetime. If the cleanup ran in that gap, logout-all no longer found the
session, wrote no session block, and the token was covered only by the
user block (no clock-skew margin).

- RefreshTokenRepository.DeleteExpired takes a cutoff:
  DeleteExpired(ctx, before).
- AuthHandler.sessionListWindow is the access-token lifetime + 1 minute
  (sessionBlockSkew) + REFRESH_REUSE_GRACE. Logout-all lists the families
  of rows that expire within it, and CleanupExpiredTokens deletes only
  rows that expired before now minus it, so the two cannot drift apart.
- Every "active" query already filters on expires_at, and Refresh checks
  the expiry of the presented row, so the extra rows (about 16 minutes
  with the defaults) change nothing else.

Tests: the repository deletes only rows before the cutoff (with rows on
both sides of it); the handler keeps rows inside the window and deletes
older ones; the context test seeds a row outside the window. Docs, the
Swagger text and the code comments drop the "rows already deleted by the
cleanup" case.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(database): set the MySQL session variables on every pooled connection

Database.configure ran `SET FOREIGN_KEY_CHECKS = 1`, `SET time_zone =
'+00:00'` and `SET SESSION sql_mode = ...` once after connecting. A SET
applies to one connection, so only one connection of the pool got them;
the others ran with the server defaults (on MySQL 8.4: the default
sql_mode, time_zone SYSTEM). Found in the #706 review.

The three variables are now DSN parameters (config.MySQLSessionParams),
which go-sql-driver/mysql sets with one SET on every new connection,
including the replacements after ConnMaxLifetime:

- sql_mode: the MySQL 8.0/8.4 default (ONLY_FULL_GROUP_BY,
  STRICT_TRANS_TABLES, NO_ZERO_IN_DATE, NO_ZERO_DATE,
  ERROR_FOR_DIVISION_BY_ZERO, NO_ENGINE_SUBSTITUTION), explicitly, so it
  does not depend on the server configuration. The old string
  (STRICT_TRANS_TABLES,NO_AUTO_VALUE_ON_ZERO,NO_ENGINE_SUBSTITUTION) was
  looser than what the pool and every integration test ran with, so
  applying it everywhere would have loosened the mode.
  NO_AUTO_VALUE_ON_ZERO is dropped: GORM never inserts 0 into an
  auto-increment key.
- time_zone: '+00:00', matching loc=UTC in the driver.
- foreign_key_checks: 1.

In steady state this does not tighten production: DB_CONN_MAX_LIFETIME
(5 minutes) replaced the one connection that had the old SET, and every
other connection already ran the server default. What changes is that
the mode no longer depends on the server's own setting, and time_zone is
UTC on every connection. The code has no TIMESTAMP columns and no NOW()
or CURRENT_TIMESTAMP (database time uses UTC_TIMESTAMP), so existing
DATETIME data is unaffected.

configure no longer runs SET statements for MySQL. Both DSN builders
(config.DatabaseConfig.DSN and database.Config.DSN) append the
parameters.

Tests: the DSN unit tests pin the encoded parameters, and a MySQL
integration test opens the database through NewFromAppConfig, holds four
pooled connections at once and checks the three variables on each. The
server in that test has time_zone SYSTEM, so it fails without the
parameters. All database and sessionstore integration tests pass with
the mode on every connection.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

security Security vulnerability or hardening

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix(auth): logout leaves the session usable without the cookie, and through other access tokens

1 participant