From 616a4642a0896b297462a56b7363cb55a7edc719 Mon Sep 17 00:00:00 2001 From: Mike Holczer Date: Mon, 1 Jun 2026 15:17:28 -0400 Subject: [PATCH 1/2] 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 df1c44c1f..6a742ca5c 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 785e3cc2e..14400142b 100644 --- a/src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs +++ b/src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs @@ -100,6 +100,7 @@ public ClientOAuthProvider( _clientId = options.ClientId; _configuredClientId = 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; @@ -983,9 +984,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 693c77943..63010e826 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/AuthTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/AuthTests.cs @@ -439,6 +439,59 @@ public async Task CanAuthenticate_WhenFirstMetadataEndpointOmitsPkce_ButAnotherA 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 73663dc94..ad3cd4059 100644 --- a/tests/ModelContextProtocol.TestOAuthServer/Program.cs +++ b/tests/ModelContextProtocol.TestOAuthServer/Program.cs @@ -135,6 +135,16 @@ public Program(ILoggerProvider? loggerProvider = null, IConnectionListenerFactor /// public HashSet MetadataPathsWithoutPkceSupport { get; } = new(StringComparer.OrdinalIgnoreCase); + /// + /// 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(); @@ -276,7 +286,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 = MetadataPathsWithoutPkceSupport.Contains(context.Request.Path) ? null From 127823cf486c0a3440c9b973af46fbc00ef54658 Mon Sep 17 00:00:00 2001 From: Stephen Halter Date: Tue, 28 Jul 2026 14:26:47 -0700 Subject: [PATCH 2/2] Complete token endpoint auth method support Validate configured methods, preserve configured intent across authorization server migration, honor DCR negotiation, and persist the effective method for refreshes. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Authentication/ClientOAuthOptions.cs | 14 +- .../Authentication/ClientOAuthProvider.cs | 57 ++++- .../OAuth/AuthTests.cs | 197 +++++++++++++++++- .../OAuth/TokenCacheTests.cs | 13 ++ .../ClientInfo.cs | 5 + .../Program.cs | 58 +++++- 6 files changed, 324 insertions(+), 20 deletions(-) diff --git a/src/ModelContextProtocol.Core/Authentication/ClientOAuthOptions.cs b/src/ModelContextProtocol.Core/Authentication/ClientOAuthOptions.cs index 6a742ca5c..ab40eccbd 100644 --- a/src/ModelContextProtocol.Core/Authentication/ClientOAuthOptions.cs +++ b/src/ModelContextProtocol.Core/Authentication/ClientOAuthOptions.cs @@ -25,14 +25,16 @@ public sealed class ClientOAuthOptions /// /// 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". + /// requesting tokens. /// /// - /// 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. + /// Supported values are none, client_secret_basic, and 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. For DCR, this value is requested during registration, + /// but the method returned by the authorization server is authoritative. + /// Set this explicitly when inference is incorrect, most notably for a public client identified by a + /// Client ID Metadata Document, which commonly uses none. /// public string? TokenEndpointAuthMethod { get; set; } diff --git a/src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs b/src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs index 14400142b..7b9647e99 100644 --- a/src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs +++ b/src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs @@ -36,6 +36,7 @@ internal sealed partial class ClientOAuthProvider : McpHttpClient private readonly bool _validateAuthorizationResponseIssuer; private readonly Uri? _clientMetadataDocumentUri; private readonly string? _configuredClientId; + private readonly string? _configuredTokenEndpointAuthMethod; // _dcrClientName, _dcrClientUri, _dcrInitialAccessToken, _dcrConfiguredApplicationType and _dcrResponseDelegate are used for dynamic client registration (RFC 7591) private readonly string? _dcrClientName; @@ -97,10 +98,19 @@ public ClientOAuthProvider( throw new ArgumentNullException(nameof(options)); } + if (options.TokenEndpointAuthMethod is not null && + !IsSupportedTokenEndpointAuthMethod(options.TokenEndpointAuthMethod)) + { + throw new ArgumentException( + $"{nameof(options.TokenEndpointAuthMethod)} must be 'client_secret_basic', 'client_secret_post', or 'none'.", + $"{nameof(options)}.{nameof(options.TokenEndpointAuthMethod)}"); + } + _clientId = options.ClientId; _configuredClientId = options.ClientId; _clientSecret = options.ClientSecret; - _tokenEndpointAuthMethod = options.TokenEndpointAuthMethod; + _configuredTokenEndpointAuthMethod = options.TokenEndpointAuthMethod; + _tokenEndpointAuthMethod = _configuredTokenEndpointAuthMethod; _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; @@ -848,12 +858,17 @@ private HttpRequestMessage CreateTokenRequest(Uri tokenEndpoint, Dictionary + tokenEndpointAuthMethod is "client_secret_basic" or "client_secret_post" or "none"; + private static string? GetResourceUri(ProtectedResourceMetadata protectedResourceMetadata) => protectedResourceMetadata.Resource; @@ -1528,7 +1558,16 @@ private void RestoreCachedClientCredentials(TokenContainer? tokens, Uri selected // 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; + if (string.Equals(cached.ClientId, _clientMetadataDocumentUri?.AbsoluteUri, StringComparison.Ordinal)) + { + // Configured intent controls CIMD clients. + _tokenEndpointAuthMethod ??= cached.TokenEndpointAuthMethod; + } + else + { + // A DCR response is authoritative for the registration and must survive a cold start. + _tokenEndpointAuthMethod = cached.TokenEndpointAuthMethod ?? _configuredTokenEndpointAuthMethod; + } _clientId = cached.ClientId; _clientCredentialsAuthorizationServer = cached.AuthorizationServer; } @@ -1557,7 +1596,7 @@ private void BindClientCredentialsToAuthorizationServer(Uri selectedAuthServer) _clientId = null; _clientSecret = null; - _tokenEndpointAuthMethod = null; + _tokenEndpointAuthMethod = _configuredTokenEndpointAuthMethod; _authServerMetadata = null; _clientCredentialsAuthorizationServer = null; } diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/AuthTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/AuthTests.cs index 63010e826..6867f4566 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/AuthTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/AuthTests.cs @@ -50,6 +50,32 @@ public async Task CanAuthenticate() transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken); } + [Theory] + [InlineData("client_secret_basic")] + [InlineData("client_secret_post")] + public async Task CanAuthenticate_WithExplicitTokenEndpointAuthMethod(string tokenEndpointAuthMethod) + { + await using var app = await StartMcpServerAsync(); + + await using var transport = new HttpClientTransport(new() + { + Endpoint = new(McpServerUrl), + OAuth = new() + { + ClientId = "demo-client", + ClientSecret = "demo-secret", + TokenEndpointAuthMethod = tokenEndpointAuthMethod, + RedirectUri = new Uri("http://localhost:1179/callback"), + AuthorizationCallbackHandler = HandleAuthorizationUrlAsync, + }, + }, HttpClient, LoggerFactory); + + await using var client = await McpClient.CreateAsync( + transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken); + + Assert.Equal(tokenEndpointAuthMethod, TestOAuthServer.LastTokenEndpointAuthMethod); + } + [Fact] public async Task AuthorizationCallbackHandler_ReceivesConfiguredRedirectUri() { @@ -176,6 +202,28 @@ public void HttpClientTransport_RejectsBothAuthorizationCallbacks() #pragma warning restore MCP9007 } + [Theory] + [InlineData("")] + [InlineData("None")] + [InlineData("private_key_jwt")] + public void HttpClientTransport_RejectsUnsupportedTokenEndpointAuthMethod(string tokenEndpointAuthMethod) + { + var ex = Assert.Throws(() => new HttpClientTransport( + new() + { + Endpoint = new(McpServerUrl), + OAuth = new() + { + RedirectUri = new Uri("http://localhost:1179/callback"), + TokenEndpointAuthMethod = tokenEndpointAuthMethod, + }, + }, + HttpClient, + LoggerFactory)); + + Assert.Equal("options.TokenEndpointAuthMethod", ex.ParamName); + } + [Fact] public async Task CanAuthenticate_WhenAuthorizationResponseStateMatches() { @@ -309,6 +357,80 @@ public async Task CanAuthenticate_WithDynamicClientRegistration() transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken); Assert.Equal("native", TestOAuthServer.LastApplicationType); + Assert.Equal("client_secret_post", TestOAuthServer.LastRegistrationTokenEndpointAuthMethod); + } + + [Fact] + public async Task DynamicClientRegistration_ResponseTokenEndpointAuthMethodIsAuthoritative() + { + TestOAuthServer.DynamicRegistrationTokenEndpointAuthMethod = "client_secret_post"; + await using var app = await StartMcpServerAsync(); + + await using var transport = new HttpClientTransport(new() + { + Endpoint = new(McpServerUrl), + OAuth = new() + { + RedirectUri = new Uri("http://localhost:1179/callback"), + TokenEndpointAuthMethod = "client_secret_basic", + AuthorizationCallbackHandler = HandleAuthorizationUrlAsync, + DynamicClientRegistration = new(), + }, + }, HttpClient, LoggerFactory); + + await using var client = await McpClient.CreateAsync( + transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken); + + Assert.Equal("client_secret_basic", TestOAuthServer.LastRegistrationTokenEndpointAuthMethod); + Assert.Equal("client_secret_post", TestOAuthServer.LastTokenEndpointAuthMethod); + } + + [Fact] + public async Task DynamicClientRegistration_UsesRequestedMethodWhenResponseOmitsIt() + { + TestOAuthServer.DynamicRegistrationTokenEndpointAuthMethod = null; + await using var app = await StartMcpServerAsync(); + + await using var transport = new HttpClientTransport(new() + { + Endpoint = new(McpServerUrl), + OAuth = new() + { + RedirectUri = new Uri("http://localhost:1179/callback"), + TokenEndpointAuthMethod = "client_secret_basic", + AuthorizationCallbackHandler = HandleAuthorizationUrlAsync, + DynamicClientRegistration = new(), + }, + }, HttpClient, LoggerFactory); + + await using var client = await McpClient.CreateAsync( + transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken); + + Assert.Equal("client_secret_basic", TestOAuthServer.LastRegistrationTokenEndpointAuthMethod); + Assert.Equal("client_secret_basic", TestOAuthServer.LastTokenEndpointAuthMethod); + } + + [Fact] + public async Task DynamicClientRegistration_RejectsUnsupportedResponseTokenEndpointAuthMethod() + { + TestOAuthServer.DynamicRegistrationTokenEndpointAuthMethod = "private_key_jwt"; + await using var app = await StartMcpServerAsync(); + + await using var transport = new HttpClientTransport(new() + { + Endpoint = new(McpServerUrl), + OAuth = new() + { + RedirectUri = new Uri("http://localhost:1179/callback"), + AuthorizationCallbackHandler = HandleAuthorizationUrlAsync, + DynamicClientRegistration = new(), + }, + }, HttpClient, LoggerFactory); + + var ex = await Assert.ThrowsAsync(() => McpClient.CreateAsync( + transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken)); + + Assert.Contains("private_key_jwt", ex.Message); } [Fact] @@ -457,7 +579,7 @@ public async Task CannotAuthenticate_WithClientMetadataDocument_WhenServerAdvert OAuth = new ClientOAuthOptions() { RedirectUri = new Uri("http://localhost:1179/callback"), - AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync, + AuthorizationCallbackHandler = HandleAuthorizationUrlAsync, ClientMetadataDocumentUri = new Uri(ClientMetadataDocumentUrl), }, }, HttpClient, LoggerFactory); @@ -482,7 +604,7 @@ public async Task CanAuthenticate_WithClientMetadataDocument_AndExplicitNoneAuth OAuth = new ClientOAuthOptions() { RedirectUri = new Uri("http://localhost:1179/callback"), - AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync, + AuthorizationCallbackHandler = HandleAuthorizationUrlAsync, ClientMetadataDocumentUri = new Uri(ClientMetadataDocumentUrl), TokenEndpointAuthMethod = "none", }, @@ -492,6 +614,77 @@ public async Task CanAuthenticate_WithClientMetadataDocument_AndExplicitNoneAuth transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken); } + [Fact] + public async Task ClientMetadataDocument_PreservesConfiguredMethodAfterAuthorizationServerMigration() + { + TestOAuthServer.SupportedTokenEndpointAuthMethods = ["client_secret_basic", "none"]; + var selectedAuthorizationServer = OAuthServerUrl; + var migrationChallengeSent = 0; + + Builder.Services.Configure(McpAuthenticationDefaults.AuthenticationScheme, options => + { + options.Events.OnResourceMetadataRequest = async context => + { + context.HandleResponse(); + var metadata = new ProtectedResourceMetadata + { + Resource = McpServerUrl, + AuthorizationServers = { selectedAuthorizationServer }, + ScopesSupported = ["mcp:tools"], + }; + await Results.Json(metadata, McpJsonUtilities.DefaultOptions).ExecuteAsync(context.HttpContext); + }; + }); + + await using var app = await StartMcpServerAsync(configureMiddleware: app => + { + app.Use(async (context, next) => + { + if (context.Request.Method == HttpMethods.Post && context.Request.Path == "/") + { + context.Request.EnableBuffering(); + var message = await JsonSerializer.DeserializeAsync( + context.Request.Body, + McpJsonUtilities.DefaultOptions.GetTypeInfo(typeof(JsonRpcMessage)), + context.RequestAborted) as JsonRpcMessage; + context.Request.Body.Position = 0; + + if (message is JsonRpcRequest { Method: "ping" } && + Interlocked.CompareExchange(ref migrationChallengeSent, 1, 0) == 0) + { + selectedAuthorizationServer = $"{OAuthServerUrl}/tenant"; + context.Response.StatusCode = StatusCodes.Status401Unauthorized; + context.Response.Headers.WWWAuthenticate = + $"{JwtBearerDefaults.AuthenticationScheme} resource_metadata=\"{McpServerUrl}/.well-known/oauth-protected-resource\""; + return; + } + } + + await next(context); + }); + }); + + await using var transport = new HttpClientTransport(new() + { + Endpoint = new(McpServerUrl), + OAuth = new() + { + RedirectUri = new Uri("http://localhost:1179/callback"), + AuthorizationCallbackHandler = HandleAuthorizationUrlAsync, + ClientMetadataDocumentUri = new Uri(ClientMetadataDocumentUrl), + TokenEndpointAuthMethod = "none", + }, + }, HttpClient, LoggerFactory); + + await using var client = await McpClient.CreateAsync( + transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken); + + await client.PingAsync(cancellationToken: TestContext.Current.CancellationToken); + + Assert.Equal("none", TestOAuthServer.LastTokenEndpointAuthMethod); + Assert.Contains("/.well-known/oauth-authorization-server/tenant", TestOAuthServer.MetadataRequests); + } + [Fact] public async Task UsesDynamicClientRegistration_WhenCimdNotSupported() { diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/TokenCacheTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/TokenCacheTests.cs index 5b6f054ba..f2654e8dd 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/TokenCacheTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/TokenCacheTests.cs @@ -379,6 +379,7 @@ public async Task GetTokenAsync_ColdStartWithDynamicRegistration_RefreshesUsingP OAuth = new() { RedirectUri = new Uri("http://localhost:1179/callback"), + TokenEndpointAuthMethod = "client_secret_basic", DynamicClientRegistration = new() { ClientName = "Test MCP Client", @@ -404,6 +405,8 @@ public async Task GetTokenAsync_ColdStartWithDynamicRegistration_RefreshesUsingP Assert.False( string.IsNullOrEmpty(tokenCache.LastStoredToken.ClientId), "The dynamically registered client ID should be persisted alongside the tokens"); + Assert.Equal("client_secret_basic", TestOAuthServer.LastRegistrationTokenEndpointAuthMethod); + Assert.Equal("client_secret_post", tokenCache.LastStoredToken.TokenEndpointAuthMethod); Assert.Equal(OAuthServerUrl, tokenCache.LastStoredToken.AuthorizationServer); // Simulate a cold start: the access token is no longer valid, but the refresh token persists. @@ -418,6 +421,7 @@ public async Task GetTokenAsync_ColdStartWithDynamicRegistration_RefreshesUsingP OAuth = new() { RedirectUri = new Uri("http://localhost:1179/callback"), + TokenEndpointAuthMethod = "client_secret_basic", DynamicClientRegistration = new() { ClientName = "Test MCP Client", @@ -437,11 +441,14 @@ public async Task GetTokenAsync_ColdStartWithDynamicRegistration_RefreshesUsingP Assert.False(authDelegateCalledAgain, "Authorization callback should not be called when the persisted refresh token can be used"); Assert.True(TestOAuthServer.HasRefreshedToken, "Token should have been refreshed using the persisted client credentials"); Assert.NotEqual("invalid-token", tokenCache.LastStoredToken.AccessToken); + Assert.Equal("client_secret_post", TestOAuthServer.LastTokenEndpointAuthMethod); + Assert.Equal("client_secret_post", tokenCache.LastStoredToken.TokenEndpointAuthMethod); } [Fact] public async Task GetTokenAsync_ColdStartWithCredentialsFromDifferentAuthorizationServer_Reregisters() { + TestOAuthServer.DynamicRegistrationTokenEndpointAuthMethod = null; await using var app = await StartMcpServerAsync(); var tokenCache = new TestTokenCache(); @@ -451,6 +458,7 @@ public async Task GetTokenAsync_ColdStartWithCredentialsFromDifferentAuthorizati OAuth = new() { RedirectUri = new Uri("http://localhost:1179/callback"), + TokenEndpointAuthMethod = "client_secret_basic", DynamicClientRegistration = new() { ClientName = "Test MCP Client" }, AuthorizationCallbackHandler = HandleAuthorizationUrlAsync, TokenCache = tokenCache, @@ -465,6 +473,7 @@ public async Task GetTokenAsync_ColdStartWithCredentialsFromDifferentAuthorizati } Assert.NotNull(tokenCache.LastStoredToken); + Assert.Equal("client_secret_basic", tokenCache.LastStoredToken.TokenEndpointAuthMethod); tokenCache.LastStoredToken.AccessToken = "invalid-token"; tokenCache.LastStoredToken.AuthorizationServer = "https://different-authorization-server.example.com"; var authorizationCallbackCalled = false; @@ -475,6 +484,7 @@ public async Task GetTokenAsync_ColdStartWithCredentialsFromDifferentAuthorizati OAuth = new() { RedirectUri = new Uri("http://localhost:1179/callback"), + TokenEndpointAuthMethod = "client_secret_basic", DynamicClientRegistration = new() { ClientName = "Test MCP Client" }, AuthorizationCallbackHandler = (context, ct) => { @@ -492,6 +502,9 @@ public async Task GetTokenAsync_ColdStartWithCredentialsFromDifferentAuthorizati Assert.True(authorizationCallbackCalled); Assert.False(TestOAuthServer.HasRefreshedToken); + Assert.Equal("client_secret_basic", TestOAuthServer.LastRegistrationTokenEndpointAuthMethod); + Assert.Equal("client_secret_basic", TestOAuthServer.LastTokenEndpointAuthMethod); + Assert.Equal("client_secret_basic", tokenCache.LastStoredToken.TokenEndpointAuthMethod); Assert.Equal(OAuthServerUrl, tokenCache.LastStoredToken.AuthorizationServer); } diff --git a/tests/ModelContextProtocol.TestOAuthServer/ClientInfo.cs b/tests/ModelContextProtocol.TestOAuthServer/ClientInfo.cs index 500142b6b..3f40f2e1d 100644 --- a/tests/ModelContextProtocol.TestOAuthServer/ClientInfo.cs +++ b/tests/ModelContextProtocol.TestOAuthServer/ClientInfo.cs @@ -22,6 +22,11 @@ internal sealed class ClientInfo /// public string? ClientSecret { get; init; } + /// + /// Gets or sets the token endpoint authentication method assigned to this client. + /// + public string? TokenEndpointAuthMethod { get; init; } + /// /// Gets or sets the list of redirect URIs allowed for this client. /// diff --git a/tests/ModelContextProtocol.TestOAuthServer/Program.cs b/tests/ModelContextProtocol.TestOAuthServer/Program.cs index ad3cd4059..838448aea 100644 --- a/tests/ModelContextProtocol.TestOAuthServer/Program.cs +++ b/tests/ModelContextProtocol.TestOAuthServer/Program.cs @@ -145,6 +145,12 @@ public Program(ILoggerProvider? loggerProvider = null, IConnectionListenerFactor /// public List SupportedTokenEndpointAuthMethods { get; set; } = ["client_secret_post"]; + /// + /// Gets or sets the token endpoint authentication method returned by dynamic client registration. + /// Set to to omit the method from the response. + /// + public string? DynamicRegistrationTokenEndpointAuthMethod { get; set; } = "client_secret_post"; + public HashSet DisabledMetadataPaths { get; } = new(StringComparer.OrdinalIgnoreCase); public IReadOnlyCollection MetadataRequests => _metadataRequests.ToArray(); @@ -157,6 +163,12 @@ public Program(ILoggerProvider? loggerProvider = null, IConnectionListenerFactor /// Gets the application_type field from the most recent Dynamic Client Registration request. public string? LastApplicationType { get; private set; } + /// Gets the token_endpoint_auth_method field from the most recent Dynamic Client Registration request. + public string? LastRegistrationTokenEndpointAuthMethod { get; private set; } + + /// Gets the authentication method used by the most recent successful token request. + public string? LastTokenEndpointAuthMethod { get; private set; } + /// /// Entry point for the application. /// @@ -231,6 +243,7 @@ public async Task RunServerAsync(string[]? args = null, CancellationToken cancel ClientId = _clientMetadataDocumentUrl, RequiresClientSecret = false, + TokenEndpointAuthMethod = "none", RedirectUris = ["http://localhost:1179/callback"], }; @@ -725,6 +738,7 @@ IResult HandleMetadataRequest(HttpContext context, string? issuerPath = null) LastRegistrationScope = registrationRequest.Scope; LastApplicationType = registrationRequest.ApplicationType; + LastRegistrationTokenEndpointAuthMethod = registrationRequest.TokenEndpointAuthMethod; // Validate redirect URIs are provided if (registrationRequest.RedirectUris.Count == 0) @@ -752,15 +766,21 @@ IResult HandleMetadataRequest(HttpContext context, string? issuerPath = null) // Generate client credentials var clientId = $"dyn-{Guid.NewGuid():N}"; - var clientSecret = GenerateRandomToken(); + var registeredTokenEndpointAuthMethod = + DynamicRegistrationTokenEndpointAuthMethod ?? + registrationRequest.TokenEndpointAuthMethod ?? + "client_secret_post"; + var requiresClientSecret = registeredTokenEndpointAuthMethod != "none"; + var clientSecret = requiresClientSecret ? GenerateRandomToken() : null; var issuedAt = DateTimeOffset.UtcNow; // Store the registered client _clients[clientId] = new ClientInfo { ClientId = clientId, - RequiresClientSecret = true, + RequiresClientSecret = requiresClientSecret, ClientSecret = clientSecret, + TokenEndpointAuthMethod = registeredTokenEndpointAuthMethod, RedirectUris = registrationRequest.RedirectUris, }; @@ -772,7 +792,7 @@ IResult HandleMetadataRequest(HttpContext context, string? issuerPath = null) RedirectUris = registrationRequest.RedirectUris, GrantTypes = ["authorization_code", "refresh_token"], ResponseTypes = ["code"], - TokenEndpointAuthMethod = "client_secret_post", + TokenEndpointAuthMethod = DynamicRegistrationTokenEndpointAuthMethod, }; return Results.Ok(registrationResponse); @@ -812,6 +832,31 @@ IResult HandleMetadataRequest(HttpContext context, string? issuerPath = null) { var clientId = form["client_id"].ToString(); var clientSecret = form["client_secret"].ToString(); + var tokenEndpointAuthMethod = string.IsNullOrEmpty(clientSecret) ? "none" : "client_secret_post"; + + var authorization = context.Request.Headers.Authorization.ToString(); + if (authorization.StartsWith("Basic ", StringComparison.Ordinal)) + { + string decodedCredentials; + try + { + decodedCredentials = Encoding.UTF8.GetString(Convert.FromBase64String(authorization["Basic ".Length..])); + } + catch (FormatException) + { + return null; + } + + var separatorIndex = decodedCredentials.IndexOf(':'); + if (separatorIndex < 0) + { + return null; + } + + clientId = Uri.UnescapeDataString(decodedCredentials[..separatorIndex]); + clientSecret = Uri.UnescapeDataString(decodedCredentials[(separatorIndex + 1)..]); + tokenEndpointAuthMethod = "client_secret_basic"; + } if (string.IsNullOrEmpty(clientId) || !_clients.TryGetValue(clientId, out var client)) { @@ -823,6 +868,13 @@ IResult HandleMetadataRequest(HttpContext context, string? issuerPath = null) return null; } + if (client.TokenEndpointAuthMethod is not null && + !string.Equals(client.TokenEndpointAuthMethod, tokenEndpointAuthMethod, StringComparison.Ordinal)) + { + return null; + } + + LastTokenEndpointAuthMethod = tokenEndpointAuthMethod; return client; }