Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions src/ModelContextProtocol.Core/Authentication/ClientOAuthOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,19 @@ public sealed class ClientOAuthOptions
/// </remarks>
public string? ClientSecret { get; set; }

/// <summary>
/// Gets or sets the token endpoint authentication method (<c>token_endpoint_auth_method</c>) to use when
/// requesting tokens, for example <c>"none"</c>, <c>"client_secret_basic"</c>, or <c>"client_secret_post"</c>.
/// </summary>
/// <remarks>
/// 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 <c>token_endpoint_auth_methods_supported</c>.
/// Set this explicitly when that inference is incorrect — most notably for a public client identified by a
/// <see cref="ClientMetadataDocumentUri">Client ID Metadata Document</see>, which must authenticate with
/// <c>"none"</c> (relying on PKCE) even when the authorization server advertises a different method first.
/// </remarks>
public string? TokenEndpointAuthMethod { get; set; }

/// <summary>
/// Gets or sets the HTTPS URL pointing to this client's metadata document.
/// </summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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!);
Expand Down
53 changes: 53 additions & 0 deletions tests/ModelContextProtocol.AspNetCore.Tests/OAuth/AuthTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<HttpRequestException>(() => 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()
{
Expand Down
12 changes: 11 additions & 1 deletion tests/ModelContextProtocol.TestOAuthServer/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,16 @@ public Program(ILoggerProvider? loggerProvider = null, IConnectionListenerFactor
/// </remarks>
public bool IncludeOfflineAccessInMetadata { get; set; }

/// <summary>
/// Gets or sets the authentication methods advertised in the discovery document's
/// <c>token_endpoint_auth_methods_supported</c>. Tests can set this to mimic authorization servers
/// (such as Auth0) that advertise <c>client_secret_basic</c> ahead of <c>none</c>.
/// </summary>
/// <remarks>
/// The default value is <c>["client_secret_post"]</c>.
/// </remarks>
public List<string> SupportedTokenEndpointAuthMethods { get; set; } = ["client_secret_post"];

public HashSet<string> DisabledMetadataPaths { get; } = new(StringComparer.OrdinalIgnoreCase);
public IReadOnlyCollection<string> MetadataRequests => _metadataRequests.ToArray();

Expand Down Expand Up @@ -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"],
Expand Down