diff --git a/src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs b/src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs index 785e3cc2e..774fe4365 100644 --- a/src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs +++ b/src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs @@ -494,6 +494,13 @@ private void ApplyClientIdMetadataDocument(Uri metadataUri) _clientId = metadataUri.AbsoluteUri; + // A CIMD client is a public client (it has no client secret, and its identifier is a URL), + // so it authenticates at the token endpoint with "none" and proves possession via PKCE. + // Without this, GetAccessTokenAsync falls through to the first method the authorization + // server advertises (e.g. client_secret_basic on Auth0), which fails with 401 access_denied. + // See https://github.com/modelcontextprotocol/csharp-sdk/issues/1612. + _tokenEndpointAuthMethod = "none"; + // See: https://datatracker.ietf.org/doc/html/draft-ietf-oauth-client-id-metadata-document-00#section-3 static bool IsValidClientMetadataDocumentUri(Uri uri) => uri.IsAbsoluteUri diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/AuthTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/AuthTests.cs index 693c77943..3b770fc6c 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/AuthTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/AuthTests.cs @@ -360,6 +360,34 @@ public async Task CanAuthenticate_WithClientMetadataDocument() transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken); } + [Fact] + public async Task CanAuthenticate_WithClientMetadataDocument_WhenServerAdvertisesClientSecretBasicFirst() + { + // Auth0 advertises client_secret_basic ahead of none. A CIMD client is a public client and + // must use "none" (PKCE) regardless of the advertised order, otherwise the token exchange + // sends an empty-secret Basic header and is rejected with 401 access_denied (#1612). + TestOAuthServer.TokenEndpointAuthMethodsSupported = ["client_secret_basic", "client_secret_post", "private_key_jwt", "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"), + AuthorizationCallbackHandler = HandleAuthorizationUrlAsync, + ClientMetadataDocumentUri = new Uri(ClientMetadataDocumentUrl), + DynamicClientRegistration = new() + { + ApplicationType = "web", + }, + }, + }, HttpClient, LoggerFactory); + + await using var client = await McpClient.CreateAsync( + transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken); + } + [Fact] public async Task CannotAuthenticate_WhenMetadataOmitsPkceMethods() { diff --git a/tests/ModelContextProtocol.TestOAuthServer/Program.cs b/tests/ModelContextProtocol.TestOAuthServer/Program.cs index 73663dc94..68239186b 100644 --- a/tests/ModelContextProtocol.TestOAuthServer/Program.cs +++ b/tests/ModelContextProtocol.TestOAuthServer/Program.cs @@ -80,6 +80,17 @@ public Program(ILoggerProvider? loggerProvider = null, IConnectionListenerFactor /// public bool ClientIdMetadataDocumentSupported { get; set; } = true; + /// + /// Gets or sets the token_endpoint_auth_methods_supported values the authorization server + /// advertises in its discovery document. Tests set this to a list that does not lead with + /// none (e.g. ["client_secret_basic", "none"], mirroring Auth0) to verify that a + /// CIMD public client still authenticates with none rather than the first advertised method. + /// + /// + /// The default value is ["client_secret_post"]. + /// + public List TokenEndpointAuthMethodsSupported { get; set; } = ["client_secret_post"]; + /// /// Gets or sets a value indicating whether the authorization server expects a resource parameter. /// When true, the resource parameter must be present and match a valid resource. @@ -276,7 +287,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 = TokenEndpointAuthMethodsSupported, ClaimsSupported = ["sub", "iss", "name", "email", "aud"], CodeChallengeMethodsSupported = MetadataPathsWithoutPkceSupport.Contains(context.Request.Path) ? null