Authorization Support (Using ASP.NET Core Native AuthN/AuthZ Integration) (#377)

This commit is contained in:
Den Delimarsky 🌺 2025-07-03 14:39:28 -07:00 • committed by GitHub
commit cdf88e7f5d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 4207 additions and 126 deletions

View file

@ -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";
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View 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
}
}

View file

@ -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();

View file

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

View file

@ -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