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 @@
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 = "Authentication complete
You can close this window now.
";
@@ -115,7 +121,7 @@
}
Console.WriteLine("Authorization code received successfully.");
- return code;
+ return new AuthorizationResult { Code = code, Iss = iss };
}
catch (Exception ex)
{
diff --git a/src/ModelContextProtocol.Core/Authentication/AuthorizationCallbackContext.cs b/src/ModelContextProtocol.Core/Authentication/AuthorizationCallbackContext.cs
new file mode 100644
index 000000000..cbc453802
--- /dev/null
+++ b/src/ModelContextProtocol.Core/Authentication/AuthorizationCallbackContext.cs
@@ -0,0 +1,17 @@
+namespace ModelContextProtocol.Authentication;
+
+///
+/// Provides the information needed to complete an OAuth authorization request.
+///
+public sealed class AuthorizationCallbackContext
+{
+ ///
+ /// Gets the authorization URI that the user needs to visit.
+ ///
+ public required Uri AuthorizationUri { get; init; }
+
+ ///
+ /// Gets the redirect URI where the authorization response will be sent.
+ ///
+ public required Uri RedirectUri { get; init; }
+}
diff --git a/src/ModelContextProtocol.Core/Authentication/AuthorizationRedirectDelegate.cs b/src/ModelContextProtocol.Core/Authentication/AuthorizationRedirectDelegate.cs
deleted file mode 100644
index a811e51cc..000000000
--- a/src/ModelContextProtocol.Core/Authentication/AuthorizationRedirectDelegate.cs
+++ /dev/null
@@ -1,28 +0,0 @@
-
-namespace ModelContextProtocol.Authentication;
-
-///
-/// Represents a method that handles the OAuth authorization URL and returns the authorization code.
-///
-/// The authorization URL that the user needs to visit.
-/// The redirect URI where the authorization code will be sent.
-/// The to monitor for cancellation requests. The default is .
-/// 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.
-///
-///
-/// This delegate provides SDK consumers with full control over how the OAuth authorization flow is handled.
-/// Implementers can choose to:
-///
-///
-/// - Start a local HTTP server and open a browser (default behavior)
-/// - Display the authorization URL to the user for manual handling
-/// - Integrate with a custom UI or authentication flow
-/// - Use a different redirect mechanism altogether
-///
-///
-/// 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.
-///
-///
-public delegate Task AuthorizationRedirectDelegate(Uri authorizationUri, Uri redirectUri, CancellationToken cancellationToken);
\ No newline at end of file
diff --git a/src/ModelContextProtocol.Core/Authentication/AuthorizationResult.cs b/src/ModelContextProtocol.Core/Authentication/AuthorizationResult.cs
new file mode 100644
index 000000000..d5bebc502
--- /dev/null
+++ b/src/ModelContextProtocol.Core/Authentication/AuthorizationResult.cs
@@ -0,0 +1,39 @@
+namespace ModelContextProtocol.Authentication;
+
+///
+/// Represents the result of an OAuth authorization redirect, containing the authorization code
+/// and optionally the issuer identifier from the authorization response.
+///
+///
+///
+/// The property should be populated from the iss query parameter in the
+/// redirect URI when present, as specified by
+/// RFC 9207.
+/// This enables the SDK to validate that the authorization response originated from the expected
+/// authorization server, mitigating mix-up attacks.
+///
+///
+public sealed class AuthorizationResult
+{
+ ///
+ /// Gets the authorization code returned by the authorization server.
+ ///
+ public string? Code { get; init; }
+
+ ///
+ /// Gets the issuer identifier returned in the authorization response per
+ /// RFC 9207.
+ ///
+ ///
+ ///
+ /// This value should be extracted from the iss query parameter of the redirect URI.
+ /// When present, the SDK validates it against the expected authorization server issuer to
+ /// prevent mix-up attacks.
+ ///
+ ///
+ /// Implementations of should populate this
+ /// property whenever the iss parameter is present in the redirect URI callback.
+ ///
+ ///
+ public string? Iss { get; init; }
+}
diff --git a/src/ModelContextProtocol.Core/Authentication/AuthorizationServerMetadata.cs b/src/ModelContextProtocol.Core/Authentication/AuthorizationServerMetadata.cs
index 87df29636..d8d684a3b 100644
--- a/src/ModelContextProtocol.Core/Authentication/AuthorizationServerMetadata.cs
+++ b/src/ModelContextProtocol.Core/Authentication/AuthorizationServerMetadata.cs
@@ -72,4 +72,11 @@ internal sealed class AuthorizationServerMetadata
///
[JsonPropertyName("client_id_metadata_document_supported")]
public bool ClientIdMetadataDocumentSupported { get; set; }
+
+ ///
+ /// Indicates whether the authorization server includes the iss parameter in authorization responses
+ /// as defined in RFC 9207.
+ ///
+ [JsonPropertyName("authorization_response_iss_parameter_supported")]
+ public bool AuthorizationResponseIssParameterSupported { get; set; }
}
diff --git a/src/ModelContextProtocol.Core/Authentication/ClientOAuthOptions.cs b/src/ModelContextProtocol.Core/Authentication/ClientOAuthOptions.cs
index 0bfb19a59..5d0cfaea5 100644
--- a/src/ModelContextProtocol.Core/Authentication/ClientOAuthOptions.cs
+++ b/src/ModelContextProtocol.Core/Authentication/ClientOAuthOptions.cs
@@ -70,19 +70,23 @@ public sealed class ClientOAuthOptions
public ScopeSelectorDelegate? ScopeSelector { get; set; }
///
- /// Gets or sets the authorization redirect delegate for handling the OAuth authorization flow.
+ /// Gets or sets the callback that handles the OAuth authorization flow.
///
///
///
- /// 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
+ /// and returns the authorization response.
+ /// If not specified, a default implementation prompts the user to enter the authorization code manually.
///
///
/// 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 code and iss query parameters
+ /// from the redirect URI callback. This enables the SDK to validate the iss parameter per
+ /// RFC 9207, which mitigates
+ /// mix-up attacks.
///
///
- public AuthorizationRedirectDelegate? AuthorizationRedirectDelegate { get; set; }
+ public Func>? AuthorizationCallbackHandler { get; set; }
///
/// Gets or sets the authorization server selector function.
diff --git a/src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs b/src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs
index c3b1af736..16b065fac 100644
--- a/src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs
+++ b/src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs
@@ -31,7 +31,7 @@ internal sealed partial class ClientOAuthProvider : McpHttpClient
private readonly ScopeSelectorDelegate? _scopeSelector;
private readonly IDictionary _additionalAuthorizationParameters;
private readonly Func, Uri?> _authServerSelector;
- private readonly AuthorizationRedirectDelegate _authorizationRedirectDelegate;
+ private readonly Func> _authorizationCallbackHandler;
private readonly Uri? _clientMetadataDocumentUri;
// _dcrClientName, _dcrClientUri, _dcrInitialAccessToken and _dcrResponseDelegate are used for dynamic client registration (RFC 7591)
@@ -92,8 +92,8 @@ public ClientOAuthProvider(
// 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)
+ _authorizationCallbackHandler = options.AuthorizationCallbackHandler ?? DefaultAuthorizationUrlHandler;
_dcrClientName = options.DynamicClientRegistration?.ClientName;
_dcrClientUri = options.DynamicClientRegistration?.ClientUri;
@@ -112,18 +112,19 @@ public ClientOAuthProvider(
///
/// Default authorization URL handler that displays the URL to the user for manual input.
///
- /// The authorization URL to handle.
- /// The redirect URI where the authorization code will be sent.
+ /// The context containing the authorization and redirect URIs.
/// The to monitor for cancellation requests.
- /// The authorization code entered by the user, or null if none was provided.
- private static Task DefaultAuthorizationUrlHandler(Uri authorizationUrl, Uri redirectUri, CancellationToken cancellationToken)
+ /// The authorization result entered by the user, or null if none was provided.
+ private static Task 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(authorizationCode);
+ return Task.FromResult(new() { Code = authorizationCode });
}
internal override async Task SendAsync(HttpRequestMessage request, JsonRpcMessage? message, CancellationToken cancellationToken)
@@ -400,8 +401,39 @@ private async Task GetAuthServerMetadataAsync(Uri a
metadata.TokenEndpointAuthMethodsSupported ??= ["client_secret_post"];
metadata.CodeChallengeMethodsSupported ??= ["S256"];
+ // 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).");
+ }
+
return metadata;
}
+ 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);
@@ -492,14 +524,28 @@ private async Task InitiateAuthorizationCodeFlowAsync(
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);
+ ValidateIssuerResponse(authResult!.Iss, authServerMetadata);
+
+ return await ExchangeCodeForTokenAsync(
+ protectedResourceMetadata,
+ authServerMetadata,
+ authResult.Code!,
+ codeVerifier,
+ cancellationToken).ConfigureAwait(false);
}
private Uri BuildAuthorizationUrl(
@@ -879,6 +925,56 @@ private bool ChallengeIntroducesNewScopes(ProtectedResourceMetadata protectedRes
return scope + " " + OfflineAccess;
}
+ ///
+ /// Validates the iss parameter from an authorization response per
+ /// RFC 9207.
+ ///
+ /// The issuer identifier received in the authorization response, or null if absent.
+ /// The authorization server metadata containing the expected issuer.
+ 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.
+ }
+ }
+
///
/// 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
diff --git a/src/ModelContextProtocol.Core/CompatibilitySuppressions.xml b/src/ModelContextProtocol.Core/CompatibilitySuppressions.xml
index e686b454f..e322bed26 100644
--- a/src/ModelContextProtocol.Core/CompatibilitySuppressions.xml
+++ b/src/ModelContextProtocol.Core/CompatibilitySuppressions.xml
@@ -1,6 +1,13 @@
+
+ CP0001
+ T:ModelContextProtocol.Authentication.AuthorizationRedirectDelegate
+ lib/net10.0/ModelContextProtocol.Core.dll
+ lib/net10.0/ModelContextProtocol.Core.dll
+ true
+
CP0001
T:ModelContextProtocol.IMcpTaskStore
@@ -155,6 +162,13 @@
lib/net10.0/ModelContextProtocol.Core.dll
true
+
+ CP0001
+ T:ModelContextProtocol.Authentication.AuthorizationRedirectDelegate
+ lib/net8.0/ModelContextProtocol.Core.dll
+ lib/net8.0/ModelContextProtocol.Core.dll
+ true
+
CP0001
T:ModelContextProtocol.IMcpTaskStore
@@ -309,6 +323,13 @@
lib/net8.0/ModelContextProtocol.Core.dll
true
+
+ CP0001
+ T:ModelContextProtocol.Authentication.AuthorizationRedirectDelegate
+ lib/net9.0/ModelContextProtocol.Core.dll
+ lib/net9.0/ModelContextProtocol.Core.dll
+ true
+
CP0001
T:ModelContextProtocol.IMcpTaskStore
@@ -463,6 +484,13 @@
lib/net9.0/ModelContextProtocol.Core.dll
true
+
+ CP0001
+ T:ModelContextProtocol.Authentication.AuthorizationRedirectDelegate
+ lib/netstandard2.0/ModelContextProtocol.Core.dll
+ lib/netstandard2.0/ModelContextProtocol.Core.dll
+ true
+
CP0001
T:ModelContextProtocol.IMcpTaskStore
@@ -638,6 +666,20 @@
lib/net10.0/ModelContextProtocol.Core.dll
true
+
+ CP0002
+ M:ModelContextProtocol.Authentication.ClientOAuthOptions.get_AuthorizationRedirectDelegate
+ lib/net10.0/ModelContextProtocol.Core.dll
+ lib/net10.0/ModelContextProtocol.Core.dll
+ true
+
+
+ CP0002
+ M:ModelContextProtocol.Authentication.ClientOAuthOptions.set_AuthorizationRedirectDelegate(ModelContextProtocol.Authentication.AuthorizationRedirectDelegate)
+ lib/net10.0/ModelContextProtocol.Core.dll
+ lib/net10.0/ModelContextProtocol.Core.dll
+ true
+
CP0002
M:ModelContextProtocol.Client.McpClient.CallToolAsTaskAsync(System.String,System.Collections.Generic.IReadOnlyDictionary{System.String,System.Object},ModelContextProtocol.Protocol.McpTaskMetadata,System.IProgress{ModelContextProtocol.ProgressNotificationValue},ModelContextProtocol.RequestOptions,System.Threading.CancellationToken)
@@ -1009,6 +1051,20 @@
lib/net8.0/ModelContextProtocol.Core.dll
true
+
+ CP0002
+ M:ModelContextProtocol.Authentication.ClientOAuthOptions.get_AuthorizationRedirectDelegate
+ lib/net8.0/ModelContextProtocol.Core.dll
+ lib/net8.0/ModelContextProtocol.Core.dll
+ true
+
+
+ CP0002
+ M:ModelContextProtocol.Authentication.ClientOAuthOptions.set_AuthorizationRedirectDelegate(ModelContextProtocol.Authentication.AuthorizationRedirectDelegate)
+ lib/net8.0/ModelContextProtocol.Core.dll
+ lib/net8.0/ModelContextProtocol.Core.dll
+ true
+
CP0002
M:ModelContextProtocol.Client.McpClient.CallToolAsTaskAsync(System.String,System.Collections.Generic.IReadOnlyDictionary{System.String,System.Object},ModelContextProtocol.Protocol.McpTaskMetadata,System.IProgress{ModelContextProtocol.ProgressNotificationValue},ModelContextProtocol.RequestOptions,System.Threading.CancellationToken)
@@ -1380,6 +1436,20 @@
lib/net9.0/ModelContextProtocol.Core.dll
true
+
+ CP0002
+ M:ModelContextProtocol.Authentication.ClientOAuthOptions.get_AuthorizationRedirectDelegate
+ lib/net9.0/ModelContextProtocol.Core.dll
+ lib/net9.0/ModelContextProtocol.Core.dll
+ true
+
+
+ CP0002
+ M:ModelContextProtocol.Authentication.ClientOAuthOptions.set_AuthorizationRedirectDelegate(ModelContextProtocol.Authentication.AuthorizationRedirectDelegate)
+ lib/net9.0/ModelContextProtocol.Core.dll
+ lib/net9.0/ModelContextProtocol.Core.dll
+ true
+
CP0002
M:ModelContextProtocol.Client.McpClient.CallToolAsTaskAsync(System.String,System.Collections.Generic.IReadOnlyDictionary{System.String,System.Object},ModelContextProtocol.Protocol.McpTaskMetadata,System.IProgress{ModelContextProtocol.ProgressNotificationValue},ModelContextProtocol.RequestOptions,System.Threading.CancellationToken)
@@ -1751,6 +1821,20 @@
lib/netstandard2.0/ModelContextProtocol.Core.dll
true
+
+ CP0002
+ M:ModelContextProtocol.Authentication.ClientOAuthOptions.get_AuthorizationRedirectDelegate
+ lib/netstandard2.0/ModelContextProtocol.Core.dll
+ lib/netstandard2.0/ModelContextProtocol.Core.dll
+ true
+
+
+ CP0002
+ M:ModelContextProtocol.Authentication.ClientOAuthOptions.set_AuthorizationRedirectDelegate(ModelContextProtocol.Authentication.AuthorizationRedirectDelegate)
+ lib/netstandard2.0/ModelContextProtocol.Core.dll
+ lib/netstandard2.0/ModelContextProtocol.Core.dll
+ true
+
CP0002
M:ModelContextProtocol.Client.McpClient.CallToolAsTaskAsync(System.String,System.Collections.Generic.IReadOnlyDictionary{System.String,System.Object},ModelContextProtocol.Protocol.McpTaskMetadata,System.IProgress{ModelContextProtocol.ProgressNotificationValue},ModelContextProtocol.RequestOptions,System.Threading.CancellationToken)
diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/AuthEventTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/AuthEventTests.cs
index 7aafd312e..1866dfe13 100644
--- a/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/AuthEventTests.cs
+++ b/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/AuthEventTests.cs
@@ -48,7 +48,7 @@ public async Task CanAuthenticate_WithResourceMetadataFromEvent()
ClientId = "demo-client",
ClientSecret = "demo-secret",
RedirectUri = new Uri("http://localhost:1179/callback"),
- AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync,
+ AuthorizationCallbackHandler = HandleAuthorizationUrlAsync,
},
},
HttpClient,
@@ -76,7 +76,7 @@ public async Task CanAuthenticate_WithDynamicClientRegistration_FromEvent()
OAuth = new ClientOAuthOptions()
{
RedirectUri = new Uri("http://localhost:1179/callback"),
- AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync,
+ AuthorizationCallbackHandler = HandleAuthorizationUrlAsync,
Scopes = ["mcp:tools"],
DynamicClientRegistration = new()
{
diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/AuthTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/AuthTests.cs
index e49cb5bf5..565f27a9a 100644
--- a/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/AuthTests.cs
+++ b/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/AuthTests.cs
@@ -41,7 +41,7 @@ public async Task CanAuthenticate()
ClientId = "demo-client",
ClientSecret = "demo-secret",
RedirectUri = new Uri("http://localhost:1179/callback"),
- AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync,
+ AuthorizationCallbackHandler = HandleAuthorizationUrlAsync,
},
}, HttpClient, LoggerFactory);
@@ -49,6 +49,37 @@ public async Task CanAuthenticate()
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);
+ }
+
[Fact]
public async Task CannotAuthenticate_WithoutOAuthConfiguration()
{
@@ -78,7 +109,7 @@ public async Task CannotAuthenticate_WithUnregisteredClient()
ClientId = "unregistered-demo-client",
ClientSecret = "demo-secret",
RedirectUri = new Uri("http://localhost:1179/callback"),
- AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync,
+ AuthorizationCallbackHandler = HandleAuthorizationUrlAsync,
},
}, HttpClient, LoggerFactory);
@@ -98,7 +129,7 @@ public async Task CanAuthenticate_WithDynamicClientRegistration()
OAuth = new ClientOAuthOptions()
{
RedirectUri = new Uri("http://localhost:1179/callback"),
- AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync,
+ AuthorizationCallbackHandler = HandleAuthorizationUrlAsync,
DynamicClientRegistration = new()
{
ClientName = "Test MCP Client",
@@ -122,7 +153,7 @@ public async Task CanAuthenticate_WithClientMetadataDocument()
OAuth = new ClientOAuthOptions()
{
RedirectUri = new Uri("http://localhost:1179/callback"),
- AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync,
+ AuthorizationCallbackHandler = HandleAuthorizationUrlAsync,
ClientMetadataDocumentUri = new Uri(ClientMetadataDocumentUrl)
},
}, HttpClient, LoggerFactory);
@@ -147,7 +178,7 @@ public async Task UsesDynamicClientRegistration_WhenCimdNotSupported()
OAuth = new ClientOAuthOptions()
{
RedirectUri = new Uri("http://localhost:1179/callback"),
- AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync,
+ AuthorizationCallbackHandler = HandleAuthorizationUrlAsync,
ClientMetadataDocumentUri = new Uri("http://invalid-cimd.example.com"),
DynamicClientRegistration = new()
{
@@ -176,7 +207,7 @@ public async Task DoesNotUseClientMetadataDocument_WhenClientIdIsSpecified()
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"),
},
}, HttpClient, LoggerFactory);
@@ -198,7 +229,7 @@ public async Task CannotAuthenticate_WithInvalidClientMetadataDocument(string ur
OAuth = new ClientOAuthOptions()
{
RedirectUri = new Uri("http://localhost:1179/callback"),
- AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync,
+ AuthorizationCallbackHandler = HandleAuthorizationUrlAsync,
ClientMetadataDocumentUri = new Uri(uri),
},
}, HttpClient, LoggerFactory);
@@ -263,7 +294,7 @@ public async Task CanAuthenticate_WithTokenRefresh()
ClientId = "demo-client",
ClientSecret = "demo-secret",
RedirectUri = new Uri("http://localhost:1179/callback"),
- AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync,
+ AuthorizationCallbackHandler = HandleAuthorizationUrlAsync,
},
}, HttpClient, LoggerFactory);
@@ -290,10 +321,10 @@ public async Task CanAuthenticate_WithExtraParams()
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
{
@@ -322,7 +353,7 @@ public async Task CannotOverrideExistingParameters_WithExtraParams()
ClientId = "demo-client",
ClientSecret = "demo-secret",
RedirectUri = new Uri("http://localhost:1179/callback"),
- AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync,
+ AuthorizationCallbackHandler = HandleAuthorizationUrlAsync,
AdditionalAuthorizationParameters = new Dictionary
{
["redirect_uri"] = "custom_value",
@@ -347,7 +378,7 @@ public async Task CanAuthenticate_WithoutResourceInWwwAuthenticateHeader()
ClientId = "demo-client",
ClientSecret = "demo-secret",
RedirectUri = new Uri("http://localhost:1179/callback"),
- AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync,
+ AuthorizationCallbackHandler = HandleAuthorizationUrlAsync,
},
}, HttpClient, LoggerFactory);
@@ -369,7 +400,7 @@ public async Task CanAuthenticate_WithoutResourceInWwwAuthenticateHeader_WithPat
ClientId = "demo-client",
ClientSecret = "demo-secret",
RedirectUri = new Uri("http://localhost:1179/callback"),
- AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync,
+ AuthorizationCallbackHandler = HandleAuthorizationUrlAsync,
},
}, HttpClient, LoggerFactory);
@@ -397,11 +428,11 @@ public async Task AuthorizationFlow_UsesScopeFromProtectedResourceMetadata()
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);
@@ -450,11 +481,11 @@ public async Task AuthorizationFlow_UsesScopeFromChallengeHeader()
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);
@@ -545,11 +576,11 @@ public async Task AuthorizationFlow_UsesScopeFromForbiddenHeader()
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);
@@ -650,11 +681,11 @@ public async Task AuthorizationFlow_AccumulatesScopesAcrossMultipleStepUps()
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);
@@ -748,11 +779,11 @@ public async Task AuthorizationFlow_StopsSteppingUpWhenChallengeAddsNoNewScope()
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);
@@ -843,11 +874,11 @@ public async Task AuthorizationFlow_AllowsOneStepUpEvenWhenChallengeAddsNoNewSco
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);
@@ -891,7 +922,7 @@ public async Task AuthorizationFails_WhenResourceMetadataPortDiffers()
ClientId = "demo-client",
ClientSecret = "demo-secret",
RedirectUri = new Uri("http://localhost:1179/callback"),
- AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync,
+ AuthorizationCallbackHandler = HandleAuthorizationUrlAsync,
},
}, HttpClient, LoggerFactory);
@@ -930,7 +961,7 @@ public async Task CannotAuthenticate_WhenProtectedResourceMetadataMissingResourc
ClientId = "demo-client",
ClientSecret = "demo-secret",
RedirectUri = new Uri("http://localhost:1179/callback"),
- AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync,
+ AuthorizationCallbackHandler = HandleAuthorizationUrlAsync,
},
}, HttpClient, LoggerFactory);
@@ -958,7 +989,7 @@ public async Task CanAuthenticate_WithAuthorizationServerPathInsertionMetadata()
ClientId = "demo-client",
ClientSecret = "demo-secret",
RedirectUri = new Uri("http://localhost:1179/callback"),
- AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync,
+ AuthorizationCallbackHandler = HandleAuthorizationUrlAsync,
},
}, HttpClient, LoggerFactory);
@@ -991,7 +1022,7 @@ public async Task CanAuthenticate_WithAuthorizationServerPathFallbacks()
ClientId = "demo-client",
ClientSecret = "demo-secret",
RedirectUri = new Uri("http://localhost:1179/callback"),
- AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync,
+ AuthorizationCallbackHandler = HandleAuthorizationUrlAsync,
},
}, HttpClient, LoggerFactory);
@@ -1008,6 +1039,64 @@ public async Task CanAuthenticate_WithAuthorizationServerPathFallbacks()
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(() => 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(() => 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);
+ }
+
+ [Fact]
+ public async Task CannotAuthenticate_WhenAuthorizationResponseIssuerMismatches()
+ {
+ TestOAuthServer.AuthorizationResponseIssParameterSupported = true;
+ TestOAuthServer.AuthorizationResponseIssuer = "https://attacker.example";
+
+ await using var app = await StartMcpServerAsync();
+ await using var transport = CreateOAuthTransport();
+
+ var ex = await Assert.ThrowsAsync(() => McpClient.CreateAsync(
+ transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken));
+
+ Assert.Contains("Authorization response issuer", ex.Message);
+ Assert.Contains("does not match expected issuer", ex.Message);
+ }
+
[Fact]
public async Task CanAuthenticate_WithResourceMetadataPathFallbacks()
{
@@ -1055,7 +1144,7 @@ public async Task CanAuthenticate_WithResourceMetadataPathFallbacks()
ClientId = "demo-client",
ClientSecret = "demo-secret",
RedirectUri = new Uri("http://localhost:1179/callback"),
- AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync,
+ AuthorizationCallbackHandler = HandleAuthorizationUrlAsync,
},
}, HttpClient, LoggerFactory);
@@ -1110,7 +1199,7 @@ public async Task CannotAuthenticate_WhenResourceMetadataResourceIsNonRootParent
ClientId = "demo-client",
ClientSecret = "demo-secret",
RedirectUri = new Uri("http://localhost:1179/callback"),
- AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync,
+ AuthorizationCallbackHandler = HandleAuthorizationUrlAsync,
},
}, HttpClient, LoggerFactory);
@@ -1157,7 +1246,7 @@ public async Task CanAuthenticate_WhenWwwAuthenticateResourceMetadataIsRootPath(
ClientId = "demo-client",
ClientSecret = "demo-secret",
RedirectUri = new Uri("http://localhost:1179/callback"),
- AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync,
+ AuthorizationCallbackHandler = HandleAuthorizationUrlAsync,
},
}, HttpClient, LoggerFactory);
@@ -1199,7 +1288,7 @@ public async Task CannotAuthenticate_WhenResourceMetadataUriDoesNotMatch()
ClientId = "demo-client",
ClientSecret = "demo-secret",
RedirectUri = new Uri("http://localhost:1179/callback"),
- AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync,
+ AuthorizationCallbackHandler = HandleAuthorizationUrlAsync,
},
}, HttpClient, LoggerFactory);
@@ -1245,7 +1334,7 @@ public async Task CannotAuthenticate_WhenResourceMetadataResourceIsDifferentPath
ClientId = "demo-client",
ClientSecret = "demo-secret",
RedirectUri = new Uri("http://localhost:1179/callback"),
- AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync,
+ AuthorizationCallbackHandler = HandleAuthorizationUrlAsync,
},
}, HttpClient, LoggerFactory);
@@ -1292,7 +1381,7 @@ public async Task ResourceMetadata_DoesNotAddTrailingSlash()
ClientId = "demo-client",
ClientSecret = "demo-secret",
RedirectUri = new Uri("http://localhost:1179/callback"),
- AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync,
+ AuthorizationCallbackHandler = HandleAuthorizationUrlAsync,
},
}, HttpClient, LoggerFactory);
@@ -1444,7 +1533,7 @@ public async Task ResourceMetadata_PreservesExplicitTrailingSlash()
ClientId = "demo-client",
ClientSecret = "demo-secret",
RedirectUri = new Uri("http://localhost:1179/callback"),
- AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync,
+ AuthorizationCallbackHandler = HandleAuthorizationUrlAsync,
},
}, HttpClient, LoggerFactory);
@@ -1561,7 +1650,7 @@ await context.Response.WriteAsync($$"""
ClientId = "demo-client",
ClientSecret = "demo-secret",
RedirectUri = new Uri("http://localhost:1179/callback"),
- AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync,
+ AuthorizationCallbackHandler = HandleAuthorizationUrlAsync,
},
}, HttpClient, LoggerFactory);
@@ -1663,7 +1752,7 @@ public async Task CanAuthenticate_WithLegacyServerUsingDefaultEndpointFallback()
ClientId = "demo-client",
ClientSecret = "demo-secret",
RedirectUri = new Uri("http://localhost:1179/callback"),
- AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync,
+ AuthorizationCallbackHandler = HandleAuthorizationUrlAsync,
},
}, HttpClient, LoggerFactory);
@@ -1687,11 +1776,11 @@ public async Task AuthorizationFlow_AppendsOfflineAccess_WhenServerAdvertisesIt(
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);
@@ -1719,11 +1808,11 @@ public async Task AuthorizationFlow_DoesNotAppendOfflineAccess_WhenServerDoesNot
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);
@@ -1758,11 +1847,11 @@ public async Task AuthorizationFlow_DoesNotDuplicateOfflineAccess_WhenAlreadyPre
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);
@@ -1795,11 +1884,11 @@ public async Task AuthorizationFlow_ScopeSelector_CanFilterServerProposedScopes(
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"),
},
@@ -1826,11 +1915,11 @@ public async Task AuthorizationFlow_ScopeSelector_CanAddCustomScope()
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"],
},
@@ -1864,7 +1953,7 @@ public async Task AuthorizationFlow_ScopeSelector_ReceivesNull_WhenServerProvide
ClientId = "demo-client",
ClientSecret = "demo-secret",
RedirectUri = new Uri("http://localhost:1179/callback"),
- AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync,
+ AuthorizationCallbackHandler = HandleAuthorizationUrlAsync,
ScopeSelector = scopes =>
{
capturedInput = scopes;
@@ -1894,10 +1983,10 @@ public async Task AuthorizationFlow_ScopeSelector_ReturningNull_OmitsScopeParame
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,
},
@@ -1924,10 +2013,10 @@ public async Task AuthorizationFlow_ScopeSelector_ReturningEmpty_OmitsScopeParam
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 = _ => [],
},
@@ -1939,6 +2028,19 @@ public async Task AuthorizationFlow_ScopeSelector_ReturningEmpty_OmitsScopeParam
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()
{
@@ -1955,7 +2057,7 @@ public async Task DynamicClientRegistration_ScopeSelector_AppliesToDcrScope()
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"),
},
diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/OAuthTestBase.cs b/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/OAuthTestBase.cs
index f9a4b64c0..49d502c98 100644
--- a/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/OAuthTestBase.cs
+++ b/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/OAuthTestBase.cs
@@ -106,16 +106,22 @@ protected async Task StartMcpServerAsync(string path = "", strin
return app;
}
- protected async Task HandleAuthorizationUrlAsync(Uri authorizationUri, Uri redirectUri, CancellationToken cancellationToken)
+ protected async Task 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;
diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/TokenCacheTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/TokenCacheTests.cs
index fb9e2bfda..09aa165c1 100644
--- a/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/TokenCacheTests.cs
+++ b/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/TokenCacheTests.cs
@@ -26,10 +26,10 @@ public async Task GetTokenAsync_CachedAccessTokenIsUsedForOutgoingRequests()
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 async Task GetTokenAsync_CachedAccessTokenIsUsedForOutgoingRequests()
// 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 async Task GetTokenAsync_CachedAccessTokenIsUsedForOutgoingRequests()
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 async Task GetTokenAsync_CachedAccessTokenIsUsedForOutgoingRequests()
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 async Task StoreTokenAsync_NewlyAcquiredAccessTokenIsCached()
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 async Task GetTokenAsync_InvalidCachedTokenTriggersAuthDelegate()
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 async Task GetTokenAsync_InvalidCachedTokenTriggersAuthDelegate()
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 async Task GetTokenAsync_InvalidAccessTokenTriggersRefresh()
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 async Task GetTokenAsync_InvalidAccessTokenTriggersRefresh()
// 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 async Task GetTokenAsync_InvalidAccessTokenTriggersRefresh()
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 async Task GetTokenAsync_InvalidAccessTokenTriggersRefresh()
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);
}
diff --git a/tests/ModelContextProtocol.ConformanceClient/Program.cs b/tests/ModelContextProtocol.ConformanceClient/Program.cs
index 60d07d46e..a52e8cfb5 100644
--- a/tests/ModelContextProtocol.ConformanceClient/Program.cs
+++ b/tests/ModelContextProtocol.ConformanceClient/Program.cs
@@ -3,6 +3,7 @@
using System.Text.Json;
using System.Web;
using Microsoft.Extensions.Logging;
+using ModelContextProtocol.Authentication;
using ModelContextProtocol.Client;
using ModelContextProtocol.Protocol;
@@ -92,7 +93,7 @@
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 @@
// Copied from ProtectedMcpClient sample
// Simulate a user opening the browser and logging in
// Copied from OAuthTestBase
-static async Task HandleAuthorizationUrlAsync(Uri authorizationUrl, Uri redirectUri, CancellationToken cancellationToken)
+static async Task 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 @@
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;
diff --git a/tests/ModelContextProtocol.TestOAuthServer/OAuthServerMetadata.cs b/tests/ModelContextProtocol.TestOAuthServer/OAuthServerMetadata.cs
index c05a45ba2..744ab0e08 100644
--- a/tests/ModelContextProtocol.TestOAuthServer/OAuthServerMetadata.cs
+++ b/tests/ModelContextProtocol.TestOAuthServer/OAuthServerMetadata.cs
@@ -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.
///
[JsonPropertyName("issuer")]
- public required string Issuer { get; init; }
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ public string? Issuer { get; init; }
///
/// 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; }
+
+ ///
+ /// Gets or sets a value indicating whether authorization responses contain an issuer parameter.
+ ///
+ [JsonPropertyName("authorization_response_iss_parameter_supported")]
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ public bool? AuthorizationResponseIssParameterSupported { get; init; }
}
diff --git a/tests/ModelContextProtocol.TestOAuthServer/Program.cs b/tests/ModelContextProtocol.TestOAuthServer/Program.cs
index 69eb60683..19b4ba899 100644
--- a/tests/ModelContextProtocol.TestOAuthServer/Program.cs
+++ b/tests/ModelContextProtocol.TestOAuthServer/Program.cs
@@ -100,6 +100,26 @@ public Program(ILoggerProvider? loggerProvider = null, IConnectionListenerFactor
///
public bool IncludeOfflineAccessInMetadata { get; set; }
+ ///
+ /// Gets or sets a value indicating whether authorization server metadata includes an issuer.
+ ///
+ public bool IncludeIssuerInMetadata { get; set; } = true;
+
+ ///
+ /// Gets or sets an issuer value that overrides the authorization server's metadata issuer.
+ ///
+ public string? MetadataIssuerOverride { get; set; }
+
+ ///
+ /// Gets or sets a value indicating whether the authorization server advertises RFC 9207 support.
+ ///
+ public bool AuthorizationResponseIssParameterSupported { get; set; }
+
+ ///
+ /// Gets or sets the issuer included in authorization responses, or to omit it.
+ ///
+ public string? AuthorizationResponseIssuer { get; set; }
+
public HashSet DisabledMetadataPaths { get; } = new(StringComparer.OrdinalIgnoreCase);
public IReadOnlyCollection MetadataRequests => _metadataRequests.ToArray();
@@ -225,7 +245,7 @@ IResult HandleMetadataRequest(HttpContext context, string? issuerPath = null)
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",
@@ -242,6 +262,7 @@ IResult HandleMetadataRequest(HttpContext context, string? issuerPath = null)
IntrospectionEndpoint = $"{_url}/introspect",
RegistrationEndpoint = $"{_url}/register",
ClientIdMetadataDocumentSupported = ClientIdMetadataDocumentSupported,
+ AuthorizationResponseIssParameterSupported = AuthorizationResponseIssParameterSupported ? true : null,
};
return Results.Ok(metadata);
@@ -373,6 +394,10 @@ IResult HandleMetadataRequest(HttpContext context, string? issuerPath = null)
{
redirectUrl += $"&state={Uri.EscapeDataString(state)}";
}
+ if (!string.IsNullOrEmpty(AuthorizationResponseIssuer))
+ {
+ redirectUrl += $"&iss={Uri.EscapeDataString(AuthorizationResponseIssuer)}";
+ }
return Results.Redirect(redirectUrl);
});