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
3 changed files with 170 additions and 26 deletions
|
|
@ -1,11 +1,16 @@
|
|||
namespace ModelContextProtocol.Authentication;
|
||||
|
||||
/// <summary>
|
||||
/// Represents the result of an OAuth authorization redirect, containing the authorization code
|
||||
/// and optionally the issuer identifier from the authorization response.
|
||||
/// Represents the result of an OAuth authorization redirect, containing the authorization code,
|
||||
/// state, and optionally the issuer identifier from the authorization response.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <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
|
||||
/// redirect URI when present, as specified by
|
||||
/// <see href="https://datatracker.ietf.org/doc/html/rfc9207">RFC 9207</see>.
|
||||
|
|
@ -20,6 +25,16 @@ public sealed class AuthorizationResult
|
|||
/// </summary>
|
||||
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>
|
||||
/// Gets the issuer identifier returned in the authorization response per
|
||||
/// <see href="https://datatracker.ietf.org/doc/html/rfc9207">RFC 9207</see>.
|
||||
|
|
|
|||
|
|
@ -80,8 +80,10 @@ public sealed class ClientOAuthOptions
|
|||
/// </para>
|
||||
/// <para>
|
||||
/// 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
|
||||
/// from the redirect URI callback. This enables the SDK to validate the <c>iss</c> parameter per
|
||||
/// the authorization response. They must return the <c>code</c> and <c>state</c> query parameters,
|
||||
/// 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
|
||||
/// mix-up attacks.
|
||||
/// </para>
|
||||
|
|
@ -96,9 +98,10 @@ public sealed class ClientOAuthOptions
|
|||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// This delegate returns only the authorization code and cannot provide the <c>iss</c> parameter from
|
||||
/// the authorization response. Consequently, RFC 9207 issuer validation is skipped when this delegate
|
||||
/// is used. Use <see cref="AuthorizationCallbackHandler"/> for issuer-aware authorization flows.
|
||||
/// This delegate returns only the authorization code and cannot provide the <c>state</c> or <c>iss</c>
|
||||
/// parameter from the authorization response. Consequently, state and RFC 9207 issuer validation are
|
||||
/// skipped when this delegate is used. Use <see cref="AuthorizationCallbackHandler"/> for response-bound,
|
||||
/// issuer-aware authorization flows.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// This property cannot be configured together with <see cref="AuthorizationCallbackHandler"/>.
|
||||
|
|
@ -139,8 +142,9 @@ public sealed class ClientOAuthOptions
|
|||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Parameters specified cannot override or append to any automatically set parameters like the "redirect_uri",
|
||||
/// which should instead be configured via <see cref="RedirectUri"/>.
|
||||
/// Parameters specified cannot override or append to any automatically set parameters like
|
||||
/// <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>
|
||||
/// </remarks>
|
||||
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 Func<IReadOnlyList<Uri>, Uri?> _authServerSelector;
|
||||
private readonly Func<AuthorizationCallbackContext, CancellationToken, Task<AuthorizationResult?>> _authorizationCallbackHandler;
|
||||
private readonly bool _validateAuthorizationResponseState;
|
||||
private readonly bool _validateAuthorizationResponseIssuer;
|
||||
private readonly Uri? _clientMetadataDocumentUri;
|
||||
|
||||
|
|
@ -120,6 +121,7 @@ internal sealed partial class ClientOAuthProvider : McpHttpClient
|
|||
if (options.AuthorizationCallbackHandler is not null)
|
||||
{
|
||||
_authorizationCallbackHandler = options.AuthorizationCallbackHandler;
|
||||
_validateAuthorizationResponseState = true;
|
||||
_validateAuthorizationResponseIssuer = true;
|
||||
}
|
||||
else if (authorizationRedirectDelegate is not null)
|
||||
|
|
@ -131,11 +133,13 @@ internal sealed partial class ClientOAuthProvider : McpHttpClient
|
|||
context.RedirectUri,
|
||||
cancellationToken).ConfigureAwait(false),
|
||||
};
|
||||
_validateAuthorizationResponseState = false;
|
||||
_validateAuthorizationResponseIssuer = false;
|
||||
}
|
||||
else
|
||||
{
|
||||
_authorizationCallbackHandler = DefaultAuthorizationUrlHandler;
|
||||
_validateAuthorizationResponseState = true;
|
||||
_validateAuthorizationResponseIssuer = true;
|
||||
}
|
||||
|
||||
|
|
@ -155,11 +159,11 @@ internal sealed partial class ClientOAuthProvider : McpHttpClient
|
|||
private static Uri? DefaultAuthServerSelector(IReadOnlyList<Uri> availableServers) => availableServers.FirstOrDefault();
|
||||
|
||||
/// <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>
|
||||
/// <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>
|
||||
/// <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(
|
||||
AuthorizationCallbackContext context,
|
||||
CancellationToken cancellationToken)
|
||||
|
|
@ -179,6 +183,7 @@ internal sealed partial class ClientOAuthProvider : McpHttpClient
|
|||
return Task.FromResult<AuthorizationResult?>(new()
|
||||
{
|
||||
Code = queryParams["code"],
|
||||
State = queryParams["state"],
|
||||
Iss = queryParams["iss"],
|
||||
});
|
||||
}
|
||||
|
|
@ -687,10 +692,11 @@ internal sealed partial class ClientOAuthProvider : McpHttpClient
|
|||
AuthorizationServerMetadata authServerMetadata,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var codeVerifier = GenerateCodeVerifier();
|
||||
var codeVerifier = GenerateRandomBase64UrlValue();
|
||||
var codeChallenge = GenerateCodeChallenge(codeVerifier);
|
||||
var state = GenerateRandomBase64UrlValue();
|
||||
|
||||
var authUrl = BuildAuthorizationUrl(protectedResourceMetadata, authServerMetadata, codeChallenge);
|
||||
var authUrl = BuildAuthorizationUrl(protectedResourceMetadata, authServerMetadata, codeChallenge, state);
|
||||
|
||||
var authResult = await _authorizationCallbackHandler(
|
||||
new AuthorizationCallbackContext
|
||||
|
|
@ -700,9 +706,19 @@ internal sealed partial class ClientOAuthProvider : McpHttpClient
|
|||
},
|
||||
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)
|
||||
|
|
@ -721,7 +737,8 @@ internal sealed partial class ClientOAuthProvider : McpHttpClient
|
|||
private Uri BuildAuthorizationUrl(
|
||||
ProtectedResourceMetadata protectedResourceMetadata,
|
||||
AuthorizationServerMetadata authServerMetadata,
|
||||
string codeChallenge)
|
||||
string codeChallenge,
|
||||
string state)
|
||||
{
|
||||
var resourceUri = GetResourceUri(protectedResourceMetadata);
|
||||
|
||||
|
|
@ -732,6 +749,7 @@ internal sealed partial class ClientOAuthProvider : McpHttpClient
|
|||
["response_type"] = "code",
|
||||
["code_challenge"] = codeChallenge,
|
||||
["code_challenge_method"] = "S256",
|
||||
["state"] = state,
|
||||
};
|
||||
|
||||
if (resourceUri is not null)
|
||||
|
|
@ -1107,6 +1125,26 @@ internal sealed partial class ClientOAuthProvider : McpHttpClient
|
|||
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>
|
||||
/// Validates the <c>iss</c> parameter from an authorization response per
|
||||
/// <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));
|
||||
}
|
||||
|
||||
private static string GenerateCodeVerifier()
|
||||
private static string GenerateRandomBase64UrlValue()
|
||||
{
|
||||
#if NET9_0_OR_GREATER
|
||||
Span<byte> bytes = stackalloc byte[32];
|
||||
|
|
|
|||
Loading…
Reference in a new issue