csharp-sdk/src/ModelContextProtocol.AspNetCore/Authentication/McpAuthenticationHandler.cs
Copilot dbf91bcb1a
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>
2026-02-12 22:28:43 -05:00

212 lines
7.9 KiB
C#

using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Microsoft.Net.Http.Headers;
using ModelContextProtocol.Authentication;
using System.Text.Encodings.Web;
namespace ModelContextProtocol.AspNetCore.Authentication;
/// <summary>
/// Represents an authentication handler for MCP protocol that adds resource metadata to challenge responses
/// and handles resource metadata endpoint requests.
/// </summary>
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>
public McpAuthenticationHandler(
IOptionsMonitor<McpAuthenticationOptions> options,
ILoggerFactory logger,
UrlEncoder encoder)
: base(options, logger, encoder)
{
}
/// <inheritdoc />
public Task<bool> HandleRequestAsync()
{
if (Options.ResourceMetadataUri is Uri configuredUri)
{
return HandleConfiguredResourceMetadataRequestAsync(configuredUri);
}
return HandleDefaultResourceMetadataRequestAsync();
}
private async Task<bool> HandleConfiguredResourceMetadataRequestAsync(Uri resourceMetadataUri)
{
if (!IsConfiguredEndpointRequest(resourceMetadataUri))
{
return false;
}
return await HandleResourceMetadataRequestAsync();
}
private async Task<bool> HandleDefaultResourceMetadataRequestAsync()
{
if (!Request.Path.StartsWithSegments(DefaultResourceMetadataPrefix, out var resourceSuffix))
{
return false;
}
// Build the derived resource string directly without trailing slash
var scheme = Request.Scheme;
var host = Request.Host.Host;
var port = Request.Host.Port;
var path = $"{Request.PathBase}{resourceSuffix}".TrimEnd('/');
string derivedResource;
if (port.HasValue && !IsDefaultPort(scheme, port.Value))
{
derivedResource = $"{scheme}://{host}:{port.Value}{path}";
}
else
{
derivedResource = $"{scheme}://{host}{path}";
}
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>
/// Gets the absolute URI for the resource metadata endpoint.
/// </summary>
private string GetAbsoluteResourceMetadataUri()
{
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;
}
if (!string.Equals(Request.Host.Host, resourceMetadataUri.Host, StringComparison.OrdinalIgnoreCase))
{
LogResourceMetadataHostMismatch(Logger, resourceMetadataUri.Host);
return false;
}
if (!string.Equals(Request.Scheme, resourceMetadataUri.Scheme, StringComparison.OrdinalIgnoreCase))
{
LogResourceMetadataSchemeMismatch(Logger, resourceMetadataUri.Scheme);
return false;
}
return true;
}
private static string GetConfiguredResourceMetadataPath(Uri resourceMetadataUri)
{
if (resourceMetadataUri.IsAbsoluteUri)
{
return resourceMetadataUri.AbsolutePath;
}
var path = resourceMetadataUri.OriginalString;
return path.StartsWith('/') ? path : $"/{path}";
}
private async Task<bool> HandleResourceMetadataRequestAsync(string? derivedResource = null)
{
var resourceMetadata = Options.ResourceMetadata?.Clone(derivedResource);
if (Options.Events.OnResourceMetadataRequest is not null)
{
var context = new ResourceMetadataRequestContext(Request.HttpContext, Scheme, Options)
{
ResourceMetadata = resourceMetadata,
};
await Options.Events.OnResourceMetadataRequest(context);
if (context.Result is not null)
{
if (context.Result.Handled)
{
return true;
}
else if (context.Result.Skipped)
{
return false;
}
else if (context.Result.Failure is not null)
{
throw new AuthenticationFailureException("An error occurred from the OnResourceMetadataRequest event.", context.Result.Failure);
}
}
resourceMetadata = context.ResourceMetadata;
}
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 ??= derivedResource;
if (resourceMetadata.Resource is null)
{
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);
return true;
}
/// <inheritdoc />
// If no forwarding is configured, this handler doesn't perform authentication
protected override async Task<AuthenticateResult> HandleAuthenticateAsync() => AuthenticateResult.NoResult();
/// <inheritdoc />
protected override Task HandleChallengeAsync(AuthenticationProperties properties)
{
// Get the absolute URI for the resource metadata
string rawPrmDocumentUri = GetAbsoluteResourceMetadataUri();
// Add the WWW-Authenticate header with Bearer scheme and resource metadata
string headerValue = $"Bearer resource_metadata=\"{rawPrmDocumentUri}\"";
Response.Headers.Append(HeaderNames.WWWAuthenticate, headerValue);
return base.HandleChallengeAsync(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);
}