Skip to content

Commit 0b89b70

Browse files
committed
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.
1 parent a87518c commit 0b89b70

4 files changed

Lines changed: 81 additions & 2 deletions

File tree

src/ModelContextProtocol.Core/Authentication/ClientOAuthOptions.cs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,19 @@ public sealed class ClientOAuthOptions
2323
/// </remarks>
2424
public string? ClientSecret { get; set; }
2525

26+
/// <summary>
27+
/// Gets or sets the token endpoint authentication method (<c>token_endpoint_auth_method</c>) to use when
28+
/// requesting tokens, for example <c>"none"</c>, <c>"client_secret_basic"</c>, or <c>"client_secret_post"</c>.
29+
/// </summary>
30+
/// <remarks>
31+
/// When not set, the method is inferred from the dynamic client registration response (when DCR is used), and
32+
/// otherwise from the first entry in the authorization server's <c>token_endpoint_auth_methods_supported</c>.
33+
/// Set this explicitly when that inference is incorrect — most notably for a public client identified by a
34+
/// <see cref="ClientMetadataDocumentUri">Client ID Metadata Document</see>, which must authenticate with
35+
/// <c>"none"</c> (relying on PKCE) even when the authorization server advertises a different method first.
36+
/// </remarks>
37+
public string? TokenEndpointAuthMethod { get; set; }
38+
2639
/// <summary>
2740
/// Gets or sets the HTTPS URL pointing to this client's metadata document.
2841
/// </summary>

src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ public ClientOAuthProvider(
7575

7676
_clientId = options.ClientId;
7777
_clientSecret = options.ClientSecret;
78+
_tokenEndpointAuthMethod = options.TokenEndpointAuthMethod;
7879
_redirectUri = options.RedirectUri ?? throw new ArgumentException("ClientOAuthOptions.RedirectUri must configured.", nameof(options));
7980
_configuredScopes = options.Scopes is null ? null : string.Join(" ", options.Scopes);
8081
_scopeSelector = options.ScopeSelector;
@@ -698,9 +699,11 @@ private async Task PerformDynamicClientRegistrationAsync(
698699
_clientSecret = registrationResponse.ClientSecret;
699700
}
700701

702+
// Honor an explicitly configured ClientOAuthOptions.TokenEndpointAuthMethod over the value returned by
703+
// dynamic client registration.
701704
if (!string.IsNullOrEmpty(registrationResponse.TokenEndpointAuthMethod))
702705
{
703-
_tokenEndpointAuthMethod = registrationResponse.TokenEndpointAuthMethod;
706+
_tokenEndpointAuthMethod ??= registrationResponse.TokenEndpointAuthMethod;
704707
}
705708

706709
LogDynamicClientRegistrationSuccessful(_clientId!);

tests/ModelContextProtocol.AspNetCore.Tests/OAuth/AuthTests.cs

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,59 @@ public async Task CanAuthenticate_WithClientMetadataDocument()
131131
transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken);
132132
}
133133

134+
[Fact]
135+
public async Task CannotAuthenticate_WithClientMetadataDocument_WhenServerAdvertisesClientSecretBasicFirst()
136+
{
137+
// Mimic authorization servers (e.g. Auth0) that advertise client_secret_basic ahead of "none".
138+
// A CIMD client is a public client, but without an explicit TokenEndpointAuthMethod the client falls back to
139+
// the first advertised method (client_secret_basic) and authenticates with the client id and an empty secret
140+
// in the Authorization header rather than placing the client id in the body — which a public client cannot
141+
// satisfy, so the token exchange fails.
142+
TestOAuthServer.SupportedTokenEndpointAuthMethods = ["client_secret_basic", "client_secret_post", "none"];
143+
144+
await using var app = await StartMcpServerAsync();
145+
146+
await using var transport = new HttpClientTransport(new()
147+
{
148+
Endpoint = new(McpServerUrl),
149+
OAuth = new ClientOAuthOptions()
150+
{
151+
RedirectUri = new Uri("http://localhost:1179/callback"),
152+
AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync,
153+
ClientMetadataDocumentUri = new Uri(ClientMetadataDocumentUrl),
154+
},
155+
}, HttpClient, LoggerFactory);
156+
157+
await Assert.ThrowsAnyAsync<HttpRequestException>(() => McpClient.CreateAsync(
158+
transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken));
159+
}
160+
161+
[Fact]
162+
public async Task CanAuthenticate_WithClientMetadataDocument_AndExplicitNoneAuthMethod()
163+
{
164+
// Same Auth0-like server that advertises client_secret_basic first, but the client explicitly declares the
165+
// public-client "none" method. The token request must then carry the client id in the body (no secret) and
166+
// succeed, proving ClientOAuthOptions.TokenEndpointAuthMethod overrides the server-advertised default.
167+
TestOAuthServer.SupportedTokenEndpointAuthMethods = ["client_secret_basic", "client_secret_post", "none"];
168+
169+
await using var app = await StartMcpServerAsync();
170+
171+
await using var transport = new HttpClientTransport(new()
172+
{
173+
Endpoint = new(McpServerUrl),
174+
OAuth = new ClientOAuthOptions()
175+
{
176+
RedirectUri = new Uri("http://localhost:1179/callback"),
177+
AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync,
178+
ClientMetadataDocumentUri = new Uri(ClientMetadataDocumentUrl),
179+
TokenEndpointAuthMethod = "none",
180+
},
181+
}, HttpClient, LoggerFactory);
182+
183+
await using var client = await McpClient.CreateAsync(
184+
transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken);
185+
}
186+
134187
[Fact]
135188
public async Task UsesDynamicClientRegistration_WhenCimdNotSupported()
136189
{

tests/ModelContextProtocol.TestOAuthServer/Program.cs

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,16 @@ public Program(ILoggerProvider? loggerProvider = null, IConnectionListenerFactor
8888
/// </remarks>
8989
public bool IncludeOfflineAccessInMetadata { get; set; }
9090

91+
/// <summary>
92+
/// Gets or sets the authentication methods advertised in the discovery document's
93+
/// <c>token_endpoint_auth_methods_supported</c>. Tests can set this to mimic authorization servers
94+
/// (such as Auth0) that advertise <c>client_secret_basic</c> ahead of <c>none</c>.
95+
/// </summary>
96+
/// <remarks>
97+
/// The default value is <c>["client_secret_post"]</c>.
98+
/// </remarks>
99+
public List<string> SupportedTokenEndpointAuthMethods { get; set; } = ["client_secret_post"];
100+
91101
public HashSet<string> DisabledMetadataPaths { get; } = new(StringComparer.OrdinalIgnoreCase);
92102
public IReadOnlyCollection<string> MetadataRequests => _metadataRequests.ToArray();
93103

@@ -204,7 +214,7 @@ IResult HandleMetadataRequest(HttpContext context, string? issuerPath = null)
204214
ScopesSupported = IncludeOfflineAccessInMetadata
205215
? ["openid", "profile", "email", "mcp:tools", "offline_access"]
206216
: ["openid", "profile", "email", "mcp:tools"],
207-
TokenEndpointAuthMethodsSupported = ["client_secret_post"],
217+
TokenEndpointAuthMethodsSupported = SupportedTokenEndpointAuthMethods,
208218
ClaimsSupported = ["sub", "iss", "name", "email", "aud"],
209219
CodeChallengeMethodsSupported = ["S256"],
210220
GrantTypesSupported = ["authorization_code", "refresh_token"],

0 commit comments

Comments
 (0)