Skip to content

fix(auth): follow-ups to the logout session blocks, and MySQL session variables on every connection (#706) - #707

Merged
omattsson merged 5 commits into
mainfrom
fix/706-session-block-followups
Oct 25, 2026
Merged

omattsson merged 5 commits into
mainfrom
fix/706-session-block-followups

Conversation

@omattsson

Copy link
Copy Markdown
Owner

Closes #706

Follow-ups to the logout session blocks of #699 (PR #704), plus two fixes the reviews found. Five commits, each builds and passes on its own.

Commits

1. fix(auth): logout-all blocks every recent session (#706 items 1–4, 6)

  • Logout-all after a failed revoke (item 1). After RevokeAllForUser, logout-all lists the user's recent session families (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.

    Each listed family and the bearer sid get a session block, the long block when the revoke failed, so no listed session can refresh.

  • Failure cases. A failed listing 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 margin (item 2). No clock-skew margin on BlockUser: 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.

  • Item 3: a docs note on failed revokes during a rolling update.

  • Item 4: a test of the 499 branch of the refresh block check.

  • Item 6: logout revokes with RevokeFamilyForUser (only the family of the user the credential names), and refreshConsumedToken's grace part moved to tryGraceRefresh, with identical behaviour.

2. test(sessionstore): benchmark the revocation lookups (item 5)

Measured, not implemented. One combined query for the jti, session and user checks would save about 0.8 ms per request on MySQL 8.4.4: 1.3–1.6 ms for three lookups against 0.5 ms for one query. At the API limit of 100 requests per minute per IP that is small, and a combined method would touch the store interface, both stores and every mock.

3. perf(sessionstore): batch the session blocks of logout-all

  • What changed. BlockSessions writes one multi-row upsert per 500 sessions instead of one write per session. The handler makes at most two calls (normal and long blocks).
  • Partial writes are intended. The batches are separate statements. A failed later batch leaves the earlier blocks written, and the error says how many. A block only ever blocks more, and a rollback would leave every session unblocked.

4. fix(auth): keep expired refresh-token rows while their session can hold a live token

  • The gap. The cleanup deleted rows at expiry, so a session whose last row had just expired could drop out of the logout-all listing while its access token was still valid.
  • The fix. It now keeps rows for the same window that the listing uses, through one shared helper (about 16 minutes with the defaults). Every "active" query already filters on expiry.

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

  • The bug. Database.configure ran SET statements for sql_mode, time_zone and foreign_key_checks once after connecting, so they reached one pooled connection only.
  • The fix. They are now DSN parameters (config.MySQLSessionParams), which the driver sets on every new connection.
  • sql_mode is the MySQL 8 default, set explicitly. The old string was looser than what the pool and every integration test ran with.

Upgrade notes

For the release text:

  • MySQL session settings: every database connection now uses sql_mode = the MySQL 8.0/8.4 default and time_zone = UTC, set by the backend instead of the server. In steady state this matches what most connections already ran with (the one connection with the old setting was recycled within DB_CONN_MAX_LIFETIME). The schema has no TIMESTAMP columns, so existing data is unaffected. A database whose server default sql_mode was looser now gets the strict mode.
  • Refresh-token rows: expired rows are kept about 16 minutes longer than before (the access-token lifetime + 1 minute + REFRESH_REUSE_GRACE).
  • Rolling update: until the leader runs this version, its cleanup still deletes at expiry.

No migration.

Tests

  • Commit 1:
    • handler tests for the listing window (rows 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;
    • repository tests on SQLite and MySQL.
  • Commit 3:
    • memory store;
    • MySQL with 1007 IDs across three batches, with a longer block pre-written in each batch;
    • a MySQL partial write;
    • the handler: one call for logout-all, two for a mix of lifetimes.
  • Commit 4: the repository deletes only rows before the cutoff; the handler keeps rows inside the window.
  • Commit 5:
    • DSN unit tests pin the encoded parameters;
    • a MySQL integration test opens the database through NewFromAppConfig, holds four pooled connections and checks the variables on each. It fails on the old code, because the server's time_zone is SYSTEM.
  • Results:
    • go vet ./... and go vet -tags integration are clean.
    • go test ./... -short passes, as does go test -race for handlers, middleware, sessionstore, websocket, database, config and api.
    • The database and sessionstore MySQL integration suites pass on MySQL 8.4.4.
    • make gosec: 0 issues.

Review

  • Commits 1 and 2: two review rounds.
  • Commits 3–5: a comprehensive review and a re-review of the fixes.
  • Outcome: no finding blocked the push, and all low and nit findings are fixed.

🤖 Generated with Claude Code

omattsson and others added 5 commits October 25, 2026 09:22
…#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>
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>
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>
…ld 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>
…tion

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>
@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: 02282e14-b937-4af6-a81b-f0854fec9e2b
  • 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 5d25dcf into main Oct 25, 2026
8 checks passed
@omattsson
omattsson deleted the fix/706-session-block-followups branch October 25, 2026 16:59
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): follow-ups to the logout session blocks (#699)

1 participant