Skip to content

Commit 919ff3e

Browse files
Copilothalter73
andcommitted
Address PR feedback: improve auth failure tracking, update DNS rebinding protection, add client credentials tests
- Replace _scopeStepUpCount with _repeatedAuthFailureCount to track all auth failures - Reset counter only on successful requests to prevent infinite loops - Replace custom DNS rebinding middleware with AllowedHosts configuration - Add client_credentials grant support to TestOAuthServer with basic auth and post methods - Add ClientCredentialsTests with infinite loop prevention test - Fix off-by-one error in MaxReconnectionAttempts Co-authored-by: halter73 <54385+halter73@users.noreply.github.com>
1 parent a4b9f02 commit 919ff3e

7 files changed

Lines changed: 309 additions & 23 deletions

File tree

src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs

Lines changed: 10 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -50,12 +50,13 @@ internal sealed partial class ClientOAuthProvider : McpHttpClient
5050
private string? _clientSecret;
5151
private ITokenCache _tokenCache;
5252
private AuthorizationServerMetadata? _authServerMetadata;
53-
private int _scopeStepUpCount;
53+
private int _repeatedAuthFailureCount;
5454

5555
/// <summary>
56-
/// Maximum number of scope step-up retries before failing.
56+
/// Maximum number of repeated auth failure retries before failing.
57+
/// This prevents infinite loops when tokens are never accepted by the server.
5758
/// </summary>
58-
private const int MaxScopeStepUpRetries = 3;
59+
private const int MaxRepeatedAuthFailures = 3;
5960

6061
/// <summary>
6162
/// Initializes a new instance of the <see cref="ClientOAuthProvider"/> class using the specified options.
@@ -166,6 +167,8 @@ internal override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage r
166167
return await HandleUnauthorizedResponseAsync(request, message, response, attemptedRefresh, cancellationToken).ConfigureAwait(false);
167168
}
168169

170+
// Reset the auth failure counter on successful request
171+
_repeatedAuthFailureCount = 0;
169172
return response;
170173
}
171174

@@ -263,18 +266,11 @@ private async Task<HttpResponseMessage> HandleUnauthorizedResponseAsync(
263266
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests.</param>
264267
private async Task<string> GetAccessTokenAsync(HttpResponseMessage response, bool attemptedRefresh, CancellationToken cancellationToken)
265268
{
266-
// Check if this is a scope step-up retry (403 with insufficient_scope)
267-
if (HasInsufficientScopeError(response))
269+
// Track all auth failures to prevent infinite redirect loops.
270+
// This counter is only reset when a request succeeds (in SendAsync).
271+
if (++_repeatedAuthFailureCount > MaxRepeatedAuthFailures)
268272
{
269-
if (++_scopeStepUpCount >= MaxScopeStepUpRetries)
270-
{
271-
ThrowFailedToHandleUnauthorizedResponse($"Maximum scope step-up retry limit ({MaxScopeStepUpRetries}) exceeded.");
272-
}
273-
}
274-
else
275-
{
276-
// Reset the counter for non-scope-step-up requests (e.g., initial auth or token expiry)
277-
_scopeStepUpCount = 0;
273+
ThrowFailedToHandleUnauthorizedResponse($"Maximum repeated authentication failure limit ({MaxRepeatedAuthFailures}) exceeded. The server may be rejecting all tokens.");
278274
}
279275

280276
// Get available authorization servers from the 401 or 403 response

src/ModelContextProtocol.Core/Client/SseClientSessionTransport.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -145,7 +145,7 @@ private async Task ReceiveMessagesAsync(CancellationToken cancellationToken)
145145
{
146146
int attempt = 0;
147147

148-
while (attempt < _options.MaxReconnectionAttempts && !cancellationToken.IsCancellationRequested)
148+
while (attempt <= _options.MaxReconnectionAttempts && !cancellationToken.IsCancellationRequested)
149149
{
150150
try
151151
{
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
using Microsoft.AspNetCore.Authentication.JwtBearer;
2+
using Microsoft.AspNetCore.Authorization;
3+
using Microsoft.AspNetCore.Builder;
4+
using Microsoft.AspNetCore.Http;
5+
using Microsoft.Extensions.DependencyInjection;
6+
using Microsoft.IdentityModel.Tokens;
7+
using ModelContextProtocol.AspNetCore;
8+
using ModelContextProtocol.AspNetCore.Authentication;
9+
using ModelContextProtocol.Authentication;
10+
using ModelContextProtocol.Client;
11+
12+
namespace ModelContextProtocol.AspNetCore.Tests.OAuth;
13+
14+
/// <summary>
15+
/// Tests for client_credentials OAuth flow with various authentication methods.
16+
/// </summary>
17+
public class ClientCredentialsTests : OAuthTestBase
18+
{
19+
public ClientCredentialsTests(ITestOutputHelper outputHelper)
20+
: base(outputHelper)
21+
{
22+
}
23+
24+
[Fact]
25+
public async Task CanAuthenticate_WithClientCredentials_ClientSecretPost()
26+
{
27+
await using var app = await StartMcpServerAsync();
28+
29+
// Use client_credentials flow with client_secret_post authentication
30+
// Note: No AuthorizationRedirectDelegate means machine-to-machine flow will be attempted
31+
await using var transport = new HttpClientTransport(new()
32+
{
33+
Endpoint = new(McpServerUrl),
34+
OAuth = new ClientOAuthOptions
35+
{
36+
ClientId = "client-credentials-post",
37+
ClientSecret = "cc-secret-post",
38+
RedirectUri = new Uri("http://localhost:1179/callback"),
39+
// No AuthorizationRedirectDelegate - triggers client_credentials flow
40+
},
41+
}, HttpClient, LoggerFactory);
42+
43+
await using var client = await McpClient.CreateAsync(
44+
transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken);
45+
46+
Assert.NotNull(client);
47+
}
48+
49+
[Fact]
50+
public async Task CanAuthenticate_WithClientCredentials_ClientSecretBasic()
51+
{
52+
await using var app = await StartMcpServerAsync();
53+
54+
// Use client_credentials flow with client_secret_basic authentication
55+
await using var transport = new HttpClientTransport(new()
56+
{
57+
Endpoint = new(McpServerUrl),
58+
OAuth = new ClientOAuthOptions
59+
{
60+
ClientId = "client-credentials-basic",
61+
ClientSecret = "cc-secret-basic",
62+
RedirectUri = new Uri("http://localhost:1179/callback"),
63+
// No AuthorizationRedirectDelegate - triggers client_credentials flow
64+
},
65+
}, HttpClient, LoggerFactory);
66+
67+
await using var client = await McpClient.CreateAsync(
68+
transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken);
69+
70+
Assert.NotNull(client);
71+
}
72+
73+
[Fact]
74+
public async Task DoesNotLoopIndefinitely_WhenTokensAlwaysRejected()
75+
{
76+
// Set up a server that always returns 401 even after authentication
77+
// This simulates a buggy MCP server that never accepts tokens
78+
var app = Builder.Build();
79+
80+
// Add middleware that always returns 401 with the MCP auth challenge
81+
app.Use((HttpContext context, RequestDelegate next) =>
82+
{
83+
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
84+
context.Response.Headers.WWWAuthenticate = $"Bearer realm=\"{OAuthServerUrl}\" resource_metadata=\"{McpServerUrl}/.well-known/oauth-protected-resource\"";
85+
return context.Response.WriteAsync("Unauthorized");
86+
});
87+
88+
await app.StartAsync(TestContext.Current.CancellationToken);
89+
await using var _ = app;
90+
91+
await using var transport = new HttpClientTransport(new()
92+
{
93+
Endpoint = new(McpServerUrl),
94+
OAuth = new ClientOAuthOptions
95+
{
96+
ClientId = "client-credentials-post",
97+
ClientSecret = "cc-secret-post",
98+
RedirectUri = new Uri("http://localhost:1179/callback"),
99+
// No AuthorizationRedirectDelegate - triggers client_credentials flow
100+
},
101+
}, HttpClient, LoggerFactory);
102+
103+
// Should throw McpException after max retries, not loop indefinitely
104+
var ex = await Assert.ThrowsAsync<McpException>(async () =>
105+
await McpClient.CreateAsync(transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken));
106+
107+
Assert.Contains("Maximum repeated authentication failure limit", ex.Message);
108+
}
109+
}

tests/ModelContextProtocol.ConformanceServer/Program.cs

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -92,8 +92,6 @@ public static async Task MainAsync(string[] args, ILoggerProvider? loggerProvide
9292

9393
var app = builder.Build();
9494

95-
app.UseMcpDnsRebindingProtection();
96-
9795
app.MapMcp();
9896

9997
app.MapGet("/health", () => "Healthy");

tests/ModelContextProtocol.ConformanceServer/appsettings.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,5 +5,5 @@
55
"Microsoft.AspNetCore": "Warning"
66
}
77
},
8-
"AllowedHosts": "*"
8+
"AllowedHosts": "localhost;127.0.0.1;[::1]"
99
}

tests/ModelContextProtocol.TestOAuthServer/ClientInfo.cs

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,4 +26,25 @@ internal sealed class ClientInfo
2626
/// Gets or sets the list of redirect URIs allowed for this client.
2727
/// </summary>
2828
public List<string> RedirectUris { get; init; } = [];
29+
30+
/// <summary>
31+
/// Gets or sets the token endpoint auth method for this client.
32+
/// Supported values: "client_secret_post", "client_secret_basic", "private_key_jwt", "none"
33+
/// </summary>
34+
public string TokenEndpointAuthMethod { get; init; } = "client_secret_post";
35+
36+
/// <summary>
37+
/// Gets or sets the allowed grant types for this client.
38+
/// </summary>
39+
public List<string> AllowedGrantTypes { get; init; } = ["authorization_code", "refresh_token"];
40+
41+
/// <summary>
42+
/// Gets or sets the client's JWKS URI for JWT client assertion verification.
43+
/// </summary>
44+
public string? JwksUri { get; init; }
45+
46+
/// <summary>
47+
/// Gets or sets the client's public key PEM for JWT client assertion verification (inline, no JWKS fetch).
48+
/// </summary>
49+
public string? PublicKeyPem { get; init; }
2950
}

0 commit comments

Comments
 (0)