Validate OAuth authorization state (#1726)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
parent
950ac7c1bc
commit
9c635ffed1
4 changed files with 170 additions and 26 deletions
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -360,9 +360,10 @@ static async Task<AuthorizationResult?> HandleAuthorizationUrlAsync(
|
|||
|
||||
if (location is not null && !string.IsNullOrEmpty(location.Query))
|
||||
{
|
||||
// Parse query string to extract "code" and "iss" parameters
|
||||
// Parse query string to extract "code", "state", and "iss" parameters
|
||||
var query = location.Query.TrimStart('?');
|
||||
string? code = null;
|
||||
string? state = null;
|
||||
string? iss = null;
|
||||
foreach (var pair in query.Split('&'))
|
||||
{
|
||||
|
|
@ -373,6 +374,10 @@ static async Task<AuthorizationResult?> HandleAuthorizationUrlAsync(
|
|||
{
|
||||
code = HttpUtility.UrlDecode(parts[1]);
|
||||
}
|
||||
else if (parts[0] == "state")
|
||||
{
|
||||
state = HttpUtility.UrlDecode(parts[1]);
|
||||
}
|
||||
else if (parts[0] == "iss")
|
||||
{
|
||||
iss = HttpUtility.UrlDecode(parts[1]);
|
||||
|
|
@ -382,7 +387,7 @@ static async Task<AuthorizationResult?> HandleAuthorizationUrlAsync(
|
|||
|
||||
if (code is not null)
|
||||
{
|
||||
return new AuthorizationResult { Code = code, Iss = iss };
|
||||
return new AuthorizationResult { Code = code, State = state, Iss = iss };
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ public sealed class Program
|
|||
private readonly ConcurrentDictionary<string, ClientInfo> _clients = new();
|
||||
|
||||
private readonly ConcurrentQueue<string> _metadataRequests = new();
|
||||
private int _authorizationCodeTokenRequestCount;
|
||||
|
||||
private readonly RSA _rsa;
|
||||
private readonly string _keyId;
|
||||
|
|
@ -137,6 +138,9 @@ public sealed class Program
|
|||
public HashSet<string> DisabledMetadataPaths { get; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
public IReadOnlyCollection<string> MetadataRequests => _metadataRequests.ToArray();
|
||||
|
||||
/// <summary>Gets the number of authorization-code token exchange requests received.</summary>
|
||||
public int AuthorizationCodeTokenRequestCount => Volatile.Read(ref _authorizationCodeTokenRequestCount);
|
||||
|
||||
/// <summary>Gets the <c>scope</c> field from the most recent Dynamic Client Registration request.</summary>
|
||||
public string? LastRegistrationScope { get; private set; }
|
||||
|
||||
|
|
@ -460,6 +464,7 @@ public sealed class Program
|
|||
|
||||
if (grant_type == "authorization_code")
|
||||
{
|
||||
Interlocked.Increment(ref _authorizationCodeTokenRequestCount);
|
||||
var code = form["code"].ToString();
|
||||
var code_verifier = form["code_verifier"].ToString();
|
||||
var redirect_uri = form["redirect_uri"].ToString();
|
||||
|
|
|
|||
Loading…
Reference in a new issue