Fall back to well-known URL in client if 401 response is missing resource_metadata parameter (#1054)
This commit is contained in:
parent
d99f882884
commit
970207989e
3 changed files with 497 additions and 134 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue