From e517f1421fbcf0bec1609672fe3ac3ceda824672 Mon Sep 17 00:00:00 2001 From: Jeff Handley Date: Mon, 23 Feb 2026 21:49:53 -0800 Subject: [PATCH 1/5] Add 2025-03-26 OAuth backward compatibility for client conformance Implement legacy OAuth fallback so the client can authenticate against MCP servers that predate Protected Resource Metadata (RFC 9728): - When PRM discovery fails, synthesize minimal metadata using the MCP server's origin as the authorization server - When auth server metadata discovery also fails, fall back to the default endpoint paths (/authorize, /token, /register) specified by the MCP 2025-03-26 spec - Conditionally omit the 'resource' parameter from authorization and token requests when operating in legacy mode - Skip resource-match verification only for synthesized (not fetched) PRM Enable the two previously-commented-out client conformance test scenarios: auth/2025-03-26-oauth-metadata-backcompat auth/2025-03-26-oauth-endpoint-fallback Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Authentication/ClientOAuthProvider.cs | 80 ++++++++++++++----- .../ClientConformanceTests.cs | 6 +- 2 files changed, 64 insertions(+), 22 deletions(-) diff --git a/src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs b/src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs index 25a4096db..42c0f066d 100644 --- a/src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs +++ b/src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs @@ -270,10 +270,20 @@ private async Task GetAccessTokenAsync(HttpResponseMessage response, boo LogSelectedAuthorizationServer(selectedAuthServer, availableAuthorizationServers.Count); // Get auth server metadata - var authServerMetadata = await GetAuthServerMetadataAsync(selectedAuthServer, cancellationToken).ConfigureAwait(false); + AuthorizationServerMetadata authServerMetadata; + try + { + authServerMetadata = await GetAuthServerMetadataAsync(selectedAuthServer, cancellationToken).ConfigureAwait(false); + } + catch (McpException) when (protectedResourceMetadata.Resource is null) + { + // 2025-03-26 backcompat: when PRM is unavailable and auth server metadata discovery + // also fails, fall back to default endpoint paths per the 2025-03-26 spec. + authServerMetadata = BuildDefaultAuthServerMetadata(selectedAuthServer); + } // The existing access token must be invalid to have resulted in a 401 response, but refresh might still work. - var resourceUri = GetRequiredResourceUri(protectedResourceMetadata); + var resourceUri = GetResourceUri(protectedResourceMetadata); // Only attempt a token refresh if we haven't attempted to already for this request. // Also only attempt a token refresh for a 401 Unauthorized responses. Other response status codes @@ -379,6 +389,25 @@ private async Task GetAuthServerMetadataAsync(Uri a throw new McpException($"Failed to find .well-known/openid-configuration or .well-known/oauth-authorization-server metadata for authorization server: '{authServerUri}'"); } + /// + /// Constructs default authorization server metadata using conventional endpoint paths + /// as specified by the MCP 2025-03-26 specification for servers without metadata discovery. + /// + private static AuthorizationServerMetadata BuildDefaultAuthServerMetadata(Uri authServerUri) + { + var baseUrl = authServerUri.GetLeftPart(UriPartial.Authority); + return new AuthorizationServerMetadata + { + AuthorizationEndpoint = new Uri($"{baseUrl}/authorize"), + TokenEndpoint = new Uri($"{baseUrl}/token"), + RegistrationEndpoint = new Uri($"{baseUrl}/register"), + ResponseTypesSupported = ["code"], + GrantTypesSupported = ["authorization_code", "refresh_token"], + TokenEndpointAuthMethodsSupported = ["client_secret_post"], + CodeChallengeMethodsSupported = ["S256"], + }; + } + private static IEnumerable GetWellKnownAuthorizationServerMetadataUris(Uri issuer) { var builder = new UriBuilder(issuer); @@ -398,15 +427,19 @@ private static IEnumerable GetWellKnownAuthorizationServerMetadataUris(Uri } } - private async Task RefreshTokensAsync(string refreshToken, string resourceUri, AuthorizationServerMetadata authServerMetadata, CancellationToken cancellationToken) + private async Task RefreshTokensAsync(string refreshToken, string? resourceUri, AuthorizationServerMetadata authServerMetadata, CancellationToken cancellationToken) { Dictionary formFields = new() { ["grant_type"] = "refresh_token", ["refresh_token"] = refreshToken, - ["resource"] = resourceUri, }; + if (resourceUri is not null) + { + formFields["resource"] = resourceUri; + } + using var request = CreateTokenRequest(authServerMetadata.TokenEndpoint, formFields); using var httpResponse = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); @@ -445,7 +478,7 @@ private Uri BuildAuthorizationUrl( AuthorizationServerMetadata authServerMetadata, string codeChallenge) { - var resourceUri = GetRequiredResourceUri(protectedResourceMetadata); + var resourceUri = GetResourceUri(protectedResourceMetadata); var queryParamsDictionary = new Dictionary { @@ -454,9 +487,13 @@ private Uri BuildAuthorizationUrl( ["response_type"] = "code", ["code_challenge"] = codeChallenge, ["code_challenge_method"] = "S256", - ["resource"] = resourceUri, }; + if (resourceUri is not null) + { + queryParamsDictionary["resource"] = resourceUri; + } + var scope = GetScopeParameter(protectedResourceMetadata); if (!string.IsNullOrEmpty(scope)) { @@ -490,7 +527,7 @@ private async Task ExchangeCodeForTokenAsync( string codeVerifier, CancellationToken cancellationToken) { - var resourceUri = GetRequiredResourceUri(protectedResourceMetadata); + var resourceUri = GetResourceUri(protectedResourceMetadata); Dictionary formFields = new() { @@ -498,9 +535,13 @@ private async Task ExchangeCodeForTokenAsync( ["code"] = authorizationCode, ["redirect_uri"] = _redirectUri.ToString(), ["code_verifier"] = codeVerifier, - ["resource"] = resourceUri, }; + if (resourceUri is not null) + { + formFields["resource"] = resourceUri; + } + using var request = CreateTokenRequest(authServerMetadata.TokenEndpoint, formFields); using var httpResponse = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); @@ -671,15 +712,8 @@ private async Task PerformDynamicClientRegistrationAsync( } } - private static string GetRequiredResourceUri(ProtectedResourceMetadata protectedResourceMetadata) - { - if (protectedResourceMetadata.Resource is null) - { - ThrowFailedToHandleUnauthorizedResponse("Protected resource metadata did not include a 'resource' value."); - } - - return protectedResourceMetadata.Resource; - } + private static string? GetResourceUri(ProtectedResourceMetadata protectedResourceMetadata) + => protectedResourceMetadata.Resource; private string? GetScopeParameter(ProtectedResourceMetadata protectedResourceMetadata) { @@ -801,6 +835,7 @@ private async Task ExtractProtectedResourceMetadata(H } ProtectedResourceMetadata? metadata = null; + bool isLegacyFallback = false; if (resourceMetadataUrl is not null) { @@ -822,7 +857,14 @@ private async Task ExtractProtectedResourceMetadata(H if (metadata is null) { - throw new McpException($"Failed to find protected resource metadata at a well-known location for {_serverUrl}"); + // 2025-03-26 backcompat: server doesn't support PRM (RFC 9728). + // Fall back to treating the MCP server's origin as the authorization server. + var serverOrigin = _serverUrl.GetLeftPart(UriPartial.Authority); + metadata = new ProtectedResourceMetadata + { + AuthorizationServers = [serverOrigin], + }; + isLegacyFallback = true; } } @@ -833,7 +875,7 @@ private async Task ExtractProtectedResourceMetadata(H // Per RFC: The resource value must be identical to the URL that the client used to make the request to the resource server LogValidatingResourceMetadata(resourceUri); - if (!VerifyResourceMatch(metadata, resourceUri)) + if (!isLegacyFallback && !VerifyResourceMatch(metadata, resourceUri)) { throw new McpException($"Resource URI in metadata ({metadata.Resource}) does not match the expected URI ({resourceUri})"); } diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/ClientConformanceTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/ClientConformanceTests.cs index 9dc52ec17..1418574a7 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/ClientConformanceTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/ClientConformanceTests.cs @@ -43,9 +43,9 @@ public ClientConformanceTests(ITestOutputHelper output) [InlineData("auth/resource-mismatch")] [InlineData("auth/pre-registration")] - // Backcompat: Legacy 2025-03-26 OAuth flows (no PRM, root-location metadata) we don't implement. - // [InlineData("auth/2025-03-26-oauth-metadata-backcompat")] - // [InlineData("auth/2025-03-26-oauth-endpoint-fallback")] + // Backcompat: Legacy 2025-03-26 OAuth flows (no PRM, root-location metadata). + [InlineData("auth/2025-03-26-oauth-metadata-backcompat")] + [InlineData("auth/2025-03-26-oauth-endpoint-fallback")] // Extensions: Require ES256 JWT signing (private_key_jwt) and client_credentials grant support. // [InlineData("auth/client-credentials-jwt")] From d6fcb499fe889bf8daf5a4a70f446d8a001c4d4c Mon Sep 17 00:00:00 2001 From: Jeff Handley Date: Mon, 23 Feb 2026 23:42:30 -0800 Subject: [PATCH 2/5] Avoid exceptions for control flow in auth server metadata fallback Refactor GetAuthServerMetadataAsync to accept an allowDefaultFallback parameter and return BuildDefaultAuthServerMetadata directly instead of throwing and catching McpException at the call site. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Authentication/ClientOAuthProvider.cs | 21 ++++++++----------- 1 file changed, 9 insertions(+), 12 deletions(-) diff --git a/src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs b/src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs index 42c0f066d..ecef8e15e 100644 --- a/src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs +++ b/src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs @@ -270,17 +270,7 @@ private async Task GetAccessTokenAsync(HttpResponseMessage response, boo LogSelectedAuthorizationServer(selectedAuthServer, availableAuthorizationServers.Count); // Get auth server metadata - AuthorizationServerMetadata authServerMetadata; - try - { - authServerMetadata = await GetAuthServerMetadataAsync(selectedAuthServer, cancellationToken).ConfigureAwait(false); - } - catch (McpException) when (protectedResourceMetadata.Resource is null) - { - // 2025-03-26 backcompat: when PRM is unavailable and auth server metadata discovery - // also fails, fall back to default endpoint paths per the 2025-03-26 spec. - authServerMetadata = BuildDefaultAuthServerMetadata(selectedAuthServer); - } + var authServerMetadata = await GetAuthServerMetadataAsync(selectedAuthServer, protectedResourceMetadata.Resource, cancellationToken).ConfigureAwait(false); // The existing access token must be invalid to have resulted in a 401 response, but refresh might still work. var resourceUri = GetResourceUri(protectedResourceMetadata); @@ -342,7 +332,7 @@ static bool IsValidClientMetadataDocumentUri(Uri uri) && uri.AbsolutePath.Length > 1; // AbsolutePath always starts with "/" } - private async Task GetAuthServerMetadataAsync(Uri authServerUri, CancellationToken cancellationToken) + private async Task GetAuthServerMetadataAsync(Uri authServerUri, string? resourceUri, CancellationToken cancellationToken) { foreach (var wellKnownEndpoint in GetWellKnownAuthorizationServerMetadataUris(authServerUri)) { @@ -386,6 +376,13 @@ private async Task GetAuthServerMetadataAsync(Uri a } } + if (resourceUri is null) + { + // 2025-03-26 backcompat: when PRM is unavailable and auth server metadata discovery + // also fails, fall back to default endpoint paths per the 2025-03-26 spec. + return BuildDefaultAuthServerMetadata(authServerUri); + } + throw new McpException($"Failed to find .well-known/openid-configuration or .well-known/oauth-authorization-server metadata for authorization server: '{authServerUri}'"); } From 1590b1f497d748d2a2ee0cb09b04a5b67ae6edf9 Mon Sep 17 00:00:00 2001 From: Jeff Handley Date: Tue, 24 Feb 2026 00:11:40 -0800 Subject: [PATCH 3/5] Add unit tests for 2025-03-26 OAuth backward compatibility Add two tests to AuthTests.cs covering legacy server scenarios: - CanAuthenticate_WithLegacyServerWithoutProtectedResourceMetadata: Server lacks RFC 9728 PRM but serves auth server metadata at well-known URLs on the MCP server origin. - CanAuthenticate_WithLegacyServerUsingDefaultEndpointFallback: Server lacks both PRM and auth server metadata, forcing fallback to default /authorize, /token, /register endpoint paths. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../OAuth/AuthTests.cs | 175 ++++++++++++++++++ 1 file changed, 175 insertions(+) diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/AuthTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/AuthTests.cs index a66bf1b9d..8ca121284 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/AuthTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/AuthTests.cs @@ -1044,4 +1044,179 @@ public async Task ResourceMetadata_PreservesExplicitTrailingSlash() await using var client = await McpClient.CreateAsync( transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken); } + + [Fact] + public async Task CanAuthenticate_WithLegacyServerWithoutProtectedResourceMetadata() + { + // 2025-03-26 backcompat: server does NOT serve PRM, but DOES serve auth server metadata. + // The client should fall back to using the MCP server's origin as the auth server + // and discover auth metadata from well-known URLs on that origin. + TestOAuthServer.RequireResource = false; + + // Use JwtBearer as the challenge scheme so the 401 response does NOT include resource_metadata. + Builder.Services.Configure(options => options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme); + + // Legacy servers don't use resource-based audiences in tokens (no resource parameter is sent). + Builder.Services.Configure(JwtBearerDefaults.AuthenticationScheme, options => + { + options.TokenValidationParameters.ValidateAudience = false; + }); + + await using var app = Builder.Build(); + + app.Use(async (context, next) => + { + // Return 404 for PRM to simulate a legacy server that doesn't support RFC 9728. + if (context.Request.Path.StartsWithSegments("/.well-known/oauth-protected-resource")) + { + context.Response.StatusCode = StatusCodes.Status404NotFound; + return; + } + + // Serve auth server metadata pointing to the real OAuth server endpoints. + // In a real 2025-03-26 deployment, the MCP server itself would be the auth server. + if (context.Request.Path.StartsWithSegments("/.well-known/oauth-authorization-server") || + context.Request.Path.StartsWithSegments("/.well-known/openid-configuration")) + { + context.Response.ContentType = "application/json"; + await context.Response.WriteAsync($$""" + { + "issuer": "{{OAuthServerUrl}}", + "authorization_endpoint": "{{OAuthServerUrl}}/authorize", + "token_endpoint": "{{OAuthServerUrl}}/token", + "registration_endpoint": "{{OAuthServerUrl}}/register", + "response_types_supported": ["code"], + "grant_types_supported": ["authorization_code", "refresh_token"], + "token_endpoint_auth_methods_supported": ["client_secret_post"], + "code_challenge_methods_supported": ["S256"] + } + """); + return; + } + + await next(); + }); + + app.UseAuthentication(); + app.UseAuthorization(); + app.MapMcp().RequireAuthorization(); + await app.StartAsync(TestContext.Current.CancellationToken); + + 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"), + AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync, + }, + }, HttpClient, LoggerFactory); + + await using var client = await McpClient.CreateAsync( + transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken); + } + + [Fact] + public async Task CanAuthenticate_WithLegacyServerUsingDefaultEndpointFallback() + { + // 2025-03-26 backcompat: server does NOT serve PRM AND does NOT serve auth server metadata. + // The client should fall back to default endpoint paths (/authorize, /token, /register) + // on the MCP server's origin. + TestOAuthServer.RequireResource = false; + + // Use JwtBearer as the challenge scheme so the 401 response does NOT include resource_metadata. + Builder.Services.Configure(options => options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme); + + // Legacy servers don't use resource-based audiences in tokens (no resource parameter is sent). + Builder.Services.Configure(JwtBearerDefaults.AuthenticationScheme, options => + { + options.TokenValidationParameters.ValidateAudience = false; + }); + + await using var app = Builder.Build(); + + // Capture HttpClient for use in the proxy middleware. + var httpClient = HttpClient; + + app.Use(async (context, next) => + { + // Return 404 for PRM to simulate a legacy server that doesn't support RFC 9728. + if (context.Request.Path.StartsWithSegments("/.well-known/oauth-protected-resource")) + { + context.Response.StatusCode = StatusCodes.Status404NotFound; + return; + } + + // Return 404 for auth server metadata to force fallback to default endpoints. + if (context.Request.Path.StartsWithSegments("/.well-known/oauth-authorization-server") || + context.Request.Path.StartsWithSegments("/.well-known/openid-configuration")) + { + context.Response.StatusCode = StatusCodes.Status404NotFound; + return; + } + + // Proxy default OAuth endpoints to the real OAuth server. + // In a real 2025-03-26 deployment, the MCP server itself would host these endpoints. + var path = context.Request.Path.Value; + if (path is "/authorize" or "/token" or "/register") + { + var targetUrl = $"{OAuthServerUrl}{path}{context.Request.QueryString}"; + using var proxyRequest = new HttpRequestMessage(new HttpMethod(context.Request.Method), targetUrl); + + if (context.Request.ContentLength > 0 || context.Request.ContentType is not null) + { + proxyRequest.Content = new StreamContent(context.Request.Body); + if (context.Request.ContentType is not null) + { + proxyRequest.Content.Headers.ContentType = System.Net.Http.Headers.MediaTypeHeaderValue.Parse(context.Request.ContentType); + } + } + + if (context.Request.Headers.Authorization.Count > 0) + { + proxyRequest.Headers.TryAddWithoutValidation("Authorization", context.Request.Headers.Authorization.ToString()); + } + + using var response = await httpClient.SendAsync(proxyRequest); + context.Response.StatusCode = (int)response.StatusCode; + + if (response.Headers.Location is not null) + { + context.Response.Headers.Location = response.Headers.Location.ToString(); + } + + if (response.Content.Headers.ContentType is not null) + { + context.Response.ContentType = response.Content.Headers.ContentType.ToString(); + } + + await response.Content.CopyToAsync(context.Response.Body); + return; + } + + await next(); + }); + + app.UseAuthentication(); + app.UseAuthorization(); + app.MapMcp().RequireAuthorization(); + await app.StartAsync(TestContext.Current.CancellationToken); + + 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"), + AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync, + }, + }, HttpClient, LoggerFactory); + + await using var client = await McpClient.CreateAsync( + transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken); + } } From cd1cbc4c6255fe751c9b83213720c8b70679da2c Mon Sep 17 00:00:00 2001 From: Jeff Handley Date: Tue, 24 Feb 2026 10:35:09 -0800 Subject: [PATCH 4/5] Proxy OAuth endpoints through MCP server in legacy auth test Update CanAuthenticate_WithLegacyServerWithoutProtectedResourceMetadata to use McpServerUrl for auth metadata endpoints and proxy OAuth requests to the real OAuth server, matching the pattern used by the endpoint fallback test. Update TestOAuthServer RequireResource=false to reject requests that include a resource parameter, ensuring the client correctly omits it in legacy mode. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../OAuth/AuthTests.cs | 50 +++++++++++++++++-- .../Program.cs | 13 +++-- 2 files changed, 55 insertions(+), 8 deletions(-) diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/AuthTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/AuthTests.cs index 8ca121284..1c326e94b 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/AuthTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/AuthTests.cs @@ -1064,6 +1064,9 @@ public async Task CanAuthenticate_WithLegacyServerWithoutProtectedResourceMetada await using var app = Builder.Build(); + // Capture HttpClient for use in the proxy middleware. + var httpClient = HttpClient; + app.Use(async (context, next) => { // Return 404 for PRM to simulate a legacy server that doesn't support RFC 9728. @@ -1073,7 +1076,7 @@ public async Task CanAuthenticate_WithLegacyServerWithoutProtectedResourceMetada return; } - // Serve auth server metadata pointing to the real OAuth server endpoints. + // Serve auth server metadata pointing to the MCP server's own endpoints. // In a real 2025-03-26 deployment, the MCP server itself would be the auth server. if (context.Request.Path.StartsWithSegments("/.well-known/oauth-authorization-server") || context.Request.Path.StartsWithSegments("/.well-known/openid-configuration")) @@ -1082,9 +1085,9 @@ public async Task CanAuthenticate_WithLegacyServerWithoutProtectedResourceMetada await context.Response.WriteAsync($$""" { "issuer": "{{OAuthServerUrl}}", - "authorization_endpoint": "{{OAuthServerUrl}}/authorize", - "token_endpoint": "{{OAuthServerUrl}}/token", - "registration_endpoint": "{{OAuthServerUrl}}/register", + "authorization_endpoint": "{{McpServerUrl}}/authorize", + "token_endpoint": "{{McpServerUrl}}/token", + "registration_endpoint": "{{McpServerUrl}}/register", "response_types_supported": ["code"], "grant_types_supported": ["authorization_code", "refresh_token"], "token_endpoint_auth_methods_supported": ["client_secret_post"], @@ -1094,6 +1097,45 @@ await context.Response.WriteAsync($$""" return; } + // Proxy OAuth endpoints to the real OAuth server. + // In a real 2025-03-26 deployment, the MCP server itself would host these endpoints. + var path = context.Request.Path.Value; + if (path is "/authorize" or "/token" or "/register") + { + var targetUrl = $"{OAuthServerUrl}{path}{context.Request.QueryString}"; + using var proxyRequest = new HttpRequestMessage(new HttpMethod(context.Request.Method), targetUrl); + + if (context.Request.ContentLength > 0 || context.Request.ContentType is not null) + { + proxyRequest.Content = new StreamContent(context.Request.Body); + if (context.Request.ContentType is not null) + { + proxyRequest.Content.Headers.ContentType = System.Net.Http.Headers.MediaTypeHeaderValue.Parse(context.Request.ContentType); + } + } + + if (context.Request.Headers.Authorization.Count > 0) + { + proxyRequest.Headers.TryAddWithoutValidation("Authorization", context.Request.Headers.Authorization.ToString()); + } + + using var response = await httpClient.SendAsync(proxyRequest); + context.Response.StatusCode = (int)response.StatusCode; + + if (response.Headers.Location is not null) + { + context.Response.Headers.Location = response.Headers.Location.ToString(); + } + + if (response.Content.Headers.ContentType is not null) + { + context.Response.ContentType = response.Content.Headers.ContentType.ToString(); + } + + await response.Content.CopyToAsync(context.Response.Body); + return; + } + await next(); }); diff --git a/tests/ModelContextProtocol.TestOAuthServer/Program.cs b/tests/ModelContextProtocol.TestOAuthServer/Program.cs index 94cc3a47e..1ac503156 100644 --- a/tests/ModelContextProtocol.TestOAuthServer/Program.cs +++ b/tests/ModelContextProtocol.TestOAuthServer/Program.cs @@ -69,6 +69,9 @@ public Program(ILoggerProvider? loggerProvider = null, IConnectionListenerFactor /// /// Gets or sets a value indicating whether the authorization server requires a resource parameter. + /// When true, the resource parameter must be present and match a valid resource. + /// When false, the resource parameter must be absent to simulate legacy servers that + /// do not support RFC 8707 resource indicators. /// /// /// The default value is true. @@ -297,8 +300,9 @@ IResult HandleMetadataRequest(HttpContext context, string? issuerPath = null) return Results.Redirect($"{redirect_uri}?error=invalid_request&error_description=Only+S256+code_challenge_method+is+supported&state={state}"); } - // Validate resource in accordance with RFC 8707 - if (RequireResource && (string.IsNullOrEmpty(resource) || !ValidResources.Contains(resource))) + // Validate resource in accordance with RFC 8707. + // When RequireResource is false, the resource parameter must be absent (legacy mode). + if (RequireResource ? (string.IsNullOrEmpty(resource) || !ValidResources.Contains(resource)) : !string.IsNullOrEmpty(resource)) { return Results.Redirect($"{redirect_uri}?error=invalid_target&error_description=The+specified+resource+is+not+valid&state={state}"); } @@ -344,9 +348,10 @@ IResult HandleMetadataRequest(HttpContext context, string? issuerPath = null) type: "https://tools.ietf.org/html/rfc6749#section-5.2"); } - // Validate resource in accordance with RFC 8707 + // Validate resource in accordance with RFC 8707. + // When RequireResource is false, the resource parameter must be absent (legacy mode). var resource = form["resource"].ToString(); - if (RequireResource && (string.IsNullOrEmpty(resource) || !ValidResources.Contains(resource))) + if (RequireResource ? (string.IsNullOrEmpty(resource) || !ValidResources.Contains(resource)) : !string.IsNullOrEmpty(resource)) { return Results.BadRequest(new OAuthErrorResponse { From e96f4dcb9e200dd0630c160f1a278b8943eb8437 Mon Sep 17 00:00:00 2001 From: Jeff Handley Date: Tue, 24 Feb 2026 13:58:32 -0800 Subject: [PATCH 5/5] Rename RequireResource to ExpectResource in TestOAuthServer The property now rejects requests that include a resource parameter when set to false, so ExpectResource better describes the bidirectional validation. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../OAuth/AuthTests.cs | 6 +++--- .../ModelContextProtocol.TestOAuthServer/Program.cs | 12 ++++++------ 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/AuthTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/AuthTests.cs index 1c326e94b..c4979fb10 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/AuthTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/AuthTests.cs @@ -586,7 +586,7 @@ await Assert.ThrowsAsync(() => McpClient.CreateAsync( [Fact] public async Task CannotAuthenticate_WhenProtectedResourceMetadataMissingResource() { - TestOAuthServer.RequireResource = false; + TestOAuthServer.ExpectResource = false; Builder.Services.Configure(McpAuthenticationDefaults.AuthenticationScheme, options => { @@ -1051,7 +1051,7 @@ public async Task CanAuthenticate_WithLegacyServerWithoutProtectedResourceMetada // 2025-03-26 backcompat: server does NOT serve PRM, but DOES serve auth server metadata. // The client should fall back to using the MCP server's origin as the auth server // and discover auth metadata from well-known URLs on that origin. - TestOAuthServer.RequireResource = false; + TestOAuthServer.ExpectResource = false; // Use JwtBearer as the challenge scheme so the 401 response does NOT include resource_metadata. Builder.Services.Configure(options => options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme); @@ -1166,7 +1166,7 @@ public async Task CanAuthenticate_WithLegacyServerUsingDefaultEndpointFallback() // 2025-03-26 backcompat: server does NOT serve PRM AND does NOT serve auth server metadata. // The client should fall back to default endpoint paths (/authorize, /token, /register) // on the MCP server's origin. - TestOAuthServer.RequireResource = false; + TestOAuthServer.ExpectResource = false; // Use JwtBearer as the challenge scheme so the 401 response does NOT include resource_metadata. Builder.Services.Configure(options => options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme); diff --git a/tests/ModelContextProtocol.TestOAuthServer/Program.cs b/tests/ModelContextProtocol.TestOAuthServer/Program.cs index 1ac503156..e882ecbef 100644 --- a/tests/ModelContextProtocol.TestOAuthServer/Program.cs +++ b/tests/ModelContextProtocol.TestOAuthServer/Program.cs @@ -68,7 +68,7 @@ public Program(ILoggerProvider? loggerProvider = null, IConnectionListenerFactor public bool ClientIdMetadataDocumentSupported { get; set; } = true; /// - /// Gets or sets a value indicating whether the authorization server requires a resource parameter. + /// Gets or sets a value indicating whether the authorization server expects a resource parameter. /// When true, the resource parameter must be present and match a valid resource. /// When false, the resource parameter must be absent to simulate legacy servers that /// do not support RFC 8707 resource indicators. @@ -76,7 +76,7 @@ public Program(ILoggerProvider? loggerProvider = null, IConnectionListenerFactor /// /// The default value is true. /// - public bool RequireResource { get; set; } = true; + public bool ExpectResource { get; set; } = true; public HashSet DisabledMetadataPaths { get; } = new(StringComparer.OrdinalIgnoreCase); public IReadOnlyCollection MetadataRequests => _metadataRequests.ToArray(); @@ -301,8 +301,8 @@ IResult HandleMetadataRequest(HttpContext context, string? issuerPath = null) } // Validate resource in accordance with RFC 8707. - // When RequireResource is false, the resource parameter must be absent (legacy mode). - if (RequireResource ? (string.IsNullOrEmpty(resource) || !ValidResources.Contains(resource)) : !string.IsNullOrEmpty(resource)) + // When ExpectResource is false, the resource parameter must be absent (legacy mode). + if (ExpectResource ? (string.IsNullOrEmpty(resource) || !ValidResources.Contains(resource)) : !string.IsNullOrEmpty(resource)) { return Results.Redirect($"{redirect_uri}?error=invalid_target&error_description=The+specified+resource+is+not+valid&state={state}"); } @@ -349,9 +349,9 @@ IResult HandleMetadataRequest(HttpContext context, string? issuerPath = null) } // Validate resource in accordance with RFC 8707. - // When RequireResource is false, the resource parameter must be absent (legacy mode). + // When ExpectResource is false, the resource parameter must be absent (legacy mode). var resource = form["resource"].ToString(); - if (RequireResource ? (string.IsNullOrEmpty(resource) || !ValidResources.Contains(resource)) : !string.IsNullOrEmpty(resource)) + if (ExpectResource ? (string.IsNullOrEmpty(resource) || !ValidResources.Contains(resource)) : !string.IsNullOrEmpty(resource)) { return Results.BadRequest(new OAuthErrorResponse {