HandleAuthorizationUrlAsync(Uri authorizationUrl, Uri redirectUri, CancellationToken cancellationToken)
{
Console.WriteLine("Starting OAuth authorization flow...");
Console.WriteLine($"Opening browser to: {authorizationUrl}");
@@ -93,6 +94,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 +117,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/AuthorizationRedirectDelegate.cs b/src/ModelContextProtocol.Core/Authentication/AuthorizationRedirectDelegate.cs
index a811e51cc..c268a47fd 100644
--- a/src/ModelContextProtocol.Core/Authentication/AuthorizationRedirectDelegate.cs
+++ b/src/ModelContextProtocol.Core/Authentication/AuthorizationRedirectDelegate.cs
@@ -22,7 +22,11 @@ namespace ModelContextProtocol.Authentication;
///
/// 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.
+/// a code query parameter in the redirect URI callback.
+///
+///
+/// For RFC 9207 issuer validation support, use
+/// instead, which allows returning both the authorization code and the iss parameter.
///
///
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..37da9087a
--- /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..08cbee53c 100644
--- a/src/ModelContextProtocol.Core/Authentication/ClientOAuthOptions.cs
+++ b/src/ModelContextProtocol.Core/Authentication/ClientOAuthOptions.cs
@@ -84,6 +84,24 @@ public sealed class ClientOAuthOptions
///
public AuthorizationRedirectDelegate? AuthorizationRedirectDelegate { get; set; }
+ ///
+ /// Gets or sets a callback that handles the full OAuth authorization flow, returning both the
+ /// authorization code and the issuer identifier for RFC 9207 validation.
+ ///
+ ///
+ ///
+ /// When set, this handler takes precedence over .
+ /// It enables the SDK to validate the iss parameter in the authorization response per
+ /// RFC 9207, which mitigates
+ /// mix-up attacks.
+ ///
+ ///
+ /// Implementations should extract both the code and iss query parameters from
+ /// the redirect URI callback and return them in an .
+ ///
+ ///
+ 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 662e436eb..a4139f132 100644
--- a/src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs
+++ b/src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs
@@ -32,6 +32,7 @@ internal sealed partial class ClientOAuthProvider : McpHttpClient
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)
@@ -84,6 +85,9 @@ public ClientOAuthProvider(
// Set up authorization server selection strategy
_authServerSelector = options.AuthServerSelector ?? DefaultAuthServerSelector;
+ // Set up authorization callback handler (new RFC 9207-aware handler takes precedence)
+ _authorizationCallbackHandler = options.AuthorizationCallbackHandler;
+
// Set up authorization URL handler (use default if not provided)
_authorizationRedirectDelegate = options.AuthorizationRedirectDelegate ?? DefaultAuthorizationUrlHandler;
@@ -370,6 +374,16 @@ 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.
+ if (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)
@@ -462,14 +476,33 @@ 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))
+ string? authorizationCode;
+ string? iss = null;
+
+ if (_authorizationCallbackHandler is not null)
{
- ThrowFailedToHandleUnauthorizedResponse($"The {nameof(AuthorizationRedirectDelegate)} returned a null or empty authorization code.");
+ var authResult = await _authorizationCallbackHandler(authUrl, _redirectUri, cancellationToken).ConfigureAwait(false);
+ if (authResult is null || string.IsNullOrEmpty(authResult.Code))
+ {
+ ThrowFailedToHandleUnauthorizedResponse($"The {nameof(ClientOAuthOptions.AuthorizationCallbackHandler)} returned a null or empty authorization code.");
+ }
+
+ authorizationCode = authResult!.Code!;
+ iss = authResult.Iss;
+ }
+ else
+ {
+ authorizationCode = await _authorizationRedirectDelegate(authUrl, _redirectUri, cancellationToken).ConfigureAwait(false);
+ if (string.IsNullOrEmpty(authorizationCode))
+ {
+ ThrowFailedToHandleUnauthorizedResponse($"The {nameof(AuthorizationRedirectDelegate)} returned a null or empty authorization code.");
+ }
}
- return await ExchangeCodeForTokenAsync(protectedResourceMetadata, authServerMetadata, authCode!, codeVerifier, cancellationToken).ConfigureAwait(false);
+ ValidateIssuerResponse(iss, authServerMetadata);
+
+ return await ExchangeCodeForTokenAsync(protectedResourceMetadata, authServerMetadata, authorizationCode!, codeVerifier, cancellationToken).ConfigureAwait(false);
}
private Uri BuildAuthorizationUrl(
@@ -773,6 +806,47 @@ private async Task PerformDynamicClientRegistrationAsync(
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 exactly matches the original request URL as required by the RFC.
/// Per RFC: The resource value must be identical to the URL that the client used to make the request to the resource server.
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 1ec6fddc6..29fe29483 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);
@@ -78,7 +78,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 +98,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 +122,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 +147,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 +176,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 +198,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 +263,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,7 +290,7 @@ public async Task CanAuthenticate_WithExtraParams()
ClientId = "demo-client",
ClientSecret = "demo-secret",
RedirectUri = new Uri("http://localhost:1179/callback"),
- AuthorizationRedirectDelegate = (uri, redirect, ct) =>
+ AuthorizationCallbackHandler = (uri, redirect, ct) =>
{
lastAuthorizationUri = uri;
return HandleAuthorizationUrlAsync(uri, redirect, ct);
@@ -322,7 +322,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 +347,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 +369,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,7 +397,7 @@ public async Task AuthorizationFlow_UsesScopeFromProtectedResourceMetadata()
ClientId = "demo-client",
ClientSecret = "demo-secret",
RedirectUri = new Uri("http://localhost:1179/callback"),
- AuthorizationRedirectDelegate = (uri, redirect, ct) =>
+ AuthorizationCallbackHandler = (uri, redirect, ct) =>
{
var query = QueryHelpers.ParseQuery(uri.Query);
requestedScope = query["scope"].ToString();
@@ -448,7 +448,7 @@ public async Task AuthorizationFlow_UsesScopeFromChallengeHeader()
ClientId = "demo-client",
ClientSecret = "demo-secret",
RedirectUri = new Uri("http://localhost:1179/callback"),
- AuthorizationRedirectDelegate = (uri, redirect, ct) =>
+ AuthorizationCallbackHandler = (uri, redirect, ct) =>
{
var query = QueryHelpers.ParseQuery(uri.Query);
requestedScope = query["scope"].ToString();
@@ -537,7 +537,7 @@ public async Task AuthorizationFlow_UsesScopeFromForbiddenHeader()
ClientId = "demo-client",
ClientSecret = "demo-secret",
RedirectUri = new Uri("http://localhost:1179/callback"),
- AuthorizationRedirectDelegate = (uri, redirect, ct) =>
+ AuthorizationCallbackHandler = (uri, redirect, ct) =>
{
var query = QueryHelpers.ParseQuery(uri.Query);
requestedScope = query["scope"].ToString();
@@ -575,7 +575,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);
@@ -614,7 +614,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);
@@ -642,7 +642,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);
@@ -675,7 +675,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);
@@ -739,7 +739,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);
@@ -794,7 +794,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);
@@ -835,7 +835,7 @@ public async Task CannotAuthenticate_WhenWwwAuthenticateResourceMetadataIsRootPa
ClientId = "demo-client",
ClientSecret = "demo-secret",
RedirectUri = new Uri("http://localhost:1179/callback"),
- AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync,
+ AuthorizationCallbackHandler = HandleAuthorizationUrlAsync,
},
}, HttpClient, LoggerFactory);
@@ -883,7 +883,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);
@@ -1035,7 +1035,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);
@@ -1152,7 +1152,7 @@ await context.Response.WriteAsync($$"""
ClientId = "demo-client",
ClientSecret = "demo-secret",
RedirectUri = new Uri("http://localhost:1179/callback"),
- AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync,
+ AuthorizationCallbackHandler = HandleAuthorizationUrlAsync,
},
}, HttpClient, LoggerFactory);
@@ -1254,7 +1254,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);
@@ -1278,7 +1278,7 @@ public async Task AuthorizationFlow_AppendsOfflineAccess_WhenServerAdvertisesIt(
ClientId = "demo-client",
ClientSecret = "demo-secret",
RedirectUri = new Uri("http://localhost:1179/callback"),
- AuthorizationRedirectDelegate = (uri, redirect, ct) =>
+ AuthorizationCallbackHandler = (uri, redirect, ct) =>
{
var query = QueryHelpers.ParseQuery(uri.Query);
requestedScope = query["scope"].ToString();
@@ -1310,7 +1310,7 @@ public async Task AuthorizationFlow_DoesNotAppendOfflineAccess_WhenServerDoesNot
ClientId = "demo-client",
ClientSecret = "demo-secret",
RedirectUri = new Uri("http://localhost:1179/callback"),
- AuthorizationRedirectDelegate = (uri, redirect, ct) =>
+ AuthorizationCallbackHandler = (uri, redirect, ct) =>
{
var query = QueryHelpers.ParseQuery(uri.Query);
requestedScope = query["scope"].ToString();
@@ -1349,7 +1349,7 @@ public async Task AuthorizationFlow_DoesNotDuplicateOfflineAccess_WhenAlreadyPre
ClientId = "demo-client",
ClientSecret = "demo-secret",
RedirectUri = new Uri("http://localhost:1179/callback"),
- AuthorizationRedirectDelegate = (uri, redirect, ct) =>
+ AuthorizationCallbackHandler = (uri, redirect, ct) =>
{
var query = QueryHelpers.ParseQuery(uri.Query);
requestedScope = query["scope"].ToString();
@@ -1386,7 +1386,7 @@ public async Task AuthorizationFlow_ScopeSelector_CanFilterServerProposedScopes(
ClientId = "demo-client",
ClientSecret = "demo-secret",
RedirectUri = new Uri("http://localhost:1179/callback"),
- AuthorizationRedirectDelegate = (uri, redirect, ct) =>
+ AuthorizationCallbackHandler = (uri, redirect, ct) =>
{
var query = QueryHelpers.ParseQuery(uri.Query);
requestedScope = query["scope"].ToString();
@@ -1417,7 +1417,7 @@ public async Task AuthorizationFlow_ScopeSelector_CanAddCustomScope()
ClientId = "demo-client",
ClientSecret = "demo-secret",
RedirectUri = new Uri("http://localhost:1179/callback"),
- AuthorizationRedirectDelegate = (uri, redirect, ct) =>
+ AuthorizationCallbackHandler = (uri, redirect, ct) =>
{
var query = QueryHelpers.ParseQuery(uri.Query);
requestedScope = query["scope"].ToString();
@@ -1455,7 +1455,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;
@@ -1485,7 +1485,7 @@ 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 = (uri, redirect, ct) =>
{
scopePresent = QueryHelpers.ParseQuery(uri.Query).ContainsKey("scope");
return HandleAuthorizationUrlAsync(uri, redirect, ct);
@@ -1515,7 +1515,7 @@ 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 = (uri, redirect, ct) =>
{
scopePresent = QueryHelpers.ParseQuery(uri.Query).ContainsKey("scope");
return HandleAuthorizationUrlAsync(uri, redirect, ct);
@@ -1546,7 +1546,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 3c1919b0b..6d3321677 100644
--- a/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/OAuthTestBase.cs
+++ b/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/OAuthTestBase.cs
@@ -106,7 +106,7 @@ protected async Task StartMcpServerAsync(string path = "", strin
return app;
}
- protected async Task HandleAuthorizationUrlAsync(Uri authorizationUri, Uri redirectUri, CancellationToken cancellationToken)
+ protected async Task HandleAuthorizationUrlAsync(Uri authorizationUri, Uri redirectUri, CancellationToken cancellationToken)
{
using var redirectResponse = await HttpClient.GetAsync(authorizationUri, cancellationToken);
Assert.Equal(HttpStatusCode.Redirect, redirectResponse.StatusCode);
@@ -115,7 +115,11 @@ protected async Task StartMcpServerAsync(string path = "", strin
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..0582394ad 100644
--- a/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/TokenCacheTests.cs
+++ b/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/TokenCacheTests.cs
@@ -26,7 +26,7 @@ public async Task GetTokenAsync_CachedAccessTokenIsUsedForOutgoingRequests()
ClientId = "demo-client",
ClientSecret = "demo-secret",
RedirectUri = new Uri("http://localhost:1179/callback"),
- AuthorizationRedirectDelegate = (uri, redirect, ct) =>
+ AuthorizationCallbackHandler = (uri, redirect, ct) =>
{
authDelegateCalledInitially = true;
return HandleAuthorizationUrlAsync(uri, redirect, ct);
@@ -53,7 +53,7 @@ public async Task GetTokenAsync_CachedAccessTokenIsUsedForOutgoingRequests()
ClientId = "demo-client",
ClientSecret = "demo-secret",
RedirectUri = new Uri("http://localhost:1179/callback"),
- AuthorizationRedirectDelegate = (uri, redirect, ct) =>
+ AuthorizationCallbackHandler = (uri, redirect, ct) =>
{
authDelegateCalledAgain = true;
return HandleAuthorizationUrlAsync(uri, redirect, ct);
@@ -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,7 +109,7 @@ public async Task GetTokenAsync_InvalidCachedTokenTriggersAuthDelegate()
ClientId = "demo-client",
ClientSecret = "demo-secret",
RedirectUri = new Uri("http://localhost:1179/callback"),
- AuthorizationRedirectDelegate = (uri, redirect, ct) =>
+ AuthorizationCallbackHandler = (uri, redirect, ct) =>
{
authDelegateCalled = true;
return HandleAuthorizationUrlAsync(uri, redirect, ct);
@@ -141,7 +141,7 @@ public async Task GetTokenAsync_InvalidAccessTokenTriggersRefresh()
ClientId = "demo-client",
ClientSecret = "demo-secret",
RedirectUri = new Uri("http://localhost:1179/callback"),
- AuthorizationRedirectDelegate = (uri, redirect, ct) =>
+ AuthorizationCallbackHandler = (uri, redirect, ct) =>
{
authDelegateCalledInitially = true;
return HandleAuthorizationUrlAsync(uri, redirect, ct);
@@ -171,7 +171,7 @@ public async Task GetTokenAsync_InvalidAccessTokenTriggersRefresh()
ClientId = "demo-client",
ClientSecret = "demo-secret",
RedirectUri = new Uri("http://localhost:1179/callback"),
- AuthorizationRedirectDelegate = (uri, redirect, ct) =>
+ AuthorizationCallbackHandler = (uri, redirect, ct) =>
{
authDelegateCalledAgain = true;
return HandleAuthorizationUrlAsync(uri, redirect, ct);
diff --git a/tests/ModelContextProtocol.ConformanceClient/Program.cs b/tests/ModelContextProtocol.ConformanceClient/Program.cs
index 7ce848907..eafbbac4d 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;
@@ -81,7 +82,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 = (authUrl, redirectUri, ct) => HandleAuthorizationUrlAsync(authUrl, redirectUri, ct),
};
if (preRegisteredClientId is not null)
@@ -329,7 +330,7 @@
// 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(Uri authorizationUrl, Uri redirectUri, CancellationToken cancellationToken)
{
Console.WriteLine("Starting OAuth authorization flow...");
Console.WriteLine($"Simulating opening browser to: {authorizationUrl}");
@@ -344,16 +345,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;