Skip to content

Make auth provider token caches thread-safe (#1595)#1708

Merged
tarekgh merged 4 commits into
modelcontextprotocol:mainfrom
tarekgh:fix-thread-safe-auth-token-caches
Jul 22, 2026
Merged

Make auth provider token caches thread-safe (#1595)#1708
tarekgh merged 4 commits into
modelcontextprotocol:mainfrom
tarekgh:fix-thread-safe-auth-token-caches

Conversation

@tarekgh

@tarekgh tarekgh commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

Fixes #1595.

Problem

ClientOAuthProvider and IdentityAssertionGrantProvider lazily populated mutable cache fields (tokens, server metadata, resolved endpoints) during 401 handling with no synchronization. When the same provider handles concurrent in-flight requests, this caused redundant token exchanges and races on the cache fields.

Fix

  • Coalesce token acquisition in both providers behind a SemaphoreSlim so only one caller performs the exchange/refresh while others await and reuse the result.
  • Valid cached tokens keep a lock-free fast path.
  • Thread the token that triggered a 401 through the flow so a stale-but-unexpired token still forces a real refresh (instead of being served back).
  • Guard InvalidateCache and document the concurrency contract on ITokenCache.

Tests

  • Added a deterministic concurrency test asserting a single exchange runs for multiple concurrent callers.
  • Existing auth unit and AspNetCore OAuth tests pass; full solution builds clean with no warnings.

ClientOAuthProvider and IdentityAssertionGrantProvider lazily populated
mutable cache fields during 401 handling with no synchronization. Under
concurrent in-flight requests on a shared handler this caused redundant
token exchanges and races on the cache fields.

Coalesce token acquisition in both providers behind a SemaphoreSlim so
only one caller performs the exchange or refresh while others await and
reuse the result. Valid cached tokens still take a lock-free fast path.
Thread the token that caused a 401 through so a stale-but-unexpired token
still forces a real refresh. Guard InvalidateCache and document the
concurrency contract on ITokenCache.

Adds a deterministic concurrency test verifying a single exchange runs
for multiple concurrent callers.

Copilot-Session: 334e4d21-b783-46ae-8dc6-3c3b5defbe37
@tarekgh
tarekgh requested a review from halter73 July 16, 2026 22:38
@tarekgh tarekgh self-assigned this Jul 16, 2026
@tarekgh

tarekgh commented Jul 16, 2026

Copy link
Copy Markdown
Contributor Author

CC @jeffhandley

)

The post-lock cache re-check only reused a freshly cached token when the
challenge carried a token (usedAccessToken is not null). During a
cold-start connect the client can issue concurrent requests that all send
no token, so each saw a null usedAccessToken, skipped the re-check and ran
a full interactive authorization, producing redundant token exchanges
(observed in CI as two authorization requests on a single connect).

When no token was sent, any valid token now present in the cache was
obtained by another caller and is safe to reuse, so drop the null guard.
The remaining checks still prevent replaying a rejected token (the cached
token must differ from the one that produced the challenge) and still run
the step-up flow for 403 insufficient_scope challenges.

Copilot-Session: 334e4d21-b783-46ae-8dc6-3c3b5defbe37
Comment thread src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs
Comment thread src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs
Tarek Mahmoud Sayed added 2 commits July 21, 2026 13:33
- Remove redundant _scopeAccumulatorLock; _tokenAcquisitionLock already
  serializes all reads and writes to _accumulatedScopes and _hasAttemptedStepUp.
- On a concurrent 403 where a step-up already ran and the challenge adds no new
  scope, reuse a still-valid token produced by another caller instead of failing.
- Capture _cachedTokens into a local on the IdentityAssertionGrantProvider
  lock-free fast path to avoid a null-deref race with InvalidateCache.
- Add a regression test for the concurrent step-up token reuse.
@tarekgh

tarekgh commented Jul 21, 2026

Copy link
Copy Markdown
Contributor Author

@halter73 I have addressed all feedback. Please let me know if you have more or we are good to go. Thanks!

@tarekgh
tarekgh merged commit 28806b1 into modelcontextprotocol:main Jul 22, 2026
21 of 24 checks passed
halter73 added a commit that referenced this pull request Jul 22, 2026
…sted

On a cold start where a durable ITokenCache returns a persisted refresh
token but no client ID has been assigned yet (DCR/CIMD clients),
GetAccessTokenCoreAsync attempted a token refresh before assigning a
client ID, causing CreateTokenRequest -> GetClientIdOrThrow() to throw
'Client ID is not available'.

Persist the client registration (client ID, secret, token endpoint auth
method) alongside the tokens so a single durable ITokenCache can use the
refresh token after a restart, and restore it on a cold start before the
refresh attempt. Also guard the refresh block with a client-ID check so
the flow falls through to client-ID assignment and the authorization-code
flow instead of throwing when no credentials are available.

The restore and persistence run inside the provider's _tokenAcquisitionLock
(added in #1708), so the shared credential fields are written under the
same lock that serializes the rest of the auth state; no new races are
introduced.

Fixes #1658

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
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.

Token/metadata caches in auth providers are not thread-safe

2 participants