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..dbfce0509 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,6 +401,24 @@ 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 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 (Exception ex)
@@ -492,14 +511,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 +912,47 @@ 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)
+ {
+ // 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.
+ 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..90f369689 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);
@@ -1055,7 +1086,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 +1141,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 +1188,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 +1230,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 +1276,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 +1323,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 +1475,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 +1592,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 +1694,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 +1718,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 +1750,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 +1789,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 +1826,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 +1857,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 +1895,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 +1925,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 +1955,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 = _ => [],
},
@@ -1955,7 +1986,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;