Enable additional client conformance scenarios (#1763)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
parent
314bdb2f49
commit
e8b80f0e6d
11 changed files with 971 additions and 44 deletions
|
|
@ -35,6 +35,7 @@ internal sealed partial class ClientOAuthProvider : McpHttpClient
|
|||
private readonly bool _validateAuthorizationResponseState;
|
||||
private readonly bool _validateAuthorizationResponseIssuer;
|
||||
private readonly Uri? _clientMetadataDocumentUri;
|
||||
private readonly string? _configuredClientId;
|
||||
|
||||
// _dcrClientName, _dcrClientUri, _dcrInitialAccessToken, _dcrConfiguredApplicationType and _dcrResponseDelegate are used for dynamic client registration (RFC 7591)
|
||||
private readonly string? _dcrClientName;
|
||||
|
|
@ -49,6 +50,7 @@ internal sealed partial class ClientOAuthProvider : McpHttpClient
|
|||
private string? _clientId;
|
||||
private string? _clientSecret;
|
||||
private string? _tokenEndpointAuthMethod;
|
||||
private string? _clientCredentialsAuthorizationServer;
|
||||
private ITokenCache _tokenCache;
|
||||
private AuthorizationServerMetadata? _authServerMetadata;
|
||||
|
||||
|
|
@ -96,6 +98,7 @@ internal sealed partial class ClientOAuthProvider : McpHttpClient
|
|||
}
|
||||
|
||||
_clientId = options.ClientId;
|
||||
_configuredClientId = options.ClientId;
|
||||
_clientSecret = options.ClientSecret;
|
||||
_redirectUri = options.RedirectUri ?? throw new ArgumentException("ClientOAuthOptions.RedirectUri must configured.", nameof(options));
|
||||
_configuredScopes = options.Scopes is null ? null : string.Join(" ", options.Scopes);
|
||||
|
|
@ -244,7 +247,9 @@ internal sealed partial class ClientOAuthProvider : McpHttpClient
|
|||
return (current.AccessToken, true);
|
||||
}
|
||||
|
||||
if (_authServerMetadata is not null && current?.RefreshToken is { Length: > 0 } refreshToken)
|
||||
if (_authServerMetadata is not null &&
|
||||
current?.RefreshToken is { Length: > 0 } refreshToken &&
|
||||
CachedTokensMatchClientCredentials(current, _clientCredentialsAuthorizationServer))
|
||||
{
|
||||
var accessToken = await RefreshTokensAsync(refreshToken, resourceUri.ToString(), _authServerMetadata, cancellationToken).ConfigureAwait(false);
|
||||
return (accessToken, true);
|
||||
|
|
@ -427,7 +432,10 @@ internal sealed partial class ClientOAuthProvider : McpHttpClient
|
|||
// provider has not assigned a client ID yet. Restoring it here makes the refresh below possible
|
||||
// and avoids a redundant dynamic client registration in the assignment block.
|
||||
var cachedTokens = await _tokenCache.GetTokensAsync(cancellationToken).ConfigureAwait(false);
|
||||
RestoreCachedClientCredentials(cachedTokens);
|
||||
RestoreCachedClientCredentials(cachedTokens, selectedAuthServer);
|
||||
BindClientCredentialsToAuthorizationServer(selectedAuthServer);
|
||||
var cachedTokensMatchClientCredentials =
|
||||
CachedTokensMatchClientCredentials(cachedTokens, selectedAuthServer.OriginalString);
|
||||
|
||||
// Only attempt a token refresh if we haven't attempted to already for this request.
|
||||
// Also only attempt a token refresh for a 401 Unauthorized responses. Other response status codes
|
||||
|
|
@ -439,6 +447,7 @@ internal sealed partial class ClientOAuthProvider : McpHttpClient
|
|||
if (!attemptedRefresh &&
|
||||
response.StatusCode == System.Net.HttpStatusCode.Unauthorized &&
|
||||
!string.IsNullOrEmpty(_clientId) &&
|
||||
cachedTokensMatchClientCredentials &&
|
||||
cachedTokens is { RefreshToken: { Length: > 0 } refreshToken })
|
||||
{
|
||||
var accessToken = await RefreshTokensAsync(refreshToken, resourceUri, authServerMetadata, cancellationToken).ConfigureAwait(false);
|
||||
|
|
@ -463,6 +472,8 @@ internal sealed partial class ClientOAuthProvider : McpHttpClient
|
|||
}
|
||||
}
|
||||
|
||||
_clientCredentialsAuthorizationServer = selectedAuthServer.OriginalString;
|
||||
|
||||
// Determine the token endpoint auth method from server metadata if not already set by DCR.
|
||||
_tokenEndpointAuthMethod ??= authServerMetadata.TokenEndpointAuthMethodsSupported?.FirstOrDefault();
|
||||
|
||||
|
|
@ -875,6 +886,7 @@ internal sealed partial class ClientOAuthProvider : McpHttpClient
|
|||
ClientId = _clientId,
|
||||
ClientSecret = _clientSecret,
|
||||
TokenEndpointAuthMethod = _tokenEndpointAuthMethod,
|
||||
AuthorizationServer = _clientCredentialsAuthorizationServer,
|
||||
};
|
||||
|
||||
await _tokenCache.StoreTokensAsync(tokens, cancellationToken).ConfigureAwait(false);
|
||||
|
|
@ -1458,33 +1470,93 @@ internal sealed partial class ClientOAuthProvider : McpHttpClient
|
|||
/// <summary>
|
||||
/// Restores the client registration persisted alongside cached tokens when this provider has not been
|
||||
/// assigned a client ID yet. This allows a durable <see cref="ITokenCache"/> to use a refresh token that
|
||||
/// survived a process restart without re-running dynamic client registration. An explicitly configured
|
||||
/// survived a process restart without re-running dynamic client registration. Credentials are restored
|
||||
/// only when the cache binds them to the currently selected authorization server. An explicitly configured
|
||||
/// client ID always takes precedence, so nothing is restored when one is already available.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Callers must hold <c>_tokenAcquisitionLock</c>: this writes the shared <c>_clientId</c>,
|
||||
/// <c>_clientSecret</c>, and <c>_tokenEndpointAuthMethod</c> fields, which the lock serializes.
|
||||
/// Like the persisted refresh token, the restored client ID assumes the durable cache belongs to the
|
||||
/// current authorization server; a single cache shared across authorization servers is not supported
|
||||
/// (an inherent property of the single-container <see cref="ITokenCache"/> design).
|
||||
/// A single cache still stores only one registration, but the persisted authorization-server issuer
|
||||
/// prevents that registration from being reused with a different server.
|
||||
/// </remarks>
|
||||
private void RestoreCachedClientCredentials(TokenContainer? tokens)
|
||||
private void RestoreCachedClientCredentials(TokenContainer? tokens, Uri selectedAuthServer)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(_clientId) || string.IsNullOrEmpty(tokens?.ClientId))
|
||||
if (tokens is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// The guard above guarantees a non-null container, but the older nullable flow analysis on
|
||||
// netstandard2.0/net472 doesn't infer that from the null-conditional check, so capture a
|
||||
// non-null local and use it for every assignment.
|
||||
// The guard above guarantees a non-null container, but older nullable flow analysis on
|
||||
// netstandard2.0/net472 may not preserve that narrowing, so capture a non-null local.
|
||||
var cached = tokens!;
|
||||
|
||||
if (_configuredClientId is not null)
|
||||
{
|
||||
if (!string.Equals(cached.ClientId, _configuredClientId, StringComparison.Ordinal))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Restore the issuer binding independently from the secret. A rotated configured secret
|
||||
// must not make credentials previously bound to another issuer appear portable.
|
||||
_clientCredentialsAuthorizationServer = cached.AuthorizationServer;
|
||||
|
||||
if (string.Equals(cached.AuthorizationServer, selectedAuthServer.OriginalString, StringComparison.Ordinal) &&
|
||||
string.Equals(cached.ClientSecret, _clientSecret, StringComparison.Ordinal))
|
||||
{
|
||||
_tokenEndpointAuthMethod ??= cached.TokenEndpointAuthMethod;
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(_clientId))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(cached.ClientId) ||
|
||||
!string.Equals(cached.AuthorizationServer, selectedAuthServer.OriginalString, StringComparison.Ordinal))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Assign _clientId last. Callers treat a non-empty _clientId as "registration complete", so the
|
||||
// secret and auth method must already be in place before _clientId becomes observable.
|
||||
_clientSecret ??= cached.ClientSecret;
|
||||
_tokenEndpointAuthMethod ??= cached.TokenEndpointAuthMethod;
|
||||
_clientId = cached.ClientId;
|
||||
_clientCredentialsAuthorizationServer = cached.AuthorizationServer;
|
||||
}
|
||||
|
||||
private bool CachedTokensMatchClientCredentials(TokenContainer? tokens, string? authorizationServer) =>
|
||||
tokens is not null &&
|
||||
string.Equals(tokens.AuthorizationServer, authorizationServer, StringComparison.Ordinal) &&
|
||||
string.Equals(tokens.ClientId, _clientId, StringComparison.Ordinal) &&
|
||||
string.Equals(tokens.ClientSecret, _clientSecret, StringComparison.Ordinal) &&
|
||||
string.Equals(tokens.TokenEndpointAuthMethod, _tokenEndpointAuthMethod, StringComparison.Ordinal);
|
||||
|
||||
private void BindClientCredentialsToAuthorizationServer(Uri selectedAuthServer)
|
||||
{
|
||||
if (_clientCredentialsAuthorizationServer is null ||
|
||||
string.Equals(_clientCredentialsAuthorizationServer, selectedAuthServer.OriginalString, StringComparison.Ordinal))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (_configuredClientId is not null)
|
||||
{
|
||||
ThrowFailedToHandleUnauthorizedResponse(
|
||||
$"The authorization server changed from '{_clientCredentialsAuthorizationServer}' to '{selectedAuthServer.OriginalString}', " +
|
||||
"but explicitly configured client credentials cannot be assumed to be valid for the new authorization server.");
|
||||
}
|
||||
|
||||
_clientId = null;
|
||||
_clientSecret = null;
|
||||
_tokenEndpointAuthMethod = null;
|
||||
_authServerMetadata = null;
|
||||
_clientCredentialsAuthorizationServer = null;
|
||||
}
|
||||
|
||||
[DoesNotReturn]
|
||||
|
|
|
|||
|
|
@ -25,6 +25,11 @@ internal sealed class ExchangeJwtBearerGrantOptions
|
|||
/// </summary>
|
||||
public string? ClientSecret { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the token endpoint authentication method.
|
||||
/// </summary>
|
||||
public string? TokenEndpointAuthMethod { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the scopes to request (space-separated). Optional.
|
||||
/// </summary>
|
||||
|
|
|
|||
|
|
@ -164,7 +164,18 @@ internal static class IdentityAssertionGrant
|
|||
["client_id"] = options.ClientId,
|
||||
};
|
||||
|
||||
if (!string.IsNullOrEmpty(options.ClientSecret))
|
||||
using var httpRequest = new HttpRequestMessage(HttpMethod.Post, options.TokenEndpoint);
|
||||
|
||||
if (string.Equals(options.TokenEndpointAuthMethod, "client_secret_basic", StringComparison.Ordinal))
|
||||
{
|
||||
formData.Remove("client_id");
|
||||
var credentials = $"{Uri.EscapeDataString(options.ClientId)}:{Uri.EscapeDataString(options.ClientSecret ?? string.Empty)}";
|
||||
httpRequest.Headers.Authorization = new AuthenticationHeaderValue(
|
||||
"Basic",
|
||||
Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(credentials)));
|
||||
}
|
||||
else if (string.Equals(options.TokenEndpointAuthMethod, "client_secret_post", StringComparison.Ordinal) &&
|
||||
!string.IsNullOrEmpty(options.ClientSecret))
|
||||
{
|
||||
formData["client_secret"] = options.ClientSecret!;
|
||||
}
|
||||
|
|
@ -174,12 +185,7 @@ internal static class IdentityAssertionGrant
|
|||
formData["scope"] = options.Scope!;
|
||||
}
|
||||
|
||||
using var requestContent = new FormUrlEncodedContent(formData);
|
||||
using var httpRequest = new HttpRequestMessage(HttpMethod.Post, options.TokenEndpoint)
|
||||
{
|
||||
Content = requestContent
|
||||
};
|
||||
|
||||
httpRequest.Content = new FormUrlEncodedContent(formData);
|
||||
httpRequest.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
|
||||
|
||||
using var httpResponse = await httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
|
||||
|
|
|
|||
|
|
@ -102,6 +102,22 @@ public sealed class IdentityAssertionGrantProvider
|
|||
throw new ArgumentNullException($"{nameof(options)}.{nameof(options.IdTokenCallback)}");
|
||||
}
|
||||
|
||||
if (options.TokenEndpointAuthMethod is not null &&
|
||||
options.TokenEndpointAuthMethod is not ("client_secret_basic" or "client_secret_post" or "none"))
|
||||
{
|
||||
throw new ArgumentException(
|
||||
$"{nameof(options.TokenEndpointAuthMethod)} must be 'client_secret_basic', 'client_secret_post', or 'none'.",
|
||||
$"{nameof(options)}.{nameof(options.TokenEndpointAuthMethod)}");
|
||||
}
|
||||
|
||||
if (options.TokenEndpointAuthMethod is "client_secret_basic" or "client_secret_post" &&
|
||||
string.IsNullOrEmpty(options.ClientSecret))
|
||||
{
|
||||
throw new ArgumentException(
|
||||
$"{nameof(options.ClientSecret)} is required when {nameof(options.TokenEndpointAuthMethod)} is '{options.TokenEndpointAuthMethod}'.",
|
||||
$"{nameof(options)}.{nameof(options.ClientSecret)}");
|
||||
}
|
||||
|
||||
_options = options;
|
||||
_httpClient = httpClient;
|
||||
_logger = (ILogger?)loggerFactory?.CreateLogger<IdentityAssertionGrantProvider>() ?? NullLogger.Instance;
|
||||
|
|
@ -197,6 +213,7 @@ public sealed class IdentityAssertionGrantProvider
|
|||
Assertion = jag,
|
||||
ClientId = _options.ClientId,
|
||||
ClientSecret = _options.ClientSecret,
|
||||
TokenEndpointAuthMethod = SelectTokenEndpointAuthMethod(mcpAuthMetadata),
|
||||
Scope = _options.Scope,
|
||||
}, _httpClient, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
|
|
@ -206,6 +223,50 @@ public sealed class IdentityAssertionGrantProvider
|
|||
return tokens;
|
||||
}
|
||||
|
||||
private string SelectTokenEndpointAuthMethod(AuthorizationServerMetadata metadata)
|
||||
{
|
||||
var supportedMethods = metadata.TokenEndpointAuthMethodsSupported;
|
||||
if (_options.TokenEndpointAuthMethod is { } configuredMethod)
|
||||
{
|
||||
if (supportedMethods is { Count: > 0 } &&
|
||||
!supportedMethods.Contains(configuredMethod, StringComparer.Ordinal))
|
||||
{
|
||||
throw new IdentityAssertionGrantException(
|
||||
$"The configured token endpoint authentication method '{configuredMethod}' is not advertised by the MCP authorization server.");
|
||||
}
|
||||
|
||||
return configuredMethod;
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(_options.ClientSecret))
|
||||
{
|
||||
if (supportedMethods is null or { Count: 0 } ||
|
||||
supportedMethods.Contains("none", StringComparer.Ordinal))
|
||||
{
|
||||
return "none";
|
||||
}
|
||||
|
||||
throw new IdentityAssertionGrantException(
|
||||
"The MCP authorization server does not advertise a token endpoint authentication method usable without a client secret.");
|
||||
}
|
||||
|
||||
// Preserve the provider's existing client_secret_post behavior when it is available.
|
||||
// RFC 8414 defines this metadata as a list of supported methods, not a preference order.
|
||||
if (supportedMethods is null or { Count: 0 } ||
|
||||
supportedMethods.Contains("client_secret_post", StringComparer.Ordinal))
|
||||
{
|
||||
return "client_secret_post";
|
||||
}
|
||||
|
||||
if (supportedMethods.Contains("client_secret_basic", StringComparer.Ordinal))
|
||||
{
|
||||
return "client_secret_basic";
|
||||
}
|
||||
|
||||
throw new IdentityAssertionGrantException(
|
||||
"The MCP authorization server does not advertise a supported token endpoint authentication method.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clears any cached tokens, forcing a fresh token exchange on the next call to <see cref="GetAccessTokenAsync"/>.
|
||||
/// </summary>
|
||||
|
|
|
|||
|
|
@ -16,6 +16,17 @@ public sealed class IdentityAssertionGrantProviderOptions
|
|||
/// </summary>
|
||||
public string? ClientSecret { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the authentication method used at the MCP authorization server's token endpoint.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Supported values are <c>client_secret_basic</c>, <c>client_secret_post</c>, and <c>none</c>.
|
||||
/// Set this to the method assigned to the pre-registered MCP client. When omitted, the provider
|
||||
/// preserves its existing <c>client_secret_post</c> behavior when supported, then falls back to
|
||||
/// another compatible method advertised by the authorization server.
|
||||
/// </remarks>
|
||||
public string? TokenEndpointAuthMethod { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the scopes to request from the MCP authorization server (space-separated). Optional.
|
||||
/// </summary>
|
||||
|
|
|
|||
|
|
@ -76,5 +76,15 @@ public sealed class TokenContainer
|
|||
/// </remarks>
|
||||
public string? TokenEndpointAuthMethod { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the authorization server issuer that issued the client credentials and tokens.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// OAuth client credentials are bound to an authorization server and must not be reused with a
|
||||
/// different issuer. Durable cache implementations should persist this value alongside the token
|
||||
/// and client registration so restored credentials can be validated before use.
|
||||
/// </remarks>
|
||||
public string? AuthorizationServer { get; set; }
|
||||
|
||||
internal bool IsExpired => ExpiresIn is not null && DateTimeOffset.UtcNow >= ObtainedAt.AddSeconds(ExpiresIn.Value);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -63,9 +63,12 @@ public class ClientConformanceTests
|
|||
[InlineData("auth/2025-03-26-oauth-metadata-backcompat")]
|
||||
[InlineData("auth/2025-03-26-oauth-endpoint-fallback")]
|
||||
|
||||
// Extensions: Require ES256 JWT signing (private_key_jwt) and client_credentials grant support.
|
||||
// [InlineData("auth/client-credentials-jwt")]
|
||||
// [InlineData("auth/client-credentials-basic")]
|
||||
[InlineData("auth/authorization-server-migration")]
|
||||
[InlineData("auth/client-credentials-jwt")]
|
||||
[InlineData("auth/client-credentials-basic")]
|
||||
[InlineData("auth/enterprise-managed-authorization")]
|
||||
[InlineData("sep-2322-client-request-state")]
|
||||
[InlineData("json-schema-ref-no-deref")]
|
||||
|
||||
public async Task RunConformanceTest(string scenario)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
using ModelContextProtocol.Authentication;
|
||||
using ModelContextProtocol.Client;
|
||||
using ModelContextProtocol.Protocol;
|
||||
|
||||
namespace ModelContextProtocol.AspNetCore.Tests.OAuth;
|
||||
|
||||
|
|
@ -187,6 +188,182 @@ public class TokenCacheTests : OAuthTestBase
|
|||
Assert.NotEqual("invalid-token", tokenCache.LastStoredToken.AccessToken);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetTokenAsync_ExplicitClientDoesNotRefreshTokenFromDifferentAuthorizationServer()
|
||||
{
|
||||
await using var app = await StartMcpServerAsync();
|
||||
|
||||
var tokenCache = new TestTokenCache();
|
||||
await using var setupTransport = new HttpClientTransport(new()
|
||||
{
|
||||
Endpoint = new(McpServerUrl),
|
||||
OAuth = new()
|
||||
{
|
||||
ClientId = "demo-client",
|
||||
ClientSecret = "demo-secret",
|
||||
RedirectUri = new Uri("http://localhost:1179/callback"),
|
||||
AuthorizationCallbackHandler = HandleAuthorizationUrlAsync,
|
||||
TokenCache = tokenCache,
|
||||
},
|
||||
}, HttpClient, LoggerFactory);
|
||||
|
||||
await using (var setupClient = await McpClient.CreateAsync(
|
||||
setupTransport,
|
||||
loggerFactory: LoggerFactory,
|
||||
cancellationToken: TestContext.Current.CancellationToken))
|
||||
{
|
||||
}
|
||||
|
||||
Assert.NotNull(tokenCache.LastStoredToken);
|
||||
tokenCache.LastStoredToken.AccessToken = "invalid-token";
|
||||
tokenCache.LastStoredToken.AuthorizationServer = "https://different-authorization-server.example.com";
|
||||
var authorizationCallbackCalled = false;
|
||||
|
||||
await using var transport = new HttpClientTransport(new()
|
||||
{
|
||||
Endpoint = new(McpServerUrl),
|
||||
OAuth = new()
|
||||
{
|
||||
ClientId = "demo-client",
|
||||
ClientSecret = "demo-secret",
|
||||
RedirectUri = new Uri("http://localhost:1179/callback"),
|
||||
AuthorizationCallbackHandler = (context, ct) =>
|
||||
{
|
||||
authorizationCallbackCalled = true;
|
||||
return HandleAuthorizationUrlAsync(context, ct);
|
||||
},
|
||||
TokenCache = tokenCache,
|
||||
},
|
||||
}, HttpClient, LoggerFactory);
|
||||
|
||||
var exception = await Assert.ThrowsAsync<McpException>(() => McpClient.CreateAsync(
|
||||
transport,
|
||||
loggerFactory: LoggerFactory,
|
||||
cancellationToken: TestContext.Current.CancellationToken));
|
||||
|
||||
Assert.Contains("explicitly configured client credentials", exception.Message);
|
||||
Assert.False(authorizationCallbackCalled);
|
||||
Assert.False(TestOAuthServer.HasRefreshedToken);
|
||||
Assert.Equal("https://different-authorization-server.example.com", tokenCache.LastStoredToken.AuthorizationServer);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetTokenAsync_ExplicitClientDoesNotRefreshTokenIssuedToDifferentClient()
|
||||
{
|
||||
await using var app = await StartMcpServerAsync();
|
||||
|
||||
var tokenCache = new TestTokenCache();
|
||||
await using var setupTransport = new HttpClientTransport(new()
|
||||
{
|
||||
Endpoint = new(McpServerUrl),
|
||||
OAuth = new()
|
||||
{
|
||||
ClientId = "demo-client",
|
||||
ClientSecret = "demo-secret",
|
||||
RedirectUri = new Uri("http://localhost:1179/callback"),
|
||||
AuthorizationCallbackHandler = HandleAuthorizationUrlAsync,
|
||||
TokenCache = tokenCache,
|
||||
},
|
||||
}, HttpClient, LoggerFactory);
|
||||
|
||||
await using (var setupClient = await McpClient.CreateAsync(
|
||||
setupTransport,
|
||||
loggerFactory: LoggerFactory,
|
||||
cancellationToken: TestContext.Current.CancellationToken))
|
||||
{
|
||||
}
|
||||
|
||||
Assert.NotNull(tokenCache.LastStoredToken);
|
||||
tokenCache.LastStoredToken.AccessToken = "invalid-token";
|
||||
tokenCache.LastStoredToken.ClientId = "different-client";
|
||||
var authorizationCallbackCalled = false;
|
||||
|
||||
await using var transport = new HttpClientTransport(new()
|
||||
{
|
||||
Endpoint = new(McpServerUrl),
|
||||
OAuth = new()
|
||||
{
|
||||
ClientId = "demo-client",
|
||||
ClientSecret = "demo-secret",
|
||||
RedirectUri = new Uri("http://localhost:1179/callback"),
|
||||
AuthorizationCallbackHandler = (context, ct) =>
|
||||
{
|
||||
authorizationCallbackCalled = true;
|
||||
return HandleAuthorizationUrlAsync(context, ct);
|
||||
},
|
||||
TokenCache = tokenCache,
|
||||
},
|
||||
}, HttpClient, LoggerFactory);
|
||||
|
||||
await using var client = await McpClient.CreateAsync(
|
||||
transport,
|
||||
loggerFactory: LoggerFactory,
|
||||
cancellationToken: TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.True(authorizationCallbackCalled);
|
||||
Assert.False(TestOAuthServer.HasRefreshedToken);
|
||||
Assert.Equal("demo-client", tokenCache.LastStoredToken.ClientId);
|
||||
Assert.Equal(OAuthServerUrl, tokenCache.LastStoredToken.AuthorizationServer);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetTokenAsync_ExplicitClientWithLegacyUnboundCache_Reauthorizes()
|
||||
{
|
||||
await using var app = await StartMcpServerAsync();
|
||||
|
||||
var tokenCache = new TestTokenCache();
|
||||
await using var setupTransport = new HttpClientTransport(new()
|
||||
{
|
||||
Endpoint = new(McpServerUrl),
|
||||
OAuth = new()
|
||||
{
|
||||
ClientId = "demo-client",
|
||||
ClientSecret = "demo-secret",
|
||||
RedirectUri = new Uri("http://localhost:1179/callback"),
|
||||
AuthorizationCallbackHandler = HandleAuthorizationUrlAsync,
|
||||
TokenCache = tokenCache,
|
||||
},
|
||||
}, HttpClient, LoggerFactory);
|
||||
|
||||
await using (var setupClient = await McpClient.CreateAsync(
|
||||
setupTransport,
|
||||
loggerFactory: LoggerFactory,
|
||||
cancellationToken: TestContext.Current.CancellationToken))
|
||||
{
|
||||
}
|
||||
|
||||
Assert.NotNull(tokenCache.LastStoredToken);
|
||||
tokenCache.LastStoredToken.AccessToken = "invalid-token";
|
||||
tokenCache.LastStoredToken.AuthorizationServer = null;
|
||||
var authorizationCallbackCalled = false;
|
||||
|
||||
await using var transport = new HttpClientTransport(new()
|
||||
{
|
||||
Endpoint = new(McpServerUrl),
|
||||
OAuth = new()
|
||||
{
|
||||
ClientId = "demo-client",
|
||||
ClientSecret = "demo-secret",
|
||||
RedirectUri = new Uri("http://localhost:1179/callback"),
|
||||
AuthorizationCallbackHandler = (context, ct) =>
|
||||
{
|
||||
authorizationCallbackCalled = true;
|
||||
return HandleAuthorizationUrlAsync(context, ct);
|
||||
},
|
||||
TokenCache = tokenCache,
|
||||
},
|
||||
}, HttpClient, LoggerFactory);
|
||||
|
||||
await using var client = await McpClient.CreateAsync(
|
||||
transport,
|
||||
loggerFactory: LoggerFactory,
|
||||
cancellationToken: TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.True(authorizationCallbackCalled);
|
||||
Assert.False(TestOAuthServer.HasRefreshedToken);
|
||||
Assert.Equal(OAuthServerUrl, tokenCache.LastStoredToken.AuthorizationServer);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetTokenAsync_ColdStartWithDynamicRegistration_RefreshesUsingPersistedCredentials()
|
||||
{
|
||||
|
|
@ -227,6 +404,7 @@ public class TokenCacheTests : OAuthTestBase
|
|||
Assert.False(
|
||||
string.IsNullOrEmpty(tokenCache.LastStoredToken.ClientId),
|
||||
"The dynamically registered client ID should be persisted alongside the tokens");
|
||||
Assert.Equal(OAuthServerUrl, tokenCache.LastStoredToken.AuthorizationServer);
|
||||
|
||||
// Simulate a cold start: the access token is no longer valid, but the refresh token persists.
|
||||
// The new provider has no client ID configured and must restore it from the cache to refresh
|
||||
|
|
@ -261,6 +439,119 @@ public class TokenCacheTests : OAuthTestBase
|
|||
Assert.NotEqual("invalid-token", tokenCache.LastStoredToken.AccessToken);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetTokenAsync_ColdStartWithCredentialsFromDifferentAuthorizationServer_Reregisters()
|
||||
{
|
||||
await using var app = await StartMcpServerAsync();
|
||||
|
||||
var tokenCache = new TestTokenCache();
|
||||
await using var setupTransport = new HttpClientTransport(new()
|
||||
{
|
||||
Endpoint = new(McpServerUrl),
|
||||
OAuth = new()
|
||||
{
|
||||
RedirectUri = new Uri("http://localhost:1179/callback"),
|
||||
DynamicClientRegistration = new() { ClientName = "Test MCP Client" },
|
||||
AuthorizationCallbackHandler = HandleAuthorizationUrlAsync,
|
||||
TokenCache = tokenCache,
|
||||
},
|
||||
}, HttpClient, LoggerFactory);
|
||||
|
||||
await using (var setupClient = await McpClient.CreateAsync(
|
||||
setupTransport,
|
||||
loggerFactory: LoggerFactory,
|
||||
cancellationToken: TestContext.Current.CancellationToken))
|
||||
{
|
||||
}
|
||||
|
||||
Assert.NotNull(tokenCache.LastStoredToken);
|
||||
tokenCache.LastStoredToken.AccessToken = "invalid-token";
|
||||
tokenCache.LastStoredToken.AuthorizationServer = "https://different-authorization-server.example.com";
|
||||
var authorizationCallbackCalled = false;
|
||||
|
||||
await using var transport = new HttpClientTransport(new()
|
||||
{
|
||||
Endpoint = new(McpServerUrl),
|
||||
OAuth = new()
|
||||
{
|
||||
RedirectUri = new Uri("http://localhost:1179/callback"),
|
||||
DynamicClientRegistration = new() { ClientName = "Test MCP Client" },
|
||||
AuthorizationCallbackHandler = (context, ct) =>
|
||||
{
|
||||
authorizationCallbackCalled = true;
|
||||
return HandleAuthorizationUrlAsync(context, ct);
|
||||
},
|
||||
TokenCache = tokenCache,
|
||||
},
|
||||
}, HttpClient, LoggerFactory);
|
||||
|
||||
await using var client = await McpClient.CreateAsync(
|
||||
transport,
|
||||
loggerFactory: LoggerFactory,
|
||||
cancellationToken: TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.True(authorizationCallbackCalled);
|
||||
Assert.False(TestOAuthServer.HasRefreshedToken);
|
||||
Assert.Equal(OAuthServerUrl, tokenCache.LastStoredToken.AuthorizationServer);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetTokenAsync_ColdStartWithLegacyUnboundCredentials_Reregisters()
|
||||
{
|
||||
await using var app = await StartMcpServerAsync();
|
||||
|
||||
var tokenCache = new TestTokenCache();
|
||||
await using var setupTransport = new HttpClientTransport(new()
|
||||
{
|
||||
Endpoint = new(McpServerUrl),
|
||||
OAuth = new()
|
||||
{
|
||||
RedirectUri = new Uri("http://localhost:1179/callback"),
|
||||
DynamicClientRegistration = new() { ClientName = "Test MCP Client" },
|
||||
AuthorizationCallbackHandler = HandleAuthorizationUrlAsync,
|
||||
TokenCache = tokenCache,
|
||||
},
|
||||
}, HttpClient, LoggerFactory);
|
||||
|
||||
await using (var setupClient = await McpClient.CreateAsync(
|
||||
setupTransport,
|
||||
loggerFactory: LoggerFactory,
|
||||
cancellationToken: TestContext.Current.CancellationToken))
|
||||
{
|
||||
}
|
||||
|
||||
Assert.NotNull(tokenCache.LastStoredToken);
|
||||
Assert.False(string.IsNullOrEmpty(tokenCache.LastStoredToken.ClientId));
|
||||
tokenCache.LastStoredToken.AccessToken = "invalid-token";
|
||||
tokenCache.LastStoredToken.AuthorizationServer = null;
|
||||
var authorizationCallbackCalled = false;
|
||||
|
||||
await using var transport = new HttpClientTransport(new()
|
||||
{
|
||||
Endpoint = new(McpServerUrl),
|
||||
OAuth = new()
|
||||
{
|
||||
RedirectUri = new Uri("http://localhost:1179/callback"),
|
||||
DynamicClientRegistration = new() { ClientName = "Test MCP Client" },
|
||||
AuthorizationCallbackHandler = (context, ct) =>
|
||||
{
|
||||
authorizationCallbackCalled = true;
|
||||
return HandleAuthorizationUrlAsync(context, ct);
|
||||
},
|
||||
TokenCache = tokenCache,
|
||||
},
|
||||
}, HttpClient, LoggerFactory);
|
||||
|
||||
await using var client = await McpClient.CreateAsync(
|
||||
transport,
|
||||
loggerFactory: LoggerFactory,
|
||||
cancellationToken: TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.True(authorizationCallbackCalled);
|
||||
Assert.False(TestOAuthServer.HasRefreshedToken);
|
||||
Assert.Equal(OAuthServerUrl, tokenCache.LastStoredToken.AuthorizationServer);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetTokenAsync_ColdStartWithoutPersistedClientId_FallsBackToReauthorization()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -0,0 +1,298 @@
|
|||
using System.Net.Http.Headers;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using ModelContextProtocol.Authentication;
|
||||
|
||||
internal sealed class ConformanceContext
|
||||
{
|
||||
private readonly JsonElement _root;
|
||||
|
||||
private ConformanceContext(JsonElement root)
|
||||
{
|
||||
_root = root;
|
||||
}
|
||||
|
||||
public static ConformanceContext? Parse(string? json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
using var document = JsonDocument.Parse(json);
|
||||
return new ConformanceContext(document.RootElement.Clone());
|
||||
}
|
||||
|
||||
public string GetRequiredString(string propertyName)
|
||||
{
|
||||
if (!_root.TryGetProperty(propertyName, out var value) ||
|
||||
value.ValueKind != JsonValueKind.String ||
|
||||
string.IsNullOrEmpty(value.GetString()))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"MCP_CONFORMANCE_CONTEXT is missing required string property '{propertyName}'.");
|
||||
}
|
||||
|
||||
return value.GetString()!;
|
||||
}
|
||||
|
||||
public string? GetString(string propertyName) =>
|
||||
_root.TryGetProperty(propertyName, out var value) && value.ValueKind == JsonValueKind.String
|
||||
? value.GetString()
|
||||
: null;
|
||||
}
|
||||
|
||||
internal static class ConformanceOAuthHelpers
|
||||
{
|
||||
private const string ClientAssertionType = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer";
|
||||
|
||||
public static async Task<string> AcquireClientCredentialsTokenAsync(
|
||||
Uri resourceUri,
|
||||
ConformanceContext context,
|
||||
bool usePrivateKeyJwt,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
using var httpClient = new HttpClient();
|
||||
var discovery = await DiscoverAsync(httpClient, resourceUri, cancellationToken).ConfigureAwait(false);
|
||||
var clientId = context.GetRequiredString("client_id");
|
||||
|
||||
Dictionary<string, string> formFields = new()
|
||||
{
|
||||
["grant_type"] = "client_credentials",
|
||||
["resource"] = discovery.Resource,
|
||||
};
|
||||
|
||||
if (discovery.Scopes.Count > 0)
|
||||
{
|
||||
formFields["scope"] = string.Join(" ", discovery.Scopes);
|
||||
}
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, discovery.TokenEndpoint);
|
||||
if (usePrivateKeyJwt)
|
||||
{
|
||||
var algorithm = context.GetString("signing_algorithm") ?? "ES256";
|
||||
if (!string.Equals(algorithm, "ES256", StringComparison.Ordinal))
|
||||
{
|
||||
throw new InvalidOperationException($"Unsupported client assertion signing algorithm '{algorithm}'.");
|
||||
}
|
||||
|
||||
formFields["client_assertion_type"] = ClientAssertionType;
|
||||
formFields["client_assertion"] = CreateEs256ClientAssertion(
|
||||
clientId,
|
||||
discovery.AuthorizationServer,
|
||||
context.GetRequiredString("private_key_pem"));
|
||||
}
|
||||
else
|
||||
{
|
||||
var credentials = $"{Uri.EscapeDataString(clientId)}:{Uri.EscapeDataString(context.GetRequiredString("client_secret"))}";
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue(
|
||||
"Basic",
|
||||
Convert.ToBase64String(Encoding.UTF8.GetBytes(credentials)));
|
||||
}
|
||||
|
||||
request.Content = new FormUrlEncodedContent(formFields);
|
||||
using var response = await httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
return await ReadAccessTokenAsync(response, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public static async Task<string> AcquireEnterpriseTokenAsync(
|
||||
Uri resourceUri,
|
||||
ConformanceContext context,
|
||||
ILoggerFactory loggerFactory,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
using var httpClient = new HttpClient();
|
||||
var discovery = await DiscoverAsync(httpClient, resourceUri, cancellationToken).ConfigureAwait(false);
|
||||
var provider = new IdentityAssertionGrantProvider(
|
||||
new IdentityAssertionGrantProviderOptions
|
||||
{
|
||||
ClientId = context.GetRequiredString("client_id"),
|
||||
ClientSecret = context.GetRequiredString("client_secret"),
|
||||
TokenEndpointAuthMethod = "client_secret_basic",
|
||||
IdpClientId = context.GetRequiredString("idp_client_id"),
|
||||
IdpTokenEndpoint = context.GetRequiredString("idp_token_endpoint"),
|
||||
IdTokenCallback = (_, _) => Task.FromResult(context.GetRequiredString("idp_id_token")),
|
||||
},
|
||||
httpClient,
|
||||
loggerFactory);
|
||||
|
||||
var tokens = await provider.GetAccessTokenAsync(
|
||||
resourceUri,
|
||||
new Uri(discovery.AuthorizationServer),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
return tokens.AccessToken;
|
||||
}
|
||||
|
||||
public static HttpClient CreateBearerHttpClient(string accessToken)
|
||||
{
|
||||
var httpClient = new HttpClient();
|
||||
httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
|
||||
return httpClient;
|
||||
}
|
||||
|
||||
private static async Task<OAuthDiscovery> DiscoverAsync(
|
||||
HttpClient httpClient,
|
||||
Uri resourceUri,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
JsonElement? protectedResourceMetadata = null;
|
||||
foreach (var metadataUri in GetProtectedResourceMetadataUris(resourceUri))
|
||||
{
|
||||
using var response = await httpClient.GetAsync(metadataUri, cancellationToken).ConfigureAwait(false);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
protectedResourceMetadata = await ReadJsonAsync(response, cancellationToken).ConfigureAwait(false);
|
||||
break;
|
||||
}
|
||||
|
||||
if (protectedResourceMetadata is not { } prm ||
|
||||
!prm.TryGetProperty("authorization_servers", out var authorizationServers) ||
|
||||
authorizationServers.ValueKind != JsonValueKind.Array ||
|
||||
authorizationServers.GetArrayLength() == 0)
|
||||
{
|
||||
throw new InvalidOperationException($"No authorization server was discovered for MCP resource '{resourceUri}'.");
|
||||
}
|
||||
|
||||
var authorizationServer = authorizationServers[0].GetString()
|
||||
?? throw new InvalidOperationException("The discovered authorization server URI was null.");
|
||||
var resource = prm.TryGetProperty("resource", out var resourceProperty)
|
||||
? resourceProperty.GetString() ?? resourceUri.ToString()
|
||||
: resourceUri.ToString();
|
||||
List<string> scopes = [];
|
||||
if (prm.TryGetProperty("scopes_supported", out var scopesProperty) &&
|
||||
scopesProperty.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var scope in scopesProperty.EnumerateArray())
|
||||
{
|
||||
if (scope.GetString() is { Length: > 0 } value)
|
||||
{
|
||||
scopes.Add(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var authorizationServerUri = new Uri(authorizationServer);
|
||||
foreach (var metadataUri in GetAuthorizationServerMetadataUris(authorizationServerUri))
|
||||
{
|
||||
using var response = await httpClient.GetAsync(metadataUri, cancellationToken).ConfigureAwait(false);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var metadata = await ReadJsonAsync(response, cancellationToken).ConfigureAwait(false);
|
||||
if (metadata.TryGetProperty("token_endpoint", out var tokenEndpointProperty) &&
|
||||
tokenEndpointProperty.GetString() is { Length: > 0 } tokenEndpoint)
|
||||
{
|
||||
var issuer = metadata.TryGetProperty("issuer", out var issuerProperty)
|
||||
? issuerProperty.GetString() ?? authorizationServer
|
||||
: authorizationServer;
|
||||
return new OAuthDiscovery(resource, issuer, new Uri(tokenEndpoint), scopes);
|
||||
}
|
||||
}
|
||||
|
||||
throw new InvalidOperationException(
|
||||
$"No authorization server metadata with a token endpoint was discovered for '{authorizationServer}'.");
|
||||
}
|
||||
|
||||
private static IEnumerable<Uri> GetProtectedResourceMetadataUris(Uri resourceUri)
|
||||
{
|
||||
var authority = resourceUri.GetLeftPart(UriPartial.Authority);
|
||||
var path = resourceUri.AbsolutePath.Trim('/');
|
||||
if (path.Length > 0)
|
||||
{
|
||||
yield return new Uri($"{authority}/.well-known/oauth-protected-resource/{path}");
|
||||
}
|
||||
yield return new Uri($"{authority}/.well-known/oauth-protected-resource");
|
||||
}
|
||||
|
||||
private static IEnumerable<Uri> GetAuthorizationServerMetadataUris(Uri authorizationServer)
|
||||
{
|
||||
var authority = authorizationServer.GetLeftPart(UriPartial.Authority);
|
||||
var path = authorizationServer.AbsolutePath.Trim('/');
|
||||
if (path.Length == 0)
|
||||
{
|
||||
yield return new Uri($"{authority}/.well-known/oauth-authorization-server");
|
||||
yield return new Uri($"{authority}/.well-known/openid-configuration");
|
||||
}
|
||||
else
|
||||
{
|
||||
yield return new Uri($"{authority}/.well-known/oauth-authorization-server/{path}");
|
||||
yield return new Uri($"{authority}/.well-known/openid-configuration/{path}");
|
||||
yield return new Uri($"{authority}/{path}/.well-known/openid-configuration");
|
||||
}
|
||||
}
|
||||
|
||||
private static string CreateEs256ClientAssertion(string clientId, string audience, string privateKeyPem)
|
||||
{
|
||||
var header = Base64UrlEncode(Encoding.UTF8.GetBytes("""{"alg":"ES256","typ":"JWT"}"""));
|
||||
var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
|
||||
|
||||
using var payloadStream = new MemoryStream();
|
||||
using (var writer = new Utf8JsonWriter(payloadStream))
|
||||
{
|
||||
writer.WriteStartObject();
|
||||
writer.WriteString("iss", clientId);
|
||||
writer.WriteString("sub", clientId);
|
||||
writer.WriteString("aud", audience);
|
||||
writer.WriteNumber("iat", now);
|
||||
writer.WriteNumber("exp", now + 300);
|
||||
writer.WriteString("jti", Guid.NewGuid());
|
||||
writer.WriteEndObject();
|
||||
}
|
||||
|
||||
var payload = Base64UrlEncode(payloadStream.ToArray());
|
||||
var signingInput = Encoding.ASCII.GetBytes($"{header}.{payload}");
|
||||
using var key = ECDsa.Create();
|
||||
key.ImportFromPem(privateKeyPem);
|
||||
var signature = key.SignData(
|
||||
signingInput,
|
||||
HashAlgorithmName.SHA256,
|
||||
DSASignatureFormat.IeeeP1363FixedFieldConcatenation);
|
||||
return $"{header}.{payload}.{Base64UrlEncode(signature)}";
|
||||
}
|
||||
|
||||
private static async Task<JsonElement> ReadJsonAsync(
|
||||
HttpResponseMessage response,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
|
||||
using var document = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
return document.RootElement.Clone();
|
||||
}
|
||||
|
||||
private static async Task<string> ReadAccessTokenAsync(
|
||||
HttpResponseMessage response,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
throw new HttpRequestException(
|
||||
$"Client credentials token request failed with status {(int)response.StatusCode}: {body}");
|
||||
}
|
||||
|
||||
using var document = JsonDocument.Parse(body);
|
||||
if (!document.RootElement.TryGetProperty("access_token", out var accessToken) ||
|
||||
accessToken.GetString() is not { Length: > 0 } value)
|
||||
{
|
||||
throw new InvalidOperationException("The token response did not contain an access_token.");
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
private static string Base64UrlEncode(byte[] bytes) =>
|
||||
Convert.ToBase64String(bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_');
|
||||
|
||||
private sealed record OAuthDiscovery(
|
||||
string Resource,
|
||||
string AuthorizationServer,
|
||||
Uri TokenEndpoint,
|
||||
IReadOnlyList<string> Scopes);
|
||||
}
|
||||
|
|
@ -40,10 +40,10 @@ McpClientOptions options = new()
|
|||
// The default client now prefers the 2026-07-28 protocol (probing with server/discover and
|
||||
// falling back to an initialize handshake). The "initialize" and "sse-retry" scenarios
|
||||
// specifically exercise the initialize handshake and SSE resumability (removed in the
|
||||
// 2026-07-28 protocol) and strictly expect initialize as the first message, so pin them to the
|
||||
// latest stable version. Other scenarios run on the 2026-07-28 default and exercise the
|
||||
// server/discover probe plus the transparent initialize-handshake fallback.
|
||||
if (scenario is "initialize" or "sse-retry")
|
||||
// 2026-07-28 protocol) and strictly expect initialize as the first message. The alpha.9
|
||||
// json-schema-ref-no-deref server also uses a bundled Node transport that rejects the draft
|
||||
// protocol header before tools/list. Pin these scenarios to the latest stable version.
|
||||
if (scenario is "initialize" or "sse-retry" or "json-schema-ref-no-deref")
|
||||
{
|
||||
options.ProtocolVersion = "2025-11-25";
|
||||
}
|
||||
|
|
@ -72,21 +72,10 @@ if (callbackPort == 0)
|
|||
var clientRedirectUri = new Uri($"http://localhost:{callbackPort}/callback");
|
||||
|
||||
// Read conformance context for scenarios that provide additional data (e.g., pre-registered credentials).
|
||||
string? preRegisteredClientId = null;
|
||||
string? preRegisteredClientSecret = null;
|
||||
var conformanceContext = Environment.GetEnvironmentVariable("MCP_CONFORMANCE_CONTEXT");
|
||||
if (!string.IsNullOrEmpty(conformanceContext))
|
||||
{
|
||||
using var doc = JsonDocument.Parse(conformanceContext);
|
||||
if (doc.RootElement.TryGetProperty("client_id", out var clientIdEl))
|
||||
{
|
||||
preRegisteredClientId = clientIdEl.GetString();
|
||||
}
|
||||
if (doc.RootElement.TryGetProperty("client_secret", out var clientSecretEl))
|
||||
{
|
||||
preRegisteredClientSecret = clientSecretEl.GetString();
|
||||
}
|
||||
}
|
||||
var parsedConformanceContext = ConformanceContext.Parse(conformanceContext);
|
||||
var preRegisteredClientId = parsedConformanceContext?.GetString("client_id");
|
||||
var preRegisteredClientSecret = parsedConformanceContext?.GetString("client_secret");
|
||||
|
||||
var oauthOptions = new ModelContextProtocol.Authentication.ClientOAuthOptions
|
||||
{
|
||||
|
|
@ -110,12 +99,55 @@ else
|
|||
};
|
||||
}
|
||||
|
||||
var clientTransport = new HttpClientTransport(new()
|
||||
var endpointUri = new Uri(endpoint);
|
||||
HttpClientTransport clientTransport;
|
||||
if (scenario is "auth/client-credentials-basic" or "auth/client-credentials-jwt")
|
||||
{
|
||||
Endpoint = new Uri(endpoint),
|
||||
var context = parsedConformanceContext
|
||||
?? throw new InvalidOperationException($"Scenario '{scenario}' requires MCP_CONFORMANCE_CONTEXT.");
|
||||
var accessToken = await ConformanceOAuthHelpers.AcquireClientCredentialsTokenAsync(
|
||||
endpointUri,
|
||||
context,
|
||||
usePrivateKeyJwt: scenario == "auth/client-credentials-jwt",
|
||||
CancellationToken.None);
|
||||
clientTransport = new HttpClientTransport(
|
||||
new()
|
||||
{
|
||||
Endpoint = endpointUri,
|
||||
TransportMode = HttpTransportMode.StreamableHttp,
|
||||
},
|
||||
ConformanceOAuthHelpers.CreateBearerHttpClient(accessToken),
|
||||
consoleLoggerFactory,
|
||||
ownsHttpClient: true);
|
||||
}
|
||||
else if (scenario == "auth/enterprise-managed-authorization")
|
||||
{
|
||||
var context = parsedConformanceContext
|
||||
?? throw new InvalidOperationException($"Scenario '{scenario}' requires MCP_CONFORMANCE_CONTEXT.");
|
||||
var accessToken = await ConformanceOAuthHelpers.AcquireEnterpriseTokenAsync(
|
||||
endpointUri,
|
||||
context,
|
||||
consoleLoggerFactory,
|
||||
CancellationToken.None);
|
||||
clientTransport = new HttpClientTransport(
|
||||
new()
|
||||
{
|
||||
Endpoint = endpointUri,
|
||||
TransportMode = HttpTransportMode.StreamableHttp,
|
||||
},
|
||||
ConformanceOAuthHelpers.CreateBearerHttpClient(accessToken),
|
||||
consoleLoggerFactory,
|
||||
ownsHttpClient: true);
|
||||
}
|
||||
else
|
||||
{
|
||||
clientTransport = new HttpClientTransport(new()
|
||||
{
|
||||
Endpoint = endpointUri,
|
||||
TransportMode = HttpTransportMode.StreamableHttp,
|
||||
OAuth = oauthOptions,
|
||||
}, loggerFactory: consoleLoggerFactory);
|
||||
}, loggerFactory: consoleLoggerFactory);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
|
|
@ -191,6 +223,19 @@ try
|
|||
}
|
||||
break;
|
||||
}
|
||||
case "auth/authorization-server-migration":
|
||||
{
|
||||
await mcpClient.ListToolsAsync();
|
||||
await mcpClient.ListToolsAsync();
|
||||
break;
|
||||
}
|
||||
case "auth/client-credentials-basic":
|
||||
case "auth/client-credentials-jwt":
|
||||
case "auth/enterprise-managed-authorization":
|
||||
{
|
||||
await mcpClient.ListToolsAsync();
|
||||
break;
|
||||
}
|
||||
case "http-standard-headers":
|
||||
{
|
||||
// List and call tools to test Mcp-Method and Mcp-Name headers
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ public sealed class IdentityAssertionGrantTests : IDisposable
|
|||
["issuer"] = "https://auth.mcp-server.example.com",
|
||||
["authorization_endpoint"] = "https://auth.mcp-server.example.com/authorize",
|
||||
["token_endpoint"] = "https://auth.mcp-server.example.com/token",
|
||||
["token_endpoint_auth_methods_supported"] = new JsonArray("client_secret_basic"),
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -52,6 +53,10 @@ public sealed class IdentityAssertionGrantTests : IDisposable
|
|||
|
||||
if (url.Contains("auth.mcp-server.example.com/token"))
|
||||
{
|
||||
Assert.Equal("Basic", request.Headers.Authorization?.Scheme);
|
||||
Assert.Equal(
|
||||
Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes("mcp-client-id:mcp-client-secret")),
|
||||
request.Headers.Authorization?.Parameter);
|
||||
return JsonResponse(HttpStatusCode.OK, new JsonObject
|
||||
{
|
||||
["access_token"] = "final-access-token",
|
||||
|
|
@ -67,6 +72,8 @@ public sealed class IdentityAssertionGrantTests : IDisposable
|
|||
new IdentityAssertionGrantProviderOptions
|
||||
{
|
||||
ClientId = "mcp-client-id",
|
||||
ClientSecret = "mcp-client-secret",
|
||||
TokenEndpointAuthMethod = "client_secret_basic",
|
||||
IdpTokenEndpoint = "https://idp.example.com/token",
|
||||
IdpClientId = "idp-client-id",
|
||||
IdTokenCallback = (context, ct) =>
|
||||
|
|
@ -88,6 +95,30 @@ public sealed class IdentityAssertionGrantTests : IDisposable
|
|||
Assert.Equal(3600, tokens.ExpiresIn);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public Task IdentityAssertionGrantProvider_DefaultsToPostRegardlessOfMetadataOrder() =>
|
||||
AssertMcpTokenEndpointAuthenticationAsync(
|
||||
new JsonArray("client_secret_basic", "client_secret_post"),
|
||||
configuredMethod: null,
|
||||
expectedAuthorizationScheme: null,
|
||||
expectSecretInBody: true);
|
||||
|
||||
[Fact]
|
||||
public Task IdentityAssertionGrantProvider_FallsBackToBasicWhenPostIsUnavailable() =>
|
||||
AssertMcpTokenEndpointAuthenticationAsync(
|
||||
new JsonArray("client_secret_basic"),
|
||||
configuredMethod: null,
|
||||
expectedAuthorizationScheme: "Basic",
|
||||
expectSecretInBody: false);
|
||||
|
||||
[Fact]
|
||||
public Task IdentityAssertionGrantProvider_NoneDoesNotSendClientSecret() =>
|
||||
AssertMcpTokenEndpointAuthenticationAsync(
|
||||
new JsonArray("none"),
|
||||
configuredMethod: "none",
|
||||
expectedAuthorizationScheme: null,
|
||||
expectSecretInBody: false);
|
||||
|
||||
[Fact]
|
||||
public async Task IdentityAssertionGrantProvider_CachesTokens()
|
||||
{
|
||||
|
|
@ -313,6 +344,39 @@ public sealed class IdentityAssertionGrantTests : IDisposable
|
|||
_httpClient));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("client_secret_basic")]
|
||||
[InlineData("client_secret_post")]
|
||||
public void IdentityAssertionGrantProvider_TokenEndpointAuthMethodRequiresSecret_MissingClientSecret_ThrowsArgumentException(string tokenEndpointAuthMethod)
|
||||
{
|
||||
Assert.Throws<ArgumentException>(() => new IdentityAssertionGrantProvider(
|
||||
new IdentityAssertionGrantProviderOptions
|
||||
{
|
||||
ClientId = "client-id",
|
||||
IdpTokenEndpoint = "https://idp.example.com/token",
|
||||
IdpClientId = "idp-client-id",
|
||||
IdTokenCallback = (_, _) => Task.FromResult("test"),
|
||||
TokenEndpointAuthMethod = tokenEndpointAuthMethod,
|
||||
ClientSecret = null,
|
||||
},
|
||||
_httpClient));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IdentityAssertionGrantProvider_UnsupportedTokenEndpointAuthMethod_ThrowsArgumentException()
|
||||
{
|
||||
Assert.Throws<ArgumentException>(() => new IdentityAssertionGrantProvider(
|
||||
new IdentityAssertionGrantProviderOptions
|
||||
{
|
||||
ClientId = "client-id",
|
||||
IdpTokenEndpoint = "https://idp.example.com/token",
|
||||
IdpClientId = "idp-client-id",
|
||||
IdTokenCallback = (_, _) => Task.FromResult("test"),
|
||||
TokenEndpointAuthMethod = "private_key_jwt",
|
||||
},
|
||||
_httpClient));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IdentityAssertionGrantProvider_ConcurrentCallers_RunExchangeOnce()
|
||||
{
|
||||
|
|
@ -434,6 +498,67 @@ public sealed class IdentityAssertionGrantTests : IDisposable
|
|||
|
||||
#region Helpers
|
||||
|
||||
private async Task AssertMcpTokenEndpointAuthenticationAsync(
|
||||
JsonArray supportedMethods,
|
||||
string? configuredMethod,
|
||||
string? expectedAuthorizationScheme,
|
||||
bool expectSecretInBody)
|
||||
{
|
||||
_mockHandler.AsyncHandler = async request =>
|
||||
{
|
||||
var url = request.RequestUri!.ToString();
|
||||
if (url.Contains(".well-known"))
|
||||
{
|
||||
return JsonResponse(HttpStatusCode.OK, new JsonObject
|
||||
{
|
||||
["issuer"] = "https://auth.example.com",
|
||||
["authorization_endpoint"] = "https://auth.example.com/authorize",
|
||||
["token_endpoint"] = "https://auth.example.com/token",
|
||||
["token_endpoint_auth_methods_supported"] = supportedMethods,
|
||||
});
|
||||
}
|
||||
|
||||
if (url.Contains("idp.example.com"))
|
||||
{
|
||||
return JsonResponse(HttpStatusCode.OK, new JsonObject
|
||||
{
|
||||
["access_token"] = "mock-jag",
|
||||
["issued_token_type"] = "urn:ietf:params:oauth:token-type:id-jag",
|
||||
["token_type"] = "N_A",
|
||||
});
|
||||
}
|
||||
|
||||
Assert.Equal(expectedAuthorizationScheme, request.Headers.Authorization?.Scheme);
|
||||
var body = await request.Content!.ReadAsStringAsync(TestContext.Current.CancellationToken);
|
||||
Assert.Equal(expectSecretInBody, body.Contains("client_secret=mcp-client-secret", StringComparison.Ordinal));
|
||||
|
||||
return JsonResponse(HttpStatusCode.OK, new JsonObject
|
||||
{
|
||||
["access_token"] = "final-access-token",
|
||||
["token_type"] = "Bearer",
|
||||
});
|
||||
};
|
||||
|
||||
var provider = new IdentityAssertionGrantProvider(
|
||||
new IdentityAssertionGrantProviderOptions
|
||||
{
|
||||
ClientId = "mcp-client-id",
|
||||
ClientSecret = "mcp-client-secret",
|
||||
TokenEndpointAuthMethod = configuredMethod,
|
||||
IdpTokenEndpoint = "https://idp.example.com/token",
|
||||
IdpClientId = "idp-client-id",
|
||||
IdTokenCallback = (_, _) => Task.FromResult("mock-id-token"),
|
||||
},
|
||||
_httpClient);
|
||||
|
||||
var tokens = await provider.GetAccessTokenAsync(
|
||||
new Uri("https://resource.example.com"),
|
||||
new Uri("https://auth.example.com"),
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal("final-access-token", tokens.AccessToken);
|
||||
}
|
||||
|
||||
private static HttpResponseMessage JsonResponse(HttpStatusCode statusCode, JsonObject payload)
|
||||
{
|
||||
return new HttpResponseMessage(statusCode)
|
||||
|
|
|
|||
Loading…
Reference in a new issue