Add 2025-03-26 OAuth backward compatibility for client conformance (#1374)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Jeff Handley 2026-02-24 15:01:40 -08:00 • committed by GitHub
commit 4e68a4e9ec
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 291 additions and 30 deletions

View file

@ -43,9 +43,9 @@ public class ClientConformanceTests
[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")]

View file

@ -586,7 +586,7 @@ public class AuthTests : OAuthTestBase
[Fact]
public async Task CannotAuthenticate_WhenProtectedResourceMetadataMissingResource()
{
TestOAuthServer.RequireResource = false;
TestOAuthServer.ExpectResource = false;
Builder.Services.Configure<McpAuthenticationOptions>(McpAuthenticationDefaults.AuthenticationScheme, options =>
{
@ -1044,4 +1044,221 @@ public class AuthTests : OAuthTestBase
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.ExpectResource = false;
// Use JwtBearer as the challenge scheme so the 401 response does NOT include resource_metadata.
Builder.Services.Configure<AuthenticationOptions>(options => options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme);
// Legacy servers don't use resource-based audiences in tokens (no resource parameter is sent).
Builder.Services.Configure<JwtBearerOptions>(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;
}
// 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"))
{
context.Response.ContentType = "application/json";
await context.Response.WriteAsync($$"""
{
"issuer": "{{OAuthServerUrl}}",
"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"],
"code_challenge_methods_supported": ["S256"]
}
""");
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();
});
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.ExpectResource = false;
// Use JwtBearer as the challenge scheme so the 401 response does NOT include resource_metadata.
Builder.Services.Configure<AuthenticationOptions>(options => options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme);
// Legacy servers don't use resource-based audiences in tokens (no resource parameter is sent).
Builder.Services.Configure<JwtBearerOptions>(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);
}
}

View file

@ -68,12 +68,15 @@ public sealed class Program
public bool ClientIdMetadataDocumentSupported { get; set; } = true;
/// <summary>
/// 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 <c>true</c>, the resource parameter must be present and match a valid resource.
/// When <c>false</c>, the resource parameter must be absent to simulate legacy servers that
/// do not support RFC 8707 resource indicators.
/// </summary>
/// <remarks>
/// The default value is <c>true</c>.
/// </remarks>
public bool RequireResource { get; set; } = true;
public bool ExpectResource { get; set; } = true;
public HashSet<string> DisabledMetadataPaths { get; } = new(StringComparer.OrdinalIgnoreCase);
public IReadOnlyCollection<string> MetadataRequests => _metadataRequests.ToArray();
@ -297,8 +300,9 @@ public sealed class Program
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 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}");
}
@ -344,9 +348,10 @@ public sealed class Program
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 ExpectResource is false, the resource parameter must be absent (legacy mode).
var resource = form["resource"].ToString();
if (RequireResource && (string.IsNullOrEmpty(resource) || !ValidResources.Contains(resource)))
if (ExpectResource ? (string.IsNullOrEmpty(resource) || !ValidResources.Contains(resource)) : !string.IsNullOrEmpty(resource))
{
return Results.BadRequest(new OAuthErrorResponse
{