diff --git a/docs/list-of-diagnostics.md b/docs/list-of-diagnostics.md index a4a71dd48..deddad8e1 100644 --- a/docs/list-of-diagnostics.md +++ b/docs/list-of-diagnostics.md @@ -45,3 +45,4 @@ When APIs are marked as obsolete, a diagnostic is emitted to warn users that the | `MCP9004` | In place | opts into the legacy SSE transport which has no built-in HTTP-level backpressure. Use Streamable HTTP instead. See [Stateless — Legacy SSE transport](xref:stateless#legacy-sse-transport) for details. | | `MCP9005` | In place | The Roots, Sampling, and Logging features are deprecated as of specification version 2026-07-28 and may be removed in a future version. See SEP-2577 for more information. | | `MCP9006` | In place | The stateful Streamable HTTP configuration knobs on — `EventStreamStore`, `SessionMigrationHandler`, `PerSessionExecutionContext`, `IdleTimeout`, and `MaxIdleSessionCount` — only apply when `Stateless = false`. Starting with the `2026-07-28` protocol revision, Streamable HTTP no longer supports sessions, and the SDK now defaults `Stateless` to `true`. These knobs remain available for back-compat with the legacy stateful Streamable HTTP transport but new code should target the stateless path. | +| `MCP9007` | In place | `AuthorizationRedirectDelegate` and `ClientOAuthOptions.AuthorizationRedirectDelegate` are retained for source and binary compatibility but cannot provide the RFC 9207 authorization-response issuer. Use `ClientOAuthOptions.AuthorizationCallbackHandler` for issuer-aware authorization flows. | diff --git a/samples/ProtectedMcpClient/Program.cs b/samples/ProtectedMcpClient/Program.cs index eba792556..9b805ad36 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", @@ -68,12 +69,16 @@ /// Handles the OAuth authorization URL by starting a local HTTP server and opening a browser. /// This implementation demonstrates how SDK consumers can provide their own authorization flow. /// -/// The authorization URL to open in the browser. -/// 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 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( + 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/Common/Obsoletions.cs b/src/Common/Obsoletions.cs index 542fddb8d..6ecab2b3c 100644 --- a/src/Common/Obsoletions.cs +++ b/src/Common/Obsoletions.cs @@ -46,4 +46,8 @@ internal static class Obsoletions public const string LegacyStatefulHttp_DiagnosticId = "MCP9006"; public const string LegacyStatefulHttp_Message = "Stateful Streamable HTTP mode is a back-compat-only escape hatch for legacy clients. Set HttpServerTransportOptions.Stateless = true (the default as of the 2026-07-28 protocol revision) for new code. See SEP-2567."; public const string LegacyStatefulHttp_Url = "https://github.com/modelcontextprotocol/csharp-sdk/blob/main/docs/list-of-diagnostics.md#obsolete-apis"; + + public const string AuthorizationRedirectDelegate_DiagnosticId = "MCP9007"; + public const string AuthorizationRedirectDelegate_Message = "AuthorizationRedirectDelegate cannot provide the RFC 9207 issuer and is retained for compatibility only. Use AuthorizationCallbackHandler instead."; + public const string AuthorizationRedirectDelegate_Url = "https://github.com/modelcontextprotocol/csharp-sdk/blob/main/docs/list-of-diagnostics.md#obsolete-apis"; } 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 index a811e51cc..b8f6a37cd 100644 --- a/src/ModelContextProtocol.Core/Authentication/AuthorizationRedirectDelegate.cs +++ b/src/ModelContextProtocol.Core/Authentication/AuthorizationRedirectDelegate.cs @@ -1,4 +1,3 @@ - namespace ModelContextProtocol.Authentication; /// @@ -9,20 +8,13 @@ namespace ModelContextProtocol.Authentication; /// 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. -/// +/// This delegate cannot return the iss parameter from the authorization response, so +/// RFC 9207 issuer validation is +/// skipped when it is used. Use for +/// issuer-aware authorization flows. /// -public delegate Task AuthorizationRedirectDelegate(Uri authorizationUri, Uri redirectUri, CancellationToken cancellationToken); \ No newline at end of file +[Obsolete( + ModelContextProtocol.Obsoletions.AuthorizationRedirectDelegate_Message, + DiagnosticId = ModelContextProtocol.Obsoletions.AuthorizationRedirectDelegate_DiagnosticId, + UrlFormat = ModelContextProtocol.Obsoletions.AuthorizationRedirectDelegate_Url)] +public delegate Task AuthorizationRedirectDelegate(Uri authorizationUri, Uri redirectUri, CancellationToken cancellationToken); 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..b536207c3 100644 --- a/src/ModelContextProtocol.Core/Authentication/ClientOAuthOptions.cs +++ b/src/ModelContextProtocol.Core/Authentication/ClientOAuthOptions.cs @@ -70,18 +70,44 @@ 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 full redirect URL 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. + /// + /// + /// This property cannot be configured together with . + /// + /// + public Func>? AuthorizationCallbackHandler { get; set; } + + /// + /// Gets or sets the legacy authorization redirect delegate for handling the OAuth authorization flow. + /// + /// + /// + /// This delegate returns only the authorization code and cannot provide the iss parameter from + /// the authorization response. Consequently, RFC 9207 issuer validation is skipped when this delegate + /// is used. Use for issuer-aware authorization flows. + /// + /// + /// This property cannot be configured together with . /// /// + [Obsolete( + ModelContextProtocol.Obsoletions.AuthorizationRedirectDelegate_Message, + DiagnosticId = ModelContextProtocol.Obsoletions.AuthorizationRedirectDelegate_DiagnosticId, + UrlFormat = ModelContextProtocol.Obsoletions.AuthorizationRedirectDelegate_Url)] public AuthorizationRedirectDelegate? AuthorizationRedirectDelegate { get; set; } /// diff --git a/src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs b/src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs index 73caa1c00..317a711f9 100644 --- a/src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs +++ b/src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs @@ -31,7 +31,8 @@ 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 bool _validateAuthorizationResponseIssuer; private readonly Uri? _clientMetadataDocumentUri; // _dcrClientName, _dcrClientUri, _dcrInitialAccessToken, _dcrConfiguredApplicationType and _dcrResponseDelegate are used for dynamic client registration (RFC 7591) @@ -104,8 +105,39 @@ 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). +#pragma warning disable MCP9007 // Read the obsolete property to provide source and binary compatibility. + var authorizationRedirectDelegate = options.AuthorizationRedirectDelegate; + + if (options.AuthorizationCallbackHandler is not null && authorizationRedirectDelegate is not null) + { + throw new ArgumentException( + $"{nameof(ClientOAuthOptions.AuthorizationCallbackHandler)} and {nameof(ClientOAuthOptions.AuthorizationRedirectDelegate)} cannot both be configured.", + nameof(options)); + } +#pragma warning restore MCP9007 + + if (options.AuthorizationCallbackHandler is not null) + { + _authorizationCallbackHandler = options.AuthorizationCallbackHandler; + _validateAuthorizationResponseIssuer = true; + } + else if (authorizationRedirectDelegate is not null) + { + _authorizationCallbackHandler = async (context, cancellationToken) => new AuthorizationResult + { + Code = await authorizationRedirectDelegate( + context.AuthorizationUri, + context.RedirectUri, + cancellationToken).ConfigureAwait(false), + }; + _validateAuthorizationResponseIssuer = false; + } + else + { + _authorizationCallbackHandler = DefaultAuthorizationUrlHandler; + _validateAuthorizationResponseIssuer = true; + } _dcrClientName = options.DynamicClientRegistration?.ClientName; _dcrClientUri = options.DynamicClientRegistration?.ClientUri; @@ -125,18 +157,30 @@ 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); + Console.Write("Enter the full redirect URL: "); + var redirectUrl = Console.ReadLine(); + if (!Uri.TryCreate(redirectUrl, UriKind.Absolute, out var responseUri)) + { + ThrowFailedToHandleUnauthorizedResponse( + "The entered redirect URL is not a valid absolute URL. Paste the full redirect URL from the browser address bar."); + } + + var queryParams = HttpUtility.ParseQueryString(responseUri.Query); + return Task.FromResult(new() + { + Code = queryParams["code"], + Iss = queryParams["iss"], + }); } internal override async Task SendAsync(HttpRequestMessage request, JsonRpcMessage? message, CancellationToken cancellationToken) @@ -468,10 +512,41 @@ private async Task GetAuthServerMetadataAsync(Uri a ThrowFailedToHandleUnauthorizedResponse($"AuthorizationEndpoint must use HTTP or HTTPS. '{metadata.AuthorizationEndpoint}' does not meet this requirement."); } + // Validate the issuer in the metadata document per RFC 8414 Section 3.3: + // the issuer value MUST be identical to the issuer identifier used to construct + // the well-known URL. + // Skip validation in legacy backcompat mode (resourceUri is null) because the + // authServerUri was derived from the server origin rather than from Protected + // Resource Metadata, so it may not match the server's canonical issuer. + // Note: resourceUri is null exclusively in the 2025-03-26 legacy path. For newer + // protocol versions, ExtractProtectedResourceMetadata throws if the PRM document + // omits the resource field (VerifyResourceMatch returns false for null Resource), + // so we never reach this point with resourceUri == null in non-legacy flows. + if (resourceUri is not null && metadata.Issuer is null) + { + ThrowFailedToHandleUnauthorizedResponse( + $"Authorization server metadata from '{wellKnownEndpoint}' did not provide the required issuer (RFC 8414 Section 2)."); + } + + // RFC 8414 requires an identical issuer value, so do not normalize URI case, + // trailing slashes, or percent-encoding before comparison. + if (resourceUri is not null && + !string.Equals(metadata.Issuer!.OriginalString, authServerUri.OriginalString, StringComparison.Ordinal)) + { + ThrowFailedToHandleUnauthorizedResponse( + $"Authorization server metadata issuer '{metadata.Issuer}' does not match the expected issuer '{authServerUri}' (RFC 8414 Section 3.3)."); + } + // A structurally valid metadata document was discovered. Even if it fails PKCE validation // below, its existence disqualifies the legacy fallback that would otherwise synthesize S256. metadataDocumentFound = true; } + catch (McpException) + { + // Metadata validation failures are security signals and must not fall back to + // another well-known endpoint. + throw; + } catch (Exception ex) { LogErrorFetchingAuthServerMetadata(ex, wellKnownEndpoint); @@ -605,14 +680,31 @@ 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); + if (_validateAuthorizationResponseIssuer) + { + ValidateIssuerResponse(authResult!.Iss, authServerMetadata); + } + + return await ExchangeCodeForTokenAsync( + protectedResourceMetadata, + authServerMetadata, + authResult.Code!, + codeVerifier, + cancellationToken).ConfigureAwait(false); } private Uri BuildAuthorizationUrl( @@ -999,6 +1091,56 @@ private bool ChallengeIntroducesNewScopes(ProtectedResourceMetadata protectedRes return scope + " " + OfflineAccess; } + /// + /// Validates the iss parameter from an authorization response per + /// RFC 9207. + /// + /// The issuer identifier received in the authorization response, or null if absent. + /// The authorization server metadata containing the expected issuer. + private void ValidateIssuerResponse(string? iss, AuthorizationServerMetadata authServerMetadata) + { + var expectedIssuer = authServerMetadata.Issuer?.OriginalString; + + if ((authServerMetadata.AuthorizationResponseIssParameterSupported || !string.IsNullOrEmpty(iss)) && + expectedIssuer is null) + { + ThrowFailedToHandleUnauthorizedResponse( + "Authorization server metadata did not provide an issuer required to validate the authorization response."); + } + + if (authServerMetadata.AuthorizationResponseIssParameterSupported) + { + // Server advertises iss support: iss MUST be present and match. + if (string.IsNullOrEmpty(iss)) + { + ThrowFailedToHandleUnauthorizedResponse( + "Authorization server advertises RFC 9207 iss parameter support but none was received in the authorization response."); + } + + // Use exact string comparison per RFC 9207 / RFC 3986 §6.2.1. + if (!string.Equals(iss, expectedIssuer, StringComparison.Ordinal)) + { + ThrowFailedToHandleUnauthorizedResponse( + $"Authorization response issuer '{iss}' does not match expected issuer '{expectedIssuer}'."); + } + } + else + { + // Server does not advertise iss support: if iss is present, still validate it. + // RFC 9207 cannot protect against a server that neither advertises support nor + // returns an iss parameter, so an absent iss is accepted in that case. + if (!string.IsNullOrEmpty(iss)) + { + if (!string.Equals(iss, expectedIssuer, StringComparison.Ordinal)) + { + ThrowFailedToHandleUnauthorizedResponse( + $"Authorization response issuer '{iss}' does not match expected issuer '{expectedIssuer}'."); + } + } + // If iss is absent and not advertised, proceed normally. + } + } + /// /// Verifies that the resource URI in the metadata matches the original request URL. /// Accepts either an exact match with the full request URL, or a match with the base URL diff --git a/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 676a9ddea..2aca606e1 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/AuthTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/AuthTests.cs @@ -42,7 +42,7 @@ public async Task CanAuthenticate() ClientId = "demo-client", ClientSecret = "demo-secret", RedirectUri = new Uri("http://localhost:1179/callback"), - AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync, + AuthorizationCallbackHandler = HandleAuthorizationUrlAsync, }, }, HttpClient, LoggerFactory); @@ -50,6 +50,132 @@ 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); + } + + [Theory] + [InlineData(false, null)] + [InlineData(true, "https://localhost:7029")] + [InlineData(true, "https://attacker.example")] + public async Task AuthorizationRedirectDelegate_ReceivesConfiguredUrisAndSkipsResponseIssuerValidation( + bool authorizationResponseIssParameterSupported, + string? authorizationResponseIssuer) + { + TestOAuthServer.AuthorizationResponseIssParameterSupported = authorizationResponseIssParameterSupported; + TestOAuthServer.AuthorizationResponseIssuer = authorizationResponseIssuer; + await using var app = await StartMcpServerAsync(); + + var redirectUri = new Uri("http://localhost:1179/callback"); + Uri? receivedAuthorizationUri = null; + Uri? receivedRedirectUri = null; + + await using var transport = new HttpClientTransport(new() + { + Endpoint = new(McpServerUrl), + OAuth = new() + { + ClientId = "demo-client", + ClientSecret = "demo-secret", + RedirectUri = redirectUri, +#pragma warning disable MCP9007 // Verify the obsolete callback remains functional during its compatibility window. + AuthorizationRedirectDelegate = (authorizationUri, callbackRedirectUri, cancellationToken) => + { + receivedAuthorizationUri = authorizationUri; + receivedRedirectUri = callbackRedirectUri; + return HandleAuthorizationUrlAsync(authorizationUri, callbackRedirectUri, cancellationToken); + }, +#pragma warning restore MCP9007 + }, + }, HttpClient, LoggerFactory); + + await using var client = await McpClient.CreateAsync( + transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken); + + Assert.NotNull(receivedAuthorizationUri); + Assert.Equal(redirectUri, receivedRedirectUri); + } + + [Fact] + public async Task AuthorizationRedirectDelegate_DoesNotSkipMetadataIssuerValidation() + { + TestOAuthServer.MetadataIssuerOverride = "https://attacker.example"; + await using var app = await StartMcpServerAsync(); + + await using var transport = new HttpClientTransport(new() + { + Endpoint = new(McpServerUrl), + OAuth = new() + { + ClientId = "demo-client", + ClientSecret = "demo-secret", + RedirectUri = new Uri("http://localhost:1179/callback"), +#pragma warning disable MCP9007 // Verify the obsolete callback retains metadata issuer validation. + AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync, +#pragma warning restore MCP9007 + }, + }, HttpClient, LoggerFactory); + + var ex = await Assert.ThrowsAsync(() => McpClient.CreateAsync( + transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken)); + + Assert.Contains("does not match the expected issuer", ex.Message); + } + + [Fact] + public void HttpClientTransport_RejectsBothAuthorizationCallbacks() + { +#pragma warning disable MCP9007 // Verify ambiguous legacy and current callback configuration is rejected. + var options = new ClientOAuthOptions + { + RedirectUri = new Uri("http://localhost:1179/callback"), + AuthorizationCallbackHandler = (_, _) => Task.FromResult(new()), + AuthorizationRedirectDelegate = (_, _, _) => Task.FromResult("code"), + }; +#pragma warning restore MCP9007 + + var ex = Assert.Throws(() => new HttpClientTransport( + new() + { + Endpoint = new(McpServerUrl), + OAuth = options, + }, + HttpClient, + LoggerFactory)); + + Assert.Contains(nameof(ClientOAuthOptions.AuthorizationCallbackHandler), ex.Message); +#pragma warning disable MCP9007 // The obsolete property name should be included in the diagnostic. + Assert.Contains(nameof(ClientOAuthOptions.AuthorizationRedirectDelegate), ex.Message); +#pragma warning restore MCP9007 + } + [Fact] public async Task CannotAuthenticate_WithoutOAuthConfiguration() { @@ -79,7 +205,7 @@ public async Task CannotAuthenticate_WithUnregisteredClient() ClientId = "unregistered-demo-client", ClientSecret = "demo-secret", RedirectUri = new Uri("http://localhost:1179/callback"), - AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync, + AuthorizationCallbackHandler = HandleAuthorizationUrlAsync, }, }, HttpClient, LoggerFactory); @@ -99,7 +225,7 @@ public async Task CanAuthenticate_WithDynamicClientRegistration() OAuth = new ClientOAuthOptions() { RedirectUri = new Uri("http://localhost:1179/callback"), - AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync, + AuthorizationCallbackHandler = HandleAuthorizationUrlAsync, DynamicClientRegistration = new() { ClientName = "Test MCP Client", @@ -125,7 +251,7 @@ public async Task DynamicClientRegistration_UsesExplicitApplicationType() OAuth = new ClientOAuthOptions() { RedirectUri = new Uri("http://localhost:1179/callback"), - AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync, + AuthorizationCallbackHandler = HandleAuthorizationUrlAsync, DynamicClientRegistration = new() { ApplicationType = "web", @@ -150,7 +276,7 @@ public async Task CanAuthenticate_WithClientMetadataDocument() OAuth = new ClientOAuthOptions() { RedirectUri = new Uri("http://localhost:1179/callback"), - AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync, + AuthorizationCallbackHandler = HandleAuthorizationUrlAsync, ClientMetadataDocumentUri = new Uri(ClientMetadataDocumentUrl), DynamicClientRegistration = new() { @@ -177,7 +303,7 @@ public async Task CannotAuthenticate_WhenMetadataOmitsPkceMethods() ClientId = "demo-client", ClientSecret = "demo-secret", RedirectUri = new Uri("http://localhost:1179/callback"), - AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync, + AuthorizationCallbackHandler = HandleAuthorizationUrlAsync, }, }, HttpClient, LoggerFactory); @@ -205,7 +331,7 @@ public async Task CannotAuthenticate_WhenMetadataLacksS256PkceMethod() ClientId = "demo-client", ClientSecret = "demo-secret", RedirectUri = new Uri("http://localhost:1179/callback"), - AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync, + AuthorizationCallbackHandler = HandleAuthorizationUrlAsync, }, }, HttpClient, LoggerFactory); @@ -234,7 +360,7 @@ public async Task CanAuthenticate_WhenFirstMetadataEndpointOmitsPkce_ButAnotherA ClientId = "demo-client", ClientSecret = "demo-secret", RedirectUri = new Uri("http://localhost:1179/callback"), - AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync, + AuthorizationCallbackHandler = HandleAuthorizationUrlAsync, }, }, HttpClient, LoggerFactory); @@ -258,7 +384,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() { @@ -287,7 +413,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"), DynamicClientRegistration = new() { @@ -313,7 +439,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); @@ -378,7 +504,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); @@ -405,10 +531,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 { @@ -437,7 +563,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", @@ -462,7 +588,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); @@ -484,7 +610,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); @@ -512,11 +638,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); @@ -565,11 +691,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); @@ -660,11 +786,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); @@ -765,11 +891,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); @@ -806,6 +932,8 @@ public async Task AuthorizationFlow_ConcurrentStepUps_ReuseSteppedUpToken_WhenCh // before either has stepped up. They serialize on the provider's token acquisition lock: the // first runs the step-up and caches the broader token, and the second must reuse that token // instead of failing as a "repeated" challenge. Only one interactive step-up should run. + TestOAuthServer.AuthorizationResponseIssParameterSupported = true; + TestOAuthServer.AuthorizationResponseIssuer = OAuthServerUrl; Builder.Services.AddMcpServer() .WithTools([ @@ -879,14 +1007,14 @@ public async Task AuthorizationFlow_ConcurrentStepUps_ReuseSteppedUpToken_WhenCh ClientId = "demo-client", ClientSecret = "demo-secret", RedirectUri = new Uri("http://localhost:1179/callback"), - AuthorizationRedirectDelegate = (uri, redirect, ct) => + AuthorizationCallbackHandler = (context, cancellationToken) => { - var query = QueryHelpers.ParseQuery(uri.Query); + var query = QueryHelpers.ParseQuery(context.AuthorizationUri.Query); lock (scopeLock) { requestedScopes.Add(query["scope"].ToString()); } - return HandleAuthorizationUrlAsync(uri, redirect, ct); + return HandleAuthorizationUrlAsync(context, cancellationToken); }, }, }, HttpClient, LoggerFactory); @@ -977,11 +1105,11 @@ public 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); @@ -1072,11 +1200,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); @@ -1120,7 +1248,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); @@ -1159,7 +1287,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); @@ -1187,7 +1315,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); @@ -1220,7 +1348,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); @@ -1237,6 +1365,69 @@ public async Task CanAuthenticate_WithAuthorizationServerPathFallbacks() TestOAuthServer.MetadataRequests); } + [Fact] + public async Task CannotAuthenticate_WhenAuthorizationServerMetadataIssuerMismatches() + { + TestOAuthServer.MetadataIssuerOverride = "https://attacker.example"; + + await using var app = await StartMcpServerAsync(); + await using var transport = CreateOAuthTransport(); + + var ex = await Assert.ThrowsAsync(() => McpClient.CreateAsync( + transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken)); + + Assert.Contains("does not match the expected issuer", ex.Message); + Assert.Single(TestOAuthServer.MetadataRequests); + } + + [Fact] + public async Task CannotAuthenticate_WhenAuthorizationServerMetadataOmitsIssuer() + { + TestOAuthServer.IncludeIssuerInMetadata = false; + + await using var app = await StartMcpServerAsync(); + await using var transport = CreateOAuthTransport(); + + var ex = await Assert.ThrowsAsync(() => McpClient.CreateAsync( + transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken)); + + Assert.Contains("did not provide the required issuer", ex.Message); + Assert.Single(TestOAuthServer.MetadataRequests); + } + + [Fact] + public async Task CanAuthenticate_WhenAuthorizationResponseIssuerMatches() + { + TestOAuthServer.AuthorizationResponseIssParameterSupported = true; + TestOAuthServer.AuthorizationResponseIssuer = OAuthServerUrl; + + await using var app = await StartMcpServerAsync(); + await using var transport = CreateOAuthTransport(); + await using var client = await McpClient.CreateAsync( + transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken); + } + + [Theory] + [InlineData(true, "https://attacker.example", "does not match expected issuer")] + [InlineData(true, null, "advertises RFC 9207 iss parameter support but none was received")] + [InlineData(false, "https://attacker.example", "does not match expected issuer")] + public async Task CannotAuthenticate_WhenAuthorizationResponseIssuerIsInvalid( + bool authorizationResponseIssParameterSupported, + string? authorizationResponseIssuer, + string expectedMessage) + { + TestOAuthServer.AuthorizationResponseIssParameterSupported = authorizationResponseIssParameterSupported; + TestOAuthServer.AuthorizationResponseIssuer = authorizationResponseIssuer; + + await using var app = await StartMcpServerAsync(); + await using var transport = CreateOAuthTransport(); + + var ex = await Assert.ThrowsAsync(() => McpClient.CreateAsync( + transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken)); + + Assert.Contains(expectedMessage, ex.Message); + } + [Fact] public async Task CanAuthenticate_WithResourceMetadataPathFallbacks() { @@ -1284,7 +1475,7 @@ public async Task CanAuthenticate_WithResourceMetadataPathFallbacks() ClientId = "demo-client", ClientSecret = "demo-secret", RedirectUri = new Uri("http://localhost:1179/callback"), - AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync, + AuthorizationCallbackHandler = HandleAuthorizationUrlAsync, }, }, HttpClient, LoggerFactory); @@ -1339,7 +1530,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); @@ -1386,7 +1577,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); @@ -1428,7 +1619,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); @@ -1474,7 +1665,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); @@ -1521,7 +1712,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); @@ -1673,7 +1864,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); @@ -1790,7 +1981,7 @@ await context.Response.WriteAsync($$""" ClientId = "demo-client", ClientSecret = "demo-secret", RedirectUri = new Uri("http://localhost:1179/callback"), - AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync, + AuthorizationCallbackHandler = HandleAuthorizationUrlAsync, }, }, HttpClient, LoggerFactory); @@ -1892,7 +2083,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); @@ -1963,7 +2154,7 @@ await context.Response.WriteAsync($$""" ClientId = "demo-client", ClientSecret = "demo-secret", RedirectUri = new Uri("http://localhost:1179/callback"), - AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync, + AuthorizationCallbackHandler = HandleAuthorizationUrlAsync, }, }, HttpClient, LoggerFactory); @@ -1991,11 +2182,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); @@ -2023,11 +2214,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); @@ -2062,11 +2253,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); @@ -2099,11 +2290,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"), }, @@ -2130,11 +2321,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"], }, @@ -2168,7 +2359,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; @@ -2198,10 +2389,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, }, @@ -2228,10 +2419,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 = _ => [], }, @@ -2243,6 +2434,19 @@ public async Task AuthorizationFlow_ScopeSelector_ReturningEmpty_OmitsScopeParam Assert.False(scopePresent); } + private HttpClientTransport CreateOAuthTransport() => + new(new() + { + Endpoint = new(McpServerUrl), + OAuth = new() + { + ClientId = "demo-client", + ClientSecret = "demo-secret", + RedirectUri = new Uri("http://localhost:1179/callback"), + AuthorizationCallbackHandler = HandleAuthorizationUrlAsync, + }, + }, HttpClient, LoggerFactory); + [Fact] public async Task DynamicClientRegistration_ScopeSelector_AppliesToDcrScope() { @@ -2259,7 +2463,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/DcrFailureTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/DcrFailureTests.cs index 9a4bd0aa9..43a69de68 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/DcrFailureTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/DcrFailureTests.cs @@ -26,7 +26,7 @@ public async Task DcrRejection_PropagatesToConsumer_WithStatusBodyAndSentParamet OAuth = new ClientOAuthOptions() { RedirectUri = new Uri("myapp://callback"), - AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync, + AuthorizationCallbackHandler = HandleAuthorizationUrlAsync, DynamicClientRegistration = new() { ClientName = "Test MCP Client" }, }, }, HttpClient, LoggerFactory); @@ -54,7 +54,7 @@ public async Task ConsumerCanRetryRegistration_WithAdjustedRedirectUri_AfterReje OAuth = new ClientOAuthOptions() { RedirectUri = new Uri("myapp://callback"), - AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync, + AuthorizationCallbackHandler = HandleAuthorizationUrlAsync, DynamicClientRegistration = new() { ClientName = "Test MCP Client", @@ -75,7 +75,7 @@ await Assert.ThrowsAsync(() => McpClient.CreateAsync( OAuth = new ClientOAuthOptions() { RedirectUri = new Uri("http://localhost:1179/callback"), - AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync, + AuthorizationCallbackHandler = HandleAuthorizationUrlAsync, DynamicClientRegistration = new() { ClientName = "Test MCP Client", diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/DefaultAuthorizationUrlHandlerTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/DefaultAuthorizationUrlHandlerTests.cs new file mode 100644 index 000000000..825ebe2fb --- /dev/null +++ b/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/DefaultAuthorizationUrlHandlerTests.cs @@ -0,0 +1,46 @@ +using ModelContextProtocol.Authentication; +using ModelContextProtocol.Client; + +namespace ModelContextProtocol.AspNetCore.Tests.OAuth; + +[CollectionDefinition(nameof(DisableConsoleParallelization), DisableParallelization = true)] +public sealed class DisableConsoleParallelization; + +[Collection(nameof(DisableConsoleParallelization))] +public class DefaultAuthorizationUrlHandlerTests(ITestOutputHelper outputHelper) : OAuthTestBase(outputHelper) +{ + [Fact] + public async Task RejectsAuthorizationCodeWithoutAbsoluteRedirectUrl() + { + await using var app = await StartMcpServerAsync(); + + await using var transport = new HttpClientTransport(new() + { + Endpoint = new(McpServerUrl), + OAuth = new() + { + ClientId = "demo-client", + ClientSecret = "demo-secret", + RedirectUri = new Uri("http://localhost:1179/callback"), + }, + }, HttpClient, LoggerFactory); + + var originalInput = Console.In; + using var consoleInput = new StringReader("authorization-code"); + Console.SetIn(consoleInput); + + try + { + var ex = await Assert.ThrowsAsync(() => McpClient.CreateAsync( + transport, + loggerFactory: LoggerFactory, + cancellationToken: TestContext.Current.CancellationToken)); + + Assert.Contains("not a valid absolute URL", ex.Message); + } + finally + { + Console.SetIn(originalInput); + } + } +} diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/OAuthTestBase.cs b/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/OAuthTestBase.cs index f9a4b64c0..5f150e99d 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/OAuthTestBase.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/OAuthTestBase.cs @@ -106,18 +106,40 @@ 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; } + + protected async Task HandleAuthorizationUrlAsync( + Uri authorizationUri, + Uri redirectUri, + CancellationToken cancellationToken) + { + var result = await HandleAuthorizationUrlAsync( + new ModelContextProtocol.Authentication.AuthorizationCallbackContext + { + AuthorizationUri = authorizationUri, + RedirectUri = redirectUri, + }, + cancellationToken); + + return result?.Code; + } } diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/TokenCacheTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/TokenCacheTests.cs index fb9e2bfda..09aa165c1 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/TokenCacheTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/TokenCacheTests.cs @@ -26,10 +26,10 @@ public async Task GetTokenAsync_CachedAccessTokenIsUsedForOutgoingRequests() ClientId = "demo-client", ClientSecret = "demo-secret", RedirectUri = new Uri("http://localhost:1179/callback"), - AuthorizationRedirectDelegate = (uri, redirect, ct) => + AuthorizationCallbackHandler = (context, ct) => { authDelegateCalledInitially = true; - return HandleAuthorizationUrlAsync(uri, redirect, ct); + return HandleAuthorizationUrlAsync(context, ct); }, TokenCache = tokenCache, }, @@ -40,7 +40,7 @@ public async Task GetTokenAsync_CachedAccessTokenIsUsedForOutgoingRequests() // Just connecting should trigger auth and storage. } - Assert.True(authDelegateCalledInitially, "AuthorizationRedirectDelegate should be called to get initial token"); + Assert.True(authDelegateCalledInitially, "AuthorizationCallbackHandler should be called to get initial token"); Assert.NotNull(tokenCache.LastStoredToken); var authDelegateCalledAgain = false; @@ -53,10 +53,10 @@ public async Task GetTokenAsync_CachedAccessTokenIsUsedForOutgoingRequests() ClientId = "demo-client", ClientSecret = "demo-secret", RedirectUri = new Uri("http://localhost:1179/callback"), - AuthorizationRedirectDelegate = (uri, redirect, ct) => + AuthorizationCallbackHandler = (context, ct) => { authDelegateCalledAgain = true; - return HandleAuthorizationUrlAsync(uri, redirect, ct); + return HandleAuthorizationUrlAsync(context, ct); }, TokenCache = tokenCache }, @@ -64,7 +64,7 @@ public async Task GetTokenAsync_CachedAccessTokenIsUsedForOutgoingRequests() await using var client = await McpClient.CreateAsync(transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken); - Assert.False(authDelegateCalledAgain, "AuthorizationRedirectDelegate should not be called when token is valid"); + Assert.False(authDelegateCalledAgain, "AuthorizationCallbackHandler should not be called when token is valid"); } [Fact] @@ -82,7 +82,7 @@ public async Task StoreTokenAsync_NewlyAcquiredAccessTokenIsCached() ClientId = "demo-client", ClientSecret = "demo-secret", RedirectUri = new Uri("http://localhost:1179/callback"), - AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync, + AuthorizationCallbackHandler = HandleAuthorizationUrlAsync, TokenCache = tokenCache }, }, HttpClient, LoggerFactory); @@ -109,10 +109,10 @@ public async Task GetTokenAsync_InvalidCachedTokenTriggersAuthDelegate() ClientId = "demo-client", ClientSecret = "demo-secret", RedirectUri = new Uri("http://localhost:1179/callback"), - AuthorizationRedirectDelegate = (uri, redirect, ct) => + AuthorizationCallbackHandler = (context, ct) => { authDelegateCalled = true; - return HandleAuthorizationUrlAsync(uri, redirect, ct); + return HandleAuthorizationUrlAsync(context, ct); }, TokenCache = tokenCache, }, @@ -120,7 +120,7 @@ public async Task GetTokenAsync_InvalidCachedTokenTriggersAuthDelegate() await using var client = await McpClient.CreateAsync(transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken); - Assert.True(authDelegateCalled, "AuthorizationRedirectDelegate should be called when cached token is invalid"); + Assert.True(authDelegateCalled, "AuthorizationCallbackHandler should be called when cached token is invalid"); Assert.NotNull(tokenCache.LastStoredToken); Assert.NotEqual("invalid-token", tokenCache.LastStoredToken.AccessToken); } @@ -141,10 +141,10 @@ public async Task GetTokenAsync_InvalidAccessTokenTriggersRefresh() ClientId = "demo-client", ClientSecret = "demo-secret", RedirectUri = new Uri("http://localhost:1179/callback"), - AuthorizationRedirectDelegate = (uri, redirect, ct) => + AuthorizationCallbackHandler = (context, ct) => { authDelegateCalledInitially = true; - return HandleAuthorizationUrlAsync(uri, redirect, ct); + return HandleAuthorizationUrlAsync(context, ct); }, TokenCache = tokenCache, }, @@ -155,7 +155,7 @@ public async Task GetTokenAsync_InvalidAccessTokenTriggersRefresh() // Just connecting should trigger auth and storage. } - Assert.True(authDelegateCalledInitially, "AuthorizationRedirectDelegate should be called to get initial token"); + Assert.True(authDelegateCalledInitially, "AuthorizationCallbackHandler should be called to get initial token"); Assert.False(TestOAuthServer.HasRefreshedToken, "Token should not have been refreshed yet"); Assert.NotNull(tokenCache.LastStoredToken); @@ -171,10 +171,10 @@ public async Task GetTokenAsync_InvalidAccessTokenTriggersRefresh() ClientId = "demo-client", ClientSecret = "demo-secret", RedirectUri = new Uri("http://localhost:1179/callback"), - AuthorizationRedirectDelegate = (uri, redirect, ct) => + AuthorizationCallbackHandler = (context, ct) => { authDelegateCalledAgain = true; - return HandleAuthorizationUrlAsync(uri, redirect, ct); + return HandleAuthorizationUrlAsync(context, ct); }, TokenCache = tokenCache }, @@ -182,7 +182,7 @@ public async Task GetTokenAsync_InvalidAccessTokenTriggersRefresh() await using var client = await McpClient.CreateAsync(transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken); - Assert.False(authDelegateCalledAgain, "AuthorizationRedirectDelegate should not be called when refresh token is valid"); + Assert.False(authDelegateCalledAgain, "AuthorizationCallbackHandler should not be called when refresh token is valid"); Assert.True(TestOAuthServer.HasRefreshedToken, "Token should have been refreshed"); Assert.NotEqual("invalid-token", tokenCache.LastStoredToken.AccessToken); } diff --git a/tests/ModelContextProtocol.ConformanceClient/Program.cs b/tests/ModelContextProtocol.ConformanceClient/Program.cs index 60d07d46e..a52e8cfb5 100644 --- a/tests/ModelContextProtocol.ConformanceClient/Program.cs +++ b/tests/ModelContextProtocol.ConformanceClient/Program.cs @@ -3,6 +3,7 @@ using System.Text.Json; using System.Web; using Microsoft.Extensions.Logging; +using ModelContextProtocol.Authentication; using ModelContextProtocol.Client; using ModelContextProtocol.Protocol; @@ -92,7 +93,7 @@ RedirectUri = clientRedirectUri, // Configure the metadata document URI for CIMD. ClientMetadataDocumentUri = new Uri("https://conformance-test.local/client-metadata.json"), - AuthorizationRedirectDelegate = (authUrl, redirectUri, ct) => HandleAuthorizationUrlAsync(authUrl, redirectUri, ct), + AuthorizationCallbackHandler = HandleAuthorizationUrlAsync, }; if (preRegisteredClientId is not null) @@ -340,8 +341,12 @@ // Copied from ProtectedMcpClient sample // Simulate a user opening the browser and logging in // Copied from OAuthTestBase -static async Task HandleAuthorizationUrlAsync(Uri authorizationUrl, Uri redirectUri, CancellationToken cancellationToken) +static async Task HandleAuthorizationUrlAsync( + AuthorizationCallbackContext authorizationContext, + CancellationToken cancellationToken) { + var authorizationUrl = authorizationContext.AuthorizationUri; + Console.WriteLine("Starting OAuth authorization flow..."); Console.WriteLine($"Simulating opening browser to: {authorizationUrl}"); @@ -355,16 +360,30 @@ if (location is not null && !string.IsNullOrEmpty(location.Query)) { - // Parse query string to extract "code" parameter + // Parse query string to extract "code" and "iss" parameters var query = location.Query.TrimStart('?'); + string? code = null; + string? iss = null; foreach (var pair in query.Split('&')) { var parts = pair.Split('=', 2); - if (parts.Length == 2 && parts[0] == "code") + if (parts.Length == 2) { - return HttpUtility.UrlDecode(parts[1]); + if (parts[0] == "code") + { + code = HttpUtility.UrlDecode(parts[1]); + } + else if (parts[0] == "iss") + { + iss = HttpUtility.UrlDecode(parts[1]); + } } } + + if (code is not null) + { + return new AuthorizationResult { Code = code, Iss = iss }; + } } return null; diff --git a/tests/ModelContextProtocol.TestOAuthServer/OAuthServerMetadata.cs b/tests/ModelContextProtocol.TestOAuthServer/OAuthServerMetadata.cs index c05a45ba2..744ab0e08 100644 --- a/tests/ModelContextProtocol.TestOAuthServer/OAuthServerMetadata.cs +++ b/tests/ModelContextProtocol.TestOAuthServer/OAuthServerMetadata.cs @@ -12,7 +12,8 @@ internal sealed class OAuthServerMetadata /// REQUIRED. The authorization server's issuer identifier, which is a URL that uses the "https" scheme and has no query or fragment components. /// [JsonPropertyName("issuer")] - public required string Issuer { get; init; } + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Issuer { get; init; } /// /// Gets or sets the authorization endpoint URL. @@ -178,4 +179,11 @@ internal sealed class OAuthServerMetadata [JsonPropertyName("client_id_metadata_document_supported")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public bool? ClientIdMetadataDocumentSupported { get; init; } + + /// + /// Gets or sets a value indicating whether authorization responses contain an issuer parameter. + /// + [JsonPropertyName("authorization_response_iss_parameter_supported")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public bool? AuthorizationResponseIssParameterSupported { get; init; } } diff --git a/tests/ModelContextProtocol.TestOAuthServer/Program.cs b/tests/ModelContextProtocol.TestOAuthServer/Program.cs index 4ab11eac0..6bedbc4f5 100644 --- a/tests/ModelContextProtocol.TestOAuthServer/Program.cs +++ b/tests/ModelContextProtocol.TestOAuthServer/Program.cs @@ -100,6 +100,26 @@ public Program(ILoggerProvider? loggerProvider = null, IConnectionListenerFactor /// public bool IncludeOfflineAccessInMetadata { get; set; } + /// + /// Gets or sets a value indicating whether authorization server metadata includes an issuer. + /// + public bool IncludeIssuerInMetadata { get; set; } = true; + + /// + /// Gets or sets an issuer value that overrides the authorization server's metadata issuer. + /// + public string? MetadataIssuerOverride { get; set; } + + /// + /// Gets or sets a value indicating whether the authorization server advertises RFC 9207 support. + /// + public bool AuthorizationResponseIssParameterSupported { get; set; } + + /// + /// Gets or sets the issuer included in authorization responses, or to omit it. + /// + public string? AuthorizationResponseIssuer { get; set; } + /// /// Gets or sets the code challenge methods advertised by metadata endpoints. /// @@ -242,7 +262,7 @@ IResult HandleMetadataRequest(HttpContext context, string? issuerPath = null) var metadata = new OAuthServerMetadata { - Issuer = $"{_url}{issuerPath}", + Issuer = IncludeIssuerInMetadata ? MetadataIssuerOverride ?? $"{_url}{issuerPath}" : null, AuthorizationEndpoint = $"{_url}/authorize", TokenEndpoint = $"{_url}/token", JwksUri = $"{_url}/.well-known/jwks.json", @@ -261,6 +281,7 @@ IResult HandleMetadataRequest(HttpContext context, string? issuerPath = null) IntrospectionEndpoint = $"{_url}/introspect", RegistrationEndpoint = $"{_url}/register", ClientIdMetadataDocumentSupported = ClientIdMetadataDocumentSupported, + AuthorizationResponseIssParameterSupported = AuthorizationResponseIssParameterSupported ? true : null, }; return Results.Ok(metadata); @@ -392,6 +413,10 @@ IResult HandleMetadataRequest(HttpContext context, string? issuerPath = null) { redirectUrl += $"&state={Uri.EscapeDataString(state)}"; } + if (!string.IsNullOrEmpty(AuthorizationResponseIssuer)) + { + redirectUrl += $"&iss={Uri.EscapeDataString(AuthorizationResponseIssuer)}"; + } return Results.Redirect(redirectUrl); });