Repository navigation
T 1532290: Serialize concurrent token refreshes to prevent one-time refresh token double-spend - #174
Merged
vishal-mb merged 4 commits intoAug 31, 2026
Conversation
…he one-time refresh token
… claim (static kill switch: refreshClaimCoordinationEnabled)
…e-concurrent-token-refreshes
vishal-mb
deleted the
Task/1532290-Serialize-concurrent-token-refreshes
branch
August 31, 2026 11:12
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.
This pull request includes (pick all that apply):
Summary
OAuth2RequestPipelineMiddleware's refresh coordination is check-then-act:isRefreshTokenLockedFor→lockRefreshTokenwith no atomicity, so two middlewares sharing a token store can both observe "unlocked" and both spend the same one-time refresh token. Against IdentityServer (OneTimeOnly, 10s consumed-grace) the straggler getsinvalid_grant, the middleware deletes the token, and the user is force-logged out. This is a major contributor to Business App's daily forced logouts (same refresh token consumed 2–4× within seconds, server-side).Implementation
OAuth2RefreshClaimRegistry: atomic per-key claim under anNSLock, keyed bytokenIdentifierFor+ client/authorization.tryClaimreturns an ownership generation; stale claims (past the relinquish interval) are stealable so a hung refresh can't wedge refreshes;release/ownsare generation-scoped so a superseded holder can't clear or clobber its successor's state (token store, persisted lock).refreshTokenAndResume/performRefresh): winner re-reads the stored token after claiming and refreshes that (never a pre-claim snapshot), persists before releasing; losers reuse the persisted fresh token or wait briefly and re-enter. Waits are capped at 0.5s and once-guarded (waitForRefreshcan fire from both timeout and notification when they race).OAuth2RequestPipelineMiddleware.refreshClaimCoordinationEnabled(static, defaulttrue) as a kill switch; when disabled, the original refresh code runs unchanged — zero diff lines vsmainin that region.Test Plan
OAuth2MiddlewareConcurrentRefreshTests— deterministic reproduction of the race through unmodified production code (red before the fix: 2 grants spending the same token); asserts coalescing to one grant with both callers served, verifies a definitiveclientFailuredeletes the token before waking a waiter and prevents a second grant, and confirms disabling the flag restores the legacy double-spend behavior.OAuth2MiddlewareRefreshClaimPathTests— superseded holders (failure and success completions) can't delete/overwrite a successor's token or unlock its lock; winner-coalesce and loser fast-path branches serve a freshly persisted token with zero grants.OAuth2RefreshClaimRegistryTests— claim atomicity (1 winner of 32 concurrent), stale-steal, generation-scoped release/ownership.Security Impact
🤖 Generated with Claude Code