Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions samples/ProtectedMcpServer/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -56,8 +56,8 @@
{
options.ResourceMetadata = new()
{
ResourceDocumentation = new Uri("https://docs.example.com/api/weather"),
AuthorizationServers = { new Uri(inMemoryOAuthServerUrl) },
ResourceDocumentation = "https://docs.example.com/api/weather",
AuthorizationServers = { inMemoryOAuthServerUrl },
ScopesSupported = ["mcp:tools"],
};
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,7 @@ private async Task<bool> HandleResourceMetadataRequestAsync(Uri? derivedResource
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 ??= derivedResourceUri?.ToString();

if (resourceMetadata.Resource is null)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,7 @@ internal override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage r
// 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)
{
var accessToken = await RefreshTokensAsync(refreshToken, resourceUri, _authServerMetadata, cancellationToken).ConfigureAwait(false);
var accessToken = await RefreshTokensAsync(refreshToken, resourceUri.ToString(), _authServerMetadata, cancellationToken).ConfigureAwait(false);
return (accessToken, true);
}

Expand Down Expand Up @@ -243,15 +243,26 @@ private async Task<string> GetAccessTokenAsync(HttpResponseMessage response, boo
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
var selectedAuthServer = _authServerSelector(availableAuthorizationServers);
var selectedAuthServer = _authServerSelector(authServerUris);

if (selectedAuthServer is null)
{
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)}");
}
Expand Down Expand Up @@ -387,13 +398,13 @@ private static IEnumerable<Uri> GetWellKnownAuthorizationServerMetadataUris(Uri
}
}

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()
{
["grant_type"] = "refresh_token",
["refresh_token"] = refreshToken,
["resource"] = resourceUri.ToString(),
["resource"] = resourceUri,
};

using var request = CreateTokenRequest(authServerMetadata.TokenEndpoint, formFields);
Expand Down Expand Up @@ -443,7 +454,7 @@ private Uri BuildAuthorizationUrl(
["response_type"] = "code",
["code_challenge"] = codeChallenge,
["code_challenge_method"] = "S256",
["resource"] = resourceUri.ToString(),
["resource"] = resourceUri,
};

var scope = GetScopeParameter(protectedResourceMetadata);
Expand Down Expand Up @@ -487,7 +498,7 @@ private async Task<string> ExchangeCodeForTokenAsync(
["code"] = authorizationCode,
["redirect_uri"] = _redirectUri.ToString(),
["code_verifier"] = codeVerifier,
["resource"] = resourceUri.ToString(),
["resource"] = resourceUri,
};

using var request = CreateTokenRequest(authServerMetadata.TokenEndpoint, formFields);
Expand Down Expand Up @@ -659,7 +670,7 @@ private async Task PerformDynamicClientRegistrationAsync(
}
}

private static Uri GetRequiredResourceUri(ProtectedResourceMetadata protectedResourceMetadata)
private static string GetRequiredResourceUri(ProtectedResourceMetadata protectedResourceMetadata)
{
if (protectedResourceMetadata.Resource is null)
{
Expand Down Expand Up @@ -732,6 +743,27 @@ private static string NormalizeUri(Uri uri)
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>
/// 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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ public sealed class ProtectedResourceMetadata
/// <b>Resource</b> must be explicitly set. Automatic inference only works with the default endpoint pattern.
/// </remarks>
[JsonPropertyName("resource")]
public Uri? Resource { get; set; }
public string? Resource { get; set; }

/// <summary>
/// Gets or sets the list of authorization server URIs.
Expand All @@ -33,7 +33,7 @@ public sealed class ProtectedResourceMetadata
/// OPTIONAL.
/// </remarks>
[JsonPropertyName("authorization_servers")]
public List<Uri> AuthorizationServers { get; set; } = [];
public List<string> AuthorizationServers { get; set; } = [];

/// <summary>
/// Gets or sets the supported bearer token methods.
Expand Down Expand Up @@ -69,7 +69,7 @@ public sealed class ProtectedResourceMetadata
/// that the resource server uses to sign resource responses. This URL MUST use the HTTPS scheme.
/// </remarks>
[JsonPropertyName("jwks_uri")]
public Uri? JwksUri { get; set; }
public string? JwksUri { get; set; }

/// <summary>
/// Gets or sets the list of the JWS signing algorithms supported by the protected resource for signing resource responses.
Expand Down Expand Up @@ -105,7 +105,7 @@ public sealed class ProtectedResourceMetadata
/// OPTIONAL.
/// </remarks>
[JsonPropertyName("resource_documentation")]
public Uri? ResourceDocumentation { get; set; }
public string? ResourceDocumentation { get; set; }

/// <summary>
/// Gets or sets the URL of a page containing human-readable information about the protected resource's requirements.
Expand All @@ -117,7 +117,7 @@ public sealed class ProtectedResourceMetadata
/// OPTIONAL.
/// </remarks>
[JsonPropertyName("resource_policy_uri")]
public Uri? ResourcePolicyUri { get; set; }
public string? ResourcePolicyUri { get; set; }

/// <summary>
/// Gets or sets the URL of a page containing human-readable information about the protected resource's terms of service.
Expand All @@ -126,7 +126,7 @@ public sealed class ProtectedResourceMetadata
/// OPTIONAL. The value of this field MAY be internationalized.
/// </remarks>
[JsonPropertyName("resource_tos_uri")]
public Uri? ResourceTosUri { get; set; }
public string? ResourceTosUri { get; set; }

/// <summary>
/// Gets or sets a value indicating whether there is protected resource support for mutual-TLS client certificate-bound access tokens.
Expand Down Expand Up @@ -201,7 +201,7 @@ public ProtectedResourceMetadata Clone(Uri? derivedResourceUri = null)
{
return new ProtectedResourceMetadata
{
Resource = Resource ?? derivedResourceUri,
Resource = Resource ?? derivedResourceUri?.ToString(),
AuthorizationServers = [.. AuthorizationServers],
BearerMethodsSupported = [.. BearerMethodsSupported],
ScopesSupported = [.. ScopesSupported],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,8 @@ public AuthEventTests(ITestOutputHelper outputHelper)
// 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;
Expand Down Expand Up @@ -124,8 +124,8 @@ public async Task ResourceMetadataEndpoint_ReturnsCorrectMetadata_FromEvent()
);

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);
}

Expand All @@ -140,8 +140,8 @@ public async Task ResourceMetadataEndpoint_CanModifyExistingMetadata_InEvent()
// Set initial metadata
options.ResourceMetadata = new ProtectedResourceMetadata
{
Resource = new Uri(McpServerUrl),
AuthorizationServers = { new Uri(OAuthServerUrl) },
Resource = McpServerUrl,
AuthorizationServers = { OAuthServerUrl },
ScopesSupported = ["mcp:basic"],
};

Expand Down Expand Up @@ -175,8 +175,8 @@ public async Task ResourceMetadataEndpoint_CanModifyExistingMetadata_InEvent()
);

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);
Expand Down
57 changes: 48 additions & 9 deletions tests/ModelContextProtocol.AspNetCore.Tests/OAuth/AuthTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
using ModelContextProtocol.Client;
using ModelContextProtocol.Server;
using System.Net;
using System.Net.Http.Json;
using System.Security.Claims;
using Xunit.Sdk;

Expand Down Expand Up @@ -506,7 +507,7 @@ public async Task AuthorizationFails_WhenResourceMetadataPortDiffers()
{
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();
Expand All @@ -532,7 +533,7 @@ public async Task CanAuthenticate_WithAuthorizationServerPathInsertionMetadata()
{
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();
Expand Down Expand Up @@ -565,7 +566,7 @@ public async Task CanAuthenticate_WithAuthorizationServerPathFallbacks()

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();
Expand Down Expand Up @@ -606,8 +607,8 @@ public async Task CanAuthenticate_WithResourceMetadataPathFallbacks()

var metadata = new ProtectedResourceMetadata
{
Resource = new Uri($"{McpServerUrl}{resourcePath}"),
AuthorizationServers = { new Uri(OAuthServerUrl) },
Resource = $"{McpServerUrl}{resourcePath}",
AuthorizationServers = { OAuthServerUrl },
};

app.Use(async (context, next) =>
Expand Down Expand Up @@ -678,8 +679,8 @@ public async Task CannotAuthenticate_WhenResourceMetadataResourceIsNonRootParent
{
options.ResourceMetadata = new ProtectedResourceMetadata
{
Resource = new Uri($"{McpServerUrl}{configuredResourcePath}"),
AuthorizationServers = { new Uri(OAuthServerUrl) },
Resource = $"{McpServerUrl}{configuredResourcePath}",
AuthorizationServers = { OAuthServerUrl },
};
});

Expand Down Expand Up @@ -719,8 +720,8 @@ public async Task CannotAuthenticate_WhenWwwAuthenticateResourceMetadataIsRootPa
{
options.ResourceMetadata = new ProtectedResourceMetadata
{
Resource = new Uri($"{McpServerUrl}"),
AuthorizationServers = { new Uri(OAuthServerUrl) },
Resource = McpServerUrl,
AuthorizationServers = { OAuthServerUrl },
};
});

Expand Down Expand Up @@ -750,4 +751,42 @@ await McpClient.CreateAsync(

Assert.Contains("does not match", ex.Message);
}

[Fact]
public async Task ResourceMetadata_DoesNotAddTrailingSlash()
{
// This test verifies that using string for Resource property avoids URI normalization
// that would add a trailing slash to URIs without paths
const string resourceWithoutTrailingSlash = "http://localhost:5000";

Builder.Services.Configure<McpAuthenticationOptions>(McpAuthenticationDefaults.AuthenticationScheme, options =>
{
options.ResourceMetadata = new ProtectedResourceMetadata
{
Resource = resourceWithoutTrailingSlash,
AuthorizationServers = { OAuthServerUrl },
ScopesSupported = ["mcp:tools"],
};
});

await using var app = await StartMcpServerAsync();

// Make a direct request to the resource metadata endpoint
using var response = await HttpClient.GetAsync(
"/.well-known/oauth-protected-resource",
TestContext.Current.CancellationToken
);

Assert.Equal(HttpStatusCode.OK, response.StatusCode);

var metadata = await response.Content.ReadFromJsonAsync<ProtectedResourceMetadata>(
McpJsonUtilities.DefaultOptions,
TestContext.Current.CancellationToken
);

Assert.NotNull(metadata);

// Verify that the Resource property does NOT have a trailing slash added
Assert.Equal(resourceWithoutTrailingSlash, metadata.Resource);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ public async Task Challenge_WithRelativeResourceMetadataUri_SetsAbsoluteUrl()
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);
Expand Down Expand Up @@ -64,7 +64,7 @@ public async Task Challenge_WithAbsoluteResourceMetadataUri_SetsConfiguredUrl()
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);
Expand Down Expand Up @@ -137,7 +137,7 @@ public async Task MetadataRequest_DefaultEndpoint_SetsResourceFromSuffix()
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]
Expand All @@ -153,7 +153,7 @@ public async Task MetadataRequest_DefaultEndpoint_WithPathBase_SetsResourceFromS
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)
Expand All @@ -169,7 +169,7 @@ private async Task<WebApplication> StartAuthenticationServerAsync(Action<McpAuth
{
options.ResourceMetadata = new()
{
AuthorizationServers = [new Uri("https://localhost:7029")],
AuthorizationServers = ["https://localhost:7029"],
ScopesSupported = ["mcp:tools"],
};
configureOptions?.Invoke(options);
Expand Down
Loading
Loading