Keep interactive OAuth flows alive when the triggering request is canceled - #1831
Conversation
…celed During the dual-path connect, the server/discover probe's 401 challenge can start an interactive authorization flow. When DiscoverProbeTimeout elapsed while the user was still completing that flow in a browser, the probe's cancellation aborted the flow, and the initialize fallback's challenge then started a second flow with a fresh state and PKCE verifier that the redirect the user eventually completed could never satisfy, failing the connect with 'The authorization response state did not match the state sent in the authorization request'. Memoize the in-flight authorization-code flow in ClientOAuthProvider and detach it from the triggering request's cancellation token, bounding it by provider disposal instead. Challenge handlers await the shared flow with their own token, so a canceled request abandons only its wait while a later challenge joins the flow and reuses its result. HttpClientTransport disposal cancels any flow still pending. Fixes modelcontextprotocol#1830 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011bjA7zRNnUXnh19qSqgpTY
There was a problem hiding this comment.
Pull request overview
Keeps interactive OAuth authorization alive across canceled requests while preserving transport-lifetime cancellation.
Changes:
- Memoizes and shares in-flight authorization flows.
- Cancels detached flows when the HTTP transport is disposed.
- Documents timeout behavior and adds regression tests.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 4 comments.
| File | Description |
|---|---|
ClientOAuthProvider.cs |
Shares detached OAuth flows. |
HttpClientTransport.cs |
Cancels flows during disposal. |
McpClientOptions.cs |
Documents OAuth timeout behavior. |
AuthTests.cs |
Tests cancellation and disposal scenarios. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
…inistic tests Only reuse a pending authorization-code flow for a challenge whose scopes it already requested. A challenge that needs a scope the flow did not request could never be satisfied by its token, so it now waits for the pending flow to settle and then runs its own step-up for the accumulated scopes, keeping at most one interactive prompt in front of the user at a time. The flow's requested scope set is recorded when the flow starts. Document InitializationTimeout as bounding how long the connect attempt waits for browser authorization rather than the flow itself, which only transport disposal cancels. Replace the fixed delay in the DiscoverProbeTimeout regression test with a signal from server middleware when the initialize fallback arrives, use the standard test timeout for the disposal test, and add a regression test for the files:read / files:write scope-mismatch scenario. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01C9kt1V3wMcWWkSMJEkrXKK
There was a problem hiding this comment.
🟡 Changes recommended
In-flight flow reuse still has race, step-up, scope-selection, and shared-state concurrency defects.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Suppressed comments (3)
src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs:531
- A detached flow can finish after the cache re-check at lines 376–381 while this caller is fetching metadata. In that race, this branch discards the successful task and opens a second authorization URL even though the new token has just been cached, recreating the state/PKCE failure this change is meant to prevent. Re-check the cache before replacing a completed flow and reuse a valid token that differs from the one rejected by this request.
if (flow is null || flow.IsCompleted)
{
_inFlightAuthorizationCodeFlow = flow = StartAuthorizationCodeFlow(protectedResourceMetadata, authServerMetadata, _disposeCts.Token);
}
src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs:789
_accumulatedScopesis not the set actually requested inauthUrl:ComputeEffectiveScopesubsequently addsoffline_accessand letsScopeSelectorfilter or add scopes. Consequently compatibility is tested against raw server scopes rather than the authorization URL. For example, a selector mapping both read and write challenges to the same custom scope causes an unnecessary second interactive flow, while a filtered-out scope can be recorded as covered even though it was omitted. Record and compare the effective scope tokens emitted in the authorization URL.
// Building the URL folds this challenge's scopes into _accumulatedScopes, so the accumulated set
// is now exactly the set of scopes this flow asks the authorization server for.
var authUrl = BuildAuthorizationUrl(protectedResourceMetadata, authServerMetadata, codeChallenge, state);
_inFlightAuthorizationCodeFlowScopes = new HashSet<string>(_accumulatedScopes, StringComparer.Ordinal);
src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs:791
- Once the caller cancels its
WaitAsync, the semaphore is released but this detached method continues reading mutable provider credentials inCreateTokenRequestand_clientCredentialsAuthorizationServerwhen storing tokens. A subsequent challenge can acquire the semaphore and rebind or dynamically register against another authorization server before this callback finishes, causing the original code exchange to use/store the later server's credentials. Capture all transaction credentials as locals for the detached flow, or keep later challenges from mutating provider authentication state until it settles.
return CompleteAuthorizationCodeFlowAsync(protectedResourceMetadata, authServerMetadata, authUrl, state, codeVerifier, cancellationToken);
- Files reviewed: 4/4 changed files
- Comments generated: 1
- Review effort level: Balanced
…hallenge Once a step-up has been attempted, a 403 insufficient_scope challenge that adds no new scope is rejected as unproductive. That guard ran before the in-flight flow was considered, so when the step-up's caller canceled its request mid-login and another request drew the same challenge, the second request failed immediately even though the pending flow would have satisfied it. Join a pending flow that covers the challenge before applying the repeated-step-up rejection, and add a regression test for the case. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01C9kt1V3wMcWWkSMJEkrXKK
There was a problem hiding this comment.
🔵 Needs a closer look
Concurrent flow completion and inaccurate scope snapshots can still trigger incorrect retries or duplicate authorization prompts.
Review details
Suppressed comments (3)
Previously missed (2) — in code that hasn't changed since the last review.
src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs:798
- This snapshots
_accumulatedScopes, not the scopes actually placed inauthUrl.ComputeEffectiveScopecan addoffline_access, andScopeSelectorcan remove challenged scopes or append custom ones, so coverage checks may join a flow that did not request the required scope or start a second prompt even though the pending flow did request it. Snapshot the URL's finalscopeparameter instead.
_inFlightAuthorizationCodeFlowScopes = new HashSet<string>(_accumulatedScopes, StringComparer.Ordinal);
src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs:540
- A detached flow can complete after the earlier cache re-check but before this condition (the intervening metadata/DCR work makes that window substantial). In that case its valid token is discarded and this starts a second authorization flow with new state/PKCE, recreating the failure this change is intended to prevent. Return immediately when a compatible flow was observed pending, and re-check the cache before replacing a flow that completed concurrently.
if (flow is null || flow.IsCompleted)
{
_inFlightAuthorizationCodeFlow = flow = StartAuthorizationCodeFlow(protectedResourceMetadata, authServerMetadata, _disposeCts.Token);
}
src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs:426
- There is a completion race between the cache read above and the
IsCompleted: falsepattern. If the detached step-up stores its token and completes in that interval, this branch is skipped and the code throws the repeated-challenge error even though a new valid token is now available. Observe the task regardless of state: await it if pending, otherwise re-check the cache before rejecting the challenge.
if (_inFlightAuthorizationCodeFlow is { IsCompleted: false } pendingStepUp &&
InFlightAuthorizationCodeFlowCoversChallenge(protectedResourceMetadata))
{
return await pendingStepUp.WaitAsync(cancellationToken).ConfigureAwait(false);
- Files reviewed: 4/4 changed files
- Comments generated: 0 new
- Review effort level: Balanced
A detached authorization-code flow can complete after a later challenge re-checked the token cache on acquiring the lock but before that challenge decided whether to join the flow (the metadata fetches in between make the window real). The challenge then saw a completed flow and started a second one, prompting the user again for a token that was already cached, unless a refresh token happened to be available. The same window existed in the repeated-step-up guard. Fold both sites into one helper that joins the in-flight flow while it is pending and otherwise reuses the token it cached, provided the flow requested every scope the challenge needs. Record those scopes from the effective scope placed in the authorization URL rather than the accumulated set, since offline_access augmentation and a ScopeSelector can make the two differ. Add a regression test that holds the second connect's metadata fetch until the first connect's detached flow has stored its token, using a cache that issues no refresh token so the cached access token is the only alternative to a second prompt. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01C9kt1V3wMcWWkSMJEkrXKK
|
Addressed the three findings from the latest Copilot review in 7e390ec:
New test |
There was a problem hiding this comment.
🟡 Changes recommended
In-flight flows do not preserve or validate authorization-server context, creating credential races and incorrect flow reuse.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Suppressed comments (2)
Previously missed (1) — in code that hasn't changed since the last review.
tests/ModelContextProtocol.AspNetCore.Tests/OAuth/AuthTests.cs:2697
- This test can still pass without exercising a flow that survives the probe timeout: if OAuth metadata discovery or dynamic registration takes longer than 500 ms, the probe is canceled before the callback starts, then the fallback starts the sole flow and
handlerInvocationsis still 1. The raw warmup request only warms the MCP server's 401 pipeline, not those client-side OAuth steps. Record that the callback entered before the unauthenticatedinitializerequest is observed (and use a sufficiently tolerant probe interval) so this regression cannot false-pass on a slow runner.
await using var client = await McpClient.CreateAsync(
transport,
new McpClientOptions { DiscoverProbeTimeout = TimeSpan.FromMilliseconds(500) },
loggerFactory: LoggerFactory,
cancellationToken: TestContext.Current.CancellationToken);
src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs:554
- Reuse is keyed only by scopes, so a concurrent challenge that selects a different authorization server can join this flow and retry with a token minted by the previous issuer. The provider explicitly supports rebinding dynamically registered clients when the selected server changes, so the in-flight descriptor also needs to record the selected authorization server/resource and require that context to match before joining.
if (_inFlightAuthorizationCodeFlow is not { } flow || !InFlightAuthorizationCodeFlowCoversChallenge(protectedResourceMetadata))
{
- Files reviewed: 4/4 changed files
- Comments generated: 1
- Review effort level: Balanced
…ion flow A detached authorization-code flow outlives the token acquisition lock once its initiating request is canceled, yet its code exchange and token persistence read the provider's mutable credential fields. A later challenge holding the lock can rebind and re-register those fields for a different authorization server while the flow is pending, so the original code would be exchanged with the wrong client credentials and cached under the wrong issuer. Capture an immutable ClientCredentials snapshot under the lock when a flow starts (and when a refresh runs) and use it for the authorization URL, the token request, and the persisted registration. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01C9kt1V3wMcWWkSMJEkrXKK
There was a problem hiding this comment.
🟡 Changes recommended
Flow reuse does not verify authorization-server identity, and the probe-timeout test can pass without exercising its intended path.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Suppressed comments (2)
Previously missed (1) — in code that hasn't changed since the last review.
tests/ModelContextProtocol.AspNetCore.Tests/OAuth/AuthTests.cs:2690
- This test can still pass without exercising a probe-started flow. If the 500 ms timeout fires before the probe reaches the callback, the
initializemiddleware signals first, the fallback invokes the only callback, andhandlerInvocations == 1passes through the fallback-only path. Warming the pipeline does not guarantee this ordering on a slow runner. Add an explicit assertion/signal that the first callback entered beforeinitializeFallbackReachedServercompleted, and use that to prove the probe—not the fallback—started the shared flow.
await using var transport = CreateOAuthTransport(async (context, cancellationToken) =>
{
Interlocked.Increment(ref handlerInvocations);
await initializeFallbackReachedServer.Task.WaitAsync(cancellationToken);
return await HandleAuthorizationUrlAsync(context, cancellationToken);
src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs:556
- The reuse predicate checks scopes but not the authorization server. With dynamic registration,
BindClientCredentialsToAuthorizationServersupports rebinding this provider to a newly selected server; a pending or completed flow from the previous server can therefore be joined when the new challenge happens to require the same scopes. Its token and PKCE registration belong to the old issuer, so the retry fails instead of starting the required flow. Record the selected authorization server (and resource context) with the in-flight flow and require it to match before reuse; otherwise wait for that flow to settle and start a new one.
if (_inFlightAuthorizationCodeFlow is not { } flow || !InFlightAuthorizationCodeFlowCoversChallenge(protectedResourceMetadata))
{
- Files reviewed: 4/4 changed files
- Comments generated: 1
- Review effort level: Balanced
…s the flow Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01C9kt1V3wMcWWkSMJEkrXKK
Fixes #1830
Problem
During the dual-path connect, the
server/discoverprobe's 401 challenge can start an interactive authorization flow viaAuthorizationCallbackHandler. WhenDiscoverProbeTimeout(default 5s) elapses while the user is still completing that flow in a browser, the probe's cancellation aborts the flow. Theinitializefallback's challenge then starts a second flow with a fresh state and PKCE verifier — with no way to surface a new authorization URL to the user, who is still completing the first flow. The redirect the user eventually completes carries the first flow's state, and the connect fails with:The user-approved code is unusable by the second flow regardless (different PKCE verifier), so this failure is unrecoverable. Observed in production against a real OAuth-protected MCP server; full analysis in #1830.
Fix
ClientOAuthProvidernow memoizes the in-flight authorization-code flow and detaches it from the triggering request's cancellation token, bounding it by provider disposal instead:Task.WaitAsync, so a canceled request (e.g. the probe) abandons only its wait.initializefallback here, or any concurrent request — joins the in-flight flow and reuses its result. The existing_tokenAcquisitionLock+ cached-token re-check already covers the "token arrived while I waited" case.ClientOAuthProvideris nowIDisposable;HttpClientTransport.DisposeAsyncdisposes it, canceling any flow still pending so a parkedAuthorizationCallbackHandlerobserves cancellation.Docs for
DiscoverProbeTimeoutandInitializationTimeoutnow describe the interactive-authorization interplay (notably that the user's login must fit withinInitializationTimeout).Tests
InteractiveAuthorization_SurvivesCancellationOfTriggeringRequest— deterministic regression test for the general mechanism: connect 1 is canceled while its flow waits on the user; the user then completes the original flow; connect 2 must reuse its outcome with exactly one handler invocation.InteractiveAuthorization_SurvivesDiscoverProbeTimeout— end-to-end dual-path scenario: probe 401 → probe canceled byDiscoverProbeTimeout→ initialize fallback joins the pending flow.DisposingTransport_CancelsDetachedAuthorizationFlow— the detached flow outlives canceled connects but is canceled by transport disposal.Both regression tests fail against the previous provider behavior and pass with this change; the full OAuth suite (101 tests) and the July 2026 protocol fallback suite (53 tests) pass.
🤖 Generated with Claude Code
https://claude.ai/code/session_011bjA7zRNnUXnh19qSqgpTY