Validate OAuth authorization state (#1726)

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Stephen Halter 2026-07-27 12:24:02 -07:00 • committed by GitHub
commit 9c635ffed1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 170 additions and 26 deletions

View file

@ -176,6 +176,77 @@ public class AuthTests : OAuthTestBase
#pragma warning restore MCP9007
}
[Fact]
public async Task CanAuthenticate_WhenAuthorizationResponseStateMatches()
{
await using var app = await StartMcpServerAsync();
string? requestedState = null;
await using var transport = CreateOAuthTransport((context, cancellationToken) =>
{
requestedState = QueryHelpers.ParseQuery(context.AuthorizationUri.Query)["state"];
return HandleAuthorizationUrlAsync(context, cancellationToken);
});
await using var client = await McpClient.CreateAsync(
transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken);
Assert.NotNull(requestedState);
Assert.True(requestedState.Length >= 43);
Assert.Equal(1, TestOAuthServer.AuthorizationCodeTokenRequestCount);
}
[Fact]
public async Task CannotAuthenticate_WhenAuthorizationResponseStateIsMissing()
{
await using var app = await StartMcpServerAsync();
await using var transport = CreateOAuthTransport(
(_, _) => Task.FromResult<ModelContextProtocol.Authentication.AuthorizationResult?>(new() { Code = "unused-code" }));
var ex = await Assert.ThrowsAsync<McpException>(() => McpClient.CreateAsync(
transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken));
Assert.Contains("did not include the required state parameter", ex.Message);
Assert.Equal(0, TestOAuthServer.AuthorizationCodeTokenRequestCount);
}
[Fact]
public async Task CannotAuthenticate_WhenAuthorizationResponseStateMismatches()
{
await using var app = await StartMcpServerAsync();
await using var transport = CreateOAuthTransport(
(_, _) => Task.FromResult<ModelContextProtocol.Authentication.AuthorizationResult?>(
new() { Code = "unused-code", State = "unexpected-state" }));
var ex = await Assert.ThrowsAsync<McpException>(() => McpClient.CreateAsync(
transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken));
Assert.Contains("state did not match", ex.Message);
Assert.Equal(0, TestOAuthServer.AuthorizationCodeTokenRequestCount);
}
[Fact]
public async Task AuthorizationRequests_UseUniqueStateValues()
{
await using var app = await StartMcpServerAsync();
List<string> requestedStates = [];
for (var i = 0; i < 2; i++)
{
await using var transport = CreateOAuthTransport((context, cancellationToken) =>
{
requestedStates.Add(QueryHelpers.ParseQuery(context.AuthorizationUri.Query)["state"].ToString());
return HandleAuthorizationUrlAsync(context, cancellationToken);
});
await using var client = await McpClient.CreateAsync(
transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken);
}
Assert.Equal(2, requestedStates.Count);
Assert.Equal(2, requestedStates.Distinct(StringComparer.Ordinal).Count());
}
[Fact]
public async Task CannotAuthenticate_WithoutOAuthConfiguration()
{
@ -550,8 +621,10 @@ public class AuthTests : OAuthTestBase
Assert.Contains("custom_param=custom_value", lastAuthorizationUri?.Query);
}
[Fact]
public async Task CannotOverrideExistingParameters_WithExtraParams()
[Theory]
[InlineData("redirect_uri")]
[InlineData("state")]
public async Task CannotOverrideExistingParameters_WithExtraParams(string parameterName)
{
await using var app = await StartMcpServerAsync();
@ -566,7 +639,7 @@ public class AuthTests : OAuthTestBase
AuthorizationCallbackHandler = HandleAuthorizationUrlAsync,
AdditionalAuthorizationParameters = new Dictionary<string, string>
{
["redirect_uri"] = "custom_value",
[parameterName] = "custom_value",
}
},
}, HttpClient, LoggerFactory);
@ -2434,7 +2507,9 @@ public class AuthTests : OAuthTestBase
Assert.False(scopePresent);
}
private HttpClientTransport CreateOAuthTransport() =>
private HttpClientTransport CreateOAuthTransport(
Func<AuthorizationCallbackContext, CancellationToken, Task<ModelContextProtocol.Authentication.AuthorizationResult?>>?
authorizationCallbackHandler = null) =>
new(new()
{
Endpoint = new(McpServerUrl),
@ -2443,7 +2518,7 @@ public class AuthTests : OAuthTestBase
ClientId = "demo-client",
ClientSecret = "demo-secret",
RedirectUri = new Uri("http://localhost:1179/callback"),
AuthorizationCallbackHandler = HandleAuthorizationUrlAsync,
AuthorizationCallbackHandler = authorizationCallbackHandler ?? HandleAuthorizationUrlAsync,
},
}, HttpClient, LoggerFactory);

View file

@ -120,6 +120,7 @@ public abstract class OAuthTestBase : KestrelInMemoryTest, IAsyncDisposable
return new ModelContextProtocol.Authentication.AuthorizationResult
{
Code = queryParams["code"],
State = queryParams["state"],
Iss = queryParams.TryGetValue("iss", out var iss) ? (string?)iss : null,
};
}