Skip to content

Commit e8b80f0

Browse files
halter73Copilot
andauthored
Enable additional client conformance scenarios (#1763)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
1 parent 314bdb2 commit e8b80f0

11 files changed

Lines changed: 971 additions & 44 deletions

File tree

src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs

Lines changed: 83 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -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]

src/ModelContextProtocol.Core/Authentication/ExchangeJwtBearerGrantOptions.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,11 @@ internal sealed class ExchangeJwtBearerGrantOptions
2525
/// </summary>
2626
public string? ClientSecret { get; set; }
2727

28+
/// <summary>
29+
/// Gets or sets the token endpoint authentication method.
30+
/// </summary>
31+
public string? TokenEndpointAuthMethod { get; set; }
32+
2833
/// <summary>
2934
/// Gets or sets the scopes to request (space-separated). Optional.
3035
/// </summary>

src/ModelContextProtocol.Core/Authentication/IdentityAssertionGrant.cs

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -164,7 +164,18 @@ public static async Task<TokenContainer> ExchangeJwtBearerGrantAsync(
164164
["client_id"] = options.ClientId,
165165
};
166166

167-
if (!string.IsNullOrEmpty(options.ClientSecret))
167+
using var httpRequest = new HttpRequestMessage(HttpMethod.Post, options.TokenEndpoint);
168+
169+
if (string.Equals(options.TokenEndpointAuthMethod, "client_secret_basic", StringComparison.Ordinal))
170+
{
171+
formData.Remove("client_id");
172+
var credentials = $"{Uri.EscapeDataString(options.ClientId)}:{Uri.EscapeDataString(options.ClientSecret ?? string.Empty)}";
173+
httpRequest.Headers.Authorization = new AuthenticationHeaderValue(
174+
"Basic",
175+
Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(credentials)));
176+
}
177+
else if (string.Equals(options.TokenEndpointAuthMethod, "client_secret_post", StringComparison.Ordinal) &&
178+
!string.IsNullOrEmpty(options.ClientSecret))
168179
{
169180
formData["client_secret"] = options.ClientSecret!;
170181
}
@@ -174,12 +185,7 @@ public static async Task<TokenContainer> ExchangeJwtBearerGrantAsync(
174185
formData["scope"] = options.Scope!;
175186
}
176187

177-
using var requestContent = new FormUrlEncodedContent(formData);
178-
using var httpRequest = new HttpRequestMessage(HttpMethod.Post, options.TokenEndpoint)
179-
{
180-
Content = requestContent
181-
};
182-
188+
httpRequest.Content = new FormUrlEncodedContent(formData);
183189
httpRequest.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
184190

185191
using var httpResponse = await httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);

src/ModelContextProtocol.Core/Authentication/IdentityAssertionGrantProvider.cs

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,22 @@ public IdentityAssertionGrantProvider(
102102
throw new ArgumentNullException($"{nameof(options)}.{nameof(options.IdTokenCallback)}");
103103
}
104104

105+
if (options.TokenEndpointAuthMethod is not null &&
106+
options.TokenEndpointAuthMethod is not ("client_secret_basic" or "client_secret_post" or "none"))
107+
{
108+
throw new ArgumentException(
109+
$"{nameof(options.TokenEndpointAuthMethod)} must be 'client_secret_basic', 'client_secret_post', or 'none'.",
110+
$"{nameof(options)}.{nameof(options.TokenEndpointAuthMethod)}");
111+
}
112+
113+
if (options.TokenEndpointAuthMethod is "client_secret_basic" or "client_secret_post" &&
114+
string.IsNullOrEmpty(options.ClientSecret))
115+
{
116+
throw new ArgumentException(
117+
$"{nameof(options.ClientSecret)} is required when {nameof(options.TokenEndpointAuthMethod)} is '{options.TokenEndpointAuthMethod}'.",
118+
$"{nameof(options)}.{nameof(options.ClientSecret)}");
119+
}
120+
105121
_options = options;
106122
_httpClient = httpClient;
107123
_logger = (ILogger?)loggerFactory?.CreateLogger<IdentityAssertionGrantProvider>() ?? NullLogger.Instance;
@@ -197,6 +213,7 @@ private async Task<TokenContainer> AcquireAccessTokenAsync(
197213
Assertion = jag,
198214
ClientId = _options.ClientId,
199215
ClientSecret = _options.ClientSecret,
216+
TokenEndpointAuthMethod = SelectTokenEndpointAuthMethod(mcpAuthMetadata),
200217
Scope = _options.Scope,
201218
}, _httpClient, cancellationToken).ConfigureAwait(false);
202219

@@ -206,6 +223,50 @@ private async Task<TokenContainer> AcquireAccessTokenAsync(
206223
return tokens;
207224
}
208225

226+
private string SelectTokenEndpointAuthMethod(AuthorizationServerMetadata metadata)
227+
{
228+
var supportedMethods = metadata.TokenEndpointAuthMethodsSupported;
229+
if (_options.TokenEndpointAuthMethod is { } configuredMethod)
230+
{
231+
if (supportedMethods is { Count: > 0 } &&
232+
!supportedMethods.Contains(configuredMethod, StringComparer.Ordinal))
233+
{
234+
throw new IdentityAssertionGrantException(
235+
$"The configured token endpoint authentication method '{configuredMethod}' is not advertised by the MCP authorization server.");
236+
}
237+
238+
return configuredMethod;
239+
}
240+
241+
if (string.IsNullOrEmpty(_options.ClientSecret))
242+
{
243+
if (supportedMethods is null or { Count: 0 } ||
244+
supportedMethods.Contains("none", StringComparer.Ordinal))
245+
{
246+
return "none";
247+
}
248+
249+
throw new IdentityAssertionGrantException(
250+
"The MCP authorization server does not advertise a token endpoint authentication method usable without a client secret.");
251+
}
252+
253+
// Preserve the provider's existing client_secret_post behavior when it is available.
254+
// RFC 8414 defines this metadata as a list of supported methods, not a preference order.
255+
if (supportedMethods is null or { Count: 0 } ||
256+
supportedMethods.Contains("client_secret_post", StringComparer.Ordinal))
257+
{
258+
return "client_secret_post";
259+
}
260+
261+
if (supportedMethods.Contains("client_secret_basic", StringComparer.Ordinal))
262+
{
263+
return "client_secret_basic";
264+
}
265+
266+
throw new IdentityAssertionGrantException(
267+
"The MCP authorization server does not advertise a supported token endpoint authentication method.");
268+
}
269+
209270
/// <summary>
210271
/// Clears any cached tokens, forcing a fresh token exchange on the next call to <see cref="GetAccessTokenAsync"/>.
211272
/// </summary>

src/ModelContextProtocol.Core/Authentication/IdentityAssertionGrantProviderOptions.cs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,17 @@ public sealed class IdentityAssertionGrantProviderOptions
1616
/// </summary>
1717
public string? ClientSecret { get; set; }
1818

19+
/// <summary>
20+
/// Gets or sets the authentication method used at the MCP authorization server's token endpoint.
21+
/// </summary>
22+
/// <remarks>
23+
/// Supported values are <c>client_secret_basic</c>, <c>client_secret_post</c>, and <c>none</c>.
24+
/// Set this to the method assigned to the pre-registered MCP client. When omitted, the provider
25+
/// preserves its existing <c>client_secret_post</c> behavior when supported, then falls back to
26+
/// another compatible method advertised by the authorization server.
27+
/// </remarks>
28+
public string? TokenEndpointAuthMethod { get; set; }
29+
1930
/// <summary>
2031
/// Gets or sets the scopes to request from the MCP authorization server (space-separated). Optional.
2132
/// </summary>

src/ModelContextProtocol.Core/Authentication/TokenContainer.cs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,5 +76,15 @@ public sealed class TokenContainer
7676
/// </remarks>
7777
public string? TokenEndpointAuthMethod { get; set; }
7878

79+
/// <summary>
80+
/// Gets or sets the authorization server issuer that issued the client credentials and tokens.
81+
/// </summary>
82+
/// <remarks>
83+
/// OAuth client credentials are bound to an authorization server and must not be reused with a
84+
/// different issuer. Durable cache implementations should persist this value alongside the token
85+
/// and client registration so restored credentials can be validated before use.
86+
/// </remarks>
87+
public string? AuthorizationServer { get; set; }
88+
7989
internal bool IsExpired => ExpiresIn is not null && DateTimeOffset.UtcNow >= ObtainedAt.AddSeconds(ExpiresIn.Value);
8090
}

tests/ModelContextProtocol.AspNetCore.Tests/ClientConformanceTests.cs

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -63,9 +63,12 @@ public ClientConformanceTests(ITestOutputHelper output)
6363
[InlineData("auth/2025-03-26-oauth-metadata-backcompat")]
6464
[InlineData("auth/2025-03-26-oauth-endpoint-fallback")]
6565

66-
// Extensions: Require ES256 JWT signing (private_key_jwt) and client_credentials grant support.
67-
// [InlineData("auth/client-credentials-jwt")]
68-
// [InlineData("auth/client-credentials-basic")]
66+
[InlineData("auth/authorization-server-migration")]
67+
[InlineData("auth/client-credentials-jwt")]
68+
[InlineData("auth/client-credentials-basic")]
69+
[InlineData("auth/enterprise-managed-authorization")]
70+
[InlineData("sep-2322-client-request-state")]
71+
[InlineData("json-schema-ref-no-deref")]
6972

7073
public async Task RunConformanceTest(string scenario)
7174
{

0 commit comments

Comments
 (0)