From 0b89b70d77d50f0ab9cbb29e096a7706cc70a7db Mon Sep 17 00:00:00 2001 From: Mike Holczer Date: Mon, 1 Jun 2026 15:17:28 -0400 Subject: [PATCH] Allow setting token endpoint auth method on ClientOAuthOptions (#1612) Add ClientOAuthOptions.TokenEndpointAuthMethod to override the token_endpoint_auth_method otherwise inferred from DCR or the server's advertised methods. Needed for CIMD public clients that must use "none" even when the server advertises client_secret_basic first (e.g. Auth0). An explicit value now takes precedence over the DCR-returned method. --- .../Authentication/ClientOAuthOptions.cs | 13 +++++ .../Authentication/ClientOAuthProvider.cs | 5 +- .../OAuth/AuthTests.cs | 53 +++++++++++++++++++ .../Program.cs | 12 ++++- 4 files changed, 81 insertions(+), 2 deletions(-) diff --git a/src/ModelContextProtocol.Core/Authentication/ClientOAuthOptions.cs b/src/ModelContextProtocol.Core/Authentication/ClientOAuthOptions.cs index 0bfb19a59..788262855 100644 --- a/src/ModelContextProtocol.Core/Authentication/ClientOAuthOptions.cs +++ b/src/ModelContextProtocol.Core/Authentication/ClientOAuthOptions.cs @@ -23,6 +23,19 @@ public sealed class ClientOAuthOptions /// public string? ClientSecret { get; set; } + /// + /// Gets or sets the token endpoint authentication method (token_endpoint_auth_method) to use when + /// requesting tokens, for example "none", "client_secret_basic", or "client_secret_post". + /// + /// + /// When not set, the method is inferred from the dynamic client registration response (when DCR is used), and + /// otherwise from the first entry in the authorization server's token_endpoint_auth_methods_supported. + /// Set this explicitly when that inference is incorrect — most notably for a public client identified by a + /// Client ID Metadata Document, which must authenticate with + /// "none" (relying on PKCE) even when the authorization server advertises a different method first. + /// + public string? TokenEndpointAuthMethod { get; set; } + /// /// Gets or sets the HTTPS URL pointing to this client's metadata document. /// diff --git a/src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs b/src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs index 662e436eb..721abe879 100644 --- a/src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs +++ b/src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs @@ -75,6 +75,7 @@ public ClientOAuthProvider( _clientId = options.ClientId; _clientSecret = options.ClientSecret; + _tokenEndpointAuthMethod = options.TokenEndpointAuthMethod; _redirectUri = options.RedirectUri ?? throw new ArgumentException("ClientOAuthOptions.RedirectUri must configured.", nameof(options)); _configuredScopes = options.Scopes is null ? null : string.Join(" ", options.Scopes); _scopeSelector = options.ScopeSelector; @@ -698,9 +699,11 @@ private async Task PerformDynamicClientRegistrationAsync( _clientSecret = registrationResponse.ClientSecret; } + // Honor an explicitly configured ClientOAuthOptions.TokenEndpointAuthMethod over the value returned by + // dynamic client registration. if (!string.IsNullOrEmpty(registrationResponse.TokenEndpointAuthMethod)) { - _tokenEndpointAuthMethod = registrationResponse.TokenEndpointAuthMethod; + _tokenEndpointAuthMethod ??= registrationResponse.TokenEndpointAuthMethod; } LogDynamicClientRegistrationSuccessful(_clientId!); diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/AuthTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/AuthTests.cs index 1ec6fddc6..b118e1cf5 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/AuthTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/AuthTests.cs @@ -131,6 +131,59 @@ public async Task CanAuthenticate_WithClientMetadataDocument() transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken); } + [Fact] + public async Task CannotAuthenticate_WithClientMetadataDocument_WhenServerAdvertisesClientSecretBasicFirst() + { + // Mimic authorization servers (e.g. Auth0) that advertise client_secret_basic ahead of "none". + // A CIMD client is a public client, but without an explicit TokenEndpointAuthMethod the client falls back to + // the first advertised method (client_secret_basic) and authenticates with the client id and an empty secret + // in the Authorization header rather than placing the client id in the body — which a public client cannot + // satisfy, so the token exchange fails. + TestOAuthServer.SupportedTokenEndpointAuthMethods = ["client_secret_basic", "client_secret_post", "none"]; + + await using var app = await StartMcpServerAsync(); + + 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 Assert.ThrowsAnyAsync(() => McpClient.CreateAsync( + transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task CanAuthenticate_WithClientMetadataDocument_AndExplicitNoneAuthMethod() + { + // Same Auth0-like server that advertises client_secret_basic first, but the client explicitly declares the + // public-client "none" method. The token request must then carry the client id in the body (no secret) and + // succeed, proving ClientOAuthOptions.TokenEndpointAuthMethod overrides the server-advertised default. + TestOAuthServer.SupportedTokenEndpointAuthMethods = ["client_secret_basic", "client_secret_post", "none"]; + + await using var app = await StartMcpServerAsync(); + + 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), + TokenEndpointAuthMethod = "none", + }, + }, HttpClient, LoggerFactory); + + await using var client = await McpClient.CreateAsync( + transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken); + } + [Fact] public async Task UsesDynamicClientRegistration_WhenCimdNotSupported() { diff --git a/tests/ModelContextProtocol.TestOAuthServer/Program.cs b/tests/ModelContextProtocol.TestOAuthServer/Program.cs index 68600f81d..bfbc5b403 100644 --- a/tests/ModelContextProtocol.TestOAuthServer/Program.cs +++ b/tests/ModelContextProtocol.TestOAuthServer/Program.cs @@ -88,6 +88,16 @@ public Program(ILoggerProvider? loggerProvider = null, IConnectionListenerFactor /// public bool IncludeOfflineAccessInMetadata { get; set; } + /// + /// Gets or sets the authentication methods advertised in the discovery document's + /// token_endpoint_auth_methods_supported. Tests can set this to mimic authorization servers + /// (such as Auth0) that advertise client_secret_basic ahead of none. + /// + /// + /// The default value is ["client_secret_post"]. + /// + public List SupportedTokenEndpointAuthMethods { get; set; } = ["client_secret_post"]; + public HashSet DisabledMetadataPaths { get; } = new(StringComparer.OrdinalIgnoreCase); public IReadOnlyCollection MetadataRequests => _metadataRequests.ToArray(); @@ -204,7 +214,7 @@ IResult HandleMetadataRequest(HttpContext context, string? issuerPath = null) ScopesSupported = IncludeOfflineAccessInMetadata ? ["openid", "profile", "email", "mcp:tools", "offline_access"] : ["openid", "profile", "email", "mcp:tools"], - TokenEndpointAuthMethodsSupported = ["client_secret_post"], + TokenEndpointAuthMethodsSupported = SupportedTokenEndpointAuthMethods, ClaimsSupported = ["sub", "iss", "name", "email", "aud"], CodeChallengeMethodsSupported = ["S256"], GrantTypesSupported = ["authorization_code", "refresh_token"],