Change ProtectedResourceMetadata URI properties to strings and build resource strings directly (#1264)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: halter73 <54385+halter73@users.noreply.github.com> Co-authored-by: Stephen Halter <halter73@gmail.com>
This commit is contained in:
parent
b4c7ded398
commit
dbf91bcb1a
4 changed files with 209 additions and 55 deletions
|
|
@ -25,8 +25,8 @@ public class AuthEventTests : OAuthTestBase
|
|||
// Dynamically provide the resource metadata
|
||||
context.ResourceMetadata = new ProtectedResourceMetadata
|
||||
{
|
||||
Resource = new Uri(McpServerUrl),
|
||||
AuthorizationServers = { new Uri(OAuthServerUrl) },
|
||||
Resource = McpServerUrl,
|
||||
AuthorizationServers = { OAuthServerUrl },
|
||||
ScopesSupported = ["mcp:tools"],
|
||||
};
|
||||
await Task.CompletedTask;
|
||||
|
|
@ -124,8 +124,8 @@ public class AuthEventTests : OAuthTestBase
|
|||
);
|
||||
|
||||
Assert.NotNull(metadata);
|
||||
Assert.Equal(new Uri(McpServerUrl), metadata.Resource);
|
||||
Assert.Contains(new Uri(OAuthServerUrl), metadata.AuthorizationServers);
|
||||
Assert.Equal(McpServerUrl, metadata.Resource);
|
||||
Assert.Contains(OAuthServerUrl, metadata.AuthorizationServers);
|
||||
Assert.Contains("mcp:tools", metadata.ScopesSupported);
|
||||
}
|
||||
|
||||
|
|
@ -140,8 +140,8 @@ public class AuthEventTests : OAuthTestBase
|
|||
// Set initial metadata
|
||||
options.ResourceMetadata = new ProtectedResourceMetadata
|
||||
{
|
||||
Resource = new Uri(McpServerUrl),
|
||||
AuthorizationServers = { new Uri(OAuthServerUrl) },
|
||||
Resource = McpServerUrl,
|
||||
AuthorizationServers = { OAuthServerUrl },
|
||||
ScopesSupported = ["mcp:basic"],
|
||||
};
|
||||
|
||||
|
|
@ -175,8 +175,8 @@ public class AuthEventTests : OAuthTestBase
|
|||
);
|
||||
|
||||
Assert.NotNull(metadata);
|
||||
Assert.Equal(new Uri(McpServerUrl), metadata.Resource);
|
||||
Assert.Contains(new Uri(OAuthServerUrl), metadata.AuthorizationServers);
|
||||
Assert.Equal(McpServerUrl, metadata.Resource);
|
||||
Assert.Contains(OAuthServerUrl, metadata.AuthorizationServers);
|
||||
Assert.Contains("mcp:basic", metadata.ScopesSupported);
|
||||
Assert.Contains("mcp:tools", metadata.ScopesSupported);
|
||||
Assert.Equal("Dynamic Test Resource", metadata.ResourceName);
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ using ModelContextProtocol.Authentication;
|
|||
using ModelContextProtocol.Client;
|
||||
using ModelContextProtocol.Server;
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Security.Claims;
|
||||
using Xunit.Sdk;
|
||||
|
||||
|
|
@ -506,7 +507,7 @@ public class AuthTests : OAuthTestBase
|
|||
{
|
||||
Builder.Services.Configure<McpAuthenticationOptions>(McpAuthenticationDefaults.AuthenticationScheme, options =>
|
||||
{
|
||||
options.ResourceMetadata!.Resource = new Uri("http://localhost:5999");
|
||||
options.ResourceMetadata!.Resource = "http://localhost:5999";
|
||||
});
|
||||
|
||||
await using var app = await StartMcpServerAsync();
|
||||
|
|
@ -532,7 +533,7 @@ public class AuthTests : OAuthTestBase
|
|||
{
|
||||
Builder.Services.Configure<McpAuthenticationOptions>(McpAuthenticationDefaults.AuthenticationScheme, options =>
|
||||
{
|
||||
options.ResourceMetadata!.AuthorizationServers = [new Uri($"{OAuthServerUrl}/tenant1")];
|
||||
options.ResourceMetadata!.AuthorizationServers = [$"{OAuthServerUrl}/tenant1"];
|
||||
});
|
||||
|
||||
await using var app = await StartMcpServerAsync();
|
||||
|
|
@ -565,7 +566,7 @@ public class AuthTests : OAuthTestBase
|
|||
|
||||
Builder.Services.Configure<McpAuthenticationOptions>(McpAuthenticationDefaults.AuthenticationScheme, options =>
|
||||
{
|
||||
options.ResourceMetadata!.AuthorizationServers = [new Uri($"{OAuthServerUrl}{issuerPath}")];
|
||||
options.ResourceMetadata!.AuthorizationServers = [$"{OAuthServerUrl}{issuerPath}"];
|
||||
});
|
||||
|
||||
await using var app = await StartMcpServerAsync();
|
||||
|
|
@ -606,8 +607,8 @@ public class AuthTests : OAuthTestBase
|
|||
|
||||
var metadata = new ProtectedResourceMetadata
|
||||
{
|
||||
Resource = new Uri($"{McpServerUrl}{resourcePath}"),
|
||||
AuthorizationServers = { new Uri(OAuthServerUrl) },
|
||||
Resource = $"{McpServerUrl}{resourcePath}",
|
||||
AuthorizationServers = { OAuthServerUrl },
|
||||
};
|
||||
|
||||
app.Use(async (context, next) =>
|
||||
|
|
@ -678,8 +679,8 @@ public class AuthTests : OAuthTestBase
|
|||
{
|
||||
options.ResourceMetadata = new ProtectedResourceMetadata
|
||||
{
|
||||
Resource = new Uri($"{McpServerUrl}{configuredResourcePath}"),
|
||||
AuthorizationServers = { new Uri(OAuthServerUrl) },
|
||||
Resource = $"{McpServerUrl}{configuredResourcePath}",
|
||||
AuthorizationServers = { OAuthServerUrl },
|
||||
};
|
||||
});
|
||||
|
||||
|
|
@ -719,8 +720,8 @@ public class AuthTests : OAuthTestBase
|
|||
{
|
||||
options.ResourceMetadata = new ProtectedResourceMetadata
|
||||
{
|
||||
Resource = new Uri($"{McpServerUrl}"),
|
||||
AuthorizationServers = { new Uri(OAuthServerUrl) },
|
||||
Resource = McpServerUrl,
|
||||
AuthorizationServers = { OAuthServerUrl },
|
||||
};
|
||||
});
|
||||
|
||||
|
|
@ -750,4 +751,106 @@ public class AuthTests : OAuthTestBase
|
|||
|
||||
Assert.Contains("does not match", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResourceMetadata_DoesNotAddTrailingSlash()
|
||||
{
|
||||
// This test verifies that automatically derived resource URIs don't have trailing slashes
|
||||
// and that the client doesn't add them during authentication
|
||||
|
||||
// Don't explicitly set Resource - let it be derived from the request
|
||||
await using var app = await StartMcpServerAsync();
|
||||
|
||||
// First, manually check the PRM document doesn't contain a trailing slash
|
||||
using var metadataResponse = await HttpClient.GetAsync(
|
||||
"/.well-known/oauth-protected-resource",
|
||||
TestContext.Current.CancellationToken
|
||||
);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, metadataResponse.StatusCode);
|
||||
|
||||
var metadata = await metadataResponse.Content.ReadFromJsonAsync<ProtectedResourceMetadata>(
|
||||
McpJsonUtilities.DefaultOptions,
|
||||
TestContext.Current.CancellationToken
|
||||
);
|
||||
|
||||
Assert.NotNull(metadata);
|
||||
Assert.Equal("http://localhost:5000", metadata.Resource);
|
||||
Assert.DoesNotMatch(@"/$", metadata.Resource); // No trailing slash
|
||||
|
||||
// Then authenticate with the client - this will use the derived resource URI
|
||||
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);
|
||||
|
||||
// This should succeed - the client should not add a trailing slash
|
||||
// If the client incorrectly added a trailing slash, ValidResources would reject it
|
||||
await using var client = await McpClient.CreateAsync(
|
||||
transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResourceMetadata_PreservesExplicitTrailingSlash()
|
||||
{
|
||||
// This test verifies that explicitly configured trailing slashes are preserved
|
||||
const string resourceWithTrailingSlash = "http://localhost:5000/";
|
||||
|
||||
// Configure ValidResources to accept the trailing slash version for this test
|
||||
TestOAuthServer.ValidResources = [resourceWithTrailingSlash, "http://localhost:5000/mcp"];
|
||||
|
||||
Builder.Services.Configure<McpAuthenticationOptions>(McpAuthenticationDefaults.AuthenticationScheme, options =>
|
||||
{
|
||||
options.ResourceMetadata = new ProtectedResourceMetadata
|
||||
{
|
||||
Resource = resourceWithTrailingSlash,
|
||||
AuthorizationServers = { OAuthServerUrl },
|
||||
ScopesSupported = ["mcp:tools"],
|
||||
};
|
||||
});
|
||||
|
||||
await using var app = await StartMcpServerAsync();
|
||||
|
||||
// First, manually check the PRM document contains the trailing slash
|
||||
using var metadataResponse = await HttpClient.GetAsync(
|
||||
"/.well-known/oauth-protected-resource",
|
||||
TestContext.Current.CancellationToken
|
||||
);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, metadataResponse.StatusCode);
|
||||
|
||||
var metadata = await metadataResponse.Content.ReadFromJsonAsync<ProtectedResourceMetadata>(
|
||||
McpJsonUtilities.DefaultOptions,
|
||||
TestContext.Current.CancellationToken
|
||||
);
|
||||
|
||||
Assert.NotNull(metadata);
|
||||
Assert.Equal(resourceWithTrailingSlash, metadata.Resource);
|
||||
Assert.Matches(@"/$", metadata.Resource); // Has trailing slash
|
||||
|
||||
// Then authenticate with the client
|
||||
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);
|
||||
|
||||
// This should succeed with the explicitly configured trailing slash
|
||||
// If the client incorrectly trimmed the slash, ValidResources would reject it
|
||||
await using var client = await McpClient.CreateAsync(
|
||||
transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ public class McpAuthenticationHandlerTests(ITestOutputHelper outputHelper) : Kes
|
|||
await using var app = await StartAuthenticationServerAsync(options =>
|
||||
{
|
||||
options.ResourceMetadataUri = new Uri(metadataPath, UriKind.Relative);
|
||||
options.ResourceMetadata!.Resource = new Uri("http://localhost:5000/challenge");
|
||||
options.ResourceMetadata!.Resource = "http://localhost:5000/challenge";
|
||||
});
|
||||
|
||||
using var challengeResponse = await HttpClient.GetAsync(new Uri("/challenge", UriKind.Relative), HttpCompletionOption.ResponseHeadersRead, TestContext.Current.CancellationToken);
|
||||
|
|
@ -64,7 +64,7 @@ public class McpAuthenticationHandlerTests(ITestOutputHelper outputHelper) : Kes
|
|||
await using var app = await StartAuthenticationServerAsync(options =>
|
||||
{
|
||||
options.ResourceMetadataUri = metadataUri;
|
||||
options.ResourceMetadata!.Resource = new Uri("http://localhost:5000/challenge");
|
||||
options.ResourceMetadata!.Resource = "http://localhost:5000/challenge";
|
||||
});
|
||||
|
||||
using var challengeResponse = await HttpClient.GetAsync(new Uri("/challenge", UriKind.Relative), HttpCompletionOption.ResponseHeadersRead, TestContext.Current.CancellationToken);
|
||||
|
|
@ -137,7 +137,7 @@ public class McpAuthenticationHandlerTests(ITestOutputHelper outputHelper) : Kes
|
|||
McpJsonUtilities.DefaultOptions,
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(metadata);
|
||||
Assert.Equal(new Uri("http://localhost:5000/resource/tools"), metadata!.Resource);
|
||||
Assert.Equal("http://localhost:5000/resource/tools", metadata!.Resource);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -153,7 +153,7 @@ public class McpAuthenticationHandlerTests(ITestOutputHelper outputHelper) : Kes
|
|||
McpJsonUtilities.DefaultOptions,
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(metadata);
|
||||
Assert.Equal(new Uri("http://localhost:5000/api/resource/tools"), metadata!.Resource);
|
||||
Assert.Equal("http://localhost:5000/api/resource/tools", metadata!.Resource);
|
||||
}
|
||||
|
||||
private async Task<WebApplication> StartAuthenticationServerAsync(Action<McpAuthenticationOptions>? configureOptions = null, PathString? pathBase = null)
|
||||
|
|
@ -169,7 +169,7 @@ public class McpAuthenticationHandlerTests(ITestOutputHelper outputHelper) : Kes
|
|||
{
|
||||
options.ResourceMetadata = new()
|
||||
{
|
||||
AuthorizationServers = [new Uri("https://localhost:7029")],
|
||||
AuthorizationServers = ["https://localhost:7029"],
|
||||
ScopesSupported = ["mcp:tools"],
|
||||
};
|
||||
configureOptions?.Invoke(options);
|
||||
|
|
|
|||
|
|
@ -55,7 +55,7 @@ public abstract class OAuthTestBase : KestrelInMemoryTest, IAsyncDisposable
|
|||
{
|
||||
options.ResourceMetadata = new ProtectedResourceMetadata
|
||||
{
|
||||
AuthorizationServers = { new Uri(OAuthServerUrl) },
|
||||
AuthorizationServers = { OAuthServerUrl },
|
||||
ScopesSupported = ["mcp:tools"]
|
||||
};
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue