Implement RFC 9207 issuer validation in ClientOAuthProvider (#1605)
This commit is contained in:
parent
990bd76b53
commit
4aade88558
18 changed files with 707 additions and 149 deletions
|
|
@ -45,3 +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. |
|
||||
| `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. |
|
||||
| `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. |
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
using Microsoft.Extensions.Logging;
|
||||
using ModelContextProtocol.Authentication;
|
||||
using ModelContextProtocol.Client;
|
||||
using ModelContextProtocol.Protocol;
|
||||
using System.Diagnostics;
|
||||
|
|
@ -32,7 +33,7 @@ var transport = new HttpClientTransport(new()
|
|||
OAuth = new()
|
||||
{
|
||||
RedirectUri = new Uri("http://localhost:1179/callback"),
|
||||
AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync,
|
||||
AuthorizationCallbackHandler = HandleAuthorizationUrlAsync,
|
||||
DynamicClientRegistration = new()
|
||||
{
|
||||
ClientName = "ProtectedMcpClient",
|
||||
|
|
@ -68,12 +69,16 @@ if (tools.Any(t => t.Name == "get_alerts"))
|
|||
/// Handles the OAuth authorization URL by starting a local HTTP server and opening a browser.
|
||||
/// This implementation demonstrates how SDK consumers can provide their own authorization flow.
|
||||
/// </summary>
|
||||
/// <param name="authorizationUrl">The authorization URL to open in the browser.</param>
|
||||
/// <param name="redirectUri">The redirect URI where the authorization code will be sent.</param>
|
||||
/// <param name="authorizationContext">The context containing the authorization and redirect URIs.</param>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
|
||||
/// <returns>The authorization code extracted from the callback, or null if the operation failed.</returns>
|
||||
static async Task<string?> HandleAuthorizationUrlAsync(Uri authorizationUrl, Uri redirectUri, CancellationToken cancellationToken)
|
||||
/// <returns>The authorization result extracted from the callback, or null if the operation failed.</returns>
|
||||
static async Task<AuthorizationResult?> HandleAuthorizationUrlAsync(
|
||||
AuthorizationCallbackContext authorizationContext,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var authorizationUrl = authorizationContext.AuthorizationUri;
|
||||
var redirectUri = authorizationContext.RedirectUri;
|
||||
|
||||
Console.WriteLine("Starting OAuth authorization flow...");
|
||||
Console.WriteLine($"Opening browser to: {authorizationUrl}");
|
||||
|
||||
|
|
@ -93,6 +98,7 @@ static async Task<string?> HandleAuthorizationUrlAsync(Uri authorizationUrl, Uri
|
|||
var context = await listener.GetContextAsync();
|
||||
var query = HttpUtility.ParseQueryString(context.Request.Url?.Query ?? string.Empty);
|
||||
var code = query["code"];
|
||||
var iss = query["iss"];
|
||||
var error = query["error"];
|
||||
|
||||
string responseHtml = "<html><body><h1>Authentication complete</h1><p>You can close this window now.</p></body></html>";
|
||||
|
|
@ -115,7 +121,7 @@ static async Task<string?> HandleAuthorizationUrlAsync(Uri authorizationUrl, Uri
|
|||
}
|
||||
|
||||
Console.WriteLine("Authorization code received successfully.");
|
||||
return code;
|
||||
return new AuthorizationResult { Code = code, Iss = iss };
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -46,4 +46,8 @@ internal static class Obsoletions
|
|||
public const string LegacyStatefulHttp_DiagnosticId = "MCP9006";
|
||||
public const string LegacyStatefulHttp_Message = "Stateful Streamable HTTP mode is a back-compat-only escape hatch for legacy clients. Set HttpServerTransportOptions.Stateless = true (the default as of the 2026-07-28 protocol revision) for new code. See SEP-2567.";
|
||||
public const string LegacyStatefulHttp_Url = "https://github.com/modelcontextprotocol/csharp-sdk/blob/main/docs/list-of-diagnostics.md#obsolete-apis";
|
||||
|
||||
public const string AuthorizationRedirectDelegate_DiagnosticId = "MCP9007";
|
||||
public const string AuthorizationRedirectDelegate_Message = "AuthorizationRedirectDelegate cannot provide the RFC 9207 issuer and is retained for compatibility only. Use AuthorizationCallbackHandler instead.";
|
||||
public const string AuthorizationRedirectDelegate_Url = "https://github.com/modelcontextprotocol/csharp-sdk/blob/main/docs/list-of-diagnostics.md#obsolete-apis";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,17 @@
|
|||
namespace ModelContextProtocol.Authentication;
|
||||
|
||||
/// <summary>
|
||||
/// Provides the information needed to complete an OAuth authorization request.
|
||||
/// </summary>
|
||||
public sealed class AuthorizationCallbackContext
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the authorization URI that the user needs to visit.
|
||||
/// </summary>
|
||||
public required Uri AuthorizationUri { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the redirect URI where the authorization response will be sent.
|
||||
/// </summary>
|
||||
public required Uri RedirectUri { get; init; }
|
||||
}
|
||||
|
|
@ -1,4 +1,3 @@
|
|||
|
||||
namespace ModelContextProtocol.Authentication;
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -9,20 +8,13 @@ namespace ModelContextProtocol.Authentication;
|
|||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
|
||||
/// <returns>A task that represents the asynchronous operation. The task result contains the authorization code if successful, or null if the operation failed or was cancelled.</returns>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// This delegate provides SDK consumers with full control over how the OAuth authorization flow is handled.
|
||||
/// Implementers can choose to:
|
||||
/// </para>
|
||||
/// <list type="bullet">
|
||||
/// <item><description>Start a local HTTP server and open a browser (default behavior)</description></item>
|
||||
/// <item><description>Display the authorization URL to the user for manual handling</description></item>
|
||||
/// <item><description>Integrate with a custom UI or authentication flow</description></item>
|
||||
/// <item><description>Use a different redirect mechanism altogether</description></item>
|
||||
/// </list>
|
||||
/// <para>
|
||||
/// The implementation should handle user interaction to visit the authorization URL and extract
|
||||
/// the authorization code from the callback. The authorization code is typically provided as
|
||||
/// a query parameter in the redirect URI callback.
|
||||
/// </para>
|
||||
/// This delegate cannot return the <c>iss</c> parameter from the authorization response, so
|
||||
/// <see href="https://datatracker.ietf.org/doc/html/rfc9207">RFC 9207</see> issuer validation is
|
||||
/// skipped when it is used. Use <see cref="ClientOAuthOptions.AuthorizationCallbackHandler"/> for
|
||||
/// issuer-aware authorization flows.
|
||||
/// </remarks>
|
||||
public delegate Task<string?> AuthorizationRedirectDelegate(Uri authorizationUri, Uri redirectUri, CancellationToken cancellationToken);
|
||||
[Obsolete(
|
||||
ModelContextProtocol.Obsoletions.AuthorizationRedirectDelegate_Message,
|
||||
DiagnosticId = ModelContextProtocol.Obsoletions.AuthorizationRedirectDelegate_DiagnosticId,
|
||||
UrlFormat = ModelContextProtocol.Obsoletions.AuthorizationRedirectDelegate_Url)]
|
||||
public delegate Task<string?> AuthorizationRedirectDelegate(Uri authorizationUri, Uri redirectUri, CancellationToken cancellationToken);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,39 @@
|
|||
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.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <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>.
|
||||
/// This enables the SDK to validate that the authorization response originated from the expected
|
||||
/// authorization server, mitigating mix-up attacks.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class AuthorizationResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the authorization code returned by the authorization server.
|
||||
/// </summary>
|
||||
public string? Code { 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>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// This value should be extracted from the <c>iss</c> query parameter of the redirect URI.
|
||||
/// When present, the SDK validates it against the expected authorization server issuer to
|
||||
/// prevent mix-up attacks.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Implementations of <see cref="ClientOAuthOptions.AuthorizationCallbackHandler"/> should populate this
|
||||
/// property whenever the <c>iss</c> parameter is present in the redirect URI callback.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public string? Iss { get; init; }
|
||||
}
|
||||
|
|
@ -72,4 +72,11 @@ internal sealed class AuthorizationServerMetadata
|
|||
/// </summary>
|
||||
[JsonPropertyName("client_id_metadata_document_supported")]
|
||||
public bool ClientIdMetadataDocumentSupported { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Indicates whether the authorization server includes the <c>iss</c> parameter in authorization responses
|
||||
/// as defined in <see href="https://datatracker.ietf.org/doc/html/rfc9207">RFC 9207</see>.
|
||||
/// </summary>
|
||||
[JsonPropertyName("authorization_response_iss_parameter_supported")]
|
||||
public bool AuthorizationResponseIssParameterSupported { get; set; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -70,18 +70,44 @@ public sealed class ClientOAuthOptions
|
|||
public ScopeSelectorDelegate? ScopeSelector { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the authorization redirect delegate for handling the OAuth authorization flow.
|
||||
/// Gets or sets the callback that handles the OAuth authorization flow.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// This delegate is responsible for handling the OAuth authorization URL and obtaining the authorization code.
|
||||
/// If not specified, a default implementation will be used that prompts the user to enter the code manually.
|
||||
/// This callback receives the authorization and redirect URIs in an
|
||||
/// <see cref="AuthorizationCallbackContext"/> and returns the authorization response.
|
||||
/// If not specified, a default implementation prompts the user to enter the full redirect URL manually.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Custom implementations might open a browser, start an HTTP listener, or use other mechanisms to capture
|
||||
/// the authorization code from the OAuth redirect.
|
||||
/// 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
|
||||
/// <see href="https://datatracker.ietf.org/doc/html/rfc9207">RFC 9207</see>, which mitigates
|
||||
/// mix-up attacks.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// This property cannot be configured together with <see cref="AuthorizationRedirectDelegate"/>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public Func<AuthorizationCallbackContext, CancellationToken, Task<AuthorizationResult?>>? AuthorizationCallbackHandler { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the legacy authorization redirect delegate for handling the OAuth authorization flow.
|
||||
/// </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.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// This property cannot be configured together with <see cref="AuthorizationCallbackHandler"/>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Obsolete(
|
||||
ModelContextProtocol.Obsoletions.AuthorizationRedirectDelegate_Message,
|
||||
DiagnosticId = ModelContextProtocol.Obsoletions.AuthorizationRedirectDelegate_DiagnosticId,
|
||||
UrlFormat = ModelContextProtocol.Obsoletions.AuthorizationRedirectDelegate_Url)]
|
||||
public AuthorizationRedirectDelegate? AuthorizationRedirectDelegate { get; set; }
|
||||
|
||||
/// <summary>
|
||||
|
|
|
|||
|
|
@ -31,7 +31,8 @@ internal sealed partial class ClientOAuthProvider : McpHttpClient
|
|||
private readonly ScopeSelectorDelegate? _scopeSelector;
|
||||
private readonly IDictionary<string, string> _additionalAuthorizationParameters;
|
||||
private readonly Func<IReadOnlyList<Uri>, Uri?> _authServerSelector;
|
||||
private readonly AuthorizationRedirectDelegate _authorizationRedirectDelegate;
|
||||
private readonly Func<AuthorizationCallbackContext, CancellationToken, Task<AuthorizationResult?>> _authorizationCallbackHandler;
|
||||
private readonly bool _validateAuthorizationResponseIssuer;
|
||||
private readonly Uri? _clientMetadataDocumentUri;
|
||||
|
||||
// _dcrClientName, _dcrClientUri, _dcrInitialAccessToken, _dcrConfiguredApplicationType and _dcrResponseDelegate are used for dynamic client registration (RFC 7591)
|
||||
|
|
@ -104,8 +105,39 @@ internal sealed partial class ClientOAuthProvider : McpHttpClient
|
|||
// Set up authorization server selection strategy
|
||||
_authServerSelector = options.AuthServerSelector ?? DefaultAuthServerSelector;
|
||||
|
||||
// Set up authorization URL handler (use default if not provided)
|
||||
_authorizationRedirectDelegate = options.AuthorizationRedirectDelegate ?? DefaultAuthorizationUrlHandler;
|
||||
// Set up authorization callback handler (use default if not provided).
|
||||
#pragma warning disable MCP9007 // Read the obsolete property to provide source and binary compatibility.
|
||||
var authorizationRedirectDelegate = options.AuthorizationRedirectDelegate;
|
||||
|
||||
if (options.AuthorizationCallbackHandler is not null && authorizationRedirectDelegate is not null)
|
||||
{
|
||||
throw new ArgumentException(
|
||||
$"{nameof(ClientOAuthOptions.AuthorizationCallbackHandler)} and {nameof(ClientOAuthOptions.AuthorizationRedirectDelegate)} cannot both be configured.",
|
||||
nameof(options));
|
||||
}
|
||||
#pragma warning restore MCP9007
|
||||
|
||||
if (options.AuthorizationCallbackHandler is not null)
|
||||
{
|
||||
_authorizationCallbackHandler = options.AuthorizationCallbackHandler;
|
||||
_validateAuthorizationResponseIssuer = true;
|
||||
}
|
||||
else if (authorizationRedirectDelegate is not null)
|
||||
{
|
||||
_authorizationCallbackHandler = async (context, cancellationToken) => new AuthorizationResult
|
||||
{
|
||||
Code = await authorizationRedirectDelegate(
|
||||
context.AuthorizationUri,
|
||||
context.RedirectUri,
|
||||
cancellationToken).ConfigureAwait(false),
|
||||
};
|
||||
_validateAuthorizationResponseIssuer = false;
|
||||
}
|
||||
else
|
||||
{
|
||||
_authorizationCallbackHandler = DefaultAuthorizationUrlHandler;
|
||||
_validateAuthorizationResponseIssuer = true;
|
||||
}
|
||||
|
||||
_dcrClientName = options.DynamicClientRegistration?.ClientName;
|
||||
_dcrClientUri = options.DynamicClientRegistration?.ClientUri;
|
||||
|
|
@ -125,18 +157,30 @@ internal sealed partial class ClientOAuthProvider : McpHttpClient
|
|||
/// <summary>
|
||||
/// Default authorization URL handler that displays the URL to the user for manual input.
|
||||
/// </summary>
|
||||
/// <param name="authorizationUrl">The authorization URL to handle.</param>
|
||||
/// <param name="redirectUri">The redirect URI where the authorization code will be sent.</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>
|
||||
/// <returns>The authorization code entered by the user, or null if none was provided.</returns>
|
||||
private static Task<string?> DefaultAuthorizationUrlHandler(Uri authorizationUrl, Uri redirectUri, CancellationToken cancellationToken)
|
||||
/// <returns>The authorization result entered by the user, or null if none was provided.</returns>
|
||||
private static Task<AuthorizationResult?> DefaultAuthorizationUrlHandler(
|
||||
AuthorizationCallbackContext context,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Console.WriteLine($"Please open the following URL in your browser to authorize the application:");
|
||||
Console.WriteLine($"{authorizationUrl}");
|
||||
Console.WriteLine($"{context.AuthorizationUri}");
|
||||
Console.WriteLine();
|
||||
Console.Write("Enter the authorization code from the redirect URL: ");
|
||||
var authorizationCode = Console.ReadLine();
|
||||
return Task.FromResult<string?>(authorizationCode);
|
||||
Console.Write("Enter the full redirect URL: ");
|
||||
var redirectUrl = Console.ReadLine();
|
||||
if (!Uri.TryCreate(redirectUrl, UriKind.Absolute, out var responseUri))
|
||||
{
|
||||
ThrowFailedToHandleUnauthorizedResponse(
|
||||
"The entered redirect URL is not a valid absolute URL. Paste the full redirect URL from the browser address bar.");
|
||||
}
|
||||
|
||||
var queryParams = HttpUtility.ParseQueryString(responseUri.Query);
|
||||
return Task.FromResult<AuthorizationResult?>(new()
|
||||
{
|
||||
Code = queryParams["code"],
|
||||
Iss = queryParams["iss"],
|
||||
});
|
||||
}
|
||||
|
||||
internal override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, JsonRpcMessage? message, CancellationToken cancellationToken)
|
||||
|
|
@ -468,10 +512,41 @@ internal sealed partial class ClientOAuthProvider : McpHttpClient
|
|||
ThrowFailedToHandleUnauthorizedResponse($"AuthorizationEndpoint must use HTTP or HTTPS. '{metadata.AuthorizationEndpoint}' does not meet this requirement.");
|
||||
}
|
||||
|
||||
// Validate the issuer in the metadata document per RFC 8414 Section 3.3:
|
||||
// the issuer value MUST be identical to the issuer identifier used to construct
|
||||
// the well-known URL.
|
||||
// Skip validation in legacy backcompat mode (resourceUri is null) because the
|
||||
// authServerUri was derived from the server origin rather than from Protected
|
||||
// Resource Metadata, so it may not match the server's canonical issuer.
|
||||
// Note: resourceUri is null exclusively in the 2025-03-26 legacy path. For newer
|
||||
// protocol versions, ExtractProtectedResourceMetadata throws if the PRM document
|
||||
// omits the resource field (VerifyResourceMatch returns false for null Resource),
|
||||
// so we never reach this point with resourceUri == null in non-legacy flows.
|
||||
if (resourceUri is not null && metadata.Issuer is null)
|
||||
{
|
||||
ThrowFailedToHandleUnauthorizedResponse(
|
||||
$"Authorization server metadata from '{wellKnownEndpoint}' did not provide the required issuer (RFC 8414 Section 2).");
|
||||
}
|
||||
|
||||
// RFC 8414 requires an identical issuer value, so do not normalize URI case,
|
||||
// trailing slashes, or percent-encoding before comparison.
|
||||
if (resourceUri is not null &&
|
||||
!string.Equals(metadata.Issuer!.OriginalString, authServerUri.OriginalString, StringComparison.Ordinal))
|
||||
{
|
||||
ThrowFailedToHandleUnauthorizedResponse(
|
||||
$"Authorization server metadata issuer '{metadata.Issuer}' does not match the expected issuer '{authServerUri}' (RFC 8414 Section 3.3).");
|
||||
}
|
||||
|
||||
// A structurally valid metadata document was discovered. Even if it fails PKCE validation
|
||||
// below, its existence disqualifies the legacy fallback that would otherwise synthesize S256.
|
||||
metadataDocumentFound = true;
|
||||
}
|
||||
catch (McpException)
|
||||
{
|
||||
// Metadata validation failures are security signals and must not fall back to
|
||||
// another well-known endpoint.
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogErrorFetchingAuthServerMetadata(ex, wellKnownEndpoint);
|
||||
|
|
@ -605,14 +680,31 @@ internal sealed partial class ClientOAuthProvider : McpHttpClient
|
|||
var codeChallenge = GenerateCodeChallenge(codeVerifier);
|
||||
|
||||
var authUrl = BuildAuthorizationUrl(protectedResourceMetadata, authServerMetadata, codeChallenge);
|
||||
var authCode = await _authorizationRedirectDelegate(authUrl, _redirectUri, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (string.IsNullOrEmpty(authCode))
|
||||
var authResult = await _authorizationCallbackHandler(
|
||||
new AuthorizationCallbackContext
|
||||
{
|
||||
AuthorizationUri = authUrl,
|
||||
RedirectUri = _redirectUri,
|
||||
},
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (authResult is null || string.IsNullOrEmpty(authResult.Code))
|
||||
{
|
||||
ThrowFailedToHandleUnauthorizedResponse($"The {nameof(AuthorizationRedirectDelegate)} returned a null or empty authorization code.");
|
||||
ThrowFailedToHandleUnauthorizedResponse($"The {nameof(ClientOAuthOptions.AuthorizationCallbackHandler)} returned a null or empty authorization code.");
|
||||
}
|
||||
|
||||
return await ExchangeCodeForTokenAsync(protectedResourceMetadata, authServerMetadata, authCode!, codeVerifier, cancellationToken).ConfigureAwait(false);
|
||||
if (_validateAuthorizationResponseIssuer)
|
||||
{
|
||||
ValidateIssuerResponse(authResult!.Iss, authServerMetadata);
|
||||
}
|
||||
|
||||
return await ExchangeCodeForTokenAsync(
|
||||
protectedResourceMetadata,
|
||||
authServerMetadata,
|
||||
authResult.Code!,
|
||||
codeVerifier,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private Uri BuildAuthorizationUrl(
|
||||
|
|
@ -999,6 +1091,56 @@ internal sealed partial class ClientOAuthProvider : McpHttpClient
|
|||
return scope + " " + OfflineAccess;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates the <c>iss</c> parameter from an authorization response per
|
||||
/// <see href="https://datatracker.ietf.org/doc/html/rfc9207">RFC 9207</see>.
|
||||
/// </summary>
|
||||
/// <param name="iss">The issuer identifier received in the authorization response, or null if absent.</param>
|
||||
/// <param name="authServerMetadata">The authorization server metadata containing the expected issuer.</param>
|
||||
private void ValidateIssuerResponse(string? iss, AuthorizationServerMetadata authServerMetadata)
|
||||
{
|
||||
var expectedIssuer = authServerMetadata.Issuer?.OriginalString;
|
||||
|
||||
if ((authServerMetadata.AuthorizationResponseIssParameterSupported || !string.IsNullOrEmpty(iss)) &&
|
||||
expectedIssuer is null)
|
||||
{
|
||||
ThrowFailedToHandleUnauthorizedResponse(
|
||||
"Authorization server metadata did not provide an issuer required to validate the authorization response.");
|
||||
}
|
||||
|
||||
if (authServerMetadata.AuthorizationResponseIssParameterSupported)
|
||||
{
|
||||
// Server advertises iss support: iss MUST be present and match.
|
||||
if (string.IsNullOrEmpty(iss))
|
||||
{
|
||||
ThrowFailedToHandleUnauthorizedResponse(
|
||||
"Authorization server advertises RFC 9207 iss parameter support but none was received in the authorization response.");
|
||||
}
|
||||
|
||||
// Use exact string comparison per RFC 9207 / RFC 3986 §6.2.1.
|
||||
if (!string.Equals(iss, expectedIssuer, StringComparison.Ordinal))
|
||||
{
|
||||
ThrowFailedToHandleUnauthorizedResponse(
|
||||
$"Authorization response issuer '{iss}' does not match expected issuer '{expectedIssuer}'.");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Server does not advertise iss support: if iss is present, still validate it.
|
||||
// RFC 9207 cannot protect against a server that neither advertises support nor
|
||||
// returns an iss parameter, so an absent iss is accepted in that case.
|
||||
if (!string.IsNullOrEmpty(iss))
|
||||
{
|
||||
if (!string.Equals(iss, expectedIssuer, StringComparison.Ordinal))
|
||||
{
|
||||
ThrowFailedToHandleUnauthorizedResponse(
|
||||
$"Authorization response issuer '{iss}' does not match expected issuer '{expectedIssuer}'.");
|
||||
}
|
||||
}
|
||||
// If iss is absent and not advertised, proceed normally.
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that the resource URI in the metadata matches the original request URL.
|
||||
/// Accepts either an exact match with the full request URL, or a match with the base URL
|
||||
|
|
|
|||
|
|
@ -48,7 +48,7 @@ public class AuthEventTests : OAuthTestBase
|
|||
ClientId = "demo-client",
|
||||
ClientSecret = "demo-secret",
|
||||
RedirectUri = new Uri("http://localhost:1179/callback"),
|
||||
AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync,
|
||||
AuthorizationCallbackHandler = HandleAuthorizationUrlAsync,
|
||||
},
|
||||
},
|
||||
HttpClient,
|
||||
|
|
@ -76,7 +76,7 @@ public class AuthEventTests : OAuthTestBase
|
|||
OAuth = new ClientOAuthOptions()
|
||||
{
|
||||
RedirectUri = new Uri("http://localhost:1179/callback"),
|
||||
AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync,
|
||||
AuthorizationCallbackHandler = HandleAuthorizationUrlAsync,
|
||||
Scopes = ["mcp:tools"],
|
||||
DynamicClientRegistration = new()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ public class AuthTests : OAuthTestBase
|
|||
ClientId = "demo-client",
|
||||
ClientSecret = "demo-secret",
|
||||
RedirectUri = new Uri("http://localhost:1179/callback"),
|
||||
AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync,
|
||||
AuthorizationCallbackHandler = HandleAuthorizationUrlAsync,
|
||||
},
|
||||
}, HttpClient, LoggerFactory);
|
||||
|
||||
|
|
@ -50,6 +50,132 @@ public class AuthTests : OAuthTestBase
|
|||
transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AuthorizationCallbackHandler_ReceivesConfiguredRedirectUri()
|
||||
{
|
||||
await using var app = await StartMcpServerAsync();
|
||||
|
||||
var redirectUri = new Uri("http://localhost:1179/callback");
|
||||
AuthorizationCallbackContext? callbackContext = null;
|
||||
|
||||
await using var transport = new HttpClientTransport(new()
|
||||
{
|
||||
Endpoint = new(McpServerUrl),
|
||||
OAuth = new()
|
||||
{
|
||||
ClientId = "demo-client",
|
||||
ClientSecret = "demo-secret",
|
||||
RedirectUri = redirectUri,
|
||||
AuthorizationCallbackHandler = (context, cancellationToken) =>
|
||||
{
|
||||
callbackContext = context;
|
||||
return HandleAuthorizationUrlAsync(context, cancellationToken);
|
||||
},
|
||||
},
|
||||
}, HttpClient, LoggerFactory);
|
||||
|
||||
await using var client = await McpClient.CreateAsync(
|
||||
transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.NotNull(callbackContext);
|
||||
Assert.Equal(redirectUri, callbackContext.RedirectUri);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(false, null)]
|
||||
[InlineData(true, "https://localhost:7029")]
|
||||
[InlineData(true, "https://attacker.example")]
|
||||
public async Task AuthorizationRedirectDelegate_ReceivesConfiguredUrisAndSkipsResponseIssuerValidation(
|
||||
bool authorizationResponseIssParameterSupported,
|
||||
string? authorizationResponseIssuer)
|
||||
{
|
||||
TestOAuthServer.AuthorizationResponseIssParameterSupported = authorizationResponseIssParameterSupported;
|
||||
TestOAuthServer.AuthorizationResponseIssuer = authorizationResponseIssuer;
|
||||
await using var app = await StartMcpServerAsync();
|
||||
|
||||
var redirectUri = new Uri("http://localhost:1179/callback");
|
||||
Uri? receivedAuthorizationUri = null;
|
||||
Uri? receivedRedirectUri = null;
|
||||
|
||||
await using var transport = new HttpClientTransport(new()
|
||||
{
|
||||
Endpoint = new(McpServerUrl),
|
||||
OAuth = new()
|
||||
{
|
||||
ClientId = "demo-client",
|
||||
ClientSecret = "demo-secret",
|
||||
RedirectUri = redirectUri,
|
||||
#pragma warning disable MCP9007 // Verify the obsolete callback remains functional during its compatibility window.
|
||||
AuthorizationRedirectDelegate = (authorizationUri, callbackRedirectUri, cancellationToken) =>
|
||||
{
|
||||
receivedAuthorizationUri = authorizationUri;
|
||||
receivedRedirectUri = callbackRedirectUri;
|
||||
return HandleAuthorizationUrlAsync(authorizationUri, callbackRedirectUri, cancellationToken);
|
||||
},
|
||||
#pragma warning restore MCP9007
|
||||
},
|
||||
}, HttpClient, LoggerFactory);
|
||||
|
||||
await using var client = await McpClient.CreateAsync(
|
||||
transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.NotNull(receivedAuthorizationUri);
|
||||
Assert.Equal(redirectUri, receivedRedirectUri);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AuthorizationRedirectDelegate_DoesNotSkipMetadataIssuerValidation()
|
||||
{
|
||||
TestOAuthServer.MetadataIssuerOverride = "https://attacker.example";
|
||||
await using var app = await StartMcpServerAsync();
|
||||
|
||||
await using var transport = new HttpClientTransport(new()
|
||||
{
|
||||
Endpoint = new(McpServerUrl),
|
||||
OAuth = new()
|
||||
{
|
||||
ClientId = "demo-client",
|
||||
ClientSecret = "demo-secret",
|
||||
RedirectUri = new Uri("http://localhost:1179/callback"),
|
||||
#pragma warning disable MCP9007 // Verify the obsolete callback retains metadata issuer validation.
|
||||
AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync,
|
||||
#pragma warning restore MCP9007
|
||||
},
|
||||
}, HttpClient, LoggerFactory);
|
||||
|
||||
var ex = await Assert.ThrowsAsync<McpException>(() => McpClient.CreateAsync(
|
||||
transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken));
|
||||
|
||||
Assert.Contains("does not match the expected issuer", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HttpClientTransport_RejectsBothAuthorizationCallbacks()
|
||||
{
|
||||
#pragma warning disable MCP9007 // Verify ambiguous legacy and current callback configuration is rejected.
|
||||
var options = new ClientOAuthOptions
|
||||
{
|
||||
RedirectUri = new Uri("http://localhost:1179/callback"),
|
||||
AuthorizationCallbackHandler = (_, _) => Task.FromResult<ModelContextProtocol.Authentication.AuthorizationResult?>(new()),
|
||||
AuthorizationRedirectDelegate = (_, _, _) => Task.FromResult<string?>("code"),
|
||||
};
|
||||
#pragma warning restore MCP9007
|
||||
|
||||
var ex = Assert.Throws<ArgumentException>(() => new HttpClientTransport(
|
||||
new()
|
||||
{
|
||||
Endpoint = new(McpServerUrl),
|
||||
OAuth = options,
|
||||
},
|
||||
HttpClient,
|
||||
LoggerFactory));
|
||||
|
||||
Assert.Contains(nameof(ClientOAuthOptions.AuthorizationCallbackHandler), ex.Message);
|
||||
#pragma warning disable MCP9007 // The obsolete property name should be included in the diagnostic.
|
||||
Assert.Contains(nameof(ClientOAuthOptions.AuthorizationRedirectDelegate), ex.Message);
|
||||
#pragma warning restore MCP9007
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CannotAuthenticate_WithoutOAuthConfiguration()
|
||||
{
|
||||
|
|
@ -79,7 +205,7 @@ public class AuthTests : OAuthTestBase
|
|||
ClientId = "unregistered-demo-client",
|
||||
ClientSecret = "demo-secret",
|
||||
RedirectUri = new Uri("http://localhost:1179/callback"),
|
||||
AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync,
|
||||
AuthorizationCallbackHandler = HandleAuthorizationUrlAsync,
|
||||
},
|
||||
}, HttpClient, LoggerFactory);
|
||||
|
||||
|
|
@ -99,7 +225,7 @@ public class AuthTests : OAuthTestBase
|
|||
OAuth = new ClientOAuthOptions()
|
||||
{
|
||||
RedirectUri = new Uri("http://localhost:1179/callback"),
|
||||
AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync,
|
||||
AuthorizationCallbackHandler = HandleAuthorizationUrlAsync,
|
||||
DynamicClientRegistration = new()
|
||||
{
|
||||
ClientName = "Test MCP Client",
|
||||
|
|
@ -125,7 +251,7 @@ public class AuthTests : OAuthTestBase
|
|||
OAuth = new ClientOAuthOptions()
|
||||
{
|
||||
RedirectUri = new Uri("http://localhost:1179/callback"),
|
||||
AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync,
|
||||
AuthorizationCallbackHandler = HandleAuthorizationUrlAsync,
|
||||
DynamicClientRegistration = new()
|
||||
{
|
||||
ApplicationType = "web",
|
||||
|
|
@ -150,7 +276,7 @@ public class AuthTests : OAuthTestBase
|
|||
OAuth = new ClientOAuthOptions()
|
||||
{
|
||||
RedirectUri = new Uri("http://localhost:1179/callback"),
|
||||
AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync,
|
||||
AuthorizationCallbackHandler = HandleAuthorizationUrlAsync,
|
||||
ClientMetadataDocumentUri = new Uri(ClientMetadataDocumentUrl),
|
||||
DynamicClientRegistration = new()
|
||||
{
|
||||
|
|
@ -177,7 +303,7 @@ public class AuthTests : OAuthTestBase
|
|||
ClientId = "demo-client",
|
||||
ClientSecret = "demo-secret",
|
||||
RedirectUri = new Uri("http://localhost:1179/callback"),
|
||||
AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync,
|
||||
AuthorizationCallbackHandler = HandleAuthorizationUrlAsync,
|
||||
},
|
||||
}, HttpClient, LoggerFactory);
|
||||
|
||||
|
|
@ -205,7 +331,7 @@ public class AuthTests : OAuthTestBase
|
|||
ClientId = "demo-client",
|
||||
ClientSecret = "demo-secret",
|
||||
RedirectUri = new Uri("http://localhost:1179/callback"),
|
||||
AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync,
|
||||
AuthorizationCallbackHandler = HandleAuthorizationUrlAsync,
|
||||
},
|
||||
}, HttpClient, LoggerFactory);
|
||||
|
||||
|
|
@ -234,7 +360,7 @@ public class AuthTests : OAuthTestBase
|
|||
ClientId = "demo-client",
|
||||
ClientSecret = "demo-secret",
|
||||
RedirectUri = new Uri("http://localhost:1179/callback"),
|
||||
AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync,
|
||||
AuthorizationCallbackHandler = HandleAuthorizationUrlAsync,
|
||||
},
|
||||
}, HttpClient, LoggerFactory);
|
||||
|
||||
|
|
@ -258,7 +384,7 @@ public class AuthTests : OAuthTestBase
|
|||
OAuth = new ClientOAuthOptions()
|
||||
{
|
||||
RedirectUri = new Uri("http://localhost:1179/callback"),
|
||||
AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync,
|
||||
AuthorizationCallbackHandler = HandleAuthorizationUrlAsync,
|
||||
ClientMetadataDocumentUri = new Uri("http://invalid-cimd.example.com"),
|
||||
DynamicClientRegistration = new()
|
||||
{
|
||||
|
|
@ -287,7 +413,7 @@ public class AuthTests : OAuthTestBase
|
|||
ClientId = "demo-client",
|
||||
ClientSecret = "demo-secret",
|
||||
RedirectUri = new Uri("http://localhost:1179/callback"),
|
||||
AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync,
|
||||
AuthorizationCallbackHandler = HandleAuthorizationUrlAsync,
|
||||
ClientMetadataDocumentUri = new Uri("http://invalid-cimd.example.com"),
|
||||
DynamicClientRegistration = new()
|
||||
{
|
||||
|
|
@ -313,7 +439,7 @@ public class AuthTests : OAuthTestBase
|
|||
OAuth = new ClientOAuthOptions()
|
||||
{
|
||||
RedirectUri = new Uri("http://localhost:1179/callback"),
|
||||
AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync,
|
||||
AuthorizationCallbackHandler = HandleAuthorizationUrlAsync,
|
||||
ClientMetadataDocumentUri = new Uri(uri),
|
||||
},
|
||||
}, HttpClient, LoggerFactory);
|
||||
|
|
@ -378,7 +504,7 @@ public class AuthTests : OAuthTestBase
|
|||
ClientId = "demo-client",
|
||||
ClientSecret = "demo-secret",
|
||||
RedirectUri = new Uri("http://localhost:1179/callback"),
|
||||
AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync,
|
||||
AuthorizationCallbackHandler = HandleAuthorizationUrlAsync,
|
||||
},
|
||||
}, HttpClient, LoggerFactory);
|
||||
|
||||
|
|
@ -405,10 +531,10 @@ public class AuthTests : OAuthTestBase
|
|||
ClientId = "demo-client",
|
||||
ClientSecret = "demo-secret",
|
||||
RedirectUri = new Uri("http://localhost:1179/callback"),
|
||||
AuthorizationRedirectDelegate = (uri, redirect, ct) =>
|
||||
AuthorizationCallbackHandler = (context, ct) =>
|
||||
{
|
||||
lastAuthorizationUri = uri;
|
||||
return HandleAuthorizationUrlAsync(uri, redirect, ct);
|
||||
lastAuthorizationUri = context.AuthorizationUri;
|
||||
return HandleAuthorizationUrlAsync(context, ct);
|
||||
},
|
||||
AdditionalAuthorizationParameters = new Dictionary<string, string>
|
||||
{
|
||||
|
|
@ -437,7 +563,7 @@ public class AuthTests : OAuthTestBase
|
|||
ClientId = "demo-client",
|
||||
ClientSecret = "demo-secret",
|
||||
RedirectUri = new Uri("http://localhost:1179/callback"),
|
||||
AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync,
|
||||
AuthorizationCallbackHandler = HandleAuthorizationUrlAsync,
|
||||
AdditionalAuthorizationParameters = new Dictionary<string, string>
|
||||
{
|
||||
["redirect_uri"] = "custom_value",
|
||||
|
|
@ -462,7 +588,7 @@ public class AuthTests : OAuthTestBase
|
|||
ClientId = "demo-client",
|
||||
ClientSecret = "demo-secret",
|
||||
RedirectUri = new Uri("http://localhost:1179/callback"),
|
||||
AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync,
|
||||
AuthorizationCallbackHandler = HandleAuthorizationUrlAsync,
|
||||
},
|
||||
}, HttpClient, LoggerFactory);
|
||||
|
||||
|
|
@ -484,7 +610,7 @@ public class AuthTests : OAuthTestBase
|
|||
ClientId = "demo-client",
|
||||
ClientSecret = "demo-secret",
|
||||
RedirectUri = new Uri("http://localhost:1179/callback"),
|
||||
AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync,
|
||||
AuthorizationCallbackHandler = HandleAuthorizationUrlAsync,
|
||||
},
|
||||
}, HttpClient, LoggerFactory);
|
||||
|
||||
|
|
@ -512,11 +638,11 @@ public class AuthTests : OAuthTestBase
|
|||
ClientId = "demo-client",
|
||||
ClientSecret = "demo-secret",
|
||||
RedirectUri = new Uri("http://localhost:1179/callback"),
|
||||
AuthorizationRedirectDelegate = (uri, redirect, ct) =>
|
||||
AuthorizationCallbackHandler = (context, ct) =>
|
||||
{
|
||||
var query = QueryHelpers.ParseQuery(uri.Query);
|
||||
var query = QueryHelpers.ParseQuery(context.AuthorizationUri.Query);
|
||||
requestedScope = query["scope"].ToString();
|
||||
return HandleAuthorizationUrlAsync(uri, redirect, ct);
|
||||
return HandleAuthorizationUrlAsync(context, ct);
|
||||
},
|
||||
},
|
||||
}, HttpClient, LoggerFactory);
|
||||
|
|
@ -565,11 +691,11 @@ public class AuthTests : OAuthTestBase
|
|||
ClientId = "demo-client",
|
||||
ClientSecret = "demo-secret",
|
||||
RedirectUri = new Uri("http://localhost:1179/callback"),
|
||||
AuthorizationRedirectDelegate = (uri, redirect, ct) =>
|
||||
AuthorizationCallbackHandler = (context, ct) =>
|
||||
{
|
||||
var query = QueryHelpers.ParseQuery(uri.Query);
|
||||
var query = QueryHelpers.ParseQuery(context.AuthorizationUri.Query);
|
||||
requestedScope = query["scope"].ToString();
|
||||
return HandleAuthorizationUrlAsync(uri, redirect, ct);
|
||||
return HandleAuthorizationUrlAsync(context, ct);
|
||||
},
|
||||
},
|
||||
}, HttpClient, LoggerFactory);
|
||||
|
|
@ -660,11 +786,11 @@ public class AuthTests : OAuthTestBase
|
|||
ClientId = "demo-client",
|
||||
ClientSecret = "demo-secret",
|
||||
RedirectUri = new Uri("http://localhost:1179/callback"),
|
||||
AuthorizationRedirectDelegate = (uri, redirect, ct) =>
|
||||
AuthorizationCallbackHandler = (context, ct) =>
|
||||
{
|
||||
var query = QueryHelpers.ParseQuery(uri.Query);
|
||||
var query = QueryHelpers.ParseQuery(context.AuthorizationUri.Query);
|
||||
requestedScope = query["scope"].ToString();
|
||||
return HandleAuthorizationUrlAsync(uri, redirect, ct);
|
||||
return HandleAuthorizationUrlAsync(context, ct);
|
||||
},
|
||||
},
|
||||
}, HttpClient, LoggerFactory);
|
||||
|
|
@ -765,11 +891,11 @@ public class AuthTests : OAuthTestBase
|
|||
ClientId = "demo-client",
|
||||
ClientSecret = "demo-secret",
|
||||
RedirectUri = new Uri("http://localhost:1179/callback"),
|
||||
AuthorizationRedirectDelegate = (uri, redirect, ct) =>
|
||||
AuthorizationCallbackHandler = (context, ct) =>
|
||||
{
|
||||
var query = QueryHelpers.ParseQuery(uri.Query);
|
||||
var query = QueryHelpers.ParseQuery(context.AuthorizationUri.Query);
|
||||
requestedScopes.Add(query["scope"].ToString());
|
||||
return HandleAuthorizationUrlAsync(uri, redirect, ct);
|
||||
return HandleAuthorizationUrlAsync(context, ct);
|
||||
},
|
||||
},
|
||||
}, HttpClient, LoggerFactory);
|
||||
|
|
@ -806,6 +932,8 @@ public class AuthTests : OAuthTestBase
|
|||
// before either has stepped up. They serialize on the provider's token acquisition lock: the
|
||||
// first runs the step-up and caches the broader token, and the second must reuse that token
|
||||
// instead of failing as a "repeated" challenge. Only one interactive step-up should run.
|
||||
TestOAuthServer.AuthorizationResponseIssParameterSupported = true;
|
||||
TestOAuthServer.AuthorizationResponseIssuer = OAuthServerUrl;
|
||||
|
||||
Builder.Services.AddMcpServer()
|
||||
.WithTools([
|
||||
|
|
@ -879,14 +1007,14 @@ public class AuthTests : OAuthTestBase
|
|||
ClientId = "demo-client",
|
||||
ClientSecret = "demo-secret",
|
||||
RedirectUri = new Uri("http://localhost:1179/callback"),
|
||||
AuthorizationRedirectDelegate = (uri, redirect, ct) =>
|
||||
AuthorizationCallbackHandler = (context, cancellationToken) =>
|
||||
{
|
||||
var query = QueryHelpers.ParseQuery(uri.Query);
|
||||
var query = QueryHelpers.ParseQuery(context.AuthorizationUri.Query);
|
||||
lock (scopeLock)
|
||||
{
|
||||
requestedScopes.Add(query["scope"].ToString());
|
||||
}
|
||||
return HandleAuthorizationUrlAsync(uri, redirect, ct);
|
||||
return HandleAuthorizationUrlAsync(context, cancellationToken);
|
||||
},
|
||||
},
|
||||
}, HttpClient, LoggerFactory);
|
||||
|
|
@ -977,11 +1105,11 @@ public class AuthTests : OAuthTestBase
|
|||
ClientId = "demo-client",
|
||||
ClientSecret = "demo-secret",
|
||||
RedirectUri = new Uri("http://localhost:1179/callback"),
|
||||
AuthorizationRedirectDelegate = (uri, redirect, ct) =>
|
||||
AuthorizationCallbackHandler = (context, ct) =>
|
||||
{
|
||||
var query = QueryHelpers.ParseQuery(uri.Query);
|
||||
var query = QueryHelpers.ParseQuery(context.AuthorizationUri.Query);
|
||||
requestedScopes.Add(query["scope"].ToString());
|
||||
return HandleAuthorizationUrlAsync(uri, redirect, ct);
|
||||
return HandleAuthorizationUrlAsync(context, ct);
|
||||
},
|
||||
},
|
||||
}, HttpClient, LoggerFactory);
|
||||
|
|
@ -1072,11 +1200,11 @@ public class AuthTests : OAuthTestBase
|
|||
ClientId = "demo-client",
|
||||
ClientSecret = "demo-secret",
|
||||
RedirectUri = new Uri("http://localhost:1179/callback"),
|
||||
AuthorizationRedirectDelegate = (uri, redirect, ct) =>
|
||||
AuthorizationCallbackHandler = (context, ct) =>
|
||||
{
|
||||
var query = QueryHelpers.ParseQuery(uri.Query);
|
||||
var query = QueryHelpers.ParseQuery(context.AuthorizationUri.Query);
|
||||
requestedScopes.Add(query["scope"].ToString());
|
||||
return HandleAuthorizationUrlAsync(uri, redirect, ct);
|
||||
return HandleAuthorizationUrlAsync(context, ct);
|
||||
},
|
||||
},
|
||||
}, HttpClient, LoggerFactory);
|
||||
|
|
@ -1120,7 +1248,7 @@ public class AuthTests : OAuthTestBase
|
|||
ClientId = "demo-client",
|
||||
ClientSecret = "demo-secret",
|
||||
RedirectUri = new Uri("http://localhost:1179/callback"),
|
||||
AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync,
|
||||
AuthorizationCallbackHandler = HandleAuthorizationUrlAsync,
|
||||
},
|
||||
}, HttpClient, LoggerFactory);
|
||||
|
||||
|
|
@ -1159,7 +1287,7 @@ public class AuthTests : OAuthTestBase
|
|||
ClientId = "demo-client",
|
||||
ClientSecret = "demo-secret",
|
||||
RedirectUri = new Uri("http://localhost:1179/callback"),
|
||||
AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync,
|
||||
AuthorizationCallbackHandler = HandleAuthorizationUrlAsync,
|
||||
},
|
||||
}, HttpClient, LoggerFactory);
|
||||
|
||||
|
|
@ -1187,7 +1315,7 @@ public class AuthTests : OAuthTestBase
|
|||
ClientId = "demo-client",
|
||||
ClientSecret = "demo-secret",
|
||||
RedirectUri = new Uri("http://localhost:1179/callback"),
|
||||
AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync,
|
||||
AuthorizationCallbackHandler = HandleAuthorizationUrlAsync,
|
||||
},
|
||||
}, HttpClient, LoggerFactory);
|
||||
|
||||
|
|
@ -1220,7 +1348,7 @@ public class AuthTests : OAuthTestBase
|
|||
ClientId = "demo-client",
|
||||
ClientSecret = "demo-secret",
|
||||
RedirectUri = new Uri("http://localhost:1179/callback"),
|
||||
AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync,
|
||||
AuthorizationCallbackHandler = HandleAuthorizationUrlAsync,
|
||||
},
|
||||
}, HttpClient, LoggerFactory);
|
||||
|
||||
|
|
@ -1237,6 +1365,69 @@ public class AuthTests : OAuthTestBase
|
|||
TestOAuthServer.MetadataRequests);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CannotAuthenticate_WhenAuthorizationServerMetadataIssuerMismatches()
|
||||
{
|
||||
TestOAuthServer.MetadataIssuerOverride = "https://attacker.example";
|
||||
|
||||
await using var app = await StartMcpServerAsync();
|
||||
await using var transport = CreateOAuthTransport();
|
||||
|
||||
var ex = await Assert.ThrowsAsync<McpException>(() => McpClient.CreateAsync(
|
||||
transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken));
|
||||
|
||||
Assert.Contains("does not match the expected issuer", ex.Message);
|
||||
Assert.Single(TestOAuthServer.MetadataRequests);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CannotAuthenticate_WhenAuthorizationServerMetadataOmitsIssuer()
|
||||
{
|
||||
TestOAuthServer.IncludeIssuerInMetadata = false;
|
||||
|
||||
await using var app = await StartMcpServerAsync();
|
||||
await using var transport = CreateOAuthTransport();
|
||||
|
||||
var ex = await Assert.ThrowsAsync<McpException>(() => McpClient.CreateAsync(
|
||||
transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken));
|
||||
|
||||
Assert.Contains("did not provide the required issuer", ex.Message);
|
||||
Assert.Single(TestOAuthServer.MetadataRequests);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CanAuthenticate_WhenAuthorizationResponseIssuerMatches()
|
||||
{
|
||||
TestOAuthServer.AuthorizationResponseIssParameterSupported = true;
|
||||
TestOAuthServer.AuthorizationResponseIssuer = OAuthServerUrl;
|
||||
|
||||
await using var app = await StartMcpServerAsync();
|
||||
await using var transport = CreateOAuthTransport();
|
||||
await using var client = await McpClient.CreateAsync(
|
||||
transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(true, "https://attacker.example", "does not match expected issuer")]
|
||||
[InlineData(true, null, "advertises RFC 9207 iss parameter support but none was received")]
|
||||
[InlineData(false, "https://attacker.example", "does not match expected issuer")]
|
||||
public async Task CannotAuthenticate_WhenAuthorizationResponseIssuerIsInvalid(
|
||||
bool authorizationResponseIssParameterSupported,
|
||||
string? authorizationResponseIssuer,
|
||||
string expectedMessage)
|
||||
{
|
||||
TestOAuthServer.AuthorizationResponseIssParameterSupported = authorizationResponseIssParameterSupported;
|
||||
TestOAuthServer.AuthorizationResponseIssuer = authorizationResponseIssuer;
|
||||
|
||||
await using var app = await StartMcpServerAsync();
|
||||
await using var transport = CreateOAuthTransport();
|
||||
|
||||
var ex = await Assert.ThrowsAsync<McpException>(() => McpClient.CreateAsync(
|
||||
transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken));
|
||||
|
||||
Assert.Contains(expectedMessage, ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CanAuthenticate_WithResourceMetadataPathFallbacks()
|
||||
{
|
||||
|
|
@ -1284,7 +1475,7 @@ public class AuthTests : OAuthTestBase
|
|||
ClientId = "demo-client",
|
||||
ClientSecret = "demo-secret",
|
||||
RedirectUri = new Uri("http://localhost:1179/callback"),
|
||||
AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync,
|
||||
AuthorizationCallbackHandler = HandleAuthorizationUrlAsync,
|
||||
},
|
||||
}, HttpClient, LoggerFactory);
|
||||
|
||||
|
|
@ -1339,7 +1530,7 @@ public class AuthTests : OAuthTestBase
|
|||
ClientId = "demo-client",
|
||||
ClientSecret = "demo-secret",
|
||||
RedirectUri = new Uri("http://localhost:1179/callback"),
|
||||
AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync,
|
||||
AuthorizationCallbackHandler = HandleAuthorizationUrlAsync,
|
||||
},
|
||||
}, HttpClient, LoggerFactory);
|
||||
|
||||
|
|
@ -1386,7 +1577,7 @@ public class AuthTests : OAuthTestBase
|
|||
ClientId = "demo-client",
|
||||
ClientSecret = "demo-secret",
|
||||
RedirectUri = new Uri("http://localhost:1179/callback"),
|
||||
AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync,
|
||||
AuthorizationCallbackHandler = HandleAuthorizationUrlAsync,
|
||||
},
|
||||
}, HttpClient, LoggerFactory);
|
||||
|
||||
|
|
@ -1428,7 +1619,7 @@ public class AuthTests : OAuthTestBase
|
|||
ClientId = "demo-client",
|
||||
ClientSecret = "demo-secret",
|
||||
RedirectUri = new Uri("http://localhost:1179/callback"),
|
||||
AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync,
|
||||
AuthorizationCallbackHandler = HandleAuthorizationUrlAsync,
|
||||
},
|
||||
}, HttpClient, LoggerFactory);
|
||||
|
||||
|
|
@ -1474,7 +1665,7 @@ public class AuthTests : OAuthTestBase
|
|||
ClientId = "demo-client",
|
||||
ClientSecret = "demo-secret",
|
||||
RedirectUri = new Uri("http://localhost:1179/callback"),
|
||||
AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync,
|
||||
AuthorizationCallbackHandler = HandleAuthorizationUrlAsync,
|
||||
},
|
||||
}, HttpClient, LoggerFactory);
|
||||
|
||||
|
|
@ -1521,7 +1712,7 @@ public class AuthTests : OAuthTestBase
|
|||
ClientId = "demo-client",
|
||||
ClientSecret = "demo-secret",
|
||||
RedirectUri = new Uri("http://localhost:1179/callback"),
|
||||
AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync,
|
||||
AuthorizationCallbackHandler = HandleAuthorizationUrlAsync,
|
||||
},
|
||||
}, HttpClient, LoggerFactory);
|
||||
|
||||
|
|
@ -1673,7 +1864,7 @@ public class AuthTests : OAuthTestBase
|
|||
ClientId = "demo-client",
|
||||
ClientSecret = "demo-secret",
|
||||
RedirectUri = new Uri("http://localhost:1179/callback"),
|
||||
AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync,
|
||||
AuthorizationCallbackHandler = HandleAuthorizationUrlAsync,
|
||||
},
|
||||
}, HttpClient, LoggerFactory);
|
||||
|
||||
|
|
@ -1790,7 +1981,7 @@ public class AuthTests : OAuthTestBase
|
|||
ClientId = "demo-client",
|
||||
ClientSecret = "demo-secret",
|
||||
RedirectUri = new Uri("http://localhost:1179/callback"),
|
||||
AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync,
|
||||
AuthorizationCallbackHandler = HandleAuthorizationUrlAsync,
|
||||
},
|
||||
}, HttpClient, LoggerFactory);
|
||||
|
||||
|
|
@ -1892,7 +2083,7 @@ public class AuthTests : OAuthTestBase
|
|||
ClientId = "demo-client",
|
||||
ClientSecret = "demo-secret",
|
||||
RedirectUri = new Uri("http://localhost:1179/callback"),
|
||||
AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync,
|
||||
AuthorizationCallbackHandler = HandleAuthorizationUrlAsync,
|
||||
},
|
||||
}, HttpClient, LoggerFactory);
|
||||
|
||||
|
|
@ -1963,7 +2154,7 @@ public class AuthTests : OAuthTestBase
|
|||
ClientId = "demo-client",
|
||||
ClientSecret = "demo-secret",
|
||||
RedirectUri = new Uri("http://localhost:1179/callback"),
|
||||
AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync,
|
||||
AuthorizationCallbackHandler = HandleAuthorizationUrlAsync,
|
||||
},
|
||||
}, HttpClient, LoggerFactory);
|
||||
|
||||
|
|
@ -1991,11 +2182,11 @@ public class AuthTests : OAuthTestBase
|
|||
ClientId = "demo-client",
|
||||
ClientSecret = "demo-secret",
|
||||
RedirectUri = new Uri("http://localhost:1179/callback"),
|
||||
AuthorizationRedirectDelegate = (uri, redirect, ct) =>
|
||||
AuthorizationCallbackHandler = (context, ct) =>
|
||||
{
|
||||
var query = QueryHelpers.ParseQuery(uri.Query);
|
||||
var query = QueryHelpers.ParseQuery(context.AuthorizationUri.Query);
|
||||
requestedScope = query["scope"].ToString();
|
||||
return HandleAuthorizationUrlAsync(uri, redirect, ct);
|
||||
return HandleAuthorizationUrlAsync(context, ct);
|
||||
},
|
||||
},
|
||||
}, HttpClient, LoggerFactory);
|
||||
|
|
@ -2023,11 +2214,11 @@ public class AuthTests : OAuthTestBase
|
|||
ClientId = "demo-client",
|
||||
ClientSecret = "demo-secret",
|
||||
RedirectUri = new Uri("http://localhost:1179/callback"),
|
||||
AuthorizationRedirectDelegate = (uri, redirect, ct) =>
|
||||
AuthorizationCallbackHandler = (context, ct) =>
|
||||
{
|
||||
var query = QueryHelpers.ParseQuery(uri.Query);
|
||||
var query = QueryHelpers.ParseQuery(context.AuthorizationUri.Query);
|
||||
requestedScope = query["scope"].ToString();
|
||||
return HandleAuthorizationUrlAsync(uri, redirect, ct);
|
||||
return HandleAuthorizationUrlAsync(context, ct);
|
||||
},
|
||||
},
|
||||
}, HttpClient, LoggerFactory);
|
||||
|
|
@ -2062,11 +2253,11 @@ public class AuthTests : OAuthTestBase
|
|||
ClientId = "demo-client",
|
||||
ClientSecret = "demo-secret",
|
||||
RedirectUri = new Uri("http://localhost:1179/callback"),
|
||||
AuthorizationRedirectDelegate = (uri, redirect, ct) =>
|
||||
AuthorizationCallbackHandler = (context, ct) =>
|
||||
{
|
||||
var query = QueryHelpers.ParseQuery(uri.Query);
|
||||
var query = QueryHelpers.ParseQuery(context.AuthorizationUri.Query);
|
||||
requestedScope = query["scope"].ToString();
|
||||
return HandleAuthorizationUrlAsync(uri, redirect, ct);
|
||||
return HandleAuthorizationUrlAsync(context, ct);
|
||||
},
|
||||
},
|
||||
}, HttpClient, LoggerFactory);
|
||||
|
|
@ -2099,11 +2290,11 @@ public class AuthTests : OAuthTestBase
|
|||
ClientId = "demo-client",
|
||||
ClientSecret = "demo-secret",
|
||||
RedirectUri = new Uri("http://localhost:1179/callback"),
|
||||
AuthorizationRedirectDelegate = (uri, redirect, ct) =>
|
||||
AuthorizationCallbackHandler = (context, ct) =>
|
||||
{
|
||||
var query = QueryHelpers.ParseQuery(uri.Query);
|
||||
var query = QueryHelpers.ParseQuery(context.AuthorizationUri.Query);
|
||||
requestedScope = query["scope"].ToString();
|
||||
return HandleAuthorizationUrlAsync(uri, redirect, ct);
|
||||
return HandleAuthorizationUrlAsync(context, ct);
|
||||
},
|
||||
ScopeSelector = scopes => scopes?.Where(s => s == "mcp:tools"),
|
||||
},
|
||||
|
|
@ -2130,11 +2321,11 @@ public class AuthTests : OAuthTestBase
|
|||
ClientId = "demo-client",
|
||||
ClientSecret = "demo-secret",
|
||||
RedirectUri = new Uri("http://localhost:1179/callback"),
|
||||
AuthorizationRedirectDelegate = (uri, redirect, ct) =>
|
||||
AuthorizationCallbackHandler = (context, ct) =>
|
||||
{
|
||||
var query = QueryHelpers.ParseQuery(uri.Query);
|
||||
var query = QueryHelpers.ParseQuery(context.AuthorizationUri.Query);
|
||||
requestedScope = query["scope"].ToString();
|
||||
return HandleAuthorizationUrlAsync(uri, redirect, ct);
|
||||
return HandleAuthorizationUrlAsync(context, ct);
|
||||
},
|
||||
ScopeSelector = scopes => scopes?.Append("custom:scope") ?? ["custom:scope"],
|
||||
},
|
||||
|
|
@ -2168,7 +2359,7 @@ public class AuthTests : OAuthTestBase
|
|||
ClientId = "demo-client",
|
||||
ClientSecret = "demo-secret",
|
||||
RedirectUri = new Uri("http://localhost:1179/callback"),
|
||||
AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync,
|
||||
AuthorizationCallbackHandler = HandleAuthorizationUrlAsync,
|
||||
ScopeSelector = scopes =>
|
||||
{
|
||||
capturedInput = scopes;
|
||||
|
|
@ -2198,10 +2389,10 @@ public class AuthTests : OAuthTestBase
|
|||
ClientId = "demo-client",
|
||||
ClientSecret = "demo-secret",
|
||||
RedirectUri = new Uri("http://localhost:1179/callback"),
|
||||
AuthorizationRedirectDelegate = (uri, redirect, ct) =>
|
||||
AuthorizationCallbackHandler = (context, ct) =>
|
||||
{
|
||||
scopePresent = QueryHelpers.ParseQuery(uri.Query).ContainsKey("scope");
|
||||
return HandleAuthorizationUrlAsync(uri, redirect, ct);
|
||||
scopePresent = QueryHelpers.ParseQuery(context.AuthorizationUri.Query).ContainsKey("scope");
|
||||
return HandleAuthorizationUrlAsync(context, ct);
|
||||
},
|
||||
ScopeSelector = _ => null,
|
||||
},
|
||||
|
|
@ -2228,10 +2419,10 @@ public class AuthTests : OAuthTestBase
|
|||
ClientId = "demo-client",
|
||||
ClientSecret = "demo-secret",
|
||||
RedirectUri = new Uri("http://localhost:1179/callback"),
|
||||
AuthorizationRedirectDelegate = (uri, redirect, ct) =>
|
||||
AuthorizationCallbackHandler = (context, ct) =>
|
||||
{
|
||||
scopePresent = QueryHelpers.ParseQuery(uri.Query).ContainsKey("scope");
|
||||
return HandleAuthorizationUrlAsync(uri, redirect, ct);
|
||||
scopePresent = QueryHelpers.ParseQuery(context.AuthorizationUri.Query).ContainsKey("scope");
|
||||
return HandleAuthorizationUrlAsync(context, ct);
|
||||
},
|
||||
ScopeSelector = _ => [],
|
||||
},
|
||||
|
|
@ -2243,6 +2434,19 @@ public class AuthTests : OAuthTestBase
|
|||
Assert.False(scopePresent);
|
||||
}
|
||||
|
||||
private HttpClientTransport CreateOAuthTransport() =>
|
||||
new(new()
|
||||
{
|
||||
Endpoint = new(McpServerUrl),
|
||||
OAuth = new()
|
||||
{
|
||||
ClientId = "demo-client",
|
||||
ClientSecret = "demo-secret",
|
||||
RedirectUri = new Uri("http://localhost:1179/callback"),
|
||||
AuthorizationCallbackHandler = HandleAuthorizationUrlAsync,
|
||||
},
|
||||
}, HttpClient, LoggerFactory);
|
||||
|
||||
[Fact]
|
||||
public async Task DynamicClientRegistration_ScopeSelector_AppliesToDcrScope()
|
||||
{
|
||||
|
|
@ -2259,7 +2463,7 @@ public class AuthTests : OAuthTestBase
|
|||
OAuth = new ClientOAuthOptions()
|
||||
{
|
||||
RedirectUri = new Uri("http://localhost:1179/callback"),
|
||||
AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync,
|
||||
AuthorizationCallbackHandler = HandleAuthorizationUrlAsync,
|
||||
DynamicClientRegistration = new() { ClientName = "Test MCP Client" },
|
||||
ScopeSelector = scopes => scopes?.Where(s => s == "mcp:tools"),
|
||||
},
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ public class DcrFailureTests : OAuthTestBase
|
|||
OAuth = new ClientOAuthOptions()
|
||||
{
|
||||
RedirectUri = new Uri("myapp://callback"),
|
||||
AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync,
|
||||
AuthorizationCallbackHandler = HandleAuthorizationUrlAsync,
|
||||
DynamicClientRegistration = new() { ClientName = "Test MCP Client" },
|
||||
},
|
||||
}, HttpClient, LoggerFactory);
|
||||
|
|
@ -54,7 +54,7 @@ public class DcrFailureTests : OAuthTestBase
|
|||
OAuth = new ClientOAuthOptions()
|
||||
{
|
||||
RedirectUri = new Uri("myapp://callback"),
|
||||
AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync,
|
||||
AuthorizationCallbackHandler = HandleAuthorizationUrlAsync,
|
||||
DynamicClientRegistration = new()
|
||||
{
|
||||
ClientName = "Test MCP Client",
|
||||
|
|
@ -75,7 +75,7 @@ public class DcrFailureTests : OAuthTestBase
|
|||
OAuth = new ClientOAuthOptions()
|
||||
{
|
||||
RedirectUri = new Uri("http://localhost:1179/callback"),
|
||||
AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync,
|
||||
AuthorizationCallbackHandler = HandleAuthorizationUrlAsync,
|
||||
DynamicClientRegistration = new()
|
||||
{
|
||||
ClientName = "Test MCP Client",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,46 @@
|
|||
using ModelContextProtocol.Authentication;
|
||||
using ModelContextProtocol.Client;
|
||||
|
||||
namespace ModelContextProtocol.AspNetCore.Tests.OAuth;
|
||||
|
||||
[CollectionDefinition(nameof(DisableConsoleParallelization), DisableParallelization = true)]
|
||||
public sealed class DisableConsoleParallelization;
|
||||
|
||||
[Collection(nameof(DisableConsoleParallelization))]
|
||||
public class DefaultAuthorizationUrlHandlerTests(ITestOutputHelper outputHelper) : OAuthTestBase(outputHelper)
|
||||
{
|
||||
[Fact]
|
||||
public async Task RejectsAuthorizationCodeWithoutAbsoluteRedirectUrl()
|
||||
{
|
||||
await using var app = await StartMcpServerAsync();
|
||||
|
||||
await using var transport = new HttpClientTransport(new()
|
||||
{
|
||||
Endpoint = new(McpServerUrl),
|
||||
OAuth = new()
|
||||
{
|
||||
ClientId = "demo-client",
|
||||
ClientSecret = "demo-secret",
|
||||
RedirectUri = new Uri("http://localhost:1179/callback"),
|
||||
},
|
||||
}, HttpClient, LoggerFactory);
|
||||
|
||||
var originalInput = Console.In;
|
||||
using var consoleInput = new StringReader("authorization-code");
|
||||
Console.SetIn(consoleInput);
|
||||
|
||||
try
|
||||
{
|
||||
var ex = await Assert.ThrowsAsync<McpException>(() => McpClient.CreateAsync(
|
||||
transport,
|
||||
loggerFactory: LoggerFactory,
|
||||
cancellationToken: TestContext.Current.CancellationToken));
|
||||
|
||||
Assert.Contains("not a valid absolute URL", ex.Message);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Console.SetIn(originalInput);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -106,18 +106,40 @@ public abstract class OAuthTestBase : KestrelInMemoryTest, IAsyncDisposable
|
|||
return app;
|
||||
}
|
||||
|
||||
protected async Task<string?> HandleAuthorizationUrlAsync(Uri authorizationUri, Uri redirectUri, CancellationToken cancellationToken)
|
||||
protected async Task<ModelContextProtocol.Authentication.AuthorizationResult?> HandleAuthorizationUrlAsync(
|
||||
ModelContextProtocol.Authentication.AuthorizationCallbackContext authorizationContext,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
using var redirectResponse = await HttpClient.GetAsync(authorizationUri, cancellationToken);
|
||||
using var redirectResponse = await HttpClient.GetAsync(authorizationContext.AuthorizationUri, cancellationToken);
|
||||
Assert.Equal(HttpStatusCode.Redirect, redirectResponse.StatusCode);
|
||||
var location = redirectResponse.Headers.Location;
|
||||
|
||||
if (location is not null && !string.IsNullOrEmpty(location.Query))
|
||||
{
|
||||
var queryParams = QueryHelpers.ParseQuery(location.Query);
|
||||
return queryParams["code"];
|
||||
return new ModelContextProtocol.Authentication.AuthorizationResult
|
||||
{
|
||||
Code = queryParams["code"],
|
||||
Iss = queryParams.TryGetValue("iss", out var iss) ? (string?)iss : null,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
protected async Task<string?> HandleAuthorizationUrlAsync(
|
||||
Uri authorizationUri,
|
||||
Uri redirectUri,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await HandleAuthorizationUrlAsync(
|
||||
new ModelContextProtocol.Authentication.AuthorizationCallbackContext
|
||||
{
|
||||
AuthorizationUri = authorizationUri,
|
||||
RedirectUri = redirectUri,
|
||||
},
|
||||
cancellationToken);
|
||||
|
||||
return result?.Code;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,10 +26,10 @@ public class TokenCacheTests : OAuthTestBase
|
|||
ClientId = "demo-client",
|
||||
ClientSecret = "demo-secret",
|
||||
RedirectUri = new Uri("http://localhost:1179/callback"),
|
||||
AuthorizationRedirectDelegate = (uri, redirect, ct) =>
|
||||
AuthorizationCallbackHandler = (context, ct) =>
|
||||
{
|
||||
authDelegateCalledInitially = true;
|
||||
return HandleAuthorizationUrlAsync(uri, redirect, ct);
|
||||
return HandleAuthorizationUrlAsync(context, ct);
|
||||
},
|
||||
TokenCache = tokenCache,
|
||||
},
|
||||
|
|
@ -40,7 +40,7 @@ public class TokenCacheTests : OAuthTestBase
|
|||
// Just connecting should trigger auth and storage.
|
||||
}
|
||||
|
||||
Assert.True(authDelegateCalledInitially, "AuthorizationRedirectDelegate should be called to get initial token");
|
||||
Assert.True(authDelegateCalledInitially, "AuthorizationCallbackHandler should be called to get initial token");
|
||||
Assert.NotNull(tokenCache.LastStoredToken);
|
||||
|
||||
var authDelegateCalledAgain = false;
|
||||
|
|
@ -53,10 +53,10 @@ public class TokenCacheTests : OAuthTestBase
|
|||
ClientId = "demo-client",
|
||||
ClientSecret = "demo-secret",
|
||||
RedirectUri = new Uri("http://localhost:1179/callback"),
|
||||
AuthorizationRedirectDelegate = (uri, redirect, ct) =>
|
||||
AuthorizationCallbackHandler = (context, ct) =>
|
||||
{
|
||||
authDelegateCalledAgain = true;
|
||||
return HandleAuthorizationUrlAsync(uri, redirect, ct);
|
||||
return HandleAuthorizationUrlAsync(context, ct);
|
||||
},
|
||||
TokenCache = tokenCache
|
||||
},
|
||||
|
|
@ -64,7 +64,7 @@ public class TokenCacheTests : OAuthTestBase
|
|||
|
||||
await using var client = await McpClient.CreateAsync(transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.False(authDelegateCalledAgain, "AuthorizationRedirectDelegate should not be called when token is valid");
|
||||
Assert.False(authDelegateCalledAgain, "AuthorizationCallbackHandler should not be called when token is valid");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -82,7 +82,7 @@ public class TokenCacheTests : OAuthTestBase
|
|||
ClientId = "demo-client",
|
||||
ClientSecret = "demo-secret",
|
||||
RedirectUri = new Uri("http://localhost:1179/callback"),
|
||||
AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync,
|
||||
AuthorizationCallbackHandler = HandleAuthorizationUrlAsync,
|
||||
TokenCache = tokenCache
|
||||
},
|
||||
}, HttpClient, LoggerFactory);
|
||||
|
|
@ -109,10 +109,10 @@ public class TokenCacheTests : OAuthTestBase
|
|||
ClientId = "demo-client",
|
||||
ClientSecret = "demo-secret",
|
||||
RedirectUri = new Uri("http://localhost:1179/callback"),
|
||||
AuthorizationRedirectDelegate = (uri, redirect, ct) =>
|
||||
AuthorizationCallbackHandler = (context, ct) =>
|
||||
{
|
||||
authDelegateCalled = true;
|
||||
return HandleAuthorizationUrlAsync(uri, redirect, ct);
|
||||
return HandleAuthorizationUrlAsync(context, ct);
|
||||
},
|
||||
TokenCache = tokenCache,
|
||||
},
|
||||
|
|
@ -120,7 +120,7 @@ public class TokenCacheTests : OAuthTestBase
|
|||
|
||||
await using var client = await McpClient.CreateAsync(transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.True(authDelegateCalled, "AuthorizationRedirectDelegate should be called when cached token is invalid");
|
||||
Assert.True(authDelegateCalled, "AuthorizationCallbackHandler should be called when cached token is invalid");
|
||||
Assert.NotNull(tokenCache.LastStoredToken);
|
||||
Assert.NotEqual("invalid-token", tokenCache.LastStoredToken.AccessToken);
|
||||
}
|
||||
|
|
@ -141,10 +141,10 @@ public class TokenCacheTests : OAuthTestBase
|
|||
ClientId = "demo-client",
|
||||
ClientSecret = "demo-secret",
|
||||
RedirectUri = new Uri("http://localhost:1179/callback"),
|
||||
AuthorizationRedirectDelegate = (uri, redirect, ct) =>
|
||||
AuthorizationCallbackHandler = (context, ct) =>
|
||||
{
|
||||
authDelegateCalledInitially = true;
|
||||
return HandleAuthorizationUrlAsync(uri, redirect, ct);
|
||||
return HandleAuthorizationUrlAsync(context, ct);
|
||||
},
|
||||
TokenCache = tokenCache,
|
||||
},
|
||||
|
|
@ -155,7 +155,7 @@ public class TokenCacheTests : OAuthTestBase
|
|||
// Just connecting should trigger auth and storage.
|
||||
}
|
||||
|
||||
Assert.True(authDelegateCalledInitially, "AuthorizationRedirectDelegate should be called to get initial token");
|
||||
Assert.True(authDelegateCalledInitially, "AuthorizationCallbackHandler should be called to get initial token");
|
||||
Assert.False(TestOAuthServer.HasRefreshedToken, "Token should not have been refreshed yet");
|
||||
Assert.NotNull(tokenCache.LastStoredToken);
|
||||
|
||||
|
|
@ -171,10 +171,10 @@ public class TokenCacheTests : OAuthTestBase
|
|||
ClientId = "demo-client",
|
||||
ClientSecret = "demo-secret",
|
||||
RedirectUri = new Uri("http://localhost:1179/callback"),
|
||||
AuthorizationRedirectDelegate = (uri, redirect, ct) =>
|
||||
AuthorizationCallbackHandler = (context, ct) =>
|
||||
{
|
||||
authDelegateCalledAgain = true;
|
||||
return HandleAuthorizationUrlAsync(uri, redirect, ct);
|
||||
return HandleAuthorizationUrlAsync(context, ct);
|
||||
},
|
||||
TokenCache = tokenCache
|
||||
},
|
||||
|
|
@ -182,7 +182,7 @@ public class TokenCacheTests : OAuthTestBase
|
|||
|
||||
await using var client = await McpClient.CreateAsync(transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.False(authDelegateCalledAgain, "AuthorizationRedirectDelegate should not be called when refresh token is valid");
|
||||
Assert.False(authDelegateCalledAgain, "AuthorizationCallbackHandler should not be called when refresh token is valid");
|
||||
Assert.True(TestOAuthServer.HasRefreshedToken, "Token should have been refreshed");
|
||||
Assert.NotEqual("invalid-token", tokenCache.LastStoredToken.AccessToken);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ using System.Net.Sockets;
|
|||
using System.Text.Json;
|
||||
using System.Web;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using ModelContextProtocol.Authentication;
|
||||
using ModelContextProtocol.Client;
|
||||
using ModelContextProtocol.Protocol;
|
||||
|
||||
|
|
@ -92,7 +93,7 @@ var oauthOptions = new ModelContextProtocol.Authentication.ClientOAuthOptions
|
|||
RedirectUri = clientRedirectUri,
|
||||
// Configure the metadata document URI for CIMD.
|
||||
ClientMetadataDocumentUri = new Uri("https://conformance-test.local/client-metadata.json"),
|
||||
AuthorizationRedirectDelegate = (authUrl, redirectUri, ct) => HandleAuthorizationUrlAsync(authUrl, redirectUri, ct),
|
||||
AuthorizationCallbackHandler = HandleAuthorizationUrlAsync,
|
||||
};
|
||||
|
||||
if (preRegisteredClientId is not null)
|
||||
|
|
@ -340,8 +341,12 @@ catch (Exception ex)
|
|||
// Copied from ProtectedMcpClient sample
|
||||
// Simulate a user opening the browser and logging in
|
||||
// Copied from OAuthTestBase
|
||||
static async Task<string?> HandleAuthorizationUrlAsync(Uri authorizationUrl, Uri redirectUri, CancellationToken cancellationToken)
|
||||
static async Task<AuthorizationResult?> HandleAuthorizationUrlAsync(
|
||||
AuthorizationCallbackContext authorizationContext,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var authorizationUrl = authorizationContext.AuthorizationUri;
|
||||
|
||||
Console.WriteLine("Starting OAuth authorization flow...");
|
||||
Console.WriteLine($"Simulating opening browser to: {authorizationUrl}");
|
||||
|
||||
|
|
@ -355,16 +360,30 @@ static async Task<string?> HandleAuthorizationUrlAsync(Uri authorizationUrl, Uri
|
|||
|
||||
if (location is not null && !string.IsNullOrEmpty(location.Query))
|
||||
{
|
||||
// Parse query string to extract "code" parameter
|
||||
// Parse query string to extract "code" and "iss" parameters
|
||||
var query = location.Query.TrimStart('?');
|
||||
string? code = null;
|
||||
string? iss = null;
|
||||
foreach (var pair in query.Split('&'))
|
||||
{
|
||||
var parts = pair.Split('=', 2);
|
||||
if (parts.Length == 2 && parts[0] == "code")
|
||||
if (parts.Length == 2)
|
||||
{
|
||||
return HttpUtility.UrlDecode(parts[1]);
|
||||
if (parts[0] == "code")
|
||||
{
|
||||
code = HttpUtility.UrlDecode(parts[1]);
|
||||
}
|
||||
else if (parts[0] == "iss")
|
||||
{
|
||||
iss = HttpUtility.UrlDecode(parts[1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (code is not null)
|
||||
{
|
||||
return new AuthorizationResult { Code = code, Iss = iss };
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
|
|
|
|||
|
|
@ -12,7 +12,8 @@ internal sealed class OAuthServerMetadata
|
|||
/// REQUIRED. The authorization server's issuer identifier, which is a URL that uses the "https" scheme and has no query or fragment components.
|
||||
/// </summary>
|
||||
[JsonPropertyName("issuer")]
|
||||
public required string Issuer { get; init; }
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? Issuer { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the authorization endpoint URL.
|
||||
|
|
@ -178,4 +179,11 @@ internal sealed class OAuthServerMetadata
|
|||
[JsonPropertyName("client_id_metadata_document_supported")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public bool? ClientIdMetadataDocumentSupported { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether authorization responses contain an issuer parameter.
|
||||
/// </summary>
|
||||
[JsonPropertyName("authorization_response_iss_parameter_supported")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public bool? AuthorizationResponseIssParameterSupported { get; init; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -100,6 +100,26 @@ public sealed class Program
|
|||
/// </remarks>
|
||||
public bool IncludeOfflineAccessInMetadata { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether authorization server metadata includes an issuer.
|
||||
/// </summary>
|
||||
public bool IncludeIssuerInMetadata { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets an issuer value that overrides the authorization server's metadata issuer.
|
||||
/// </summary>
|
||||
public string? MetadataIssuerOverride { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether the authorization server advertises RFC 9207 support.
|
||||
/// </summary>
|
||||
public bool AuthorizationResponseIssParameterSupported { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the issuer included in authorization responses, or <see langword="null"/> to omit it.
|
||||
/// </summary>
|
||||
public string? AuthorizationResponseIssuer { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the code challenge methods advertised by metadata endpoints.
|
||||
/// </summary>
|
||||
|
|
@ -242,7 +262,7 @@ public sealed class Program
|
|||
|
||||
var metadata = new OAuthServerMetadata
|
||||
{
|
||||
Issuer = $"{_url}{issuerPath}",
|
||||
Issuer = IncludeIssuerInMetadata ? MetadataIssuerOverride ?? $"{_url}{issuerPath}" : null,
|
||||
AuthorizationEndpoint = $"{_url}/authorize",
|
||||
TokenEndpoint = $"{_url}/token",
|
||||
JwksUri = $"{_url}/.well-known/jwks.json",
|
||||
|
|
@ -261,6 +281,7 @@ public sealed class Program
|
|||
IntrospectionEndpoint = $"{_url}/introspect",
|
||||
RegistrationEndpoint = $"{_url}/register",
|
||||
ClientIdMetadataDocumentSupported = ClientIdMetadataDocumentSupported,
|
||||
AuthorizationResponseIssParameterSupported = AuthorizationResponseIssParameterSupported ? true : null,
|
||||
};
|
||||
|
||||
return Results.Ok(metadata);
|
||||
|
|
@ -392,6 +413,10 @@ public sealed class Program
|
|||
{
|
||||
redirectUrl += $"&state={Uri.EscapeDataString(state)}";
|
||||
}
|
||||
if (!string.IsNullOrEmpty(AuthorizationResponseIssuer))
|
||||
{
|
||||
redirectUrl += $"&iss={Uri.EscapeDataString(AuthorizationResponseIssuer)}";
|
||||
}
|
||||
|
||||
return Results.Redirect(redirectUrl);
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue