diff --git a/samples/ProtectedMcpClient/Program.cs b/samples/ProtectedMcpClient/Program.cs index eba792556..ade93da96 100644 --- a/samples/ProtectedMcpClient/Program.cs +++ b/samples/ProtectedMcpClient/Program.cs @@ -1,4 +1,5 @@ using Microsoft.Extensions.Logging; +using ModelContextProtocol.Authentication; using ModelContextProtocol.Client; using ModelContextProtocol.Protocol; using System.Diagnostics; @@ -32,7 +33,7 @@ OAuth = new() { RedirectUri = new Uri("http://localhost:1179/callback"), - AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync, + AuthorizationCallbackHandler = HandleAuthorizationUrlAsync, DynamicClientRegistration = new() { ClientName = "ProtectedMcpClient", @@ -71,8 +72,8 @@ /// The authorization URL to open in the browser. /// The redirect URI where the authorization code will be sent. /// The to monitor for cancellation requests. The default is . -/// The authorization code extracted from the callback, or null if the operation failed. -static async Task HandleAuthorizationUrlAsync(Uri authorizationUrl, Uri redirectUri, CancellationToken cancellationToken) +/// The authorization result extracted from the callback, or null if the operation failed. +static async Task 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;