csharp-sdk/src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs

763 lines
35 KiB
C#

using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
#if NET9_0_OR_GREATER
using System.Buffers.Text;
#endif
using System.Diagnostics.CodeAnalysis;
using System.Net.Http.Headers;
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.
/// </summary>
internal sealed partial class ClientOAuthProvider
{
/// <summary>
/// The Bearer authentication scheme.
/// </summary>
private const string BearerScheme = "Bearer";
private static readonly string[] s_wellKnownPaths = [".well-known/openid-configuration", ".well-known/oauth-authorization-server"];
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;
private readonly Uri? _clientMetadataDocumentUri;
// _dcrClientName, _dcrClientUri, _dcrInitialAccessToken and _dcrResponseDelegate are used for dynamic client registration (RFC 7591)
private readonly string? _dcrClientName;
private readonly Uri? _dcrClientUri;
private readonly string? _dcrInitialAccessToken;
private readonly Func<DynamicClientRegistrationResponse, CancellationToken, Task>? _dcrResponseDelegate;
private readonly HttpClient _httpClient;
private readonly ILogger _logger;
private string? _clientId;
private string? _clientSecret;
private ITokenCache _tokenCache;
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 is used.</param>
/// <param name="loggerFactory">A logger factory to handle diagnostic messages.</param>
/// <exception cref="ArgumentNullException"><paramref name="serverUrl"/> or <paramref name="options"/> is null.</exception>
public ClientOAuthProvider(
Uri serverUrl,
ClientOAuthOptions options,
HttpClient httpClient,
ILoggerFactory? loggerFactory = null)
{
_serverUrl = serverUrl ?? throw new ArgumentNullException(nameof(serverUrl));
_httpClient = 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.", nameof(options));
_scopes = options.Scopes?.ToArray();
_additionalAuthorizationParameters = options.AdditionalAuthorizationParameters;
_clientMetadataDocumentUri = options.ClientMetadataDocumentUri;
// Set up authorization server selection strategy
_authServerSelector = options.AuthServerSelector ?? DefaultAuthServerSelector;
// Set up authorization URL handler (use default if not provided)
_authorizationRedirectDelegate = options.AuthorizationRedirectDelegate ?? DefaultAuthorizationUrlHandler;
_dcrClientName = options.DynamicClientRegistration?.ClientName;
_dcrClientUri = options.DynamicClientRegistration?.ClientUri;
_dcrInitialAccessToken = options.DynamicClientRegistration?.InitialAccessToken;
_dcrResponseDelegate = options.DynamicClientRegistration?.ResponseDelegate;
_tokenCache = options.TokenCache ?? new InMemoryTokenCache();
}
/// <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 <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</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">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</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);
var tokens = await _tokenCache.GetTokensAsync(cancellationToken).ConfigureAwait(false);
// Return the token if it's valid
if (tokens is not null && !tokens.IsExpired)
{
return tokens.AccessToken;
}
// Try to refresh the access token if it is invalid and we have a refresh token.
if (tokens?.RefreshToken != null && _authServerMetadata != null)
{
var newTokens = await RefreshTokenAsync(tokens.RefreshToken, resourceUri, _authServerMetadata, cancellationToken).ConfigureAwait(false);
if (newTokens is not null)
{
return newTokens.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">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</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)
{
ThrowIfNotBearerScheme(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">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>A result object 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);
// Store auth server metadata for future refresh operations
_authServerMetadata = authServerMetadata;
// The existing access token must be invalid to have resulted in a 401 response, but refresh might still work.
if (await _tokenCache.GetTokensAsync(cancellationToken).ConfigureAwait(false) is { RefreshToken: { } refreshToken })
{
var refreshedTokens = await RefreshTokenAsync(refreshToken, protectedResourceMetadata.Resource, authServerMetadata, cancellationToken).ConfigureAwait(false);
if (refreshedTokens is not null)
{
// A non-null result indicates the refresh succeeded and the new tokens have been stored.
return;
}
}
// Assign a client ID if necessary
if (string.IsNullOrEmpty(_clientId))
{
// Try using a client metadata document before falling back to dynamic client registration
if (authServerMetadata.ClientIdMetadataDocumentSupported && _clientMetadataDocumentUri is not null)
{
ApplyClientIdMetadataDocument(_clientMetadataDocumentUri);
}
else
{
await PerformDynamicClientRegistrationAsync(authServerMetadata, cancellationToken).ConfigureAwait(false);
}
}
// Perform the OAuth flow
await InitiateAuthorizationCodeFlowAsync(protectedResourceMetadata, authServerMetadata, cancellationToken).ConfigureAwait(false);
LogOAuthAuthorizationCompleted();
}
private void ApplyClientIdMetadataDocument(Uri metadataUri)
{
if (!IsValidClientMetadataDocumentUri(metadataUri))
{
ThrowFailedToHandleUnauthorizedResponse(
$"{nameof(ClientOAuthOptions.ClientMetadataDocumentUri)} must be an HTTPS URL with a non-root absolute path. Value: '{metadataUri}'.");
}
_clientId = metadataUri.AbsoluteUri;
// See: https://datatracker.ietf.org/doc/html/draft-ietf-oauth-client-id-metadata-document-00#section-3
static bool IsValidClientMetadataDocumentUri(Uri uri)
=> uri.IsAbsoluteUri
&& string.Equals(uri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase)
&& uri.AbsolutePath.Length > 1; // AbsolutePath always starts with "/"
}
private async Task<AuthorizationServerMetadata> GetAuthServerMetadataAsync(Uri authServerUri, CancellationToken cancellationToken)
{
if (authServerUri.OriginalString.Length == 0 ||
authServerUri.OriginalString[authServerUri.OriginalString.Length - 1] != '/')
{
authServerUri = new Uri($"{authServerUri.OriginalString}/");
}
foreach (var path in s_wellKnownPaths)
{
try
{
var wellKnownEndpoint = new Uri(authServerUri, path);
var response = await _httpClient.GetAsync(wellKnownEndpoint, 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 is null)
{
continue;
}
if (metadata.AuthorizationEndpoint is null)
{
ThrowFailedToHandleUnauthorizedResponse($"No authorization_endpoint was provided via '{wellKnownEndpoint}'.");
}
if (metadata.AuthorizationEndpoint.Scheme != Uri.UriSchemeHttp &&
metadata.AuthorizationEndpoint.Scheme != Uri.UriSchemeHttps)
{
ThrowFailedToHandleUnauthorizedResponse($"AuthorizationEndpoint must use HTTP or HTTPS. '{metadata.AuthorizationEndpoint}' does not meet this requirement.");
}
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);
}
}
throw new McpException($"Failed to find .well-known/openid-configuration or .well-known/oauth-authorization-server metadata for authorization server: '{authServerUri}'");
}
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
};
using var httpResponse = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
if (!httpResponse.IsSuccessStatusCode)
{
return null;
}
return await HandleSuccessfulTokenResponseAsync(httpResponse, cancellationToken).ConfigureAwait(false);
}
private async Task 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))
{
ThrowFailedToHandleUnauthorizedResponse($"The {nameof(AuthorizationRedirectDelegate)} returned a null or empty authorization code.");
}
await ExchangeCodeForTokenAsync(protectedResourceMetadata, authServerMetadata, authCode!, codeVerifier, cancellationToken).ConfigureAwait(false);
}
private Uri BuildAuthorizationUrl(
ProtectedResourceMetadata protectedResourceMetadata,
AuthorizationServerMetadata authServerMetadata,
string codeChallenge)
{
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 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
};
using var httpResponse = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
httpResponse.EnsureSuccessStatusCode();
await HandleSuccessfulTokenResponseAsync(httpResponse, cancellationToken).ConfigureAwait(false);
}
private async Task<TokenContainer> HandleSuccessfulTokenResponseAsync(HttpResponseMessage response, CancellationToken cancellationToken)
{
using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
var tokenResponse = await JsonSerializer.DeserializeAsync(stream, McpJsonUtilities.JsonContext.Default.TokenResponse, cancellationToken).ConfigureAwait(false);
if (tokenResponse is null)
{
ThrowFailedToHandleUnauthorizedResponse($"The token endpoint '{response.RequestMessage?.RequestUri}' returned an empty response.");
}
if (tokenResponse.TokenType is null || !string.Equals(tokenResponse.TokenType, BearerScheme, StringComparison.OrdinalIgnoreCase))
{
ThrowFailedToHandleUnauthorizedResponse($"The token endpoint '{response.RequestMessage?.RequestUri}' returned an unsupported token type: '{tokenResponse.TokenType ?? "<null>"}'. Only 'Bearer' tokens are supported.");
}
TokenContainer tokens = new()
{
AccessToken = tokenResponse.AccessToken,
RefreshToken = tokenResponse.RefreshToken,
ExpiresIn = tokenResponse.ExpiresIn,
TokenType = tokenResponse.TokenType,
Scope = tokenResponse.Scope,
ObtainedAt = DateTimeOffset.UtcNow,
};
await _tokenCache.StoreTokensAsync(tokens, cancellationToken).ConfigureAwait(false);
return tokens;
}
/// <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">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</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">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</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 = _dcrClientName,
ClientUri = _dcrClientUri?.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
};
if (!string.IsNullOrEmpty(_dcrInitialAccessToken))
{
request.Headers.Authorization = new AuthenticationHeaderValue(BearerScheme, _dcrInitialAccessToken);
}
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!);
if (_dcrResponseDelegate is not null)
{
await _dcrResponseDelegate(registrationResponse, cancellationToken).ConfigureAwait(false);
}
}
/// <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.Length > 0 && builder.Path[builder.Path.Length - 1] == '/')
{
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">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</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, BearerScheme, 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;
}
ReadOnlySpan<char> key = trimmedPart.AsSpan().Slice(0, equalsIndex).Trim();
if (key.Equals(parameterName, StringComparison.OrdinalIgnoreCase))
{
ReadOnlySpan<char> value = trimmedPart.AsSpan(equalsIndex + 1).Trim();
if (value.Length > 0 && value[0] == '"' && value[value.Length - 1] == '"')
{
value = value.Slice(1, value.Length - 2);
}
return value.ToString();
}
}
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)
{
#if NET9_0_OR_GREATER
Span<byte> hash = stackalloc byte[SHA256.HashSizeInBytes];
SHA256.HashData(Encoding.UTF8.GetBytes(codeVerifier), hash);
return Base64Url.EncodeToString(hash);
#else
using var sha256 = SHA256.Create();
var challengeBytes = sha256.ComputeHash(Encoding.UTF8.GetBytes(codeVerifier));
return Convert.ToBase64String(challengeBytes)
.TrimEnd('=')
.Replace('+', '-')
.Replace('/', '_');
#endif
}
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);
}