Skip to content

Commit 127823c

Browse files
halter73Copilot
andcommitted
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>
1 parent 616a464 commit 127823c

6 files changed

Lines changed: 324 additions & 20 deletions

File tree

src/ModelContextProtocol.Core/Authentication/ClientOAuthOptions.cs

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -25,14 +25,16 @@ public sealed class ClientOAuthOptions
2525

2626
/// <summary>
2727
/// 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>.
28+
/// requesting tokens.
2929
/// </summary>
3030
/// <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.
31+
/// Supported values are <c>none</c>, <c>client_secret_basic</c>, and <c>client_secret_post</c>.
32+
/// When not set, the method is inferred from the dynamic client registration response (when DCR is used),
33+
/// and otherwise from the first entry in the authorization server's
34+
/// <c>token_endpoint_auth_methods_supported</c>. For DCR, this value is requested during registration,
35+
/// but the method returned by the authorization server is authoritative.
36+
/// Set this explicitly when inference is incorrect, most notably for a public client identified by a
37+
/// <see cref="ClientMetadataDocumentUri">Client ID Metadata Document</see>, which commonly uses <c>none</c>.
3638
/// </remarks>
3739
public string? TokenEndpointAuthMethod { get; set; }
3840

src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs

Lines changed: 48 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ internal sealed partial class ClientOAuthProvider : McpHttpClient
3636
private readonly bool _validateAuthorizationResponseIssuer;
3737
private readonly Uri? _clientMetadataDocumentUri;
3838
private readonly string? _configuredClientId;
39+
private readonly string? _configuredTokenEndpointAuthMethod;
3940

4041
// _dcrClientName, _dcrClientUri, _dcrInitialAccessToken, _dcrConfiguredApplicationType and _dcrResponseDelegate are used for dynamic client registration (RFC 7591)
4142
private readonly string? _dcrClientName;
@@ -97,10 +98,19 @@ public ClientOAuthProvider(
9798
throw new ArgumentNullException(nameof(options));
9899
}
99100

101+
if (options.TokenEndpointAuthMethod is not null &&
102+
!IsSupportedTokenEndpointAuthMethod(options.TokenEndpointAuthMethod))
103+
{
104+
throw new ArgumentException(
105+
$"{nameof(options.TokenEndpointAuthMethod)} must be 'client_secret_basic', 'client_secret_post', or 'none'.",
106+
$"{nameof(options)}.{nameof(options.TokenEndpointAuthMethod)}");
107+
}
108+
100109
_clientId = options.ClientId;
101110
_configuredClientId = options.ClientId;
102111
_clientSecret = options.ClientSecret;
103-
_tokenEndpointAuthMethod = options.TokenEndpointAuthMethod;
112+
_configuredTokenEndpointAuthMethod = options.TokenEndpointAuthMethod;
113+
_tokenEndpointAuthMethod = _configuredTokenEndpointAuthMethod;
104114
_redirectUri = options.RedirectUri ?? throw new ArgumentException("ClientOAuthOptions.RedirectUri must configured.", nameof(options));
105115
_configuredScopes = options.Scopes is null ? null : string.Join(" ", options.Scopes);
106116
_scopeSelector = options.ScopeSelector;
@@ -848,12 +858,17 @@ private HttpRequestMessage CreateTokenRequest(Uri tokenEndpoint, Dictionary<stri
848858
// Public client: include client_id in the body but no secret.
849859
formFields["client_id"] = clientId;
850860
}
851-
else
861+
else if (_tokenEndpointAuthMethod is null or "client_secret_post")
852862
{
853863
// Default to client_secret_post: include credentials in the body.
854864
formFields["client_id"] = clientId;
855865
formFields["client_secret"] = _clientSecret ?? string.Empty;
856866
}
867+
else
868+
{
869+
ThrowFailedToHandleUnauthorizedResponse(
870+
$"Token endpoint authentication method '{_tokenEndpointAuthMethod}' is not supported.");
871+
}
857872

858873
request.Content = new FormUrlEncodedContent(formFields);
859874
return request;
@@ -935,7 +950,7 @@ private async Task PerformDynamicClientRegistrationAsync(
935950
RedirectUris = [_redirectUri.ToString()],
936951
GrantTypes = ["authorization_code", "refresh_token"],
937952
ResponseTypes = ["code"],
938-
TokenEndpointAuthMethod = "client_secret_post",
953+
TokenEndpointAuthMethod = _configuredTokenEndpointAuthMethod ?? "client_secret_post",
939954
ClientName = _dcrClientName,
940955
ClientUri = _dcrClientUri?.ToString(),
941956
Scope = ComputeEffectiveScope(protectedResourceMetadata, authServerMetadata),
@@ -984,11 +999,23 @@ private async Task PerformDynamicClientRegistrationAsync(
984999
_clientSecret = registrationResponse.ClientSecret;
9851000
}
9861001

987-
// Honor an explicitly configured ClientOAuthOptions.TokenEndpointAuthMethod over the value returned by
988-
// dynamic client registration.
989-
if (!string.IsNullOrEmpty(registrationResponse.TokenEndpointAuthMethod))
1002+
// The response describes the registration the server created and is authoritative. Some servers omit the
1003+
// field, in which case use the method requested during registration.
1004+
var registeredTokenEndpointAuthMethod = registrationResponse.TokenEndpointAuthMethod;
1005+
if (!string.IsNullOrEmpty(registeredTokenEndpointAuthMethod))
1006+
{
1007+
if (!IsSupportedTokenEndpointAuthMethod(registeredTokenEndpointAuthMethod!))
1008+
{
1009+
ThrowFailedToHandleUnauthorizedResponse(
1010+
$"Dynamic client registration returned unsupported token endpoint authentication method " +
1011+
$"'{registeredTokenEndpointAuthMethod}'.");
1012+
}
1013+
1014+
_tokenEndpointAuthMethod = registeredTokenEndpointAuthMethod;
1015+
}
1016+
else
9901017
{
991-
_tokenEndpointAuthMethod ??= registrationResponse.TokenEndpointAuthMethod;
1018+
_tokenEndpointAuthMethod = registrationRequest.TokenEndpointAuthMethod;
9921019
}
9931020

9941021
LogDynamicClientRegistrationSuccessful(_clientId!);
@@ -1011,6 +1038,9 @@ private static string InferApplicationType(Uri redirectUri)
10111038
return "native";
10121039
}
10131040

1041+
private static bool IsSupportedTokenEndpointAuthMethod(string tokenEndpointAuthMethod) =>
1042+
tokenEndpointAuthMethod is "client_secret_basic" or "client_secret_post" or "none";
1043+
10141044
private static string? GetResourceUri(ProtectedResourceMetadata protectedResourceMetadata)
10151045
=> protectedResourceMetadata.Resource;
10161046

@@ -1528,7 +1558,16 @@ private void RestoreCachedClientCredentials(TokenContainer? tokens, Uri selected
15281558
// Assign _clientId last. Callers treat a non-empty _clientId as "registration complete", so the
15291559
// secret and auth method must already be in place before _clientId becomes observable.
15301560
_clientSecret ??= cached.ClientSecret;
1531-
_tokenEndpointAuthMethod ??= cached.TokenEndpointAuthMethod;
1561+
if (string.Equals(cached.ClientId, _clientMetadataDocumentUri?.AbsoluteUri, StringComparison.Ordinal))
1562+
{
1563+
// Configured intent controls CIMD clients.
1564+
_tokenEndpointAuthMethod ??= cached.TokenEndpointAuthMethod;
1565+
}
1566+
else
1567+
{
1568+
// A DCR response is authoritative for the registration and must survive a cold start.
1569+
_tokenEndpointAuthMethod = cached.TokenEndpointAuthMethod ?? _configuredTokenEndpointAuthMethod;
1570+
}
15321571
_clientId = cached.ClientId;
15331572
_clientCredentialsAuthorizationServer = cached.AuthorizationServer;
15341573
}
@@ -1557,7 +1596,7 @@ private void BindClientCredentialsToAuthorizationServer(Uri selectedAuthServer)
15571596

15581597
_clientId = null;
15591598
_clientSecret = null;
1560-
_tokenEndpointAuthMethod = null;
1599+
_tokenEndpointAuthMethod = _configuredTokenEndpointAuthMethod;
15611600
_authServerMetadata = null;
15621601
_clientCredentialsAuthorizationServer = null;
15631602
}

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

Lines changed: 195 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,32 @@ public async Task CanAuthenticate()
5050
transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken);
5151
}
5252

53+
[Theory]
54+
[InlineData("client_secret_basic")]
55+
[InlineData("client_secret_post")]
56+
public async Task CanAuthenticate_WithExplicitTokenEndpointAuthMethod(string tokenEndpointAuthMethod)
57+
{
58+
await using var app = await StartMcpServerAsync();
59+
60+
await using var transport = new HttpClientTransport(new()
61+
{
62+
Endpoint = new(McpServerUrl),
63+
OAuth = new()
64+
{
65+
ClientId = "demo-client",
66+
ClientSecret = "demo-secret",
67+
TokenEndpointAuthMethod = tokenEndpointAuthMethod,
68+
RedirectUri = new Uri("http://localhost:1179/callback"),
69+
AuthorizationCallbackHandler = HandleAuthorizationUrlAsync,
70+
},
71+
}, HttpClient, LoggerFactory);
72+
73+
await using var client = await McpClient.CreateAsync(
74+
transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken);
75+
76+
Assert.Equal(tokenEndpointAuthMethod, TestOAuthServer.LastTokenEndpointAuthMethod);
77+
}
78+
5379
[Fact]
5480
public async Task AuthorizationCallbackHandler_ReceivesConfiguredRedirectUri()
5581
{
@@ -176,6 +202,28 @@ public void HttpClientTransport_RejectsBothAuthorizationCallbacks()
176202
#pragma warning restore MCP9007
177203
}
178204

205+
[Theory]
206+
[InlineData("")]
207+
[InlineData("None")]
208+
[InlineData("private_key_jwt")]
209+
public void HttpClientTransport_RejectsUnsupportedTokenEndpointAuthMethod(string tokenEndpointAuthMethod)
210+
{
211+
var ex = Assert.Throws<ArgumentException>(() => new HttpClientTransport(
212+
new()
213+
{
214+
Endpoint = new(McpServerUrl),
215+
OAuth = new()
216+
{
217+
RedirectUri = new Uri("http://localhost:1179/callback"),
218+
TokenEndpointAuthMethod = tokenEndpointAuthMethod,
219+
},
220+
},
221+
HttpClient,
222+
LoggerFactory));
223+
224+
Assert.Equal("options.TokenEndpointAuthMethod", ex.ParamName);
225+
}
226+
179227
[Fact]
180228
public async Task CanAuthenticate_WhenAuthorizationResponseStateMatches()
181229
{
@@ -309,6 +357,80 @@ public async Task CanAuthenticate_WithDynamicClientRegistration()
309357
transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken);
310358

311359
Assert.Equal("native", TestOAuthServer.LastApplicationType);
360+
Assert.Equal("client_secret_post", TestOAuthServer.LastRegistrationTokenEndpointAuthMethod);
361+
}
362+
363+
[Fact]
364+
public async Task DynamicClientRegistration_ResponseTokenEndpointAuthMethodIsAuthoritative()
365+
{
366+
TestOAuthServer.DynamicRegistrationTokenEndpointAuthMethod = "client_secret_post";
367+
await using var app = await StartMcpServerAsync();
368+
369+
await using var transport = new HttpClientTransport(new()
370+
{
371+
Endpoint = new(McpServerUrl),
372+
OAuth = new()
373+
{
374+
RedirectUri = new Uri("http://localhost:1179/callback"),
375+
TokenEndpointAuthMethod = "client_secret_basic",
376+
AuthorizationCallbackHandler = HandleAuthorizationUrlAsync,
377+
DynamicClientRegistration = new(),
378+
},
379+
}, HttpClient, LoggerFactory);
380+
381+
await using var client = await McpClient.CreateAsync(
382+
transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken);
383+
384+
Assert.Equal("client_secret_basic", TestOAuthServer.LastRegistrationTokenEndpointAuthMethod);
385+
Assert.Equal("client_secret_post", TestOAuthServer.LastTokenEndpointAuthMethod);
386+
}
387+
388+
[Fact]
389+
public async Task DynamicClientRegistration_UsesRequestedMethodWhenResponseOmitsIt()
390+
{
391+
TestOAuthServer.DynamicRegistrationTokenEndpointAuthMethod = null;
392+
await using var app = await StartMcpServerAsync();
393+
394+
await using var transport = new HttpClientTransport(new()
395+
{
396+
Endpoint = new(McpServerUrl),
397+
OAuth = new()
398+
{
399+
RedirectUri = new Uri("http://localhost:1179/callback"),
400+
TokenEndpointAuthMethod = "client_secret_basic",
401+
AuthorizationCallbackHandler = HandleAuthorizationUrlAsync,
402+
DynamicClientRegistration = new(),
403+
},
404+
}, HttpClient, LoggerFactory);
405+
406+
await using var client = await McpClient.CreateAsync(
407+
transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken);
408+
409+
Assert.Equal("client_secret_basic", TestOAuthServer.LastRegistrationTokenEndpointAuthMethod);
410+
Assert.Equal("client_secret_basic", TestOAuthServer.LastTokenEndpointAuthMethod);
411+
}
412+
413+
[Fact]
414+
public async Task DynamicClientRegistration_RejectsUnsupportedResponseTokenEndpointAuthMethod()
415+
{
416+
TestOAuthServer.DynamicRegistrationTokenEndpointAuthMethod = "private_key_jwt";
417+
await using var app = await StartMcpServerAsync();
418+
419+
await using var transport = new HttpClientTransport(new()
420+
{
421+
Endpoint = new(McpServerUrl),
422+
OAuth = new()
423+
{
424+
RedirectUri = new Uri("http://localhost:1179/callback"),
425+
AuthorizationCallbackHandler = HandleAuthorizationUrlAsync,
426+
DynamicClientRegistration = new(),
427+
},
428+
}, HttpClient, LoggerFactory);
429+
430+
var ex = await Assert.ThrowsAsync<McpException>(() => McpClient.CreateAsync(
431+
transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken));
432+
433+
Assert.Contains("private_key_jwt", ex.Message);
312434
}
313435

314436
[Fact]
@@ -457,7 +579,7 @@ public async Task CannotAuthenticate_WithClientMetadataDocument_WhenServerAdvert
457579
OAuth = new ClientOAuthOptions()
458580
{
459581
RedirectUri = new Uri("http://localhost:1179/callback"),
460-
AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync,
582+
AuthorizationCallbackHandler = HandleAuthorizationUrlAsync,
461583
ClientMetadataDocumentUri = new Uri(ClientMetadataDocumentUrl),
462584
},
463585
}, HttpClient, LoggerFactory);
@@ -482,7 +604,7 @@ public async Task CanAuthenticate_WithClientMetadataDocument_AndExplicitNoneAuth
482604
OAuth = new ClientOAuthOptions()
483605
{
484606
RedirectUri = new Uri("http://localhost:1179/callback"),
485-
AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync,
607+
AuthorizationCallbackHandler = HandleAuthorizationUrlAsync,
486608
ClientMetadataDocumentUri = new Uri(ClientMetadataDocumentUrl),
487609
TokenEndpointAuthMethod = "none",
488610
},
@@ -492,6 +614,77 @@ public async Task CanAuthenticate_WithClientMetadataDocument_AndExplicitNoneAuth
492614
transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken);
493615
}
494616

617+
[Fact]
618+
public async Task ClientMetadataDocument_PreservesConfiguredMethodAfterAuthorizationServerMigration()
619+
{
620+
TestOAuthServer.SupportedTokenEndpointAuthMethods = ["client_secret_basic", "none"];
621+
var selectedAuthorizationServer = OAuthServerUrl;
622+
var migrationChallengeSent = 0;
623+
624+
Builder.Services.Configure<McpAuthenticationOptions>(McpAuthenticationDefaults.AuthenticationScheme, options =>
625+
{
626+
options.Events.OnResourceMetadataRequest = async context =>
627+
{
628+
context.HandleResponse();
629+
var metadata = new ProtectedResourceMetadata
630+
{
631+
Resource = McpServerUrl,
632+
AuthorizationServers = { selectedAuthorizationServer },
633+
ScopesSupported = ["mcp:tools"],
634+
};
635+
await Results.Json(metadata, McpJsonUtilities.DefaultOptions).ExecuteAsync(context.HttpContext);
636+
};
637+
});
638+
639+
await using var app = await StartMcpServerAsync(configureMiddleware: app =>
640+
{
641+
app.Use(async (context, next) =>
642+
{
643+
if (context.Request.Method == HttpMethods.Post && context.Request.Path == "/")
644+
{
645+
context.Request.EnableBuffering();
646+
var message = await JsonSerializer.DeserializeAsync(
647+
context.Request.Body,
648+
McpJsonUtilities.DefaultOptions.GetTypeInfo(typeof(JsonRpcMessage)),
649+
context.RequestAborted) as JsonRpcMessage;
650+
context.Request.Body.Position = 0;
651+
652+
if (message is JsonRpcRequest { Method: "ping" } &&
653+
Interlocked.CompareExchange(ref migrationChallengeSent, 1, 0) == 0)
654+
{
655+
selectedAuthorizationServer = $"{OAuthServerUrl}/tenant";
656+
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
657+
context.Response.Headers.WWWAuthenticate =
658+
$"{JwtBearerDefaults.AuthenticationScheme} resource_metadata=\"{McpServerUrl}/.well-known/oauth-protected-resource\"";
659+
return;
660+
}
661+
}
662+
663+
await next(context);
664+
});
665+
});
666+
667+
await using var transport = new HttpClientTransport(new()
668+
{
669+
Endpoint = new(McpServerUrl),
670+
OAuth = new()
671+
{
672+
RedirectUri = new Uri("http://localhost:1179/callback"),
673+
AuthorizationCallbackHandler = HandleAuthorizationUrlAsync,
674+
ClientMetadataDocumentUri = new Uri(ClientMetadataDocumentUrl),
675+
TokenEndpointAuthMethod = "none",
676+
},
677+
}, HttpClient, LoggerFactory);
678+
679+
await using var client = await McpClient.CreateAsync(
680+
transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken);
681+
682+
await client.PingAsync(cancellationToken: TestContext.Current.CancellationToken);
683+
684+
Assert.Equal("none", TestOAuthServer.LastTokenEndpointAuthMethod);
685+
Assert.Contains("/.well-known/oauth-authorization-server/tenant", TestOAuthServer.MetadataRequests);
686+
}
687+
495688
[Fact]
496689
public async Task UsesDynamicClientRegistration_WhenCimdNotSupported()
497690
{

0 commit comments

Comments
 (0)