Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
1 change: 0 additions & 1 deletion samples/ProtectedMcpServer/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,6 @@
{
options.ResourceMetadata = new()
{
Resource = new Uri(serverUrl),
ResourceDocumentation = new Uri("https://docs.example.com/api/weather"),
AuthorizationServers = { new Uri(inMemoryOAuthServerUrl) },
ScopesSupported = ["mcp:tools"],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,11 @@ namespace ModelContextProtocol.AspNetCore.Authentication;
/// Represents an authentication handler for MCP protocol that adds resource metadata to challenge responses
/// and handles resource metadata endpoint requests.
/// </summary>
public class McpAuthenticationHandler : AuthenticationHandler<McpAuthenticationOptions>, IAuthenticationRequestHandler
public partial class McpAuthenticationHandler : AuthenticationHandler<McpAuthenticationOptions>, IAuthenticationRequestHandler
{
private const string DefaultResourceMetadataPath = "/.well-known/oauth-protected-resource";
private static readonly PathString DefaultResourceMetadataPrefix = new(DefaultResourceMetadataPath);

/// <summary>
/// Initializes a new instance of the <see cref="McpAuthenticationHandler"/> class.
/// </summary>
Expand All @@ -25,67 +28,114 @@ public McpAuthenticationHandler(
}

/// <inheritdoc />
public async Task<bool> HandleRequestAsync()
public Task<bool> HandleRequestAsync()
{
// Check if the request is for the resource metadata endpoint
string requestPath = Request.Path.Value ?? string.Empty;

string expectedMetadataPath = Options.ResourceMetadataUri?.ToString() ?? string.Empty;
if (Options.ResourceMetadataUri != null && !Options.ResourceMetadataUri.IsAbsoluteUri)
if (Options.ResourceMetadataUri is Uri configuredUri)
{
// For relative URIs, it's just the path component.
expectedMetadataPath = Options.ResourceMetadataUri.OriginalString;
return HandleConfiguredResourceMetadataRequestAsync(configuredUri);
}

// If the path doesn't match, let the request continue through the pipeline
if (!string.Equals(requestPath, expectedMetadataPath, StringComparison.OrdinalIgnoreCase))
return HandleDefaultResourceMetadataRequestAsync();
}

private async Task<bool> HandleConfiguredResourceMetadataRequestAsync(Uri resourceMetadataUri)
{
if (!IsConfiguredEndpointRequest(resourceMetadataUri))
{
return false;
}

return await HandleResourceMetadataRequestAsync();
}

/// <summary>
/// Gets the base URL from the current request, including scheme, host, and path base.
/// </summary>
private string GetBaseUrl() => $"{Request.Scheme}://{Request.Host}{Request.PathBase}";
private async Task<bool> HandleDefaultResourceMetadataRequestAsync()
{
if (!Request.Path.StartsWithSegments(DefaultResourceMetadataPrefix, out var resourceSuffix))
{
return false;
}

var deriveResourceUriBuilder = new UriBuilder(Request.Scheme, Request.Host.Host)
{
Path = $"{Request.PathBase}{resourceSuffix}",
};

if (Request.Host.Port is not null)
{
deriveResourceUriBuilder.Port = Request.Host.Port.Value;
}

return await HandleResourceMetadataRequestAsync(deriveResourceUriBuilder.Uri);
}

/// <summary>
/// Gets the absolute URI for the resource metadata endpoint.
/// </summary>
private string GetAbsoluteResourceMetadataUri()
{
var resourceMetadataUri = Options.ResourceMetadataUri;
if (Options.ResourceMetadataUri is Uri resourceMetadataUri)
{
if (resourceMetadataUri.IsAbsoluteUri)
{
return resourceMetadataUri.ToString();
}

var separator = resourceMetadataUri.OriginalString.StartsWith('/') ? "" : "/";
return $"{Request.Scheme}://{Request.Host.ToUriComponent()}{Request.PathBase}{separator}{resourceMetadataUri.OriginalString}";
}

return $"{Request.Scheme}://{Request.Host.ToUriComponent()}{Request.PathBase}{DefaultResourceMetadataPath}{Request.Path}";
}

private bool IsConfiguredEndpointRequest(Uri resourceMetadataUri)
{
var expectedPath = GetConfiguredResourceMetadataPath(resourceMetadataUri);

if (!string.Equals(Request.Path.Value, expectedPath, StringComparison.OrdinalIgnoreCase))
{
return false;
}

if (!resourceMetadataUri.IsAbsoluteUri)
{
return true;
}

string currentPath = resourceMetadataUri?.ToString() ?? string.Empty;
if (!string.Equals(Request.Host.Host, resourceMetadataUri.Host, StringComparison.OrdinalIgnoreCase))
{
LogResourceMetadataHostMismatch(Logger, resourceMetadataUri.Host);
return false;
}

if (resourceMetadataUri != null && resourceMetadataUri.IsAbsoluteUri)
if (!string.Equals(Request.Scheme, resourceMetadataUri.Scheme, StringComparison.OrdinalIgnoreCase))
{
return currentPath;
LogResourceMetadataSchemeMismatch(Logger, resourceMetadataUri.Scheme);
return false;
}

// For relative URIs, combine with the base URL
string baseUrl = GetBaseUrl();
string relativePath = resourceMetadataUri?.OriginalString.TrimStart('/') ?? string.Empty;
return true;
}

if (!Uri.TryCreate($"{baseUrl.TrimEnd('/')}/{relativePath}", UriKind.Absolute, out var absoluteUri))
private static string GetConfiguredResourceMetadataPath(Uri resourceMetadataUri)
{
if (resourceMetadataUri.IsAbsoluteUri)
{
throw new InvalidOperationException($"Could not create absolute URI for resource metadata. Base URL: {baseUrl}, Relative Path: {relativePath}");
return resourceMetadataUri.AbsolutePath;
}

return absoluteUri.ToString();
var path = resourceMetadataUri.OriginalString;
return path.StartsWith('/') ? path : $"/{path}";
}

private async Task<bool> HandleResourceMetadataRequestAsync()
private async Task<bool> HandleResourceMetadataRequestAsync(Uri? derivedResourceUri = null)
{
var resourceMetadata = Options.ResourceMetadata;
var resourceMetadata = CloneResourceMetadata(Options.ResourceMetadata, derivedResourceUri);

if (Options.Events.OnResourceMetadataRequest is not null)
{
var context = new ResourceMetadataRequestContext(Request.HttpContext, Scheme, Options)
{
ResourceMetadata = CloneResourceMetadata(resourceMetadata),
ResourceMetadata = resourceMetadata,
};

await Options.Events.OnResourceMetadataRequest(context);
Expand All @@ -109,11 +159,16 @@ private async Task<bool> HandleResourceMetadataRequestAsync()
resourceMetadata = context.ResourceMetadata;
}

if (resourceMetadata == null)
if (resourceMetadata is null)
{
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;

if (resourceMetadata.Resource is null)
{
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.Resource could not be determined. Please set McpAuthenticationOptions.ResourceMetadata.Resource or avoid setting a custom McpAuthenticationOptions.ResourceMetadataUri.");
}

await Results.Json(resourceMetadata, McpJsonUtilities.DefaultOptions.GetTypeInfo(typeof(ProtectedResourceMetadata))).ExecuteAsync(Context);
Expand Down Expand Up @@ -142,7 +197,7 @@ protected override Task HandleChallengeAsync(AuthenticationProperties properties
return base.HandleChallengeAsync(properties);
}

internal static ProtectedResourceMetadata? CloneResourceMetadata(ProtectedResourceMetadata? resourceMetadata)
internal static ProtectedResourceMetadata? CloneResourceMetadata(ProtectedResourceMetadata? resourceMetadata, Uri? derivedResourceUri = null)
{
if (resourceMetadata is null)
{
Expand All @@ -151,7 +206,7 @@ protected override Task HandleChallengeAsync(AuthenticationProperties properties

return new ProtectedResourceMetadata
{
Resource = resourceMetadata.Resource,
Resource = resourceMetadata.Resource ?? derivedResourceUri,
AuthorizationServers = [.. resourceMetadata.AuthorizationServers],
BearerMethodsSupported = [.. resourceMetadata.BearerMethodsSupported],
ScopesSupported = [.. resourceMetadata.ScopesSupported],
Expand All @@ -168,4 +223,9 @@ protected override Task HandleChallengeAsync(AuthenticationProperties properties
};
}

[LoggerMessage(Level = LogLevel.Warning, Message = "Resource metadata request host did not match configured host '{ConfiguredHost}'.")]
private static partial void LogResourceMetadataHostMismatch(ILogger logger, string configuredHost);

[LoggerMessage(Level = LogLevel.Warning, Message = "Resource metadata request scheme did not match configured scheme '{ConfiguredScheme}'.")]
private static partial void LogResourceMetadataSchemeMismatch(ILogger logger, string configuredScheme);
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,13 @@ namespace ModelContextProtocol.AspNetCore.Authentication;
/// </summary>
public class McpAuthenticationOptions : AuthenticationSchemeOptions
{
private static readonly Uri DefaultResourceMetadataUri = new("/.well-known/oauth-protected-resource", UriKind.Relative);

/// <summary>
/// Initializes a new instance of the <see cref="McpAuthenticationOptions"/> class.
/// </summary>
public McpAuthenticationOptions()
{
// "Bearer" is JwtBearerDefaults.AuthenticationScheme, but we don't have a reference to the JwtBearer package here.
ForwardAuthenticate = "Bearer";
ResourceMetadataUri = DefaultResourceMetadataUri;
Events = new McpAuthenticationEvents();
}

Expand All @@ -35,8 +32,10 @@ public McpAuthenticationOptions()
/// </summary>
/// <remarks>
/// This URI is included in the WWW-Authenticate header when a 401 response is returned.
/// When <see langword="null"/>, the handler automatically uses the default
/// <c>/.well-known/oauth-protected-resource/&lt;resource-path&gt;</c> endpoint that mirrors the requested resource path.
/// </remarks>
public Uri ResourceMetadataUri { get; set; }
public Uri? ResourceMetadataUri { get; set; }

/// <summary>
/// Gets or sets the protected resource metadata.
Expand Down
Loading