Repository navigation
fix(auth): follow-ups to the logout session blocks, and MySQL session variables on every connection (#706) - #707
Merged
Conversation
…#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>
|
Important
This repository does not receive automatic reviews because it has fewer than 10 stars.
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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):REFRESH_REUSE_GRACE), revoked or not, so a family revoked by idle timeout or the token cap is included;Each listed family and the bearer
sidget 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), andrefreshConsumedToken's grace part moved totryGraceRefresh, 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
BlockSessionswrites one multi-row upsert per 500 sessions instead of one write per session. The handler makes at most two calls (normal and long blocks).4. fix(auth): keep expired refresh-token rows while their session can hold a live token
5. fix(database): set the MySQL session variables on every pooled connection
Database.configureranSETstatements forsql_mode,time_zoneandforeign_key_checksonce after connecting, so they reached one pooled connection only.config.MySQLSessionParams), which the driver sets on every new connection.sql_modeis 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:
Tests
NewFromAppConfig, holds four pooled connections and checks the variables on each. It fails on the old code, because the server'stime_zoneisSYSTEM.go vet ./...andgo vet -tags integrationare clean.go test ./... -shortpasses, as doesgo test -racefor handlers, middleware,sessionstore,websocket,database,configandapi.databaseandsessionstoreMySQL integration suites pass on MySQL 8.4.4.make gosec: 0 issues.Review
🤖 Generated with Claude Code