Skip to content

T 1532290: Serialize concurrent token refreshes to prevent one-time refresh token double-spend - #174

Merged
vishal-mb merged 4 commits into
mainfrom
Task/1532290-Serialize-concurrent-token-refreshes
Aug 31, 2026
Merged

vishal-mb merged 4 commits into
mainfrom
Task/1532290-Serialize-concurrent-token-refreshes

Conversation

@vishal-mb

@vishal-mb vishal-mb commented Aug 17, 2026 •

Copy link
Copy Markdown
Contributor

This pull request includes (pick all that apply):

  • Bugfixes
  • New features
  • Breaking changes
  • Documentation updates
  • Unit tests
  • Other

Summary

OAuth2RequestPipelineMiddleware's refresh coordination is check-then-act: isRefreshTokenLockedFor → lockRefreshToken with 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 gets invalid_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

  • New internal OAuth2RefreshClaimRegistry: atomic per-key claim under an NSLock, keyed by tokenIdentifierFor + client/authorization. tryClaim returns an ownership generation; stale claims (past the relinquish interval) are stealable so a hung refresh can't wedge refreshes; release/owns are generation-scoped so a superseded holder can't clear or clobber its successor's state (token store, persisted lock).
  • Coordinated path (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 (waitForRefresh can fire from both timeout and notification when they race).
  • Gated behind OAuth2RequestPipelineMiddleware.refreshClaimCoordinationEnabled (static, default true) as a kill switch; when disabled, the original refresh code runs unchanged — zero diff lines vs main in 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 definitive clientFailure deletes 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.
  • All 12 tests green; SwiftLint clean.

Security Impact

  • No security impact

🤖 Generated with Claude Code

@AnshulMindbody AnshulMindbody left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I found a few concurrency edge cases that may still allow the same one-time refresh token to be spent more than once.

Comment thread Sources/Conduit/Auth/OAuth2RequestPipelineMiddleware.swift
Comment thread Sources/Conduit/Auth/OAuth2RequestPipelineMiddleware.swift

@AnshulMindbody AnshulMindbody left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@vishal-mb
vishal-mb merged commit ebc7128 into main Aug 31, 2026
1 check passed
@vishal-mb
vishal-mb deleted the Task/1532290-Serialize-concurrent-token-refreshes branch August 31, 2026 11:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants