From 9003781f025c38c1921bb0e7b884bc89483d5282 Mon Sep 17 00:00:00 2001 From: Mackinnon Buck Date: Mon, 24 Nov 2025 13:31:16 -0800 Subject: [PATCH 1/2] Add support for CIMD --- .../AuthorizationServerMetadata.cs | 6 + .../Authentication/ClientOAuthOptions.cs | 11 ++ .../Authentication/ClientOAuthProvider.cs | 30 ++++- .../AuthTests.cs | 124 ++++++++++++++++++ .../AuthorizationServerMetadata.cs | 6 + .../ClientInfo.cs | 7 +- .../OAuthServerMetadata.cs | 7 + .../Program.cs | 38 +++++- 8 files changed, 221 insertions(+), 8 deletions(-) diff --git a/src/ModelContextProtocol.Core/Authentication/AuthorizationServerMetadata.cs b/src/ModelContextProtocol.Core/Authentication/AuthorizationServerMetadata.cs index e94fce7a9..87df29636 100644 --- a/src/ModelContextProtocol.Core/Authentication/AuthorizationServerMetadata.cs +++ b/src/ModelContextProtocol.Core/Authentication/AuthorizationServerMetadata.cs @@ -66,4 +66,10 @@ internal sealed class AuthorizationServerMetadata /// [JsonPropertyName("scopes_supported")] public List? ScopesSupported { get; set; } + + /// + /// Indicates if the server supports OAuth Client ID Metadata Documents. + /// + [JsonPropertyName("client_id_metadata_document_supported")] + public bool ClientIdMetadataDocumentSupported { get; set; } } diff --git a/src/ModelContextProtocol.Core/Authentication/ClientOAuthOptions.cs b/src/ModelContextProtocol.Core/Authentication/ClientOAuthOptions.cs index cc6a8952e..575fbec38 100644 --- a/src/ModelContextProtocol.Core/Authentication/ClientOAuthOptions.cs +++ b/src/ModelContextProtocol.Core/Authentication/ClientOAuthOptions.cs @@ -23,6 +23,17 @@ public sealed class ClientOAuthOptions /// public string? ClientSecret { get; set; } + /// + /// Gets or sets the HTTPS URL pointing to this client's metadata document. + /// + /// + /// When specified, and when the authorization server metadata reports + /// client_id_metadata_document_supported = true, the OAuth client will respond to + /// challenges by sending this URL as the client identifier instead of performing dynamic + /// client registration. + /// + public Uri? ClientMetadataDocumentUri { get; set; } + /// /// Gets or sets the OAuth scopes to request. /// diff --git a/src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs b/src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs index 468728982..b3c1f8ad3 100644 --- a/src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs +++ b/src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs @@ -30,6 +30,7 @@ internal sealed partial class ClientOAuthProvider private readonly IDictionary _additionalAuthorizationParameters; private readonly Func, Uri?> _authServerSelector; private readonly AuthorizationRedirectDelegate _authorizationRedirectDelegate; + private readonly Uri? _clientMetadataDocumentUri; // _dcrClientName, _dcrClientUri, _dcrInitialAccessToken and _dcrResponseDelegate are used for dynamic client registration (RFC 7591) private readonly string? _dcrClientName; @@ -74,6 +75,7 @@ public ClientOAuthProvider( _redirectUri = options.RedirectUri ?? throw new ArgumentException("ClientOAuthOptions.RedirectUri must configured.", nameof(options)); _scopes = options.Scopes?.ToArray(); _additionalAuthorizationParameters = options.AdditionalAuthorizationParameters; + _clientMetadataDocumentUri = options.ClientMetadataDocumentUri; // Set up authorization server selection strategy _authServerSelector = options.AuthServerSelector ?? DefaultAuthServerSelector; @@ -223,10 +225,17 @@ private async Task PerformOAuthAuthorizationAsync( // Store auth server metadata for future refresh operations _authServerMetadata = authServerMetadata; - // Perform dynamic client registration if needed if (string.IsNullOrEmpty(_clientId)) { - await PerformDynamicClientRegistrationAsync(authServerMetadata, cancellationToken).ConfigureAwait(false); + // Try using a client metadata document before falling back to dynamic client registration + if (authServerMetadata.ClientIdMetadataDocumentSupported && _clientMetadataDocumentUri is not null) + { + ApplyClientIdMetadataDocument(_clientMetadataDocumentUri); + } + else + { + await PerformDynamicClientRegistrationAsync(authServerMetadata, cancellationToken).ConfigureAwait(false); + } } // Perform the OAuth flow @@ -241,6 +250,23 @@ private async Task PerformOAuthAuthorizationAsync( LogOAuthAuthorizationCompleted(); } + private void ApplyClientIdMetadataDocument(Uri metadataUri) + { + if (!IsValidClientMetadataDocumentUri(metadataUri)) + { + ThrowFailedToHandleUnauthorizedResponse( + $"{nameof(ClientOAuthOptions.ClientMetadataDocumentUri)} must be an HTTPS URL with a non-root absolute path. Value: '{metadataUri}'."); + } + + _clientId = metadataUri.AbsoluteUri; + + // See: https://datatracker.ietf.org/doc/html/draft-ietf-oauth-client-id-metadata-document-00#section-3 + static bool IsValidClientMetadataDocumentUri(Uri uri) + => uri.IsAbsoluteUri + && string.Equals(uri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase) + && uri.AbsolutePath.Length > 1; + } + private static readonly string[] s_wellKnownPaths = [".well-known/openid-configuration", ".well-known/oauth-authorization-server"]; private async Task GetAuthServerMetadataAsync(Uri authServerUri, CancellationToken cancellationToken) diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/AuthTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/AuthTests.cs index fff7d6d42..eec5504e3 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/AuthTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/AuthTests.cs @@ -17,6 +17,7 @@ public class AuthTests : KestrelInMemoryTest, IAsyncDisposable { private const string McpServerUrl = "http://localhost:5000"; private const string OAuthServerUrl = "https://localhost:7029"; + private const string ClientMetadataDocumentUrl = $"{OAuthServerUrl}/client-metadata/cimd-client.json"; private readonly CancellationTokenSource _testCts = new(); private readonly TestOAuthServer.Program _testOAuthServer; @@ -194,6 +195,129 @@ public async Task CanAuthenticate_WithDynamicClientRegistration() transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken); } + [Fact] + public async Task CanAuthenticate_WithClientMetadataDocument() + { + Builder.Services.AddMcpServer().WithHttpTransport(); + + await using var app = Builder.Build(); + + app.MapMcp().RequireAuthorization(); + + await app.StartAsync(TestContext.Current.CancellationToken); + + await using var transport = new HttpClientTransport(new() + { + Endpoint = new(McpServerUrl), + OAuth = new ClientOAuthOptions() + { + RedirectUri = new Uri("http://localhost:1179/callback"), + AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync, + ClientMetadataDocumentUri = new Uri(ClientMetadataDocumentUrl) + }, + }, HttpClient, LoggerFactory); + + await using var client = await McpClient.CreateAsync( + transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken); + } + + [Fact] + public async Task UsesDynamicClientRegistration_WhenCimdNotSupported() + { + // Disable CIMD support on the test OAuth server so the client + // falls back to dynamic registration even if a CIMD URL is provided. + _testOAuthServer.ClientIdMetadataDocumentSupported = false; + + Builder.Services.AddMcpServer().WithHttpTransport(); + + await using var app = Builder.Build(); + + app.MapMcp().RequireAuthorization(); + + await app.StartAsync(TestContext.Current.CancellationToken); + + // Provide an invalid CIMD URL; if CIMD were used, auth would fail. + await using var transport = new HttpClientTransport(new() + { + Endpoint = new(McpServerUrl), + OAuth = new ClientOAuthOptions() + { + RedirectUri = new Uri("http://localhost:1179/callback"), + AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync, + ClientMetadataDocumentUri = new Uri("http://invalid-cimd.example.com"), + Scopes = ["mcp:tools"], + DynamicClientRegistration = new() + { + ClientName = "Test MCP Client (No CIMD)", + ClientUri = new Uri("https://example.com/no-cimd"), + }, + }, + }, HttpClient, LoggerFactory); + + // Should succeed via dynamic client registration. + await using var client = await McpClient.CreateAsync( + transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken); + } + + [Fact] + public async Task DoesNotUseClientMetadataDocument_WhenClientIdIsSpecified() + { + Builder.Services.AddMcpServer().WithHttpTransport(); + + await using var app = Builder.Build(); + + app.MapMcp().RequireAuthorization(); + + await app.StartAsync(TestContext.Current.CancellationToken); + + // Provide an invalid CIMD URL; if CIMD were used, auth would fail. + await using var transport = new HttpClientTransport(new() + { + Endpoint = new(McpServerUrl), + OAuth = new ClientOAuthOptions() + { + ClientId = "demo-client", + ClientSecret = "demo-secret", + RedirectUri = new Uri("http://localhost:1179/callback"), + AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync, + ClientMetadataDocumentUri = new Uri("http://invalid-cimd.example.com"), + }, + }, HttpClient, LoggerFactory); + + await using var client = await McpClient.CreateAsync( + transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken); + } + + [Theory] + [InlineData("http://localhost:7029/client-metadata/cimd-client.json")] // Non-HTTPS Scheme + [InlineData("http://localhost:7029")] // Missing path + public async Task CannotAuthenticate_WithInvalidClientMetadataDocument(string uri) + { + Builder.Services.AddMcpServer().WithHttpTransport(); + + await using var app = Builder.Build(); + + app.MapMcp().RequireAuthorization(); + + await app.StartAsync(TestContext.Current.CancellationToken); + + await using var transport = new HttpClientTransport(new() + { + Endpoint = new(McpServerUrl), + OAuth = new ClientOAuthOptions() + { + RedirectUri = new Uri("http://localhost:1179/callback"), + AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync, + ClientMetadataDocumentUri = new Uri(uri), + }, + }, HttpClient, LoggerFactory); + + var ex = await Assert.ThrowsAsync(() => McpClient.CreateAsync( + transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken)); + + Assert.StartsWith("Failed to handle unauthorized response", ex.Message); + } + [Fact] public async Task CanAuthenticate_WithTokenRefresh() { diff --git a/tests/ModelContextProtocol.TestOAuthServer/AuthorizationServerMetadata.cs b/tests/ModelContextProtocol.TestOAuthServer/AuthorizationServerMetadata.cs index a192bbf60..627bc5c89 100644 --- a/tests/ModelContextProtocol.TestOAuthServer/AuthorizationServerMetadata.cs +++ b/tests/ModelContextProtocol.TestOAuthServer/AuthorizationServerMetadata.cs @@ -60,4 +60,10 @@ internal sealed class AuthorizationServerMetadata /// [JsonPropertyName("scopes_supported")] public List? ScopesSupported { get; init; } + + /// + /// Gets or sets a value indicating whether CIMD client IDs are supported. + /// + [JsonPropertyName("client_id_metadata_document_supported")] + public bool ClientIdMetadataDocumentSupported { get; init; } } \ No newline at end of file diff --git a/tests/ModelContextProtocol.TestOAuthServer/ClientInfo.cs b/tests/ModelContextProtocol.TestOAuthServer/ClientInfo.cs index 7983476fa..500142b6b 100644 --- a/tests/ModelContextProtocol.TestOAuthServer/ClientInfo.cs +++ b/tests/ModelContextProtocol.TestOAuthServer/ClientInfo.cs @@ -12,10 +12,15 @@ internal sealed class ClientInfo /// public required string ClientId { get; init; } + /// + /// Gets or sets whether a client secret is required. + /// + public required bool RequiresClientSecret { get; init; } + /// /// Gets or sets the client secret. /// - public required string ClientSecret { get; init; } + public string? ClientSecret { get; init; } /// /// Gets or sets the list of redirect URIs allowed for this client. diff --git a/tests/ModelContextProtocol.TestOAuthServer/OAuthServerMetadata.cs b/tests/ModelContextProtocol.TestOAuthServer/OAuthServerMetadata.cs index 646a39929..c05a45ba2 100644 --- a/tests/ModelContextProtocol.TestOAuthServer/OAuthServerMetadata.cs +++ b/tests/ModelContextProtocol.TestOAuthServer/OAuthServerMetadata.cs @@ -171,4 +171,11 @@ internal sealed class OAuthServerMetadata [JsonPropertyName("claims_supported")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public List? ClaimsSupported { get; init; } + + /// + /// Gets or sets a value indicating whether CIMD client IDs are supported. + /// + [JsonPropertyName("client_id_metadata_document_supported")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public bool? ClientIdMetadataDocumentSupported { get; init; } } diff --git a/tests/ModelContextProtocol.TestOAuthServer/Program.cs b/tests/ModelContextProtocol.TestOAuthServer/Program.cs index dea484bfe..eab38af98 100644 --- a/tests/ModelContextProtocol.TestOAuthServer/Program.cs +++ b/tests/ModelContextProtocol.TestOAuthServer/Program.cs @@ -13,6 +13,7 @@ public sealed class Program { private const int _port = 7029; private static readonly string _url = $"https://localhost:{_port}"; + private static readonly string _clientMetadataDocumentUrl = $"{_url}/client-metadata/cimd-client.json"; // Port 5000 is used by tests and port 7071 is used by the ProtectedMcpServer sample private static readonly string[] ValidResources = ["http://localhost:5000/", "http://localhost:7071/"]; @@ -44,6 +45,16 @@ public Program(ILoggerProvider? loggerProvider = null, IConnectionListenerFactor public bool HasIssuedExpiredToken { get; set; } public bool HasIssuedRefreshToken { get; set; } + /// + /// Gets or sets a value indicating whether the authorization server + /// advertises support for client ID metadata documents in its discovery + /// document. This is used by tests to toggle CIMD support. + /// + /// + /// The default value is true. + /// + public bool ClientIdMetadataDocumentSupported { get; set; } = true; + /// /// Entry point for the application. /// @@ -102,6 +113,7 @@ public async Task RunServerAsync(string[]? args = null, CancellationToken cancel _clients[clientId] = new ClientInfo { ClientId = clientId, + RequiresClientSecret = true, ClientSecret = clientSecret, RedirectUris = ["http://localhost:1179/callback"], }; @@ -111,10 +123,24 @@ public async Task RunServerAsync(string[]? args = null, CancellationToken cancel _clients["test-refresh-client"] = new ClientInfo { ClientId = "test-refresh-client", + RequiresClientSecret = true, ClientSecret = "test-refresh-secret", RedirectUris = ["http://localhost:1179/callback"], }; + // This client is pre-registered to support testing Client ID Metadata Documents (CIMD). + // A non-test OAuth server implementation would fetch the metadata document from the client-specified + // URL during authorization, but we just register the client here to keep the test implementation simple. + // We also set 'RequiresClientSecret' to 'false' here because client secrets are disallowed when using CIMD. + // See https://datatracker.ietf.org/doc/html/draft-ietf-oauth-client-id-metadata-document-00#section-4.1 + _clients[_clientMetadataDocumentUrl] = new ClientInfo + { + ClientId = _clientMetadataDocumentUrl, + + RequiresClientSecret = false, + RedirectUris = ["http://localhost:1179/callback"], + }; + // The MCP spec tells the client to use /.well-known/oauth-authorization-server but AddJwtBearer looks for // /.well-known/openid-configuration by default. To make things easier, we support both with the same response // which seems to be common. Ex. https://github.com/keycloak/keycloak/pull/29628 @@ -144,7 +170,8 @@ public async Task RunServerAsync(string[]? args = null, CancellationToken cancel CodeChallengeMethodsSupported = ["S256"], GrantTypesSupported = ["authorization_code", "refresh_token"], IntrospectionEndpoint = $"{_url}/introspect", - RegistrationEndpoint = $"{_url}/register" + RegistrationEndpoint = $"{_url}/register", + ClientIdMetadataDocumentSupported = ClientIdMetadataDocumentSupported, }; return Results.Ok(metadata); @@ -468,6 +495,7 @@ public async Task RunServerAsync(string[]? args = null, CancellationToken cancel _clients[clientId] = new ClientInfo { ClientId = clientId, + RequiresClientSecret = true, ClientSecret = clientSecret, RedirectUris = registrationRequest.RedirectUris, }; @@ -508,17 +536,17 @@ public async Task RunServerAsync(string[]? args = null, CancellationToken cancel var clientId = form["client_id"].ToString(); var clientSecret = form["client_secret"].ToString(); - if (string.IsNullOrEmpty(clientId) || string.IsNullOrEmpty(clientSecret)) + if (string.IsNullOrEmpty(clientId) || !_clients.TryGetValue(clientId, out var client)) { return null; } - if (_clients.TryGetValue(clientId, out var client) && client.ClientSecret == clientSecret) + if (client.RequiresClientSecret && client.ClientSecret != clientSecret) { - return client; + return null; } - return null; + return client; } /// From ab50c70276d2fdcd60e2e7a652dc77121642e01a Mon Sep 17 00:00:00 2001 From: Mackinnon Buck Date: Mon, 24 Nov 2025 14:01:20 -0800 Subject: [PATCH 2/2] Add clarifying comment --- .../Authentication/ClientOAuthProvider.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs b/src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs index b3c1f8ad3..ebd88aeac 100644 --- a/src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs +++ b/src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs @@ -264,7 +264,7 @@ private void ApplyClientIdMetadataDocument(Uri metadataUri) static bool IsValidClientMetadataDocumentUri(Uri uri) => uri.IsAbsoluteUri && string.Equals(uri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase) - && uri.AbsolutePath.Length > 1; + && uri.AbsolutePath.Length > 1; // AbsolutePath always starts with "/" } private static readonly string[] s_wellKnownPaths = [".well-known/openid-configuration", ".well-known/oauth-authorization-server"];