Fall back to well-known URL in client if 401 response is missing resource_metadata parameter (#1054)

This commit is contained in:
Stephen Halter 2025-12-05 14:12:51 -08:00 • committed by GitHub
commit 970207989e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 497 additions and 134 deletions

View file

@ -1,3 +1,4 @@
using Microsoft.AspNetCore.Authentication.JwtBearer;
using ModelContextProtocol.AspNetCore.Authentication;
using ModelContextProtocol.Authentication;
using ModelContextProtocol.Client;
@ -283,6 +284,71 @@ public class AuthTests : OAuthTestBase
transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken));
}
[Fact]
public async Task CanAuthenticate_WithoutResourceInWwwAuthenticateHeader()
{
await using var app = await StartMcpServerAsync(authScheme: JwtBearerDefaults.AuthenticationScheme);
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_WithoutResourceInWwwAuthenticateHeader_WithPathSuffix()
{
const string serverPath = "/mcp";
await using var app = await StartMcpServerAsync(serverPath, authScheme: JwtBearerDefaults.AuthenticationScheme);
await using var transport = new HttpClientTransport(new()
{
Endpoint = new Uri($"{McpServerUrl}{serverPath}"),
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 JwtBearerChallenge_DoesNotIncludeResourceMetadata()
{
await using var app = await StartMcpServerAsync(authScheme: JwtBearerDefaults.AuthenticationScheme);
using var unauthorizedResponse = await HttpClient.GetAsync(McpServerUrl, HttpCompletionOption.ResponseHeadersRead, TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.Unauthorized, unauthorizedResponse.StatusCode);
var headerFound = false;
foreach (var header in unauthorizedResponse.Headers.WwwAuthenticate)
{
headerFound = true;
Assert.Equal("Bearer", header.Scheme);
Assert.True(header.Parameter is null || !header.Parameter.Contains("resource_metadata", StringComparison.OrdinalIgnoreCase));
}
Assert.True(headerFound);
using var metadataResponse = await HttpClient.GetAsync(new Uri("/.well-known/oauth-protected-resource", UriKind.Relative), TestContext.Current.CancellationToken);
metadataResponse.EnsureSuccessStatusCode();
}
[Fact]
public void CloneResourceMetadataClonesAllProperties()
{
@ -312,7 +378,7 @@ public class AuthTests : OAuthTestBase
var cloneMethod = handlerType.GetMethod("CloneResourceMetadata", BindingFlags.Static | BindingFlags.NonPublic);
Assert.NotNull(cloneMethod);
var clonedMetadata = (ProtectedResourceMetadata?)cloneMethod.Invoke(null, [metadata]);
var clonedMetadata = (ProtectedResourceMetadata?)cloneMethod.Invoke(null, [metadata, null]);
Assert.NotNull(clonedMetadata);
// Ensure the cloned metadata is not the same instance

View file

@ -0,0 +1,203 @@
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using ModelContextProtocol.AspNetCore.Authentication;
using ModelContextProtocol.AspNetCore.Tests.Utils;
using ModelContextProtocol.Authentication;
using System.Net;
using System.Net.Http.Json;
using System.Text.Encodings.Web;
namespace ModelContextProtocol.AspNetCore.Tests.OAuth;
public class McpAuthenticationHandlerTests(ITestOutputHelper outputHelper) : KestrelInMemoryTest(outputHelper)
{
[Fact]
public async Task Challenge_WithRelativeResourceMetadataUri_SetsAbsoluteUrl()
{
const string metadataPath = "/.well-known/custom-relative";
await using var app = await StartAuthenticationServerAsync(options =>
{
options.ResourceMetadataUri = new Uri(metadataPath, UriKind.Relative);
options.ResourceMetadata!.Resource = new Uri("http://localhost:5000/challenge");
});
using var challengeResponse = await HttpClient.GetAsync(new Uri("/challenge", UriKind.Relative), HttpCompletionOption.ResponseHeadersRead, TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.Unauthorized, challengeResponse.StatusCode);
var header = Assert.Single(challengeResponse.Headers.WwwAuthenticate);
Assert.Equal("Bearer", header.Scheme);
Assert.Contains($"resource_metadata=\"http://localhost:5000{metadataPath}\"", header.Parameter);
using var metadataResponse = await HttpClient.GetAsync(new Uri(metadataPath, UriKind.Relative), TestContext.Current.CancellationToken);
metadataResponse.EnsureSuccessStatusCode();
}
[Fact]
public async Task MetadataRequest_CustomResourceMetadataUriWithoutResource_ThrowsInvalidOperationException()
{
const string metadataPath = "/.well-known/custom-metadata";
await using var app = await StartAuthenticationServerAsync(options =>
{
options.ResourceMetadataUri = new Uri(metadataPath, UriKind.Relative);
});
using var response = await HttpClient.GetAsync(new Uri(metadataPath, UriKind.Relative), TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode);
Assert.Contains(MockLoggerProvider.LogMessages, log =>
log.LogLevel == LogLevel.Error &&
log.Exception is InvalidOperationException &&
log.Exception.Message.Contains("ResourceMetadata.Resource could not be determined", StringComparison.Ordinal));
}
[Fact]
public async Task Challenge_WithAbsoluteResourceMetadataUri_SetsConfiguredUrl()
{
var metadataUri = new Uri("http://localhost:5000/.well-known/custom-absolute");
await using var app = await StartAuthenticationServerAsync(options =>
{
options.ResourceMetadataUri = metadataUri;
options.ResourceMetadata!.Resource = new Uri("http://localhost:5000/challenge");
});
using var challengeResponse = await HttpClient.GetAsync(new Uri("/challenge", UriKind.Relative), HttpCompletionOption.ResponseHeadersRead, TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.Unauthorized, challengeResponse.StatusCode);
var header = Assert.Single(challengeResponse.Headers.WwwAuthenticate);
Assert.Equal("Bearer", header.Scheme);
Assert.Contains($"resource_metadata=\"{metadataUri}\"", header.Parameter);
using var metadataResponse = await HttpClient.GetAsync(metadataUri, TestContext.Current.CancellationToken);
metadataResponse.EnsureSuccessStatusCode();
}
[Fact]
public async Task MetadataRequest_WithHostMismatch_LogsWarning()
{
var metadataUri = new Uri("http://expected-host:5000/.well-known/host-mismatch");
await using var app = await StartAuthenticationServerAsync(options =>
{
options.ResourceMetadataUri = metadataUri;
});
using var metadataRequest = new HttpRequestMessage(HttpMethod.Get, new Uri("http://localhost:5000/.well-known/host-mismatch"));
using var metadataResponse = await HttpClient.SendAsync(metadataRequest, TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.NotFound, metadataResponse.StatusCode);
Assert.Contains(MockLoggerProvider.LogMessages, log =>
log.LogLevel == LogLevel.Warning &&
log.Message.Contains("Resource metadata request host", StringComparison.OrdinalIgnoreCase) &&
log.Message.Contains("expected-host", StringComparison.OrdinalIgnoreCase));
}
[Fact]
public async Task Challenge_WithDefaultMetadata_ComposesResourceSpecificEndpoint()
{
await using var app = await StartAuthenticationServerAsync();
using var challengeResponse = await HttpClient.GetAsync(new Uri("/resource/tools/list", UriKind.Relative), HttpCompletionOption.ResponseHeadersRead, TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.Unauthorized, challengeResponse.StatusCode);
var header = Assert.Single(challengeResponse.Headers.WwwAuthenticate);
Assert.Equal("Bearer", header.Scheme);
Assert.Contains("resource_metadata=\"http://localhost:5000/.well-known/oauth-protected-resource/resource/tools/list\"", header.Parameter);
}
[Fact]
public async Task Challenge_WithDefaultMetadata_AndPathBase_ComposesResourceSpecificEndpoint()
{
await using var app = await StartAuthenticationServerAsync(pathBase: new PathString("/api"));
using var challengeResponse = await HttpClient.GetAsync(new Uri("/api/resource/tools/list", UriKind.Relative), HttpCompletionOption.ResponseHeadersRead, TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.Unauthorized, challengeResponse.StatusCode);
var header = Assert.Single(challengeResponse.Headers.WwwAuthenticate);
Assert.Equal("Bearer", header.Scheme);
Assert.Contains("resource_metadata=\"http://localhost:5000/api/.well-known/oauth-protected-resource/resource/tools/list\"", header.Parameter);
}
[Fact]
public async Task MetadataRequest_DefaultEndpoint_SetsResourceFromSuffix()
{
await using var app = await StartAuthenticationServerAsync();
using var metadataResponse = await HttpClient.GetAsync(new Uri("/.well-known/oauth-protected-resource/resource/tools", UriKind.Relative), TestContext.Current.CancellationToken);
metadataResponse.EnsureSuccessStatusCode();
var metadata = await metadataResponse.Content.ReadFromJsonAsync<ProtectedResourceMetadata>(
McpJsonUtilities.DefaultOptions,
TestContext.Current.CancellationToken);
Assert.NotNull(metadata);
Assert.Equal(new Uri("http://localhost:5000/resource/tools"), metadata!.Resource);
}
[Fact]
public async Task MetadataRequest_DefaultEndpoint_WithPathBase_SetsResourceFromSuffix()
{
await using var app = await StartAuthenticationServerAsync(pathBase: new PathString("/api"));
using var metadataResponse = await HttpClient.GetAsync(new Uri("/api/.well-known/oauth-protected-resource/resource/tools", UriKind.Relative), TestContext.Current.CancellationToken);
metadataResponse.EnsureSuccessStatusCode();
var metadata = await metadataResponse.Content.ReadFromJsonAsync<ProtectedResourceMetadata>(
McpJsonUtilities.DefaultOptions,
TestContext.Current.CancellationToken);
Assert.NotNull(metadata);
Assert.Equal(new Uri("http://localhost:5000/api/resource/tools"), metadata!.Resource);
}
private async Task<WebApplication> StartAuthenticationServerAsync(Action<McpAuthenticationOptions>? configureOptions = null, PathString? pathBase = null)
{
var authenticationBuilder = Builder.Services.AddAuthentication(options =>
{
options.DefaultScheme = McpAuthenticationDefaults.AuthenticationScheme;
options.DefaultAuthenticateScheme = McpAuthenticationDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = McpAuthenticationDefaults.AuthenticationScheme;
});
authenticationBuilder.AddScheme<McpAuthenticationOptions, McpAuthenticationHandler>(McpAuthenticationDefaults.AuthenticationScheme, options =>
{
options.ResourceMetadata = new()
{
AuthorizationServers = [new Uri("https://localhost:7029")],
ScopesSupported = ["mcp:tools"],
};
configureOptions?.Invoke(options);
});
authenticationBuilder.AddScheme<AuthenticationSchemeOptions, NoopBearerAuthenticationHandler>("Bearer", options => { });
Builder.Services.AddAuthorization();
var app = Builder.Build();
if (pathBase is PathString basePath && basePath.HasValue)
{
app.UsePathBase(basePath);
}
app.UseAuthentication();
app.UseAuthorization();
app.MapGet("/challenge", context => context.ChallengeAsync(McpAuthenticationDefaults.AuthenticationScheme));
app.MapGet("/resource/{*resourcePath}", context => context.ChallengeAsync(McpAuthenticationDefaults.AuthenticationScheme));
await app.StartAsync(TestContext.Current.CancellationToken);
return app;
}
private sealed class NoopBearerAuthenticationHandler(
IOptionsMonitor<AuthenticationSchemeOptions> options,
ILoggerFactory logger,
UrlEncoder encoder)
: AuthenticationHandler<AuthenticationSchemeOptions>(options, logger, encoder)
{
protected override Task<AuthenticateResult> HandleAuthenticateAsync() => Task.FromResult(AuthenticateResult.NoResult());
}
}

View file

@ -1,4 +1,5 @@
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.WebUtilities;
using Microsoft.Extensions.DependencyInjection;
@ -55,7 +56,6 @@ public abstract class OAuthTestBase : KestrelInMemoryTest, IAsyncDisposable
{
options.ResourceMetadata = new ProtectedResourceMetadata
{
Resource = new Uri(McpServerUrl),
AuthorizationServers = { new Uri(OAuthServerUrl) },
ScopesSupported = ["mcp:tools"]
};
@ -82,10 +82,18 @@ public abstract class OAuthTestBase : KestrelInMemoryTest, IAsyncDisposable
}
}
protected async Task<WebApplication> StartMcpServerAsync()
protected async Task<WebApplication> StartMcpServerAsync(string path = "", string? authScheme = null)
{
Builder.Services.PostConfigure<JwtBearerOptions>(JwtBearerDefaults.AuthenticationScheme, options =>
{
options.TokenValidationParameters.ValidAudience = $"{McpServerUrl}{path}";
});
var app = Builder.Build();
app.MapMcp().RequireAuthorization();
app.MapMcp(path).RequireAuthorization(new AuthorizeAttribute
{
AuthenticationSchemes = authScheme
});
await app.StartAsync(TestContext.Current.CancellationToken);
return app;
}