@@ -35,6 +35,7 @@ internal sealed partial class ClientOAuthProvider : McpHttpClient
3535 private readonly bool _validateAuthorizationResponseState ;
3636 private readonly bool _validateAuthorizationResponseIssuer ;
3737 private readonly Uri ? _clientMetadataDocumentUri ;
38+ private readonly string ? _configuredClientId ;
3839
3940 // _dcrClientName, _dcrClientUri, _dcrInitialAccessToken, _dcrConfiguredApplicationType and _dcrResponseDelegate are used for dynamic client registration (RFC 7591)
4041 private readonly string ? _dcrClientName ;
@@ -49,6 +50,7 @@ internal sealed partial class ClientOAuthProvider : McpHttpClient
4950 private string ? _clientId ;
5051 private string ? _clientSecret ;
5152 private string ? _tokenEndpointAuthMethod ;
53+ private string ? _clientCredentialsAuthorizationServer ;
5254 private ITokenCache _tokenCache ;
5355 private AuthorizationServerMetadata ? _authServerMetadata ;
5456
@@ -96,6 +98,7 @@ public ClientOAuthProvider(
9698 }
9799
98100 _clientId = options . ClientId ;
101+ _configuredClientId = options . ClientId ;
99102 _clientSecret = options . ClientSecret ;
100103 _redirectUri = options . RedirectUri ?? throw new ArgumentException ( "ClientOAuthOptions.RedirectUri must configured." , nameof ( options ) ) ;
101104 _configuredScopes = options . Scopes is null ? null : string . Join ( " " , options . Scopes ) ;
@@ -244,7 +247,9 @@ internal override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage r
244247 return ( current . AccessToken , true ) ;
245248 }
246249
247- if ( _authServerMetadata is not null && current ? . RefreshToken is { Length : > 0 } refreshToken )
250+ if ( _authServerMetadata is not null &&
251+ current ? . RefreshToken is { Length : > 0 } refreshToken &&
252+ CachedTokensMatchClientCredentials ( current , _clientCredentialsAuthorizationServer ) )
248253 {
249254 var accessToken = await RefreshTokensAsync ( refreshToken , resourceUri . ToString ( ) , _authServerMetadata , cancellationToken ) . ConfigureAwait ( false ) ;
250255 return ( accessToken , true ) ;
@@ -427,7 +432,10 @@ private async Task<string> GetAccessTokenCoreAsync(HttpResponseMessage response,
427432 // provider has not assigned a client ID yet. Restoring it here makes the refresh below possible
428433 // and avoids a redundant dynamic client registration in the assignment block.
429434 var cachedTokens = await _tokenCache . GetTokensAsync ( cancellationToken ) . ConfigureAwait ( false ) ;
430- RestoreCachedClientCredentials ( cachedTokens ) ;
435+ RestoreCachedClientCredentials ( cachedTokens , selectedAuthServer ) ;
436+ BindClientCredentialsToAuthorizationServer ( selectedAuthServer ) ;
437+ var cachedTokensMatchClientCredentials =
438+ CachedTokensMatchClientCredentials ( cachedTokens , selectedAuthServer . OriginalString ) ;
431439
432440 // Only attempt a token refresh if we haven't attempted to already for this request.
433441 // Also only attempt a token refresh for a 401 Unauthorized responses. Other response status codes
@@ -439,6 +447,7 @@ private async Task<string> GetAccessTokenCoreAsync(HttpResponseMessage response,
439447 if ( ! attemptedRefresh &&
440448 response . StatusCode == System . Net . HttpStatusCode . Unauthorized &&
441449 ! string . IsNullOrEmpty ( _clientId ) &&
450+ cachedTokensMatchClientCredentials &&
442451 cachedTokens is { RefreshToken : { Length : > 0 } refreshToken } )
443452 {
444453 var accessToken = await RefreshTokensAsync ( refreshToken , resourceUri , authServerMetadata , cancellationToken ) . ConfigureAwait ( false ) ;
@@ -463,6 +472,8 @@ private async Task<string> GetAccessTokenCoreAsync(HttpResponseMessage response,
463472 }
464473 }
465474
475+ _clientCredentialsAuthorizationServer = selectedAuthServer . OriginalString ;
476+
466477 // Determine the token endpoint auth method from server metadata if not already set by DCR.
467478 _tokenEndpointAuthMethod ??= authServerMetadata . TokenEndpointAuthMethodsSupported ? . FirstOrDefault ( ) ;
468479
@@ -875,6 +886,7 @@ private async Task<TokenContainer> HandleSuccessfulTokenResponseAsync(HttpRespon
875886 ClientId = _clientId ,
876887 ClientSecret = _clientSecret ,
877888 TokenEndpointAuthMethod = _tokenEndpointAuthMethod ,
889+ AuthorizationServer = _clientCredentialsAuthorizationServer ,
878890 } ;
879891
880892 await _tokenCache . StoreTokensAsync ( tokens , cancellationToken ) . ConfigureAwait ( false ) ;
@@ -1458,33 +1470,93 @@ private static string ToBase64UrlString(byte[] bytes)
14581470 /// <summary>
14591471 /// Restores the client registration persisted alongside cached tokens when this provider has not been
14601472 /// assigned a client ID yet. This allows a durable <see cref="ITokenCache"/> to use a refresh token that
1461- /// survived a process restart without re-running dynamic client registration. An explicitly configured
1473+ /// survived a process restart without re-running dynamic client registration. Credentials are restored
1474+ /// only when the cache binds them to the currently selected authorization server. An explicitly configured
14621475 /// client ID always takes precedence, so nothing is restored when one is already available.
14631476 /// </summary>
14641477 /// <remarks>
14651478 /// Callers must hold <c>_tokenAcquisitionLock</c>: this writes the shared <c>_clientId</c>,
14661479 /// <c>_clientSecret</c>, and <c>_tokenEndpointAuthMethod</c> fields, which the lock serializes.
1467- /// Like the persisted refresh token, the restored client ID assumes the durable cache belongs to the
1468- /// current authorization server; a single cache shared across authorization servers is not supported
1469- /// (an inherent property of the single-container <see cref="ITokenCache"/> design).
1480+ /// A single cache still stores only one registration, but the persisted authorization-server issuer
1481+ /// prevents that registration from being reused with a different server.
14701482 /// </remarks>
1471- private void RestoreCachedClientCredentials ( TokenContainer ? tokens )
1483+ private void RestoreCachedClientCredentials ( TokenContainer ? tokens , Uri selectedAuthServer )
14721484 {
1473- if ( ! string . IsNullOrEmpty ( _clientId ) || string . IsNullOrEmpty ( tokens ? . ClientId ) )
1485+ if ( tokens is null )
14741486 {
14751487 return ;
14761488 }
14771489
1478- // The guard above guarantees a non-null container, but the older nullable flow analysis on
1479- // netstandard2.0/net472 doesn't infer that from the null-conditional check, so capture a
1480- // non-null local and use it for every assignment.
1490+ // The guard above guarantees a non-null container, but older nullable flow analysis on
1491+ // netstandard2.0/net472 may not preserve that narrowing, so capture a non-null local.
14811492 var cached = tokens ! ;
14821493
1494+ if ( _configuredClientId is not null )
1495+ {
1496+ if ( ! string . Equals ( cached . ClientId , _configuredClientId , StringComparison . Ordinal ) )
1497+ {
1498+ return ;
1499+ }
1500+
1501+ // Restore the issuer binding independently from the secret. A rotated configured secret
1502+ // must not make credentials previously bound to another issuer appear portable.
1503+ _clientCredentialsAuthorizationServer = cached . AuthorizationServer ;
1504+
1505+ if ( string . Equals ( cached . AuthorizationServer , selectedAuthServer . OriginalString , StringComparison . Ordinal ) &&
1506+ string . Equals ( cached . ClientSecret , _clientSecret , StringComparison . Ordinal ) )
1507+ {
1508+ _tokenEndpointAuthMethod ??= cached . TokenEndpointAuthMethod ;
1509+ }
1510+
1511+ return ;
1512+ }
1513+
1514+ if ( ! string . IsNullOrEmpty ( _clientId ) )
1515+ {
1516+ return ;
1517+ }
1518+
1519+ if ( string . IsNullOrEmpty ( cached . ClientId ) ||
1520+ ! string . Equals ( cached . AuthorizationServer , selectedAuthServer . OriginalString , StringComparison . Ordinal ) )
1521+ {
1522+ return ;
1523+ }
1524+
14831525 // Assign _clientId last. Callers treat a non-empty _clientId as "registration complete", so the
14841526 // secret and auth method must already be in place before _clientId becomes observable.
14851527 _clientSecret ??= cached . ClientSecret ;
14861528 _tokenEndpointAuthMethod ??= cached . TokenEndpointAuthMethod ;
14871529 _clientId = cached . ClientId ;
1530+ _clientCredentialsAuthorizationServer = cached . AuthorizationServer ;
1531+ }
1532+
1533+ private bool CachedTokensMatchClientCredentials ( TokenContainer ? tokens , string ? authorizationServer ) =>
1534+ tokens is not null &&
1535+ string . Equals ( tokens . AuthorizationServer , authorizationServer , StringComparison . Ordinal ) &&
1536+ string . Equals ( tokens . ClientId , _clientId , StringComparison . Ordinal ) &&
1537+ string . Equals ( tokens . ClientSecret , _clientSecret , StringComparison . Ordinal ) &&
1538+ string . Equals ( tokens . TokenEndpointAuthMethod , _tokenEndpointAuthMethod , StringComparison . Ordinal ) ;
1539+
1540+ private void BindClientCredentialsToAuthorizationServer ( Uri selectedAuthServer )
1541+ {
1542+ if ( _clientCredentialsAuthorizationServer is null ||
1543+ string . Equals ( _clientCredentialsAuthorizationServer , selectedAuthServer . OriginalString , StringComparison . Ordinal ) )
1544+ {
1545+ return ;
1546+ }
1547+
1548+ if ( _configuredClientId is not null )
1549+ {
1550+ ThrowFailedToHandleUnauthorizedResponse (
1551+ $ "The authorization server changed from '{ _clientCredentialsAuthorizationServer } ' to '{ selectedAuthServer . OriginalString } ', " +
1552+ "but explicitly configured client credentials cannot be assumed to be valid for the new authorization server." ) ;
1553+ }
1554+
1555+ _clientId = null ;
1556+ _clientSecret = null ;
1557+ _tokenEndpointAuthMethod = null ;
1558+ _authServerMetadata = null ;
1559+ _clientCredentialsAuthorizationServer = null ;
14881560 }
14891561
14901562 [ DoesNotReturn ]
0 commit comments