diff --git a/src/ModelContextProtocol.Core/Authentication/ClientOAuthOptions.cs b/src/ModelContextProtocol.Core/Authentication/ClientOAuthOptions.cs
index df1c44c1f..ab40eccbd 100644
--- a/src/ModelContextProtocol.Core/Authentication/ClientOAuthOptions.cs
+++ b/src/ModelContextProtocol.Core/Authentication/ClientOAuthOptions.cs
@@ -23,6 +23,21 @@ 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.
+ ///
+ ///
+ /// 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; }
+
///
/// 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..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,9 +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;
+ _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;
@@ -847,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;
@@ -1525,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;
}
@@ -1554,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 693c77943..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]
@@ -439,6 +561,130 @@ 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"),
+ AuthorizationCallbackHandler = 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"),
+ AuthorizationCallbackHandler = 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 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 73663dc94..838448aea 100644
--- a/tests/ModelContextProtocol.TestOAuthServer/Program.cs
+++ b/tests/ModelContextProtocol.TestOAuthServer/Program.cs
@@ -135,6 +135,22 @@ 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"];
+
+ ///
+ /// 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();
@@ -147,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.
///
@@ -221,6 +243,7 @@ public async Task RunServerAsync(string[]? args = null, CancellationToken cancel
ClientId = _clientMetadataDocumentUrl,
RequiresClientSecret = false,
+ TokenEndpointAuthMethod = "none",
RedirectUris = ["http://localhost:1179/callback"],
};
@@ -276,7 +299,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
@@ -715,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)
@@ -742,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,
};
@@ -762,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);
@@ -802,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))
{
@@ -813,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;
}