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
9 changed files with 170 additions and 26 deletions
|
|
@ -45,4 +45,4 @@ When APIs are marked as obsolete, a diagnostic is emitted to warn users that the
|
||||||
| `MCP9004` | In place | <xref:ModelContextProtocol.AspNetCore.HttpServerTransportOptions.EnableLegacySse> opts into the legacy SSE transport which has no built-in HTTP-level backpressure. Use Streamable HTTP instead. See [Stateless — Legacy SSE transport](xref:stateless#legacy-sse-transport) for details. |
|
| `MCP9004` | In place | <xref:ModelContextProtocol.AspNetCore.HttpServerTransportOptions.EnableLegacySse> opts into the legacy SSE transport which has no built-in HTTP-level backpressure. Use Streamable HTTP instead. See [Stateless — Legacy SSE transport](xref:stateless#legacy-sse-transport) for details. |
|
||||||
| `MCP9005` | In place | The Roots, Sampling, and Logging features are deprecated as of specification version 2026-07-28 and may be removed in a future version. See SEP-2577 for more information. |
|
| `MCP9005` | In place | The Roots, Sampling, and Logging features are deprecated as of specification version 2026-07-28 and may be removed in a future version. See SEP-2577 for more information. |
|
||||||
| `MCP9006` | In place | The stateful Streamable HTTP configuration knobs on <xref:ModelContextProtocol.AspNetCore.HttpServerTransportOptions> — `EventStreamStore`, `SessionMigrationHandler`, `PerSessionExecutionContext`, `IdleTimeout`, and `MaxIdleSessionCount` — only apply when `Stateless = false`. Starting with the `2026-07-28` protocol revision, Streamable HTTP no longer supports sessions, and the SDK now defaults `Stateless` to `true`. These knobs remain available for back-compat with the legacy stateful Streamable HTTP transport but new code should target the stateless path. |
|
| `MCP9006` | In place | The stateful Streamable HTTP configuration knobs on <xref:ModelContextProtocol.AspNetCore.HttpServerTransportOptions> — `EventStreamStore`, `SessionMigrationHandler`, `PerSessionExecutionContext`, `IdleTimeout`, and `MaxIdleSessionCount` — only apply when `Stateless = false`. Starting with the `2026-07-28` protocol revision, Streamable HTTP no longer supports sessions, and the SDK now defaults `Stateless` to `true`. These knobs remain available for back-compat with the legacy stateful Streamable HTTP transport but new code should target the stateless path. |
|
||||||
| `MCP9007` | In place | `AuthorizationRedirectDelegate` and `ClientOAuthOptions.AuthorizationRedirectDelegate` are retained for source and binary compatibility but cannot provide the RFC 9207 authorization-response issuer. Use `ClientOAuthOptions.AuthorizationCallbackHandler` for issuer-aware authorization flows. |
|
| `MCP9007` | In place | `AuthorizationRedirectDelegate` and `ClientOAuthOptions.AuthorizationRedirectDelegate` are retained for source and binary compatibility but cannot provide the authorization-response state or RFC 9207 issuer. State and issuer validation are skipped when these APIs are used. Use `ClientOAuthOptions.AuthorizationCallbackHandler` for response-bound, issuer-aware authorization flows. |
|
||||||
|
|
|
||||||
|
|
@ -98,6 +98,7 @@ static async Task<AuthorizationResult?> HandleAuthorizationUrlAsync(
|
||||||
var context = await listener.GetContextAsync();
|
var context = await listener.GetContextAsync();
|
||||||
var query = HttpUtility.ParseQueryString(context.Request.Url?.Query ?? string.Empty);
|
var query = HttpUtility.ParseQueryString(context.Request.Url?.Query ?? string.Empty);
|
||||||
var code = query["code"];
|
var code = query["code"];
|
||||||
|
var state = query["state"];
|
||||||
var iss = query["iss"];
|
var iss = query["iss"];
|
||||||
var error = query["error"];
|
var error = query["error"];
|
||||||
|
|
||||||
|
|
@ -121,7 +122,7 @@ static async Task<AuthorizationResult?> HandleAuthorizationUrlAsync(
|
||||||
}
|
}
|
||||||
|
|
||||||
Console.WriteLine("Authorization code received successfully.");
|
Console.WriteLine("Authorization code received successfully.");
|
||||||
return new AuthorizationResult { Code = code, Iss = iss };
|
return new AuthorizationResult { Code = code, State = state, Iss = iss };
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,16 @@
|
||||||
namespace ModelContextProtocol.Authentication;
|
namespace ModelContextProtocol.Authentication;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Represents the result of an OAuth authorization redirect, containing the authorization code
|
/// Represents the result of an OAuth authorization redirect, containing the authorization code,
|
||||||
/// and optionally the issuer identifier from the authorization response.
|
/// state, and optionally the issuer identifier from the authorization response.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <para>
|
/// <para>
|
||||||
|
/// The <see cref="State"/> property must be populated from the <c>state</c> query parameter in the
|
||||||
|
/// redirect URI. The SDK validates it against the value sent in the authorization request to bind
|
||||||
|
/// the response to the initiating transaction and mitigate cross-site request forgery attacks.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
/// The <see cref="Iss"/> property should be populated from the <c>iss</c> query parameter in the
|
/// The <see cref="Iss"/> property should be populated from the <c>iss</c> query parameter in the
|
||||||
/// redirect URI when present, as specified by
|
/// redirect URI when present, as specified by
|
||||||
/// <see href="https://datatracker.ietf.org/doc/html/rfc9207">RFC 9207</see>.
|
/// <see href="https://datatracker.ietf.org/doc/html/rfc9207">RFC 9207</see>.
|
||||||
|
|
@ -20,6 +25,16 @@ public sealed class AuthorizationResult
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string? Code { get; init; }
|
public string? Code { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the state value returned in the authorization response.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Implementations of <see cref="ClientOAuthOptions.AuthorizationCallbackHandler"/> must populate this
|
||||||
|
/// property from the <c>state</c> query parameter of the redirect URI callback. The SDK requires an
|
||||||
|
/// exact match with the state sent in the authorization request before exchanging the authorization code.
|
||||||
|
/// </remarks>
|
||||||
|
public string? State { get; init; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets the issuer identifier returned in the authorization response per
|
/// Gets the issuer identifier returned in the authorization response per
|
||||||
/// <see href="https://datatracker.ietf.org/doc/html/rfc9207">RFC 9207</see>.
|
/// <see href="https://datatracker.ietf.org/doc/html/rfc9207">RFC 9207</see>.
|
||||||
|
|
|
||||||
|
|
@ -80,8 +80,10 @@ public sealed class ClientOAuthOptions
|
||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// Custom implementations might open a browser, start an HTTP listener, or use other mechanisms to capture
|
/// Custom implementations might open a browser, start an HTTP listener, or use other mechanisms to capture
|
||||||
/// the authorization response. They should return both the <c>code</c> and <c>iss</c> query parameters
|
/// the authorization response. They must return the <c>code</c> and <c>state</c> query parameters,
|
||||||
/// from the redirect URI callback. This enables the SDK to validate the <c>iss</c> parameter per
|
/// and should return the <c>iss</c> query parameter when present, from the redirect URI callback.
|
||||||
|
/// The SDK requires an exact state match before exchanging the code. Returning <c>iss</c> enables
|
||||||
|
/// the SDK to validate the parameter per
|
||||||
/// <see href="https://datatracker.ietf.org/doc/html/rfc9207">RFC 9207</see>, which mitigates
|
/// <see href="https://datatracker.ietf.org/doc/html/rfc9207">RFC 9207</see>, which mitigates
|
||||||
/// mix-up attacks.
|
/// mix-up attacks.
|
||||||
/// </para>
|
/// </para>
|
||||||
|
|
@ -96,9 +98,10 @@ public sealed class ClientOAuthOptions
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// This delegate returns only the authorization code and cannot provide the <c>iss</c> parameter from
|
/// This delegate returns only the authorization code and cannot provide the <c>state</c> or <c>iss</c>
|
||||||
/// the authorization response. Consequently, RFC 9207 issuer validation is skipped when this delegate
|
/// parameter from the authorization response. Consequently, state and RFC 9207 issuer validation are
|
||||||
/// is used. Use <see cref="AuthorizationCallbackHandler"/> for issuer-aware authorization flows.
|
/// skipped when this delegate is used. Use <see cref="AuthorizationCallbackHandler"/> for response-bound,
|
||||||
|
/// issuer-aware authorization flows.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// This property cannot be configured together with <see cref="AuthorizationCallbackHandler"/>.
|
/// This property cannot be configured together with <see cref="AuthorizationCallbackHandler"/>.
|
||||||
|
|
@ -139,8 +142,9 @@ public sealed class ClientOAuthOptions
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// Parameters specified cannot override or append to any automatically set parameters like the "redirect_uri",
|
/// Parameters specified cannot override or append to any automatically set parameters like
|
||||||
/// which should instead be configured via <see cref="RedirectUri"/>.
|
/// <c>redirect_uri</c> or <c>state</c>. The redirect URI should instead be configured via
|
||||||
|
/// <see cref="RedirectUri"/>, while state is generated uniquely for each authorization transaction.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
public IDictionary<string, string> AdditionalAuthorizationParameters { get; set; } = new Dictionary<string, string>();
|
public IDictionary<string, string> AdditionalAuthorizationParameters { get; set; } = new Dictionary<string, string>();
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,7 @@ internal sealed partial class ClientOAuthProvider : McpHttpClient
|
||||||
private readonly IDictionary<string, string> _additionalAuthorizationParameters;
|
private readonly IDictionary<string, string> _additionalAuthorizationParameters;
|
||||||
private readonly Func<IReadOnlyList<Uri>, Uri?> _authServerSelector;
|
private readonly Func<IReadOnlyList<Uri>, Uri?> _authServerSelector;
|
||||||
private readonly Func<AuthorizationCallbackContext, CancellationToken, Task<AuthorizationResult?>> _authorizationCallbackHandler;
|
private readonly Func<AuthorizationCallbackContext, CancellationToken, Task<AuthorizationResult?>> _authorizationCallbackHandler;
|
||||||
|
private readonly bool _validateAuthorizationResponseState;
|
||||||
private readonly bool _validateAuthorizationResponseIssuer;
|
private readonly bool _validateAuthorizationResponseIssuer;
|
||||||
private readonly Uri? _clientMetadataDocumentUri;
|
private readonly Uri? _clientMetadataDocumentUri;
|
||||||
|
|
||||||
|
|
@ -120,6 +121,7 @@ internal sealed partial class ClientOAuthProvider : McpHttpClient
|
||||||
if (options.AuthorizationCallbackHandler is not null)
|
if (options.AuthorizationCallbackHandler is not null)
|
||||||
{
|
{
|
||||||
_authorizationCallbackHandler = options.AuthorizationCallbackHandler;
|
_authorizationCallbackHandler = options.AuthorizationCallbackHandler;
|
||||||
|
_validateAuthorizationResponseState = true;
|
||||||
_validateAuthorizationResponseIssuer = true;
|
_validateAuthorizationResponseIssuer = true;
|
||||||
}
|
}
|
||||||
else if (authorizationRedirectDelegate is not null)
|
else if (authorizationRedirectDelegate is not null)
|
||||||
|
|
@ -131,11 +133,13 @@ internal sealed partial class ClientOAuthProvider : McpHttpClient
|
||||||
context.RedirectUri,
|
context.RedirectUri,
|
||||||
cancellationToken).ConfigureAwait(false),
|
cancellationToken).ConfigureAwait(false),
|
||||||
};
|
};
|
||||||
|
_validateAuthorizationResponseState = false;
|
||||||
_validateAuthorizationResponseIssuer = false;
|
_validateAuthorizationResponseIssuer = false;
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
_authorizationCallbackHandler = DefaultAuthorizationUrlHandler;
|
_authorizationCallbackHandler = DefaultAuthorizationUrlHandler;
|
||||||
|
_validateAuthorizationResponseState = true;
|
||||||
_validateAuthorizationResponseIssuer = true;
|
_validateAuthorizationResponseIssuer = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -155,11 +159,11 @@ internal sealed partial class ClientOAuthProvider : McpHttpClient
|
||||||
private static Uri? DefaultAuthServerSelector(IReadOnlyList<Uri> availableServers) => availableServers.FirstOrDefault();
|
private static Uri? DefaultAuthServerSelector(IReadOnlyList<Uri> availableServers) => availableServers.FirstOrDefault();
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Default authorization URL handler that displays the URL to the user for manual input.
|
/// Default authorization URL handler that displays the URL to the user and parses the resulting redirect URL.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="context">The context containing the authorization and redirect URIs.</param>
|
/// <param name="context">The context containing the authorization and redirect URIs.</param>
|
||||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests.</param>
|
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests.</param>
|
||||||
/// <returns>The authorization result entered by the user, or null if none was provided.</returns>
|
/// <returns>The authorization result parsed from the redirect URL.</returns>
|
||||||
private static Task<AuthorizationResult?> DefaultAuthorizationUrlHandler(
|
private static Task<AuthorizationResult?> DefaultAuthorizationUrlHandler(
|
||||||
AuthorizationCallbackContext context,
|
AuthorizationCallbackContext context,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
|
|
@ -179,6 +183,7 @@ internal sealed partial class ClientOAuthProvider : McpHttpClient
|
||||||
return Task.FromResult<AuthorizationResult?>(new()
|
return Task.FromResult<AuthorizationResult?>(new()
|
||||||
{
|
{
|
||||||
Code = queryParams["code"],
|
Code = queryParams["code"],
|
||||||
|
State = queryParams["state"],
|
||||||
Iss = queryParams["iss"],
|
Iss = queryParams["iss"],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -687,10 +692,11 @@ internal sealed partial class ClientOAuthProvider : McpHttpClient
|
||||||
AuthorizationServerMetadata authServerMetadata,
|
AuthorizationServerMetadata authServerMetadata,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var codeVerifier = GenerateCodeVerifier();
|
var codeVerifier = GenerateRandomBase64UrlValue();
|
||||||
var codeChallenge = GenerateCodeChallenge(codeVerifier);
|
var codeChallenge = GenerateCodeChallenge(codeVerifier);
|
||||||
|
var state = GenerateRandomBase64UrlValue();
|
||||||
|
|
||||||
var authUrl = BuildAuthorizationUrl(protectedResourceMetadata, authServerMetadata, codeChallenge);
|
var authUrl = BuildAuthorizationUrl(protectedResourceMetadata, authServerMetadata, codeChallenge, state);
|
||||||
|
|
||||||
var authResult = await _authorizationCallbackHandler(
|
var authResult = await _authorizationCallbackHandler(
|
||||||
new AuthorizationCallbackContext
|
new AuthorizationCallbackContext
|
||||||
|
|
@ -700,9 +706,19 @@ internal sealed partial class ClientOAuthProvider : McpHttpClient
|
||||||
},
|
},
|
||||||
cancellationToken).ConfigureAwait(false);
|
cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
if (authResult is null || string.IsNullOrEmpty(authResult.Code))
|
if (authResult is null)
|
||||||
{
|
{
|
||||||
ThrowFailedToHandleUnauthorizedResponse($"The {nameof(ClientOAuthOptions.AuthorizationCallbackHandler)} returned a null or empty authorization code.");
|
ThrowFailedToHandleUnauthorizedResponse($"The {nameof(ClientOAuthOptions.AuthorizationCallbackHandler)} returned a null authorization result.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_validateAuthorizationResponseState)
|
||||||
|
{
|
||||||
|
ValidateStateResponse(authResult!.State, state);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(authResult.Code))
|
||||||
|
{
|
||||||
|
ThrowFailedToHandleUnauthorizedResponse("The authorization callback returned a null or empty authorization code.");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (_validateAuthorizationResponseIssuer)
|
if (_validateAuthorizationResponseIssuer)
|
||||||
|
|
@ -721,7 +737,8 @@ internal sealed partial class ClientOAuthProvider : McpHttpClient
|
||||||
private Uri BuildAuthorizationUrl(
|
private Uri BuildAuthorizationUrl(
|
||||||
ProtectedResourceMetadata protectedResourceMetadata,
|
ProtectedResourceMetadata protectedResourceMetadata,
|
||||||
AuthorizationServerMetadata authServerMetadata,
|
AuthorizationServerMetadata authServerMetadata,
|
||||||
string codeChallenge)
|
string codeChallenge,
|
||||||
|
string state)
|
||||||
{
|
{
|
||||||
var resourceUri = GetResourceUri(protectedResourceMetadata);
|
var resourceUri = GetResourceUri(protectedResourceMetadata);
|
||||||
|
|
||||||
|
|
@ -732,6 +749,7 @@ internal sealed partial class ClientOAuthProvider : McpHttpClient
|
||||||
["response_type"] = "code",
|
["response_type"] = "code",
|
||||||
["code_challenge"] = codeChallenge,
|
["code_challenge"] = codeChallenge,
|
||||||
["code_challenge_method"] = "S256",
|
["code_challenge_method"] = "S256",
|
||||||
|
["state"] = state,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (resourceUri is not null)
|
if (resourceUri is not null)
|
||||||
|
|
@ -1107,6 +1125,26 @@ internal sealed partial class ClientOAuthProvider : McpHttpClient
|
||||||
return scope + " " + OfflineAccess;
|
return scope + " " + OfflineAccess;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Validates that an authorization response is bound to the transaction that initiated it.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="state">The state returned in the authorization response.</param>
|
||||||
|
/// <param name="expectedState">The state sent in the authorization request.</param>
|
||||||
|
private static void ValidateStateResponse(string? state, string expectedState)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(state))
|
||||||
|
{
|
||||||
|
ThrowFailedToHandleUnauthorizedResponse(
|
||||||
|
"The authorization response did not include the required state parameter.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.Equals(state, expectedState, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
ThrowFailedToHandleUnauthorizedResponse(
|
||||||
|
"The authorization response state did not match the state sent in the authorization request.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Validates the <c>iss</c> parameter from an authorization response per
|
/// Validates the <c>iss</c> parameter from an authorization response per
|
||||||
/// <see href="https://datatracker.ietf.org/doc/html/rfc9207">RFC 9207</see>.
|
/// <see href="https://datatracker.ietf.org/doc/html/rfc9207">RFC 9207</see>.
|
||||||
|
|
@ -1378,7 +1416,7 @@ internal sealed partial class ClientOAuthProvider : McpHttpClient
|
||||||
yield return (new Uri($"{hostBase}{ProtectedResourceMetadataWellKnownPath}"), new Uri(hostBase));
|
yield return (new Uri($"{hostBase}{ProtectedResourceMetadataWellKnownPath}"), new Uri(hostBase));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string GenerateCodeVerifier()
|
private static string GenerateRandomBase64UrlValue()
|
||||||
{
|
{
|
||||||
#if NET9_0_OR_GREATER
|
#if NET9_0_OR_GREATER
|
||||||
Span<byte> bytes = stackalloc byte[32];
|
Span<byte> bytes = stackalloc byte[32];
|
||||||
|
|
|
||||||
|
|
@ -176,6 +176,77 @@ public class AuthTests : OAuthTestBase
|
||||||
#pragma warning restore MCP9007
|
#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]
|
[Fact]
|
||||||
public async Task CannotAuthenticate_WithoutOAuthConfiguration()
|
public async Task CannotAuthenticate_WithoutOAuthConfiguration()
|
||||||
{
|
{
|
||||||
|
|
@ -550,8 +621,10 @@ public class AuthTests : OAuthTestBase
|
||||||
Assert.Contains("custom_param=custom_value", lastAuthorizationUri?.Query);
|
Assert.Contains("custom_param=custom_value", lastAuthorizationUri?.Query);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Theory]
|
||||||
public async Task CannotOverrideExistingParameters_WithExtraParams()
|
[InlineData("redirect_uri")]
|
||||||
|
[InlineData("state")]
|
||||||
|
public async Task CannotOverrideExistingParameters_WithExtraParams(string parameterName)
|
||||||
{
|
{
|
||||||
await using var app = await StartMcpServerAsync();
|
await using var app = await StartMcpServerAsync();
|
||||||
|
|
||||||
|
|
@ -566,7 +639,7 @@ public class AuthTests : OAuthTestBase
|
||||||
AuthorizationCallbackHandler = HandleAuthorizationUrlAsync,
|
AuthorizationCallbackHandler = HandleAuthorizationUrlAsync,
|
||||||
AdditionalAuthorizationParameters = new Dictionary<string, string>
|
AdditionalAuthorizationParameters = new Dictionary<string, string>
|
||||||
{
|
{
|
||||||
["redirect_uri"] = "custom_value",
|
[parameterName] = "custom_value",
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
}, HttpClient, LoggerFactory);
|
}, HttpClient, LoggerFactory);
|
||||||
|
|
@ -2434,7 +2507,9 @@ public class AuthTests : OAuthTestBase
|
||||||
Assert.False(scopePresent);
|
Assert.False(scopePresent);
|
||||||
}
|
}
|
||||||
|
|
||||||
private HttpClientTransport CreateOAuthTransport() =>
|
private HttpClientTransport CreateOAuthTransport(
|
||||||
|
Func<AuthorizationCallbackContext, CancellationToken, Task<ModelContextProtocol.Authentication.AuthorizationResult?>>?
|
||||||
|
authorizationCallbackHandler = null) =>
|
||||||
new(new()
|
new(new()
|
||||||
{
|
{
|
||||||
Endpoint = new(McpServerUrl),
|
Endpoint = new(McpServerUrl),
|
||||||
|
|
@ -2443,7 +2518,7 @@ public class AuthTests : OAuthTestBase
|
||||||
ClientId = "demo-client",
|
ClientId = "demo-client",
|
||||||
ClientSecret = "demo-secret",
|
ClientSecret = "demo-secret",
|
||||||
RedirectUri = new Uri("http://localhost:1179/callback"),
|
RedirectUri = new Uri("http://localhost:1179/callback"),
|
||||||
AuthorizationCallbackHandler = HandleAuthorizationUrlAsync,
|
AuthorizationCallbackHandler = authorizationCallbackHandler ?? HandleAuthorizationUrlAsync,
|
||||||
},
|
},
|
||||||
}, HttpClient, LoggerFactory);
|
}, HttpClient, LoggerFactory);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -120,6 +120,7 @@ public abstract class OAuthTestBase : KestrelInMemoryTest, IAsyncDisposable
|
||||||
return new ModelContextProtocol.Authentication.AuthorizationResult
|
return new ModelContextProtocol.Authentication.AuthorizationResult
|
||||||
{
|
{
|
||||||
Code = queryParams["code"],
|
Code = queryParams["code"],
|
||||||
|
State = queryParams["state"],
|
||||||
Iss = queryParams.TryGetValue("iss", out var iss) ? (string?)iss : null,
|
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))
|
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('?');
|
var query = location.Query.TrimStart('?');
|
||||||
string? code = null;
|
string? code = null;
|
||||||
|
string? state = null;
|
||||||
string? iss = null;
|
string? iss = null;
|
||||||
foreach (var pair in query.Split('&'))
|
foreach (var pair in query.Split('&'))
|
||||||
{
|
{
|
||||||
|
|
@ -373,6 +374,10 @@ static async Task<AuthorizationResult?> HandleAuthorizationUrlAsync(
|
||||||
{
|
{
|
||||||
code = HttpUtility.UrlDecode(parts[1]);
|
code = HttpUtility.UrlDecode(parts[1]);
|
||||||
}
|
}
|
||||||
|
else if (parts[0] == "state")
|
||||||
|
{
|
||||||
|
state = HttpUtility.UrlDecode(parts[1]);
|
||||||
|
}
|
||||||
else if (parts[0] == "iss")
|
else if (parts[0] == "iss")
|
||||||
{
|
{
|
||||||
iss = HttpUtility.UrlDecode(parts[1]);
|
iss = HttpUtility.UrlDecode(parts[1]);
|
||||||
|
|
@ -382,7 +387,7 @@ static async Task<AuthorizationResult?> HandleAuthorizationUrlAsync(
|
||||||
|
|
||||||
if (code is not null)
|
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 ConcurrentDictionary<string, ClientInfo> _clients = new();
|
||||||
|
|
||||||
private readonly ConcurrentQueue<string> _metadataRequests = new();
|
private readonly ConcurrentQueue<string> _metadataRequests = new();
|
||||||
|
private int _authorizationCodeTokenRequestCount;
|
||||||
|
|
||||||
private readonly RSA _rsa;
|
private readonly RSA _rsa;
|
||||||
private readonly string _keyId;
|
private readonly string _keyId;
|
||||||
|
|
@ -137,6 +138,9 @@ public sealed class Program
|
||||||
public HashSet<string> DisabledMetadataPaths { get; } = new(StringComparer.OrdinalIgnoreCase);
|
public HashSet<string> DisabledMetadataPaths { get; } = new(StringComparer.OrdinalIgnoreCase);
|
||||||
public IReadOnlyCollection<string> MetadataRequests => _metadataRequests.ToArray();
|
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>
|
/// <summary>Gets the <c>scope</c> field from the most recent Dynamic Client Registration request.</summary>
|
||||||
public string? LastRegistrationScope { get; private set; }
|
public string? LastRegistrationScope { get; private set; }
|
||||||
|
|
||||||
|
|
@ -460,6 +464,7 @@ public sealed class Program
|
||||||
|
|
||||||
if (grant_type == "authorization_code")
|
if (grant_type == "authorization_code")
|
||||||
{
|
{
|
||||||
|
Interlocked.Increment(ref _authorizationCodeTokenRequestCount);
|
||||||
var code = form["code"].ToString();
|
var code = form["code"].ToString();
|
||||||
var code_verifier = form["code_verifier"].ToString();
|
var code_verifier = form["code_verifier"].ToString();
|
||||||
var redirect_uri = form["redirect_uri"].ToString();
|
var redirect_uri = form["redirect_uri"].ToString();
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue