Repository navigation
fix(auth): logout ends the bearer session and blocks all its access tokens (#699) - #704
Merged
Merged
Conversation
…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>
|
Important
This repository does not receive automatic reviews because it has fewer than 10 stars.
|
5 tasks
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>
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 #699
Summary
Logout could leave a session usable:
Session blocks
sessionstoregetsBlockSession(sid, until)andIsSessionBlocked(sid): a new entry kindsession_blockinsession_entries(MySQL), a map in the memory store.Cleanupalready deletes every kind byexpires_at.GREATESTon MySQL).middleware.RevocationStatuschecks the jti, then the session, then the user, so JWT auth, the/wsupgrade and the socket revalidation reject tokens of a blocked session with 401 "Session revoked". The revalidation now knows each socket's session.Logout
sidwhen the cookie is missing or unknown.SESSION_MAX_LIFETIME+ 1 minute), and the answer is 500.Logout-all and others
BlockUser, like disable and password reset.Upgrade notes
For the release text:
Tests
/ws, and the socket revalidation;go vet ./...andgo vet -tags integrationfordatabaseandsessionstore: clean.go test ./... -short: pass.go test -racefor handlers, middleware,sessionstoreandwebsocket: pass.make gosec: 0 issues.sessionstoreMySQL integration tests (19, including 6 new) pass on MySQL 8.4.4.Not in this PR
auth-sessions.md.🤖 Generated with Claude Code