Authorization Support (Using ASP.NET Core Native AuthN/AuthZ Integration) (#377)
This commit is contained in:
parent
cd8e5d5f7f
commit
cdf88e7f5d
22 changed files with 4207 additions and 126 deletions
|
|
@ -0,0 +1,17 @@
|
|||
namespace ModelContextProtocol.AspNetCore.Authentication;
|
||||
|
||||
/// <summary>
|
||||
/// Default values used by MCP authentication.
|
||||
/// </summary>
|
||||
public static class McpAuthenticationDefaults
|
||||
{
|
||||
/// <summary>
|
||||
/// The default value used for authentication scheme name.
|
||||
/// </summary>
|
||||
public const string AuthenticationScheme = "McpAuth";
|
||||
|
||||
/// <summary>
|
||||
/// The default value used for authentication scheme display name.
|
||||
/// </summary>
|
||||
public const string DisplayName = "MCP Authentication";
|
||||
}
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
namespace ModelContextProtocol.AspNetCore.Authentication;
|
||||
|
||||
/// <summary>
|
||||
/// Represents the authentication events for Model Context Protocol.
|
||||
/// </summary>
|
||||
public class McpAuthenticationEvents
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the function that is invoked when resource metadata is requested.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This function is called when a resource metadata request is made to the protected resource metadata endpoint.
|
||||
/// The implementer should set the <see cref="ResourceMetadataRequestContext.ResourceMetadata"/> property
|
||||
/// to provide the appropriate metadata for the current request.
|
||||
/// </remarks>
|
||||
public Func<ResourceMetadataRequestContext, Task> OnResourceMetadataRequest { get; set; } = context => Task.CompletedTask;
|
||||
}
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
using Microsoft.AspNetCore.Authentication;
|
||||
using ModelContextProtocol.AspNetCore.Authentication;
|
||||
|
||||
namespace Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for adding MCP authorization support to ASP.NET Core applications.
|
||||
/// </summary>
|
||||
public static class McpAuthenticationExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds MCP authorization support to the application.
|
||||
/// </summary>
|
||||
/// <param name="builder">The authentication builder.</param>
|
||||
/// <param name="configureOptions">An action to configure MCP authentication options.</param>
|
||||
/// <returns>The authentication builder for chaining.</returns>
|
||||
public static AuthenticationBuilder AddMcp(
|
||||
this AuthenticationBuilder builder,
|
||||
Action<McpAuthenticationOptions>? configureOptions = null)
|
||||
{
|
||||
return AddMcp(
|
||||
builder,
|
||||
McpAuthenticationDefaults.AuthenticationScheme,
|
||||
McpAuthenticationDefaults.DisplayName,
|
||||
configureOptions);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds MCP authorization support to the application with a custom scheme name.
|
||||
/// </summary>
|
||||
/// <param name="builder">The authentication builder.</param>
|
||||
/// <param name="authenticationScheme">The authentication scheme name to use.</param>
|
||||
/// <param name="displayName">The display name for the authentication scheme.</param>
|
||||
/// <param name="configureOptions">An action to configure MCP authentication options.</param>
|
||||
/// <returns>The authentication builder for chaining.</returns>
|
||||
public static AuthenticationBuilder AddMcp(
|
||||
this AuthenticationBuilder builder,
|
||||
string authenticationScheme,
|
||||
string displayName,
|
||||
Action<McpAuthenticationOptions>? configureOptions = null)
|
||||
{
|
||||
return builder.AddScheme<McpAuthenticationOptions, McpAuthenticationHandler>(
|
||||
authenticationScheme,
|
||||
displayName,
|
||||
configureOptions);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,157 @@
|
|||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ModelContextProtocol.Authentication;
|
||||
using System.Text.Encodings.Web;
|
||||
|
||||
namespace ModelContextProtocol.AspNetCore.Authentication;
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
{
|
||||
/// <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 async 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)
|
||||
{
|
||||
// For relative URIs, it's just the path component.
|
||||
expectedMetadataPath = Options.ResourceMetadataUri.OriginalString;
|
||||
}
|
||||
|
||||
// If the path doesn't match, let the request continue through the pipeline
|
||||
if (!string.Equals(requestPath, expectedMetadataPath, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var cancellationToken = Request.HttpContext.RequestAborted;
|
||||
await HandleResourceMetadataRequestAsync(cancellationToken);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <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}";
|
||||
|
||||
/// <summary>
|
||||
/// Gets the absolute URI for the resource metadata endpoint.
|
||||
/// </summary>
|
||||
private string GetAbsoluteResourceMetadataUri()
|
||||
{
|
||||
var resourceMetadataUri = Options.ResourceMetadataUri;
|
||||
|
||||
string currentPath = resourceMetadataUri?.ToString() ?? string.Empty;
|
||||
|
||||
if (resourceMetadataUri != null && resourceMetadataUri.IsAbsoluteUri)
|
||||
{
|
||||
return currentPath;
|
||||
}
|
||||
|
||||
// For relative URIs, combine with the base URL
|
||||
string baseUrl = GetBaseUrl();
|
||||
string relativePath = resourceMetadataUri?.OriginalString.TrimStart('/') ?? string.Empty;
|
||||
|
||||
if (!Uri.TryCreate($"{baseUrl.TrimEnd('/')}/{relativePath}", UriKind.Absolute, out var absoluteUri))
|
||||
{
|
||||
throw new InvalidOperationException($"Could not create absolute URI for resource metadata. Base URL: {baseUrl}, Relative Path: {relativePath}");
|
||||
}
|
||||
|
||||
return absoluteUri.ToString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles the resource metadata request.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">A token to cancel the operation.</param>
|
||||
private async Task HandleResourceMetadataRequestAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var resourceMetadata = Options.ResourceMetadata;
|
||||
|
||||
if (Options.Events.OnResourceMetadataRequest is not null)
|
||||
{
|
||||
var context = new ResourceMetadataRequestContext(Request.HttpContext, Scheme, Options)
|
||||
{
|
||||
ResourceMetadata = CloneResourceMetadata(resourceMetadata),
|
||||
};
|
||||
|
||||
await Options.Events.OnResourceMetadataRequest(context);
|
||||
}
|
||||
|
||||
|
||||
if (resourceMetadata == null)
|
||||
{
|
||||
throw new InvalidOperationException("ResourceMetadata has not been configured. Please set McpAuthenticationOptions.ResourceMetadata.");
|
||||
}
|
||||
|
||||
await Results.Json(resourceMetadata, McpJsonUtilities.DefaultOptions.GetTypeInfo(typeof(ProtectedResourceMetadata))).ExecuteAsync(Context);
|
||||
}
|
||||
|
||||
/// <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();
|
||||
|
||||
properties ??= new AuthenticationProperties();
|
||||
|
||||
// Store the resource_metadata in properties in case other handlers need it
|
||||
properties.Items["resource_metadata"] = rawPrmDocumentUri;
|
||||
|
||||
// Add the WWW-Authenticate header with Bearer scheme and resource metadata
|
||||
string headerValue = $"Bearer realm=\"{Scheme.Name}\", resource_metadata=\"{rawPrmDocumentUri}\"";
|
||||
Response.Headers.Append("WWW-Authenticate", headerValue);
|
||||
|
||||
return base.HandleChallengeAsync(properties);
|
||||
}
|
||||
|
||||
internal static ProtectedResourceMetadata? CloneResourceMetadata(ProtectedResourceMetadata? resourceMetadata)
|
||||
{
|
||||
if (resourceMetadata is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new ProtectedResourceMetadata
|
||||
{
|
||||
Resource = resourceMetadata.Resource,
|
||||
AuthorizationServers = [.. resourceMetadata.AuthorizationServers],
|
||||
BearerMethodsSupported = [.. resourceMetadata.BearerMethodsSupported],
|
||||
ScopesSupported = [.. resourceMetadata.ScopesSupported],
|
||||
JwksUri = resourceMetadata.JwksUri,
|
||||
ResourceSigningAlgValuesSupported = resourceMetadata.ResourceSigningAlgValuesSupported is not null ? [.. resourceMetadata.ResourceSigningAlgValuesSupported] : null,
|
||||
ResourceName = resourceMetadata.ResourceName,
|
||||
ResourceDocumentation = resourceMetadata.ResourceDocumentation,
|
||||
ResourcePolicyUri = resourceMetadata.ResourcePolicyUri,
|
||||
ResourceTosUri = resourceMetadata.ResourceTosUri,
|
||||
TlsClientCertificateBoundAccessTokens = resourceMetadata.TlsClientCertificateBoundAccessTokens,
|
||||
AuthorizationDetailsTypesSupported = resourceMetadata.AuthorizationDetailsTypesSupported is not null ? [.. resourceMetadata.AuthorizationDetailsTypesSupported] : null,
|
||||
DpopSigningAlgValuesSupported = resourceMetadata.DpopSigningAlgValuesSupported is not null ? [.. resourceMetadata.DpopSigningAlgValuesSupported] : null,
|
||||
DpopBoundAccessTokensRequired = resourceMetadata.DpopBoundAccessTokensRequired
|
||||
};
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
using Microsoft.AspNetCore.Authentication;
|
||||
using ModelContextProtocol.Authentication;
|
||||
|
||||
namespace ModelContextProtocol.AspNetCore.Authentication;
|
||||
|
||||
/// <summary>
|
||||
/// Options for the MCP authentication handler.
|
||||
/// </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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the events used to handle authentication events.
|
||||
/// </summary>
|
||||
public new McpAuthenticationEvents Events
|
||||
{
|
||||
get { return (McpAuthenticationEvents)base.Events!; }
|
||||
set { base.Events = value; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The URI to the resource metadata document.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This URI will be included in the WWW-Authenticate header when a 401 response is returned.
|
||||
/// </remarks>
|
||||
public Uri ResourceMetadataUri { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the protected resource metadata.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This contains the OAuth metadata for the protected resource, including authorization servers,
|
||||
/// supported scopes, and other information needed for clients to authenticate.
|
||||
/// </remarks>
|
||||
public ProtectedResourceMetadata? ResourceMetadata { get; set; }
|
||||
}
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using ModelContextProtocol.Authentication;
|
||||
|
||||
namespace ModelContextProtocol.AspNetCore.Authentication;
|
||||
|
||||
/// <summary>
|
||||
/// Context for resource metadata request events.
|
||||
/// </summary>
|
||||
public class ResourceMetadataRequestContext : HandleRequestContext<McpAuthenticationOptions>
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ResourceMetadataRequestContext"/> class.
|
||||
/// </summary>
|
||||
/// <param name="context">The HTTP context.</param>
|
||||
/// <param name="scheme">The authentication scheme.</param>
|
||||
/// <param name="options">The authentication options.</param>
|
||||
public ResourceMetadataRequestContext(
|
||||
HttpContext context,
|
||||
AuthenticationScheme scheme,
|
||||
McpAuthenticationOptions options)
|
||||
: base(context, scheme, options)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the protected resource metadata for the current request.
|
||||
/// </summary>
|
||||
public ProtectedResourceMetadata? ResourceMetadata { get; set; }
|
||||
}
|
||||
|
|
@ -0,0 +1,118 @@
|
|||
using ModelContextProtocol.Client;
|
||||
using ModelContextProtocol.Protocol;
|
||||
using System.Net.Http.Headers;
|
||||
|
||||
namespace ModelContextProtocol.Authentication;
|
||||
|
||||
/// <summary>
|
||||
/// A delegating handler that adds authentication tokens to requests and handles 401 responses.
|
||||
/// </summary>
|
||||
internal sealed class AuthenticatingMcpHttpClient(HttpClient httpClient, ClientOAuthProvider credentialProvider) : McpHttpClient(httpClient)
|
||||
{
|
||||
// Select first supported scheme as the default
|
||||
private string _currentScheme = credentialProvider.SupportedSchemes.FirstOrDefault() ??
|
||||
throw new ArgumentException("Authorization provider must support at least one authentication scheme.", nameof(credentialProvider));
|
||||
|
||||
/// <summary>
|
||||
/// Sends an HTTP request with authentication handling.
|
||||
/// </summary>
|
||||
internal override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, JsonRpcMessage? message, CancellationToken cancellationToken)
|
||||
{
|
||||
if (request.Headers.Authorization == null)
|
||||
{
|
||||
await AddAuthorizationHeaderAsync(request, _currentScheme, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
var response = await base.SendAsync(request, message, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized)
|
||||
{
|
||||
return await HandleUnauthorizedResponseAsync(request, message, response, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles a 401 Unauthorized response by attempting to authenticate and retry the request.
|
||||
/// </summary>
|
||||
private async Task<HttpResponseMessage> HandleUnauthorizedResponseAsync(
|
||||
HttpRequestMessage originalRequest,
|
||||
JsonRpcMessage? originalJsonRpcMessage,
|
||||
HttpResponseMessage response,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// Gather the schemes the server wants us to use from WWW-Authenticate headers
|
||||
var serverSchemes = ExtractServerSupportedSchemes(response);
|
||||
|
||||
if (!serverSchemes.Contains(_currentScheme))
|
||||
{
|
||||
// Find the first server scheme that's in our supported set
|
||||
var bestSchemeMatch = serverSchemes.Intersect(credentialProvider.SupportedSchemes, StringComparer.OrdinalIgnoreCase).FirstOrDefault();
|
||||
|
||||
if (bestSchemeMatch is not null)
|
||||
{
|
||||
_currentScheme = bestSchemeMatch;
|
||||
}
|
||||
else if (serverSchemes.Count > 0)
|
||||
{
|
||||
// If no match was found, either throw an exception or use default
|
||||
throw new McpException(
|
||||
$"The server does not support any of the provided authentication schemes." +
|
||||
$"Server supports: [{string.Join(", ", serverSchemes)}], " +
|
||||
$"Provider supports: [{string.Join(", ", credentialProvider.SupportedSchemes)}].");
|
||||
}
|
||||
}
|
||||
|
||||
// Try to handle the 401 response with the selected scheme
|
||||
await credentialProvider.HandleUnauthorizedResponseAsync(_currentScheme, response, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
using var retryRequest = new HttpRequestMessage(originalRequest.Method, originalRequest.RequestUri);
|
||||
|
||||
// Copy headers except Authorization which we'll set separately
|
||||
foreach (var header in originalRequest.Headers)
|
||||
{
|
||||
if (!header.Key.Equals("Authorization", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
retryRequest.Headers.TryAddWithoutValidation(header.Key, header.Value);
|
||||
}
|
||||
}
|
||||
|
||||
await AddAuthorizationHeaderAsync(retryRequest, _currentScheme, cancellationToken).ConfigureAwait(false);
|
||||
return await base.SendAsync(retryRequest, originalJsonRpcMessage, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts the authentication schemes that the server supports from the WWW-Authenticate headers.
|
||||
/// </summary>
|
||||
private static HashSet<string> ExtractServerSupportedSchemes(HttpResponseMessage response)
|
||||
{
|
||||
var serverSchemes = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var header in response.Headers.WwwAuthenticate)
|
||||
{
|
||||
serverSchemes.Add(header.Scheme);
|
||||
}
|
||||
|
||||
return serverSchemes;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds an authorization header to the request.
|
||||
/// </summary>
|
||||
private async Task AddAuthorizationHeaderAsync(HttpRequestMessage request, string scheme, CancellationToken cancellationToken)
|
||||
{
|
||||
if (request.RequestUri is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var token = await credentialProvider.GetCredentialAsync(scheme, request.RequestUri, cancellationToken).ConfigureAwait(false);
|
||||
if (string.IsNullOrEmpty(token))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue(scheme, token);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
|
||||
namespace ModelContextProtocol.Authentication;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a method that handles the OAuth authorization URL and returns the authorization code.
|
||||
/// </summary>
|
||||
/// <param name="authorizationUri">The authorization URL that the user needs to visit.</param>
|
||||
/// <param name="redirectUri">The redirect URI where the authorization code will be sent.</param>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>A task that represents the asynchronous operation. The task result contains the authorization code if successful, or null if the operation failed or was cancelled.</returns>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// This delegate provides SDK consumers with full control over how the OAuth authorization flow is handled.
|
||||
/// Implementers can choose to:
|
||||
/// </para>
|
||||
/// <list type="bullet">
|
||||
/// <item><description>Start a local HTTP server and open a browser (default behavior)</description></item>
|
||||
/// <item><description>Display the authorization URL to the user for manual handling</description></item>
|
||||
/// <item><description>Integrate with a custom UI or authentication flow</description></item>
|
||||
/// <item><description>Use a different redirect mechanism altogether</description></item>
|
||||
/// </list>
|
||||
/// <para>
|
||||
/// The implementation should handle user interaction to visit the authorization URL and extract
|
||||
/// the authorization code from the callback. The authorization code is typically provided as
|
||||
/// a query parameter in the redirect URI callback.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public delegate Task<string?> AuthorizationRedirectDelegate(Uri authorizationUri, Uri redirectUri, CancellationToken cancellationToken);
|
||||
|
|
@ -0,0 +1,69 @@
|
|||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace ModelContextProtocol.Authentication;
|
||||
|
||||
/// <summary>
|
||||
/// Represents the metadata about an OAuth authorization server.
|
||||
/// </summary>
|
||||
internal sealed class AuthorizationServerMetadata
|
||||
{
|
||||
/// <summary>
|
||||
/// The authorization endpoint URI.
|
||||
/// </summary>
|
||||
[JsonPropertyName("authorization_endpoint")]
|
||||
public Uri AuthorizationEndpoint { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// The token endpoint URI.
|
||||
/// </summary>
|
||||
[JsonPropertyName("token_endpoint")]
|
||||
public Uri TokenEndpoint { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// The registration endpoint URI.
|
||||
/// </summary>
|
||||
[JsonPropertyName("registration_endpoint")]
|
||||
public Uri? RegistrationEndpoint { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The revocation endpoint URI.
|
||||
/// </summary>
|
||||
[JsonPropertyName("revocation_endpoint")]
|
||||
public Uri? RevocationEndpoint { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The response types supported by the authorization server.
|
||||
/// </summary>
|
||||
[JsonPropertyName("response_types_supported")]
|
||||
public List<string>? ResponseTypesSupported { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The grant types supported by the authorization server.
|
||||
/// </summary>
|
||||
[JsonPropertyName("grant_types_supported")]
|
||||
public List<string>? GrantTypesSupported { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The token endpoint authentication methods supported by the authorization server.
|
||||
/// </summary>
|
||||
[JsonPropertyName("token_endpoint_auth_methods_supported")]
|
||||
public List<string>? TokenEndpointAuthMethodsSupported { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The code challenge methods supported by the authorization server.
|
||||
/// </summary>
|
||||
[JsonPropertyName("code_challenge_methods_supported")]
|
||||
public List<string>? CodeChallengeMethodsSupported { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The issuer URI of the authorization server.
|
||||
/// </summary>
|
||||
[JsonPropertyName("issuer")]
|
||||
public Uri? Issuer { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The scopes supported by the authorization server.
|
||||
/// </summary>
|
||||
[JsonPropertyName("scopes_supported")]
|
||||
public List<string>? ScopesSupported { get; set; }
|
||||
}
|
||||
|
|
@ -0,0 +1,99 @@
|
|||
namespace ModelContextProtocol.Authentication;
|
||||
|
||||
/// <summary>
|
||||
/// Provides configuration options for the <see cref="ClientOAuthProvider"/>.
|
||||
/// </summary>
|
||||
public sealed class ClientOAuthOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the OAuth redirect URI.
|
||||
/// </summary>
|
||||
public required Uri RedirectUri { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the OAuth client ID. If not provided, the client will attempt to register dynamically.
|
||||
/// </summary>
|
||||
public string? ClientId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the OAuth client secret.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This is optional for public clients or when using PKCE without client authentication.
|
||||
/// </remarks>
|
||||
public string? ClientSecret { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the OAuth scopes to request.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// When specified, these scopes will be used instead of the scopes advertised by the protected resource.
|
||||
/// If not specified, the provider will use the scopes from the protected resource metadata.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Common OAuth scopes include "openid", "profile", "email", etc.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public IEnumerable<string>? Scopes { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the authorization redirect delegate for handling the OAuth authorization flow.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// This delegate is responsible for handling the OAuth authorization URL and obtaining the authorization code.
|
||||
/// If not specified, a default implementation will be used that prompts the user to enter the code manually.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Custom implementations might open a browser, start an HTTP listener, or use other mechanisms to capture
|
||||
/// the authorization code from the OAuth redirect.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public AuthorizationRedirectDelegate? AuthorizationRedirectDelegate { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the authorization server selector function.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// This function is used to select which authorization server to use when multiple servers are available.
|
||||
/// If not specified, the first available server will be selected.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The function receives a list of available authorization server URIs and should return the selected server,
|
||||
/// or null if no suitable server is found.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public Func<IReadOnlyList<Uri>, Uri?>? AuthServerSelector { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the client name to use during dynamic client registration.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This is a human-readable name for the client that may be displayed to users during authorization.
|
||||
/// Only used when a <see cref="ClientId"/> is not specified.
|
||||
/// </remarks>
|
||||
public string? ClientName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the client URI to use during dynamic client registration.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This should be a URL pointing to the client's home page or information page.
|
||||
/// Only used when a <see cref="ClientId"/> is not specified.
|
||||
/// </remarks>
|
||||
public Uri? ClientUri { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets additional parameters to include in the query string of the OAuth authorization request
|
||||
/// providing extra information or fulfilling specific requirements of the OAuth provider.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Parameters specified cannot override or append to any automatically set parameters like the "redirect_uri"
|
||||
/// which should instead be configured via <see cref="RedirectUri"/>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public IDictionary<string, string> AdditionalAuthorizationParameters { get; set; } = new Dictionary<string, string>();
|
||||
}
|
||||
|
|
@ -0,0 +1,687 @@
|
|||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using System.Collections.Specialized;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Web;
|
||||
|
||||
namespace ModelContextProtocol.Authentication;
|
||||
|
||||
/// <summary>
|
||||
/// A generic implementation of an OAuth authorization provider for MCP. This does not do any advanced token
|
||||
/// protection or caching - it acquires a token and server metadata and holds it in memory.
|
||||
/// This is suitable for demonstration and development purposes.
|
||||
/// </summary>
|
||||
internal sealed partial class ClientOAuthProvider
|
||||
{
|
||||
/// <summary>
|
||||
/// The Bearer authentication scheme.
|
||||
/// </summary>
|
||||
private const string BearerScheme = "Bearer";
|
||||
|
||||
private readonly Uri _serverUrl;
|
||||
private readonly Uri _redirectUri;
|
||||
private readonly string[]? _scopes;
|
||||
private readonly IDictionary<string, string> _additionalAuthorizationParameters;
|
||||
private readonly Func<IReadOnlyList<Uri>, Uri?> _authServerSelector;
|
||||
private readonly AuthorizationRedirectDelegate _authorizationRedirectDelegate;
|
||||
|
||||
// _clientName and _client URI is used for dynamic client registration (RFC 7591)
|
||||
private readonly string? _clientName;
|
||||
private readonly Uri? _clientUri;
|
||||
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly ILogger _logger;
|
||||
|
||||
private string? _clientId;
|
||||
private string? _clientSecret;
|
||||
|
||||
private TokenContainer? _token;
|
||||
private AuthorizationServerMetadata? _authServerMetadata;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ClientOAuthProvider"/> class using the specified options.
|
||||
/// </summary>
|
||||
/// <param name="serverUrl">The MCP server URL.</param>
|
||||
/// <param name="options">The OAuth provider configuration options.</param>
|
||||
/// <param name="httpClient">The HTTP client to use for OAuth requests. If null, a default HttpClient will be used.</param>
|
||||
/// <param name="loggerFactory">A logger factory to handle diagnostic messages.</param>
|
||||
/// <exception cref="ArgumentNullException">Thrown when serverUrl or options are null.</exception>
|
||||
public ClientOAuthProvider(
|
||||
Uri serverUrl,
|
||||
ClientOAuthOptions options,
|
||||
HttpClient? httpClient = null,
|
||||
ILoggerFactory? loggerFactory = null)
|
||||
{
|
||||
_serverUrl = serverUrl ?? throw new ArgumentNullException(nameof(serverUrl));
|
||||
_httpClient = httpClient ?? new HttpClient();
|
||||
_logger = (ILogger?)loggerFactory?.CreateLogger<ClientOAuthProvider>() ?? NullLogger.Instance;
|
||||
|
||||
if (options is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(options));
|
||||
}
|
||||
|
||||
_clientId = options.ClientId;
|
||||
_clientSecret = options.ClientSecret;
|
||||
_redirectUri = options.RedirectUri ?? throw new ArgumentException("ClientOAuthOptions.RedirectUri must configured.");
|
||||
_clientName = options.ClientName;
|
||||
_clientUri = options.ClientUri;
|
||||
_scopes = options.Scopes?.ToArray();
|
||||
_additionalAuthorizationParameters = options.AdditionalAuthorizationParameters;
|
||||
|
||||
// Set up authorization server selection strategy
|
||||
_authServerSelector = options.AuthServerSelector ?? DefaultAuthServerSelector;
|
||||
|
||||
// Set up authorization URL handler (use default if not provided)
|
||||
_authorizationRedirectDelegate = options.AuthorizationRedirectDelegate ?? DefaultAuthorizationUrlHandler;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Default authorization server selection strategy that selects the first available server.
|
||||
/// </summary>
|
||||
/// <param name="availableServers">List of available authorization servers.</param>
|
||||
/// <returns>The selected authorization server, or null if none are available.</returns>
|
||||
private static Uri? DefaultAuthServerSelector(IReadOnlyList<Uri> availableServers) => availableServers.FirstOrDefault();
|
||||
|
||||
/// <summary>
|
||||
/// Default authorization URL handler that displays the URL to the user for manual input.
|
||||
/// </summary>
|
||||
/// <param name="authorizationUrl">The authorization URL to handle.</param>
|
||||
/// <param name="redirectUri">The redirect URI where the authorization code will be sent.</param>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>The authorization code entered by the user, or null if none was provided.</returns>
|
||||
private static Task<string?> DefaultAuthorizationUrlHandler(Uri authorizationUrl, Uri redirectUri, CancellationToken cancellationToken)
|
||||
{
|
||||
Console.WriteLine($"Please open the following URL in your browser to authorize the application:");
|
||||
Console.WriteLine($"{authorizationUrl}");
|
||||
Console.WriteLine();
|
||||
Console.Write("Enter the authorization code from the redirect URL: ");
|
||||
var authorizationCode = Console.ReadLine();
|
||||
return Task.FromResult<string?>(authorizationCode);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the collection of authentication schemes supported by this provider.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// This property returns all authentication schemes that this provider can handle,
|
||||
/// allowing clients to select the appropriate scheme based on server capabilities.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Common values include "Bearer" for JWT tokens, "Basic" for username/password authentication,
|
||||
/// and "Negotiate" for integrated Windows authentication.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public IEnumerable<string> SupportedSchemes => [BearerScheme];
|
||||
|
||||
/// <summary>
|
||||
/// Gets an authentication token or credential for authenticating requests to a resource
|
||||
/// using the specified authentication scheme.
|
||||
/// </summary>
|
||||
/// <param name="scheme">The authentication scheme to use.</param>
|
||||
/// <param name="resourceUri">The URI of the resource requiring authentication.</param>
|
||||
/// <param name="cancellationToken">A token to cancel the operation.</param>
|
||||
/// <returns>An authentication token string or null if no token could be obtained for the specified scheme.</returns>
|
||||
public async Task<string?> GetCredentialAsync(string scheme, Uri resourceUri, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ThrowIfNotBearerScheme(scheme);
|
||||
|
||||
// Return the token if it's valid
|
||||
if (_token != null && _token.ExpiresAt > DateTimeOffset.UtcNow.AddMinutes(5))
|
||||
{
|
||||
return _token.AccessToken;
|
||||
}
|
||||
|
||||
// Try to refresh the token if we have a refresh token
|
||||
if (_token?.RefreshToken != null && _authServerMetadata != null)
|
||||
{
|
||||
var newToken = await RefreshTokenAsync(_token.RefreshToken, resourceUri, _authServerMetadata, cancellationToken).ConfigureAwait(false);
|
||||
if (newToken != null)
|
||||
{
|
||||
_token = newToken;
|
||||
return _token.AccessToken;
|
||||
}
|
||||
}
|
||||
|
||||
// No valid token - auth handler will trigger the 401 flow
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles a 401 Unauthorized response from a resource.
|
||||
/// </summary>
|
||||
/// <param name="scheme">The authentication scheme that was used when the unauthorized response was received.</param>
|
||||
/// <param name="response">The HTTP response that contained the 401 status code.</param>
|
||||
/// <param name="cancellationToken">A token to cancel the operation.</param>
|
||||
/// <returns>
|
||||
/// A result object indicating if the provider was able to handle the unauthorized response,
|
||||
/// and the authentication scheme that should be used for the next attempt, if any.
|
||||
/// </returns>
|
||||
public async Task HandleUnauthorizedResponseAsync(
|
||||
string scheme,
|
||||
HttpResponseMessage response,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// This provider only supports Bearer scheme
|
||||
if (!string.Equals(scheme, BearerScheme, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new InvalidOperationException("This credential provider only supports the Bearer scheme");
|
||||
}
|
||||
|
||||
await PerformOAuthAuthorizationAsync(response, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Performs OAuth authorization by selecting an appropriate authorization server and completing the OAuth flow.
|
||||
/// </summary>
|
||||
/// <param name="response">The 401 Unauthorized response containing authentication challenge.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Result indicating whether authorization was successful.</returns>
|
||||
private async Task PerformOAuthAuthorizationAsync(
|
||||
HttpResponseMessage response,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// Get available authorization servers from the 401 response
|
||||
var protectedResourceMetadata = await ExtractProtectedResourceMetadata(response, _serverUrl, cancellationToken).ConfigureAwait(false);
|
||||
var availableAuthorizationServers = protectedResourceMetadata.AuthorizationServers;
|
||||
|
||||
if (availableAuthorizationServers.Count == 0)
|
||||
{
|
||||
ThrowFailedToHandleUnauthorizedResponse("No authorization servers found in authentication challenge");
|
||||
}
|
||||
|
||||
// Select authorization server using configured strategy
|
||||
var selectedAuthServer = _authServerSelector(availableAuthorizationServers);
|
||||
|
||||
if (selectedAuthServer is null)
|
||||
{
|
||||
ThrowFailedToHandleUnauthorizedResponse($"Authorization server selection returned null. Available servers: {string.Join(", ", availableAuthorizationServers)}");
|
||||
}
|
||||
|
||||
if (!availableAuthorizationServers.Contains(selectedAuthServer))
|
||||
{
|
||||
ThrowFailedToHandleUnauthorizedResponse($"Authorization server selector returned a server not in the available list: {selectedAuthServer}. Available servers: {string.Join(", ", availableAuthorizationServers)}");
|
||||
}
|
||||
|
||||
LogSelectedAuthorizationServer(selectedAuthServer, availableAuthorizationServers.Count);
|
||||
|
||||
// Get auth server metadata
|
||||
var authServerMetadata = await GetAuthServerMetadataAsync(selectedAuthServer, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (authServerMetadata is null)
|
||||
{
|
||||
ThrowFailedToHandleUnauthorizedResponse($"Failed to retrieve metadata for authorization server: '{selectedAuthServer}'");
|
||||
}
|
||||
|
||||
// Store auth server metadata for future refresh operations
|
||||
_authServerMetadata = authServerMetadata;
|
||||
|
||||
// Perform dynamic client registration if needed
|
||||
if (string.IsNullOrEmpty(_clientId))
|
||||
{
|
||||
await PerformDynamicClientRegistrationAsync(authServerMetadata, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// Perform the OAuth flow
|
||||
var token = await InitiateAuthorizationCodeFlowAsync(protectedResourceMetadata, authServerMetadata, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (token is null)
|
||||
{
|
||||
ThrowFailedToHandleUnauthorizedResponse($"The {nameof(AuthorizationRedirectDelegate)} returned a null or empty token.");
|
||||
}
|
||||
|
||||
_token = token;
|
||||
LogOAuthAuthorizationCompleted();
|
||||
}
|
||||
|
||||
private async Task<AuthorizationServerMetadata?> GetAuthServerMetadataAsync(Uri authServerUri, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!authServerUri.OriginalString.EndsWith("/"))
|
||||
{
|
||||
authServerUri = new Uri(authServerUri.OriginalString + "/");
|
||||
}
|
||||
|
||||
foreach (var path in new[] { ".well-known/openid-configuration", ".well-known/oauth-authorization-server" })
|
||||
{
|
||||
try
|
||||
{
|
||||
var response = await _httpClient.GetAsync(new Uri(authServerUri, path), cancellationToken).ConfigureAwait(false);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
|
||||
var metadata = await JsonSerializer.DeserializeAsync(stream, McpJsonUtilities.JsonContext.Default.AuthorizationServerMetadata, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (metadata != null)
|
||||
{
|
||||
metadata.ResponseTypesSupported ??= ["code"];
|
||||
metadata.GrantTypesSupported ??= ["authorization_code", "refresh_token"];
|
||||
metadata.TokenEndpointAuthMethodsSupported ??= ["client_secret_post"];
|
||||
metadata.CodeChallengeMethodsSupported ??= ["S256"];
|
||||
|
||||
return metadata;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogErrorFetchingAuthServerMetadata(ex, path);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private async Task<TokenContainer> RefreshTokenAsync(string refreshToken, Uri resourceUri, AuthorizationServerMetadata authServerMetadata, CancellationToken cancellationToken)
|
||||
{
|
||||
var requestContent = new FormUrlEncodedContent(new Dictionary<string, string>
|
||||
{
|
||||
["grant_type"] = "refresh_token",
|
||||
["refresh_token"] = refreshToken,
|
||||
["client_id"] = GetClientIdOrThrow(),
|
||||
["client_secret"] = _clientSecret ?? string.Empty,
|
||||
["resource"] = resourceUri.ToString(),
|
||||
});
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, authServerMetadata.TokenEndpoint)
|
||||
{
|
||||
Content = requestContent
|
||||
};
|
||||
|
||||
return await FetchTokenAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task<TokenContainer?> InitiateAuthorizationCodeFlowAsync(
|
||||
ProtectedResourceMetadata protectedResourceMetadata,
|
||||
AuthorizationServerMetadata authServerMetadata,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var codeVerifier = GenerateCodeVerifier();
|
||||
var codeChallenge = GenerateCodeChallenge(codeVerifier);
|
||||
|
||||
var authUrl = BuildAuthorizationUrl(protectedResourceMetadata, authServerMetadata, codeChallenge);
|
||||
var authCode = await _authorizationRedirectDelegate(authUrl, _redirectUri, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (string.IsNullOrEmpty(authCode))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return await ExchangeCodeForTokenAsync(protectedResourceMetadata, authServerMetadata, authCode!, codeVerifier, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private Uri BuildAuthorizationUrl(
|
||||
ProtectedResourceMetadata protectedResourceMetadata,
|
||||
AuthorizationServerMetadata authServerMetadata,
|
||||
string codeChallenge)
|
||||
{
|
||||
if (authServerMetadata.AuthorizationEndpoint.Scheme != Uri.UriSchemeHttp &&
|
||||
authServerMetadata.AuthorizationEndpoint.Scheme != Uri.UriSchemeHttps)
|
||||
{
|
||||
throw new ArgumentException("AuthorizationEndpoint must use HTTP or HTTPS.", nameof(authServerMetadata));
|
||||
}
|
||||
|
||||
var queryParamsDictionary = new Dictionary<string, string>
|
||||
{
|
||||
["client_id"] = GetClientIdOrThrow(),
|
||||
["redirect_uri"] = _redirectUri.ToString(),
|
||||
["response_type"] = "code",
|
||||
["code_challenge"] = codeChallenge,
|
||||
["code_challenge_method"] = "S256",
|
||||
["resource"] = protectedResourceMetadata.Resource.ToString(),
|
||||
};
|
||||
|
||||
var scopesSupported = protectedResourceMetadata.ScopesSupported;
|
||||
if (_scopes is not null || scopesSupported.Count > 0)
|
||||
{
|
||||
queryParamsDictionary["scope"] = string.Join(" ", _scopes ?? scopesSupported.ToArray());
|
||||
}
|
||||
|
||||
// Add extra parameters if provided. Load into a dictionary before constructing to avoid overwiting values.
|
||||
foreach (var kvp in _additionalAuthorizationParameters)
|
||||
{
|
||||
queryParamsDictionary.Add(kvp.Key, kvp.Value);
|
||||
}
|
||||
|
||||
var queryParams = HttpUtility.ParseQueryString(string.Empty);
|
||||
foreach (var kvp in queryParamsDictionary)
|
||||
{
|
||||
queryParams[kvp.Key] = kvp.Value;
|
||||
}
|
||||
|
||||
var uriBuilder = new UriBuilder(authServerMetadata.AuthorizationEndpoint)
|
||||
{
|
||||
Query = queryParams.ToString()
|
||||
};
|
||||
|
||||
return uriBuilder.Uri;
|
||||
}
|
||||
|
||||
private async Task<TokenContainer> ExchangeCodeForTokenAsync(
|
||||
ProtectedResourceMetadata protectedResourceMetadata,
|
||||
AuthorizationServerMetadata authServerMetadata,
|
||||
string authorizationCode,
|
||||
string codeVerifier,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var requestContent = new FormUrlEncodedContent(new Dictionary<string, string>
|
||||
{
|
||||
["grant_type"] = "authorization_code",
|
||||
["code"] = authorizationCode,
|
||||
["redirect_uri"] = _redirectUri.ToString(),
|
||||
["client_id"] = GetClientIdOrThrow(),
|
||||
["code_verifier"] = codeVerifier,
|
||||
["client_secret"] = _clientSecret ?? string.Empty,
|
||||
["resource"] = protectedResourceMetadata.Resource.ToString(),
|
||||
});
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, authServerMetadata.TokenEndpoint)
|
||||
{
|
||||
Content = requestContent
|
||||
};
|
||||
|
||||
return await FetchTokenAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task<TokenContainer> FetchTokenAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
using var httpResponse = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
httpResponse.EnsureSuccessStatusCode();
|
||||
|
||||
using var stream = await httpResponse.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
|
||||
var tokenResponse = await JsonSerializer.DeserializeAsync(stream, McpJsonUtilities.JsonContext.Default.TokenContainer, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (tokenResponse is null)
|
||||
{
|
||||
ThrowFailedToHandleUnauthorizedResponse($"The token endpoint '{request.RequestUri}' returned an empty response.");
|
||||
}
|
||||
|
||||
tokenResponse.ObtainedAt = DateTimeOffset.UtcNow;
|
||||
return tokenResponse;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fetches the protected resource metadata from the provided URL.
|
||||
/// </summary>
|
||||
/// <param name="metadataUrl">The URL to fetch the metadata from.</param>
|
||||
/// <param name="cancellationToken">A token to cancel the operation.</param>
|
||||
/// <returns>The fetched ProtectedResourceMetadata, or null if it couldn't be fetched.</returns>
|
||||
private async Task<ProtectedResourceMetadata?> FetchProtectedResourceMetadataAsync(Uri metadataUrl, CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var httpResponse = await _httpClient.GetAsync(metadataUrl, cancellationToken).ConfigureAwait(false);
|
||||
httpResponse.EnsureSuccessStatusCode();
|
||||
|
||||
using var stream = await httpResponse.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
|
||||
return await JsonSerializer.DeserializeAsync(stream, McpJsonUtilities.JsonContext.Default.ProtectedResourceMetadata, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Performs dynamic client registration with the authorization server.
|
||||
/// </summary>
|
||||
/// <param name="authServerMetadata">The authorization server metadata.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>A task representing the asynchronous operation.</returns>
|
||||
private async Task PerformDynamicClientRegistrationAsync(
|
||||
AuthorizationServerMetadata authServerMetadata,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (authServerMetadata.RegistrationEndpoint is null)
|
||||
{
|
||||
ThrowFailedToHandleUnauthorizedResponse("Authorization server does not support dynamic client registration");
|
||||
}
|
||||
|
||||
LogPerformingDynamicClientRegistration(authServerMetadata.RegistrationEndpoint);
|
||||
|
||||
var registrationRequest = new DynamicClientRegistrationRequest
|
||||
{
|
||||
RedirectUris = [_redirectUri.ToString()],
|
||||
GrantTypes = ["authorization_code", "refresh_token"],
|
||||
ResponseTypes = ["code"],
|
||||
TokenEndpointAuthMethod = "client_secret_post",
|
||||
ClientName = _clientName,
|
||||
ClientUri = _clientUri?.ToString(),
|
||||
Scope = _scopes is not null ? string.Join(" ", _scopes) : null
|
||||
};
|
||||
|
||||
var requestJson = JsonSerializer.Serialize(registrationRequest, McpJsonUtilities.JsonContext.Default.DynamicClientRegistrationRequest);
|
||||
using var requestContent = new StringContent(requestJson, Encoding.UTF8, "application/json");
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, authServerMetadata.RegistrationEndpoint)
|
||||
{
|
||||
Content = requestContent
|
||||
};
|
||||
|
||||
using var httpResponse = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!httpResponse.IsSuccessStatusCode)
|
||||
{
|
||||
var errorContent = await httpResponse.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
ThrowFailedToHandleUnauthorizedResponse($"Dynamic client registration failed with status {httpResponse.StatusCode}: {errorContent}");
|
||||
}
|
||||
|
||||
using var responseStream = await httpResponse.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
|
||||
var registrationResponse = await JsonSerializer.DeserializeAsync(
|
||||
responseStream,
|
||||
McpJsonUtilities.JsonContext.Default.DynamicClientRegistrationResponse,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (registrationResponse is null)
|
||||
{
|
||||
ThrowFailedToHandleUnauthorizedResponse("Dynamic client registration returned empty response");
|
||||
}
|
||||
|
||||
// Update client credentials
|
||||
_clientId = registrationResponse.ClientId;
|
||||
if (!string.IsNullOrEmpty(registrationResponse.ClientSecret))
|
||||
{
|
||||
_clientSecret = registrationResponse.ClientSecret;
|
||||
}
|
||||
|
||||
LogDynamicClientRegistrationSuccessful(_clientId!);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that the resource URI in the metadata exactly matches the original request URL as required by the RFC.
|
||||
/// Per RFC: The resource value must be identical to the URL that the client used to make the request to the resource server.
|
||||
/// </summary>
|
||||
/// <param name="protectedResourceMetadata">The metadata to verify.</param>
|
||||
/// <param name="resourceLocation">The original URL the client used to make the request to the resource server.</param>
|
||||
/// <returns>True if the resource URI exactly matches the original request URL, otherwise false.</returns>
|
||||
private static bool VerifyResourceMatch(ProtectedResourceMetadata protectedResourceMetadata, Uri resourceLocation)
|
||||
{
|
||||
if (protectedResourceMetadata.Resource == null || resourceLocation == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Per RFC: The resource value must be identical to the URL that the client used
|
||||
// to make the request to the resource server. Compare entire URIs, not just the host.
|
||||
|
||||
// Normalize the URIs to ensure consistent comparison
|
||||
string normalizedMetadataResource = NormalizeUri(protectedResourceMetadata.Resource);
|
||||
string normalizedResourceLocation = NormalizeUri(resourceLocation);
|
||||
|
||||
return string.Equals(normalizedMetadataResource, normalizedResourceLocation, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Normalizes a URI for consistent comparison.
|
||||
/// </summary>
|
||||
/// <param name="uri">The URI to normalize.</param>
|
||||
/// <returns>A normalized string representation of the URI.</returns>
|
||||
private static string NormalizeUri(Uri uri)
|
||||
{
|
||||
var builder = new UriBuilder(uri)
|
||||
{
|
||||
Port = -1 // Always remove port
|
||||
};
|
||||
|
||||
if (builder.Path == "/")
|
||||
{
|
||||
builder.Path = string.Empty;
|
||||
}
|
||||
else if (builder.Path.Length > 1 && builder.Path.EndsWith("/"))
|
||||
{
|
||||
builder.Path = builder.Path.TrimEnd('/');
|
||||
}
|
||||
|
||||
return builder.Uri.ToString();
|
||||
}
|
||||
|
||||
/// <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.
|
||||
/// </summary>
|
||||
/// <param name="response">The HTTP response containing the WWW-Authenticate header.</param>
|
||||
/// <param name="serverUrl">The server URL to verify against the resource metadata.</param>
|
||||
/// <param name="cancellationToken">A token to cancel the operation.</param>
|
||||
/// <returns>The resource metadata if the resource matches the server, otherwise throws an exception.</returns>
|
||||
/// <exception cref="InvalidOperationException">Thrown when the response is not a 401, lacks a WWW-Authenticate header,
|
||||
/// lacks a resource_metadata parameter, the metadata can't be fetched, or the resource URI doesn't match the server URL.</exception>
|
||||
private async Task<ProtectedResourceMetadata> ExtractProtectedResourceMetadata(HttpResponseMessage response, Uri serverUrl, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (response.StatusCode != System.Net.HttpStatusCode.Unauthorized)
|
||||
{
|
||||
throw new InvalidOperationException($"Expected a 401 Unauthorized response, but received {(int)response.StatusCode} {response.StatusCode}");
|
||||
}
|
||||
|
||||
// Extract the WWW-Authenticate header
|
||||
if (response.Headers.WwwAuthenticate.Count == 0)
|
||||
{
|
||||
throw new McpException("The 401 response does not contain a WWW-Authenticate header");
|
||||
}
|
||||
|
||||
// Look for the Bearer authentication scheme with resource_metadata parameter
|
||||
string? resourceMetadataUrl = null;
|
||||
foreach (var header in response.Headers.WwwAuthenticate)
|
||||
{
|
||||
if (string.Equals(header.Scheme, "Bearer", StringComparison.OrdinalIgnoreCase) && !string.IsNullOrEmpty(header.Parameter))
|
||||
{
|
||||
resourceMetadataUrl = ParseWwwAuthenticateParameters(header.Parameter, "resource_metadata");
|
||||
if (resourceMetadataUrl != null)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (resourceMetadataUrl == null)
|
||||
{
|
||||
throw new McpException("The WWW-Authenticate header does not contain a resource_metadata parameter");
|
||||
}
|
||||
|
||||
Uri metadataUri = new(resourceMetadataUrl);
|
||||
var metadata = await FetchProtectedResourceMetadataAsync(metadataUri, cancellationToken).ConfigureAwait(false)
|
||||
?? throw new McpException($"Failed to fetch resource metadata from {resourceMetadataUrl}");
|
||||
|
||||
// Per RFC: The resource value must be identical to the URL that the client used
|
||||
// to make the request to the resource server
|
||||
LogValidatingResourceMetadata(serverUrl);
|
||||
|
||||
if (!VerifyResourceMatch(metadata, serverUrl))
|
||||
{
|
||||
throw new McpException($"Resource URI in metadata ({metadata.Resource}) does not match the expected URI ({serverUrl})");
|
||||
}
|
||||
|
||||
return metadata;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses the WWW-Authenticate header parameters to extract a specific parameter.
|
||||
/// </summary>
|
||||
/// <param name="parameters">The parameter string from the WWW-Authenticate header.</param>
|
||||
/// <param name="parameterName">The name of the parameter to extract.</param>
|
||||
/// <returns>The value of the parameter, or null if not found.</returns>
|
||||
private static string? ParseWwwAuthenticateParameters(string parameters, string parameterName)
|
||||
{
|
||||
if (parameters.IndexOf(parameterName, StringComparison.OrdinalIgnoreCase) == -1)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach (var part in parameters.Split(','))
|
||||
{
|
||||
string trimmedPart = part.Trim();
|
||||
int equalsIndex = trimmedPart.IndexOf('=');
|
||||
|
||||
if (equalsIndex <= 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
string key = trimmedPart.Substring(0, equalsIndex).Trim();
|
||||
|
||||
if (string.Equals(key, parameterName, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
string value = trimmedPart.Substring(equalsIndex + 1).Trim();
|
||||
|
||||
if (value.StartsWith("\"") && value.EndsWith("\""))
|
||||
{
|
||||
value = value.Substring(1, value.Length - 2);
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string GenerateCodeVerifier()
|
||||
{
|
||||
var bytes = new byte[32];
|
||||
using var rng = RandomNumberGenerator.Create();
|
||||
rng.GetBytes(bytes);
|
||||
return Convert.ToBase64String(bytes)
|
||||
.TrimEnd('=')
|
||||
.Replace('+', '-')
|
||||
.Replace('/', '_');
|
||||
}
|
||||
|
||||
private static string GenerateCodeChallenge(string codeVerifier)
|
||||
{
|
||||
using var sha256 = SHA256.Create();
|
||||
var challengeBytes = sha256.ComputeHash(Encoding.UTF8.GetBytes(codeVerifier));
|
||||
return Convert.ToBase64String(challengeBytes)
|
||||
.TrimEnd('=')
|
||||
.Replace('+', '-')
|
||||
.Replace('/', '_');
|
||||
}
|
||||
|
||||
private string GetClientIdOrThrow() => _clientId ?? throw new InvalidOperationException("Client ID is not available. This may indicate an issue with dynamic client registration.");
|
||||
|
||||
private static void ThrowIfNotBearerScheme(string scheme)
|
||||
{
|
||||
if (!string.Equals(scheme, BearerScheme, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new InvalidOperationException($"The '{scheme}' is not supported. This credential provider only supports the '{BearerScheme}' scheme");
|
||||
}
|
||||
}
|
||||
|
||||
[DoesNotReturn]
|
||||
private static void ThrowFailedToHandleUnauthorizedResponse(string message) =>
|
||||
throw new McpException($"Failed to handle unauthorized response with 'Bearer' scheme. {message}");
|
||||
|
||||
[LoggerMessage(Level = LogLevel.Information, Message = "Selected authorization server: {Server} from {Count} available servers")]
|
||||
partial void LogSelectedAuthorizationServer(Uri server, int count);
|
||||
|
||||
[LoggerMessage(Level = LogLevel.Information, Message = "OAuth authorization completed successfully")]
|
||||
partial void LogOAuthAuthorizationCompleted();
|
||||
|
||||
[LoggerMessage(Level = LogLevel.Error, Message = "Error fetching auth server metadata from {Path}")]
|
||||
partial void LogErrorFetchingAuthServerMetadata(Exception ex, string path);
|
||||
|
||||
[LoggerMessage(Level = LogLevel.Information, Message = "Performing dynamic client registration with {RegistrationEndpoint}")]
|
||||
partial void LogPerformingDynamicClientRegistration(Uri registrationEndpoint);
|
||||
|
||||
[LoggerMessage(Level = LogLevel.Information, Message = "Dynamic client registration successful. Client ID: {ClientId}")]
|
||||
partial void LogDynamicClientRegistrationSuccessful(string clientId);
|
||||
|
||||
[LoggerMessage(Level = LogLevel.Debug, Message = "Validating resource metadata against original server URL: {ServerUrl}")]
|
||||
partial void LogValidatingResourceMetadata(Uri serverUrl);
|
||||
}
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace ModelContextProtocol.Authentication;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a client registration request for OAuth 2.0 Dynamic Client Registration (RFC 7591).
|
||||
/// </summary>
|
||||
internal sealed class DynamicClientRegistrationRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the redirect URIs for the client.
|
||||
/// </summary>
|
||||
[JsonPropertyName("redirect_uris")]
|
||||
public required string[] RedirectUris { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the token endpoint authentication method.
|
||||
/// </summary>
|
||||
[JsonPropertyName("token_endpoint_auth_method")]
|
||||
public string? TokenEndpointAuthMethod { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the grant types that the client will use.
|
||||
/// </summary>
|
||||
[JsonPropertyName("grant_types")]
|
||||
public string[]? GrantTypes { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the response types that the client will use.
|
||||
/// </summary>
|
||||
[JsonPropertyName("response_types")]
|
||||
public string[]? ResponseTypes { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the human-readable name of the client.
|
||||
/// </summary>
|
||||
[JsonPropertyName("client_name")]
|
||||
public string? ClientName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the URL of the client's home page.
|
||||
/// </summary>
|
||||
[JsonPropertyName("client_uri")]
|
||||
public string? ClientUri { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the scope values that the client will use.
|
||||
/// </summary>
|
||||
[JsonPropertyName("scope")]
|
||||
public string? Scope { get; init; }
|
||||
}
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace ModelContextProtocol.Authentication;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a client registration response for OAuth 2.0 Dynamic Client Registration (RFC 7591).
|
||||
/// </summary>
|
||||
internal sealed class DynamicClientRegistrationResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the client identifier.
|
||||
/// </summary>
|
||||
[JsonPropertyName("client_id")]
|
||||
public required string ClientId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the client secret.
|
||||
/// </summary>
|
||||
[JsonPropertyName("client_secret")]
|
||||
public string? ClientSecret { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the redirect URIs for the client.
|
||||
/// </summary>
|
||||
[JsonPropertyName("redirect_uris")]
|
||||
public string[]? RedirectUris { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the token endpoint authentication method.
|
||||
/// </summary>
|
||||
[JsonPropertyName("token_endpoint_auth_method")]
|
||||
public string? TokenEndpointAuthMethod { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the grant types that the client will use.
|
||||
/// </summary>
|
||||
[JsonPropertyName("grant_types")]
|
||||
public string[]? GrantTypes { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the response types that the client will use.
|
||||
/// </summary>
|
||||
[JsonPropertyName("response_types")]
|
||||
public string[]? ResponseTypes { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the client ID issued timestamp.
|
||||
/// </summary>
|
||||
[JsonPropertyName("client_id_issued_at")]
|
||||
public long? ClientIdIssuedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the client secret expiration time.
|
||||
/// </summary>
|
||||
[JsonPropertyName("client_secret_expires_at")]
|
||||
public long? ClientSecretExpiresAt { get; init; }
|
||||
}
|
||||
|
|
@ -0,0 +1,145 @@
|
|||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace ModelContextProtocol.Authentication;
|
||||
|
||||
/// <summary>
|
||||
/// Represents the resource metadata for OAuth authorization as defined in RFC 9396.
|
||||
/// Defined by <see href="https://datatracker.ietf.org/doc/rfc9728/">RFC 9728</see>.
|
||||
/// </summary>
|
||||
public sealed class ProtectedResourceMetadata
|
||||
{
|
||||
/// <summary>
|
||||
/// The resource URI.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// REQUIRED. The protected resource's resource identifier.
|
||||
/// </remarks>
|
||||
[JsonPropertyName("resource")]
|
||||
public required Uri Resource { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The list of authorization server URIs.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// OPTIONAL. JSON array containing a list of OAuth authorization server issuer identifiers
|
||||
/// for authorization servers that can be used with this protected resource.
|
||||
/// </remarks>
|
||||
[JsonPropertyName("authorization_servers")]
|
||||
public List<Uri> AuthorizationServers { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// The supported bearer token methods.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// OPTIONAL. JSON array containing a list of the supported methods of sending an OAuth 2.0 bearer token
|
||||
/// to the protected resource. Defined values are ["header", "body", "query"].
|
||||
/// </remarks>
|
||||
[JsonPropertyName("bearer_methods_supported")]
|
||||
public List<string> BearerMethodsSupported { get; set; } = ["header"];
|
||||
|
||||
/// <summary>
|
||||
/// The supported scopes.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// RECOMMENDED. JSON array containing a list of scope values that are used in authorization
|
||||
/// requests to request access to this protected resource.
|
||||
/// </remarks>
|
||||
[JsonPropertyName("scopes_supported")]
|
||||
public List<string> ScopesSupported { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// URL of the protected resource's JSON Web Key (JWK) Set document.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// OPTIONAL. This contains public keys belonging to the protected resource, such as signing key(s)
|
||||
/// 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; }
|
||||
|
||||
/// <summary>
|
||||
/// List of the JWS signing algorithms supported by the protected resource for signing resource responses.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// OPTIONAL. JSON array containing a list of the JWS signing algorithms (alg values) supported by the protected resource
|
||||
/// for signing resource responses. No default algorithms are implied if this entry is omitted. The value none MUST NOT be used.
|
||||
/// </remarks>
|
||||
[JsonPropertyName("resource_signing_alg_values_supported")]
|
||||
public List<string>? ResourceSigningAlgValuesSupported { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Human-readable name of the protected resource intended for display to the end user.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// RECOMMENDED. It is recommended that protected resource metadata include this field.
|
||||
/// The value of this field MAY be internationalized.
|
||||
/// </remarks>
|
||||
[JsonPropertyName("resource_name")]
|
||||
public string? ResourceName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The URI to the resource documentation.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// OPTIONAL. URL of a page containing human-readable information that developers might want or need to know
|
||||
/// when using the protected resource.
|
||||
/// </remarks>
|
||||
[JsonPropertyName("resource_documentation")]
|
||||
public Uri? ResourceDocumentation { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// URL of a page containing human-readable information about the protected resource's requirements.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// OPTIONAL. Information about how the client can use the data provided by the protected resource.
|
||||
/// </remarks>
|
||||
[JsonPropertyName("resource_policy_uri")]
|
||||
public Uri? ResourcePolicyUri { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// URL of a page containing human-readable information about the protected resource's terms of service.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// OPTIONAL. The value of this field MAY be internationalized.
|
||||
/// </remarks>
|
||||
[JsonPropertyName("resource_tos_uri")]
|
||||
public Uri? ResourceTosUri { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Boolean value indicating protected resource support for mutual-TLS client certificate-bound access tokens.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// OPTIONAL. If omitted, the default value is false.
|
||||
/// </remarks>
|
||||
[JsonPropertyName("tls_client_certificate_bound_access_tokens")]
|
||||
public bool? TlsClientCertificateBoundAccessTokens { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// List of the authorization details type values supported by the resource server.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// OPTIONAL. JSON array containing a list of the authorization details type values supported by the resource server
|
||||
/// when the authorization_details request parameter is used.
|
||||
/// </remarks>
|
||||
[JsonPropertyName("authorization_details_types_supported")]
|
||||
public List<string>? AuthorizationDetailsTypesSupported { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// List of the JWS algorithm values supported by the resource server for validating DPoP proof JWTs.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// OPTIONAL. JSON array containing a list of the JWS alg values supported by the resource server
|
||||
/// for validating Demonstrating Proof of Possession (DPoP) proof JWTs.
|
||||
/// </remarks>
|
||||
[JsonPropertyName("dpop_signing_alg_values_supported")]
|
||||
public List<string>? DpopSigningAlgValuesSupported { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Boolean value specifying whether the protected resource always requires the use of DPoP-bound access tokens.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// OPTIONAL. If omitted, the default value is false.
|
||||
/// </remarks>
|
||||
[JsonPropertyName("dpop_bound_access_tokens_required")]
|
||||
public bool? DpopBoundAccessTokensRequired { get; set; }
|
||||
}
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace ModelContextProtocol.Authentication;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a token response from the OAuth server.
|
||||
/// </summary>
|
||||
internal sealed class TokenContainer
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the access token.
|
||||
/// </summary>
|
||||
[JsonPropertyName("access_token")]
|
||||
public string AccessToken { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the refresh token.
|
||||
/// </summary>
|
||||
[JsonPropertyName("refresh_token")]
|
||||
public string? RefreshToken { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the number of seconds until the access token expires.
|
||||
/// </summary>
|
||||
[JsonPropertyName("expires_in")]
|
||||
public int ExpiresIn { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the extended expiration time in seconds.
|
||||
/// </summary>
|
||||
[JsonPropertyName("ext_expires_in")]
|
||||
public int ExtExpiresIn { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the token type (typically "Bearer").
|
||||
/// </summary>
|
||||
[JsonPropertyName("token_type")]
|
||||
public string TokenType { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the scope of the access token.
|
||||
/// </summary>
|
||||
[JsonPropertyName("scope")]
|
||||
public string Scope { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the timestamp when the token was obtained.
|
||||
/// </summary>
|
||||
[JsonIgnore]
|
||||
public DateTimeOffset ObtainedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the timestamp when the token expires, calculated from ObtainedAt and ExpiresIn.
|
||||
/// </summary>
|
||||
[JsonIgnore]
|
||||
public DateTimeOffset ExpiresAt => ObtainedAt.AddSeconds(ExpiresIn);
|
||||
}
|
||||
|
|
@ -13,13 +13,13 @@ namespace ModelContextProtocol.Client;
|
|||
internal sealed partial class AutoDetectingClientSessionTransport : ITransport
|
||||
{
|
||||
private readonly SseClientTransportOptions _options;
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly McpHttpClient _httpClient;
|
||||
private readonly ILoggerFactory? _loggerFactory;
|
||||
private readonly ILogger _logger;
|
||||
private readonly string _name;
|
||||
private readonly Channel<JsonRpcMessage> _messageChannel;
|
||||
|
||||
public AutoDetectingClientSessionTransport(SseClientTransportOptions transportOptions, HttpClient httpClient, ILoggerFactory? loggerFactory, string endpointName)
|
||||
public AutoDetectingClientSessionTransport(string endpointName, SseClientTransportOptions transportOptions, McpHttpClient httpClient, ILoggerFactory? loggerFactory)
|
||||
{
|
||||
Throw.IfNull(transportOptions);
|
||||
Throw.IfNull(httpClient);
|
||||
|
|
|
|||
42
src/ModelContextProtocol.Core/Client/McpHttpClient.cs
Normal file
42
src/ModelContextProtocol.Core/Client/McpHttpClient.cs
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
using ModelContextProtocol.Protocol;
|
||||
using System.Diagnostics;
|
||||
|
||||
#if NET
|
||||
using System.Net.Http.Json;
|
||||
#else
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
#endif
|
||||
|
||||
namespace ModelContextProtocol.Client;
|
||||
|
||||
internal class McpHttpClient(HttpClient httpClient)
|
||||
{
|
||||
internal virtual async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, JsonRpcMessage? message, CancellationToken cancellationToken)
|
||||
{
|
||||
Debug.Assert(request.Content is null, "The request body should only be supplied as a JsonRpcMessage");
|
||||
Debug.Assert(message is null || request.Method == HttpMethod.Post, "All messages should be sent in POST requests.");
|
||||
|
||||
using var content = CreatePostBodyContent(message);
|
||||
request.Content = content;
|
||||
return await httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private HttpContent? CreatePostBodyContent(JsonRpcMessage? message)
|
||||
{
|
||||
if (message is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
#if NET
|
||||
return JsonContent.Create(message, McpJsonUtilities.JsonContext.Default.JsonRpcMessage);
|
||||
#else
|
||||
return new StringContent(
|
||||
JsonSerializer.Serialize(message, McpJsonUtilities.JsonContext.Default.JsonRpcMessage),
|
||||
Encoding.UTF8,
|
||||
"application/json"
|
||||
);
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
|
@ -4,7 +4,6 @@ using ModelContextProtocol.Protocol;
|
|||
using System.Diagnostics;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.ServerSentEvents;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Threading.Channels;
|
||||
|
||||
|
|
@ -15,7 +14,7 @@ namespace ModelContextProtocol.Client;
|
|||
/// </summary>
|
||||
internal sealed partial class SseClientSessionTransport : TransportBase
|
||||
{
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly McpHttpClient _httpClient;
|
||||
private readonly SseClientTransportOptions _options;
|
||||
private readonly Uri _sseEndpoint;
|
||||
private Uri? _messageEndpoint;
|
||||
|
|
@ -31,7 +30,7 @@ internal sealed partial class SseClientSessionTransport : TransportBase
|
|||
public SseClientSessionTransport(
|
||||
string endpointName,
|
||||
SseClientTransportOptions transportOptions,
|
||||
HttpClient httpClient,
|
||||
McpHttpClient httpClient,
|
||||
Channel<JsonRpcMessage>? messageChannel,
|
||||
ILoggerFactory? loggerFactory)
|
||||
: base(endpointName, messageChannel, loggerFactory)
|
||||
|
|
@ -74,12 +73,6 @@ internal sealed partial class SseClientSessionTransport : TransportBase
|
|||
if (_messageEndpoint == null)
|
||||
throw new InvalidOperationException("Transport not connected");
|
||||
|
||||
using var content = new StringContent(
|
||||
JsonSerializer.Serialize(message, McpJsonUtilities.JsonContext.Default.JsonRpcMessage),
|
||||
Encoding.UTF8,
|
||||
"application/json"
|
||||
);
|
||||
|
||||
string messageId = "(no id)";
|
||||
|
||||
if (message is JsonRpcMessageWithId messageWithId)
|
||||
|
|
@ -87,12 +80,9 @@ internal sealed partial class SseClientSessionTransport : TransportBase
|
|||
messageId = messageWithId.Id.ToString();
|
||||
}
|
||||
|
||||
using var httpRequestMessage = new HttpRequestMessage(HttpMethod.Post, _messageEndpoint)
|
||||
{
|
||||
Content = content,
|
||||
};
|
||||
using var httpRequestMessage = new HttpRequestMessage(HttpMethod.Post, _messageEndpoint);
|
||||
StreamableHttpClientSessionTransport.CopyAdditionalHeaders(httpRequestMessage.Headers, _options.AdditionalHeaders, sessionId: null, protocolVersion: null);
|
||||
var response = await _httpClient.SendAsync(httpRequestMessage, cancellationToken).ConfigureAwait(false);
|
||||
var response = await _httpClient.SendAsync(httpRequestMessage, message, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
|
|
@ -154,11 +144,7 @@ internal sealed partial class SseClientSessionTransport : TransportBase
|
|||
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("text/event-stream"));
|
||||
StreamableHttpClientSessionTransport.CopyAdditionalHeaders(request.Headers, _options.AdditionalHeaders, sessionId: null, protocolVersion: null);
|
||||
|
||||
using var response = await _httpClient.SendAsync(
|
||||
request,
|
||||
HttpCompletionOption.ResponseHeadersRead,
|
||||
cancellationToken
|
||||
).ConfigureAwait(false);
|
||||
using var response = await _httpClient.SendAsync(request, message: null, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
using Microsoft.Extensions.Logging;
|
||||
using ModelContextProtocol.Authentication;
|
||||
using ModelContextProtocol.Protocol;
|
||||
|
||||
namespace ModelContextProtocol.Client;
|
||||
|
|
@ -15,9 +16,10 @@ namespace ModelContextProtocol.Client;
|
|||
public sealed class SseClientTransport : IClientTransport, IAsyncDisposable
|
||||
{
|
||||
private readonly SseClientTransportOptions _options;
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly McpHttpClient _mcpHttpClient;
|
||||
private readonly ILoggerFactory? _loggerFactory;
|
||||
private readonly bool _ownsHttpClient;
|
||||
|
||||
private readonly HttpClient? _ownedHttpClient;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="SseClientTransport"/> class.
|
||||
|
|
@ -45,10 +47,23 @@ public sealed class SseClientTransport : IClientTransport, IAsyncDisposable
|
|||
Throw.IfNull(httpClient);
|
||||
|
||||
_options = transportOptions;
|
||||
_httpClient = httpClient;
|
||||
_loggerFactory = loggerFactory;
|
||||
_ownsHttpClient = ownsHttpClient;
|
||||
Name = transportOptions.Name ?? transportOptions.Endpoint.ToString();
|
||||
|
||||
if (transportOptions.OAuth is { } clientOAuthOptions)
|
||||
{
|
||||
var oAuthProvider = new ClientOAuthProvider(_options.Endpoint, clientOAuthOptions, httpClient, loggerFactory);
|
||||
_mcpHttpClient = new AuthenticatingMcpHttpClient(httpClient, oAuthProvider);
|
||||
}
|
||||
else
|
||||
{
|
||||
_mcpHttpClient = new(httpClient);
|
||||
}
|
||||
|
||||
if (ownsHttpClient)
|
||||
{
|
||||
_ownedHttpClient = httpClient;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
|
|
@ -59,8 +74,8 @@ public sealed class SseClientTransport : IClientTransport, IAsyncDisposable
|
|||
{
|
||||
return _options.TransportMode switch
|
||||
{
|
||||
HttpTransportMode.AutoDetect => new AutoDetectingClientSessionTransport(_options, _httpClient, _loggerFactory, Name),
|
||||
HttpTransportMode.StreamableHttp => new StreamableHttpClientSessionTransport(Name, _options, _httpClient, messageChannel: null, _loggerFactory),
|
||||
HttpTransportMode.AutoDetect => new AutoDetectingClientSessionTransport(Name, _options, _mcpHttpClient, _loggerFactory),
|
||||
HttpTransportMode.StreamableHttp => new StreamableHttpClientSessionTransport(Name, _options, _mcpHttpClient, messageChannel: null, _loggerFactory),
|
||||
HttpTransportMode.Sse => await ConnectSseTransportAsync(cancellationToken).ConfigureAwait(false),
|
||||
_ => throw new InvalidOperationException($"Unsupported transport mode: {_options.TransportMode}"),
|
||||
};
|
||||
|
|
@ -68,7 +83,7 @@ public sealed class SseClientTransport : IClientTransport, IAsyncDisposable
|
|||
|
||||
private async Task<ITransport> ConnectSseTransportAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var sessionTransport = new SseClientSessionTransport(Name, _options, _httpClient, messageChannel: null, _loggerFactory);
|
||||
var sessionTransport = new SseClientSessionTransport(Name, _options, _mcpHttpClient, messageChannel: null, _loggerFactory);
|
||||
|
||||
try
|
||||
{
|
||||
|
|
@ -85,11 +100,7 @@ public sealed class SseClientTransport : IClientTransport, IAsyncDisposable
|
|||
/// <inheritdoc />
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
if (_ownsHttpClient)
|
||||
{
|
||||
_httpClient.Dispose();
|
||||
}
|
||||
|
||||
_ownedHttpClient?.Dispose();
|
||||
return default;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,3 +1,5 @@
|
|||
using ModelContextProtocol.Authentication;
|
||||
|
||||
namespace ModelContextProtocol.Client;
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -46,7 +48,7 @@ public sealed class SseClientTransportOptions
|
|||
public HttpTransportMode TransportMode { get; set; } = HttpTransportMode.AutoDetect;
|
||||
|
||||
/// <summary>
|
||||
/// Gets a transport identifier used for logging purposes.
|
||||
/// Gets or sets a transport identifier used for logging purposes.
|
||||
/// </summary>
|
||||
public string? Name { get; set; }
|
||||
|
||||
|
|
@ -70,4 +72,9 @@ public sealed class SseClientTransportOptions
|
|||
/// Use this property to specify custom HTTP headers that should be sent with each request to the server.
|
||||
/// </remarks>
|
||||
public IDictionary<string, string>? AdditionalHeaders { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets sor sets the authorization provider to use for authentication.
|
||||
/// </summary>
|
||||
public ClientOAuthOptions? OAuth { get; set; }
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue