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
9 changed files with 209 additions and 55 deletions
|
|
@ -56,8 +56,8 @@ builder.Services.AddAuthentication(options =>
|
||||||
{
|
{
|
||||||
options.ResourceMetadata = new()
|
options.ResourceMetadata = new()
|
||||||
{
|
{
|
||||||
ResourceDocumentation = new Uri("https://docs.example.com/api/weather"),
|
ResourceDocumentation = "https://docs.example.com/api/weather",
|
||||||
AuthorizationServers = { new Uri(inMemoryOAuthServerUrl) },
|
AuthorizationServers = { inMemoryOAuthServerUrl },
|
||||||
ScopesSupported = ["mcp:tools"],
|
ScopesSupported = ["mcp:tools"],
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -56,17 +56,29 @@ public partial class McpAuthenticationHandler : AuthenticationHandler<McpAuthent
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
var deriveResourceUriBuilder = new UriBuilder(Request.Scheme, Request.Host.Host)
|
// Build the derived resource string directly without trailing slash
|
||||||
{
|
var scheme = Request.Scheme;
|
||||||
Path = $"{Request.PathBase}{resourceSuffix}",
|
var host = Request.Host.Host;
|
||||||
};
|
var port = Request.Host.Port;
|
||||||
|
var path = $"{Request.PathBase}{resourceSuffix}".TrimEnd('/');
|
||||||
|
|
||||||
if (Request.Host.Port is not null)
|
string derivedResource;
|
||||||
|
if (port.HasValue && !IsDefaultPort(scheme, port.Value))
|
||||||
{
|
{
|
||||||
deriveResourceUriBuilder.Port = Request.Host.Port.Value;
|
derivedResource = $"{scheme}://{host}:{port.Value}{path}";
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
derivedResource = $"{scheme}://{host}{path}";
|
||||||
}
|
}
|
||||||
|
|
||||||
return await HandleResourceMetadataRequestAsync(deriveResourceUriBuilder.Uri);
|
return await HandleResourceMetadataRequestAsync(derivedResource);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsDefaultPort(string scheme, int port)
|
||||||
|
{
|
||||||
|
return (scheme.Equals("http", StringComparison.OrdinalIgnoreCase) && port == 80) ||
|
||||||
|
(scheme.Equals("https", StringComparison.OrdinalIgnoreCase) && port == 443);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
@ -128,9 +140,9 @@ public partial class McpAuthenticationHandler : AuthenticationHandler<McpAuthent
|
||||||
return path.StartsWith('/') ? path : $"/{path}";
|
return path.StartsWith('/') ? path : $"/{path}";
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<bool> HandleResourceMetadataRequestAsync(Uri? derivedResourceUri = null)
|
private async Task<bool> HandleResourceMetadataRequestAsync(string? derivedResource = null)
|
||||||
{
|
{
|
||||||
var resourceMetadata = Options.ResourceMetadata?.Clone(derivedResourceUri);
|
var resourceMetadata = Options.ResourceMetadata?.Clone(derivedResource);
|
||||||
|
|
||||||
if (Options.Events.OnResourceMetadataRequest is not null)
|
if (Options.Events.OnResourceMetadataRequest is not null)
|
||||||
{
|
{
|
||||||
|
|
@ -165,7 +177,7 @@ public partial class McpAuthenticationHandler : AuthenticationHandler<McpAuthent
|
||||||
throw new InvalidOperationException("ResourceMetadata has not been configured. Please set McpAuthenticationOptions.ResourceMetadata or ensure context.ResourceMetadata is set inside McpAuthenticationOptions.Events.OnResourceMetadataRequest.");
|
throw new InvalidOperationException("ResourceMetadata has not been configured. Please set McpAuthenticationOptions.ResourceMetadata or ensure context.ResourceMetadata is set inside McpAuthenticationOptions.Events.OnResourceMetadataRequest.");
|
||||||
}
|
}
|
||||||
|
|
||||||
resourceMetadata.Resource ??= derivedResourceUri;
|
resourceMetadata.Resource ??= derivedResource;
|
||||||
|
|
||||||
if (resourceMetadata.Resource is null)
|
if (resourceMetadata.Resource is null)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -154,7 +154,7 @@ internal sealed partial class ClientOAuthProvider : McpHttpClient
|
||||||
// Try to refresh the access token if it is invalid and we have a refresh token.
|
// Try to refresh the access token if it is invalid and we have a refresh token.
|
||||||
if (_authServerMetadata is not null && tokens?.RefreshToken is { Length: > 0 } refreshToken)
|
if (_authServerMetadata is not null && tokens?.RefreshToken is { Length: > 0 } refreshToken)
|
||||||
{
|
{
|
||||||
var accessToken = await RefreshTokensAsync(refreshToken, resourceUri, _authServerMetadata, cancellationToken).ConfigureAwait(false);
|
var accessToken = await RefreshTokensAsync(refreshToken, resourceUri.ToString(), _authServerMetadata, cancellationToken).ConfigureAwait(false);
|
||||||
return (accessToken, true);
|
return (accessToken, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -243,15 +243,26 @@ internal sealed partial class ClientOAuthProvider : McpHttpClient
|
||||||
ThrowFailedToHandleUnauthorizedResponse("No authorization servers found in authentication challenge");
|
ThrowFailedToHandleUnauthorizedResponse("No authorization servers found in authentication challenge");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Convert string URIs to Uri objects for the selector
|
||||||
|
List<Uri> authServerUris = [];
|
||||||
|
foreach (var serverUriString in availableAuthorizationServers)
|
||||||
|
{
|
||||||
|
if (!Uri.TryCreate(serverUriString, UriKind.Absolute, out var serverUri))
|
||||||
|
{
|
||||||
|
ThrowFailedToHandleUnauthorizedResponse($"Invalid authorization server URI: '{serverUriString}'. Available servers: {string.Join(", ", availableAuthorizationServers)}");
|
||||||
|
}
|
||||||
|
authServerUris.Add(serverUri);
|
||||||
|
}
|
||||||
|
|
||||||
// Select authorization server using configured strategy
|
// Select authorization server using configured strategy
|
||||||
var selectedAuthServer = _authServerSelector(availableAuthorizationServers);
|
var selectedAuthServer = _authServerSelector(authServerUris);
|
||||||
|
|
||||||
if (selectedAuthServer is null)
|
if (selectedAuthServer is null)
|
||||||
{
|
{
|
||||||
ThrowFailedToHandleUnauthorizedResponse($"Authorization server selection returned null. Available servers: {string.Join(", ", availableAuthorizationServers)}");
|
ThrowFailedToHandleUnauthorizedResponse($"Authorization server selection returned null. Available servers: {string.Join(", ", availableAuthorizationServers)}");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!availableAuthorizationServers.Contains(selectedAuthServer))
|
if (!authServerUris.Contains(selectedAuthServer))
|
||||||
{
|
{
|
||||||
ThrowFailedToHandleUnauthorizedResponse($"Authorization server selector returned a server not in the available list: {selectedAuthServer}. Available servers: {string.Join(", ", availableAuthorizationServers)}");
|
ThrowFailedToHandleUnauthorizedResponse($"Authorization server selector returned a server not in the available list: {selectedAuthServer}. Available servers: {string.Join(", ", availableAuthorizationServers)}");
|
||||||
}
|
}
|
||||||
|
|
@ -387,13 +398,13 @@ internal sealed partial class ClientOAuthProvider : McpHttpClient
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<string?> RefreshTokensAsync(string refreshToken, Uri resourceUri, AuthorizationServerMetadata authServerMetadata, CancellationToken cancellationToken)
|
private async Task<string?> RefreshTokensAsync(string refreshToken, string resourceUri, AuthorizationServerMetadata authServerMetadata, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
Dictionary<string, string> formFields = new()
|
Dictionary<string, string> formFields = new()
|
||||||
{
|
{
|
||||||
["grant_type"] = "refresh_token",
|
["grant_type"] = "refresh_token",
|
||||||
["refresh_token"] = refreshToken,
|
["refresh_token"] = refreshToken,
|
||||||
["resource"] = resourceUri.ToString(),
|
["resource"] = resourceUri,
|
||||||
};
|
};
|
||||||
|
|
||||||
using var request = CreateTokenRequest(authServerMetadata.TokenEndpoint, formFields);
|
using var request = CreateTokenRequest(authServerMetadata.TokenEndpoint, formFields);
|
||||||
|
|
@ -443,7 +454,7 @@ internal sealed partial class ClientOAuthProvider : McpHttpClient
|
||||||
["response_type"] = "code",
|
["response_type"] = "code",
|
||||||
["code_challenge"] = codeChallenge,
|
["code_challenge"] = codeChallenge,
|
||||||
["code_challenge_method"] = "S256",
|
["code_challenge_method"] = "S256",
|
||||||
["resource"] = resourceUri.ToString(),
|
["resource"] = resourceUri,
|
||||||
};
|
};
|
||||||
|
|
||||||
var scope = GetScopeParameter(protectedResourceMetadata);
|
var scope = GetScopeParameter(protectedResourceMetadata);
|
||||||
|
|
@ -487,7 +498,7 @@ internal sealed partial class ClientOAuthProvider : McpHttpClient
|
||||||
["code"] = authorizationCode,
|
["code"] = authorizationCode,
|
||||||
["redirect_uri"] = _redirectUri.ToString(),
|
["redirect_uri"] = _redirectUri.ToString(),
|
||||||
["code_verifier"] = codeVerifier,
|
["code_verifier"] = codeVerifier,
|
||||||
["resource"] = resourceUri.ToString(),
|
["resource"] = resourceUri,
|
||||||
};
|
};
|
||||||
|
|
||||||
using var request = CreateTokenRequest(authServerMetadata.TokenEndpoint, formFields);
|
using var request = CreateTokenRequest(authServerMetadata.TokenEndpoint, formFields);
|
||||||
|
|
@ -659,7 +670,7 @@ internal sealed partial class ClientOAuthProvider : McpHttpClient
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Uri GetRequiredResourceUri(ProtectedResourceMetadata protectedResourceMetadata)
|
private static string GetRequiredResourceUri(ProtectedResourceMetadata protectedResourceMetadata)
|
||||||
{
|
{
|
||||||
if (protectedResourceMetadata.Resource is null)
|
if (protectedResourceMetadata.Resource is null)
|
||||||
{
|
{
|
||||||
|
|
@ -732,6 +743,27 @@ internal sealed partial class ClientOAuthProvider : McpHttpClient
|
||||||
return builder.ToString();
|
return builder.ToString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Normalizes a URI string for consistent comparison.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="uriString">The URI string to normalize.</param>
|
||||||
|
/// <returns>
|
||||||
|
/// A normalized string representation of the URI. If the string is a valid absolute URI,
|
||||||
|
/// it is parsed and normalized (scheme, host, port, and path without trailing slash).
|
||||||
|
/// If the string is not a valid absolute URI, only the trailing slash is removed.
|
||||||
|
/// </returns>
|
||||||
|
private static string NormalizeUri(string uriString)
|
||||||
|
{
|
||||||
|
// Parse the string as a URI to normalize it
|
||||||
|
if (!Uri.TryCreate(uriString, UriKind.Absolute, out var uri))
|
||||||
|
{
|
||||||
|
// If it's not a valid URI, return the string with trailing slash removed
|
||||||
|
return uriString.TrimEnd('/');
|
||||||
|
}
|
||||||
|
|
||||||
|
return NormalizeUri(uri);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Responds to a 401 challenge by parsing the WWW-Authenticate header, fetching the resource metadata,
|
/// Responds to a 401 challenge by parsing the WWW-Authenticate header, fetching the resource metadata,
|
||||||
/// verifying the resource match, and returning the metadata if valid.
|
/// verifying the resource match, and returning the metadata if valid.
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
using System.Diagnostics.CodeAnalysis;
|
||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
namespace ModelContextProtocol.Authentication;
|
namespace ModelContextProtocol.Authentication;
|
||||||
|
|
@ -20,7 +21,8 @@ public sealed class ProtectedResourceMetadata
|
||||||
/// <b>Resource</b> must be explicitly set. Automatic inference only works with the default endpoint pattern.
|
/// <b>Resource</b> must be explicitly set. Automatic inference only works with the default endpoint pattern.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
[JsonPropertyName("resource")]
|
[JsonPropertyName("resource")]
|
||||||
public Uri? Resource { get; set; }
|
[StringSyntax(StringSyntaxAttribute.Uri)]
|
||||||
|
public string? Resource { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets or sets the list of authorization server URIs.
|
/// Gets or sets the list of authorization server URIs.
|
||||||
|
|
@ -33,7 +35,7 @@ public sealed class ProtectedResourceMetadata
|
||||||
/// OPTIONAL.
|
/// OPTIONAL.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
[JsonPropertyName("authorization_servers")]
|
[JsonPropertyName("authorization_servers")]
|
||||||
public List<Uri> AuthorizationServers { get; set; } = [];
|
public List<string> AuthorizationServers { get; set; } = [];
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets or sets the supported bearer token methods.
|
/// Gets or sets the supported bearer token methods.
|
||||||
|
|
@ -69,7 +71,8 @@ public sealed class ProtectedResourceMetadata
|
||||||
/// that the resource server uses to sign resource responses. This URL MUST use the HTTPS scheme.
|
/// that the resource server uses to sign resource responses. This URL MUST use the HTTPS scheme.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
[JsonPropertyName("jwks_uri")]
|
[JsonPropertyName("jwks_uri")]
|
||||||
public Uri? JwksUri { get; set; }
|
[StringSyntax(StringSyntaxAttribute.Uri)]
|
||||||
|
public string? JwksUri { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets or sets the list of the JWS signing algorithms supported by the protected resource for signing resource responses.
|
/// Gets or sets the list of the JWS signing algorithms supported by the protected resource for signing resource responses.
|
||||||
|
|
@ -105,7 +108,8 @@ public sealed class ProtectedResourceMetadata
|
||||||
/// OPTIONAL.
|
/// OPTIONAL.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
[JsonPropertyName("resource_documentation")]
|
[JsonPropertyName("resource_documentation")]
|
||||||
public Uri? ResourceDocumentation { get; set; }
|
[StringSyntax(StringSyntaxAttribute.Uri)]
|
||||||
|
public string? ResourceDocumentation { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets or sets the URL of a page containing human-readable information about the protected resource's requirements.
|
/// Gets or sets the URL of a page containing human-readable information about the protected resource's requirements.
|
||||||
|
|
@ -117,7 +121,8 @@ public sealed class ProtectedResourceMetadata
|
||||||
/// OPTIONAL.
|
/// OPTIONAL.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
[JsonPropertyName("resource_policy_uri")]
|
[JsonPropertyName("resource_policy_uri")]
|
||||||
public Uri? ResourcePolicyUri { get; set; }
|
[StringSyntax(StringSyntaxAttribute.Uri)]
|
||||||
|
public string? ResourcePolicyUri { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets or sets the URL of a page containing human-readable information about the protected resource's terms of service.
|
/// Gets or sets the URL of a page containing human-readable information about the protected resource's terms of service.
|
||||||
|
|
@ -126,7 +131,8 @@ public sealed class ProtectedResourceMetadata
|
||||||
/// OPTIONAL. The value of this field MAY be internationalized.
|
/// OPTIONAL. The value of this field MAY be internationalized.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
[JsonPropertyName("resource_tos_uri")]
|
[JsonPropertyName("resource_tos_uri")]
|
||||||
public Uri? ResourceTosUri { get; set; }
|
[StringSyntax(StringSyntaxAttribute.Uri)]
|
||||||
|
public string? ResourceTosUri { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets or sets a value indicating whether there is protected resource support for mutual-TLS client certificate-bound access tokens.
|
/// Gets or sets a value indicating whether there is protected resource support for mutual-TLS client certificate-bound access tokens.
|
||||||
|
|
@ -195,13 +201,13 @@ public sealed class ProtectedResourceMetadata
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Creates a deep copy of this <see cref="ProtectedResourceMetadata"/> instance, optionally overriding the Resource property.
|
/// Creates a deep copy of this <see cref="ProtectedResourceMetadata"/> instance, optionally overriding the Resource property.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="derivedResourceUri">Optional URI to use for the Resource property if the original Resource is null.</param>
|
/// <param name="derivedResource">Optional resource URI string to use for the Resource property if the original Resource is null.</param>
|
||||||
/// <returns>A new instance of <see cref="ProtectedResourceMetadata"/> with cloned values.</returns>
|
/// <returns>A new instance of <see cref="ProtectedResourceMetadata"/> with cloned values.</returns>
|
||||||
public ProtectedResourceMetadata Clone(Uri? derivedResourceUri = null)
|
public ProtectedResourceMetadata Clone(string? derivedResource = null)
|
||||||
{
|
{
|
||||||
return new ProtectedResourceMetadata
|
return new ProtectedResourceMetadata
|
||||||
{
|
{
|
||||||
Resource = Resource ?? derivedResourceUri,
|
Resource = Resource ?? derivedResource,
|
||||||
AuthorizationServers = [.. AuthorizationServers],
|
AuthorizationServers = [.. AuthorizationServers],
|
||||||
BearerMethodsSupported = [.. BearerMethodsSupported],
|
BearerMethodsSupported = [.. BearerMethodsSupported],
|
||||||
ScopesSupported = [.. ScopesSupported],
|
ScopesSupported = [.. ScopesSupported],
|
||||||
|
|
|
||||||
|
|
@ -25,8 +25,8 @@ public class AuthEventTests : OAuthTestBase
|
||||||
// Dynamically provide the resource metadata
|
// Dynamically provide the resource metadata
|
||||||
context.ResourceMetadata = new ProtectedResourceMetadata
|
context.ResourceMetadata = new ProtectedResourceMetadata
|
||||||
{
|
{
|
||||||
Resource = new Uri(McpServerUrl),
|
Resource = McpServerUrl,
|
||||||
AuthorizationServers = { new Uri(OAuthServerUrl) },
|
AuthorizationServers = { OAuthServerUrl },
|
||||||
ScopesSupported = ["mcp:tools"],
|
ScopesSupported = ["mcp:tools"],
|
||||||
};
|
};
|
||||||
await Task.CompletedTask;
|
await Task.CompletedTask;
|
||||||
|
|
@ -124,8 +124,8 @@ public class AuthEventTests : OAuthTestBase
|
||||||
);
|
);
|
||||||
|
|
||||||
Assert.NotNull(metadata);
|
Assert.NotNull(metadata);
|
||||||
Assert.Equal(new Uri(McpServerUrl), metadata.Resource);
|
Assert.Equal(McpServerUrl, metadata.Resource);
|
||||||
Assert.Contains(new Uri(OAuthServerUrl), metadata.AuthorizationServers);
|
Assert.Contains(OAuthServerUrl, metadata.AuthorizationServers);
|
||||||
Assert.Contains("mcp:tools", metadata.ScopesSupported);
|
Assert.Contains("mcp:tools", metadata.ScopesSupported);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -140,8 +140,8 @@ public class AuthEventTests : OAuthTestBase
|
||||||
// Set initial metadata
|
// Set initial metadata
|
||||||
options.ResourceMetadata = new ProtectedResourceMetadata
|
options.ResourceMetadata = new ProtectedResourceMetadata
|
||||||
{
|
{
|
||||||
Resource = new Uri(McpServerUrl),
|
Resource = McpServerUrl,
|
||||||
AuthorizationServers = { new Uri(OAuthServerUrl) },
|
AuthorizationServers = { OAuthServerUrl },
|
||||||
ScopesSupported = ["mcp:basic"],
|
ScopesSupported = ["mcp:basic"],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -175,8 +175,8 @@ public class AuthEventTests : OAuthTestBase
|
||||||
);
|
);
|
||||||
|
|
||||||
Assert.NotNull(metadata);
|
Assert.NotNull(metadata);
|
||||||
Assert.Equal(new Uri(McpServerUrl), metadata.Resource);
|
Assert.Equal(McpServerUrl, metadata.Resource);
|
||||||
Assert.Contains(new Uri(OAuthServerUrl), metadata.AuthorizationServers);
|
Assert.Contains(OAuthServerUrl, metadata.AuthorizationServers);
|
||||||
Assert.Contains("mcp:basic", metadata.ScopesSupported);
|
Assert.Contains("mcp:basic", metadata.ScopesSupported);
|
||||||
Assert.Contains("mcp:tools", metadata.ScopesSupported);
|
Assert.Contains("mcp:tools", metadata.ScopesSupported);
|
||||||
Assert.Equal("Dynamic Test Resource", metadata.ResourceName);
|
Assert.Equal("Dynamic Test Resource", metadata.ResourceName);
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ using ModelContextProtocol.Authentication;
|
||||||
using ModelContextProtocol.Client;
|
using ModelContextProtocol.Client;
|
||||||
using ModelContextProtocol.Server;
|
using ModelContextProtocol.Server;
|
||||||
using System.Net;
|
using System.Net;
|
||||||
|
using System.Net.Http.Json;
|
||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
using Xunit.Sdk;
|
using Xunit.Sdk;
|
||||||
|
|
||||||
|
|
@ -506,7 +507,7 @@ public class AuthTests : OAuthTestBase
|
||||||
{
|
{
|
||||||
Builder.Services.Configure<McpAuthenticationOptions>(McpAuthenticationDefaults.AuthenticationScheme, options =>
|
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();
|
await using var app = await StartMcpServerAsync();
|
||||||
|
|
@ -532,7 +533,7 @@ public class AuthTests : OAuthTestBase
|
||||||
{
|
{
|
||||||
Builder.Services.Configure<McpAuthenticationOptions>(McpAuthenticationDefaults.AuthenticationScheme, options =>
|
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();
|
await using var app = await StartMcpServerAsync();
|
||||||
|
|
@ -565,7 +566,7 @@ public class AuthTests : OAuthTestBase
|
||||||
|
|
||||||
Builder.Services.Configure<McpAuthenticationOptions>(McpAuthenticationDefaults.AuthenticationScheme, options =>
|
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();
|
await using var app = await StartMcpServerAsync();
|
||||||
|
|
@ -606,8 +607,8 @@ public class AuthTests : OAuthTestBase
|
||||||
|
|
||||||
var metadata = new ProtectedResourceMetadata
|
var metadata = new ProtectedResourceMetadata
|
||||||
{
|
{
|
||||||
Resource = new Uri($"{McpServerUrl}{resourcePath}"),
|
Resource = $"{McpServerUrl}{resourcePath}",
|
||||||
AuthorizationServers = { new Uri(OAuthServerUrl) },
|
AuthorizationServers = { OAuthServerUrl },
|
||||||
};
|
};
|
||||||
|
|
||||||
app.Use(async (context, next) =>
|
app.Use(async (context, next) =>
|
||||||
|
|
@ -678,8 +679,8 @@ public class AuthTests : OAuthTestBase
|
||||||
{
|
{
|
||||||
options.ResourceMetadata = new ProtectedResourceMetadata
|
options.ResourceMetadata = new ProtectedResourceMetadata
|
||||||
{
|
{
|
||||||
Resource = new Uri($"{McpServerUrl}{configuredResourcePath}"),
|
Resource = $"{McpServerUrl}{configuredResourcePath}",
|
||||||
AuthorizationServers = { new Uri(OAuthServerUrl) },
|
AuthorizationServers = { OAuthServerUrl },
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -719,8 +720,8 @@ public class AuthTests : OAuthTestBase
|
||||||
{
|
{
|
||||||
options.ResourceMetadata = new ProtectedResourceMetadata
|
options.ResourceMetadata = new ProtectedResourceMetadata
|
||||||
{
|
{
|
||||||
Resource = new Uri($"{McpServerUrl}"),
|
Resource = McpServerUrl,
|
||||||
AuthorizationServers = { new Uri(OAuthServerUrl) },
|
AuthorizationServers = { OAuthServerUrl },
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -750,4 +751,106 @@ public class AuthTests : OAuthTestBase
|
||||||
|
|
||||||
Assert.Contains("does not match", ex.Message);
|
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 =>
|
await using var app = await StartAuthenticationServerAsync(options =>
|
||||||
{
|
{
|
||||||
options.ResourceMetadataUri = new Uri(metadataPath, UriKind.Relative);
|
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);
|
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 =>
|
await using var app = await StartAuthenticationServerAsync(options =>
|
||||||
{
|
{
|
||||||
options.ResourceMetadataUri = metadataUri;
|
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);
|
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,
|
McpJsonUtilities.DefaultOptions,
|
||||||
TestContext.Current.CancellationToken);
|
TestContext.Current.CancellationToken);
|
||||||
Assert.NotNull(metadata);
|
Assert.NotNull(metadata);
|
||||||
Assert.Equal(new Uri("http://localhost:5000/resource/tools"), metadata!.Resource);
|
Assert.Equal("http://localhost:5000/resource/tools", metadata!.Resource);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|
@ -153,7 +153,7 @@ public class McpAuthenticationHandlerTests(ITestOutputHelper outputHelper) : Kes
|
||||||
McpJsonUtilities.DefaultOptions,
|
McpJsonUtilities.DefaultOptions,
|
||||||
TestContext.Current.CancellationToken);
|
TestContext.Current.CancellationToken);
|
||||||
Assert.NotNull(metadata);
|
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)
|
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()
|
options.ResourceMetadata = new()
|
||||||
{
|
{
|
||||||
AuthorizationServers = [new Uri("https://localhost:7029")],
|
AuthorizationServers = ["https://localhost:7029"],
|
||||||
ScopesSupported = ["mcp:tools"],
|
ScopesSupported = ["mcp:tools"],
|
||||||
};
|
};
|
||||||
configureOptions?.Invoke(options);
|
configureOptions?.Invoke(options);
|
||||||
|
|
|
||||||
|
|
@ -55,7 +55,7 @@ public abstract class OAuthTestBase : KestrelInMemoryTest, IAsyncDisposable
|
||||||
{
|
{
|
||||||
options.ResourceMetadata = new ProtectedResourceMetadata
|
options.ResourceMetadata = new ProtectedResourceMetadata
|
||||||
{
|
{
|
||||||
AuthorizationServers = { new Uri(OAuthServerUrl) },
|
AuthorizationServers = { OAuthServerUrl },
|
||||||
ScopesSupported = ["mcp:tools"]
|
ScopesSupported = ["mcp:tools"]
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,10 +16,11 @@ public sealed class Program
|
||||||
private static readonly string _clientMetadataDocumentUrl = $"{_url}/client-metadata/cimd-client.json";
|
private static readonly string _clientMetadataDocumentUrl = $"{_url}/client-metadata/cimd-client.json";
|
||||||
|
|
||||||
// Port 5000 is used by tests and port 7071 is used by the ProtectedMcpServer sample
|
// Port 5000 is used by tests and port 7071 is used by the ProtectedMcpServer sample
|
||||||
private static readonly string[] ValidResources = [
|
// Per MCP spec, URIs should not have trailing slashes unless semantically significant
|
||||||
"http://localhost:5000/",
|
public string[] ValidResources { get; set; } = [
|
||||||
|
"http://localhost:5000",
|
||||||
"http://localhost:5000/mcp",
|
"http://localhost:5000/mcp",
|
||||||
"http://localhost:7071/"
|
"http://localhost:7071"
|
||||||
];
|
];
|
||||||
|
|
||||||
private readonly ConcurrentDictionary<string, AuthorizationCodeInfo> _authCodes = new();
|
private readonly ConcurrentDictionary<string, AuthorizationCodeInfo> _authCodes = new();
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue