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;
///
/// A generic implementation of an OAuth authorization provider.
///
internal sealed partial class ClientOAuthProvider
{
///
/// The Bearer authentication scheme.
///
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 _additionalAuthorizationParameters;
private readonly Func, 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? _dcrResponseDelegate;
private readonly HttpClient _httpClient;
private readonly ILogger _logger;
private string? _clientId;
private string? _clientSecret;
private ITokenCache _tokenCache;
private AuthorizationServerMetadata? _authServerMetadata;
///
/// Initializes a new instance of the class using the specified options.
///
/// The MCP server URL.
/// The OAuth provider configuration options.
/// The HTTP client to use for OAuth requests. If null, a default HttpClient is used.
/// A logger factory to handle diagnostic messages.
/// or is null.
public ClientOAuthProvider(
Uri serverUrl,
ClientOAuthOptions options,
HttpClient httpClient,
ILoggerFactory? loggerFactory = null)
{
_serverUrl = serverUrl ?? throw new ArgumentNullException(nameof(serverUrl));
_httpClient = httpClient;
_logger = (ILogger?)loggerFactory?.CreateLogger() ?? 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();
}
///
/// Default authorization server selection strategy that selects the first available server.
///
/// List of available authorization servers.
/// The selected authorization server, or null if none are available.
private static Uri? DefaultAuthServerSelector(IReadOnlyList availableServers) => availableServers.FirstOrDefault();
///
/// Default authorization URL handler that displays the URL to the user for manual input.
///
/// The authorization URL to handle.
/// The redirect URI where the authorization code will be sent.
/// The to monitor for cancellation requests. The default is .
/// The authorization code entered by the user, or null if none was provided.
private static Task 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(authorizationCode);
}
///
/// Gets the collection of authentication schemes supported by this provider.
///
///
///
/// This property returns all authentication schemes that this provider can handle,
/// allowing clients to select the appropriate scheme based on server capabilities.
///
///
/// Common values include "Bearer" for JWT tokens, "Basic" for username/password authentication,
/// and "Negotiate" for integrated Windows authentication.
///
///
public IEnumerable SupportedSchemes => [BearerScheme];
///
/// Gets an authentication token or credential for authenticating requests to a resource
/// using the specified authentication scheme.
///
/// The authentication scheme to use.
/// The URI of the resource requiring authentication.
/// The to monitor for cancellation requests. The default is .
/// An authentication token string, or null if no token could be obtained for the specified scheme.
public async Task 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;
}
///
/// Handles a 401 Unauthorized response from a resource.
///
/// The authentication scheme that was used when the unauthorized response was received.
/// The HTTP response that contained the 401 status code.
/// The to monitor for cancellation requests. The default is .
///
/// 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.
///
public async Task HandleUnauthorizedResponseAsync(
string scheme,
HttpResponseMessage response,
CancellationToken cancellationToken = default)
{
ThrowIfNotBearerScheme(scheme);
await PerformOAuthAuthorizationAsync(response, cancellationToken).ConfigureAwait(false);
}
///
/// Performs OAuth authorization by selecting an appropriate authorization server and completing the OAuth flow.
///
/// The 401 Unauthorized response containing authentication challenge.
/// The to monitor for cancellation requests. The default is .
/// A result object indicating whether authorization was successful.
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 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 RefreshTokenAsync(string refreshToken, Uri resourceUri, AuthorizationServerMetadata authServerMetadata, CancellationToken cancellationToken)
{
var requestContent = new FormUrlEncodedContent(new Dictionary
{
["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
{
["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
{
["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 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 ?? ""}'. 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;
}
///
/// Fetches the protected resource metadata from the provided URL.
///
/// The URL to fetch the metadata from.
/// The to monitor for cancellation requests. The default is .
/// The fetched ProtectedResourceMetadata, or null if it couldn't be fetched.
private async Task 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);
}
///
/// Performs dynamic client registration with the authorization server.
///
/// The authorization server metadata.
/// The to monitor for cancellation requests. The default is .
/// A task representing the asynchronous operation.
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);
}
}
///
/// 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.
///
/// The metadata to verify.
/// The original URL the client used to make the request to the resource server.
/// True if the resource URI exactly matches the original request URL, otherwise false.
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);
}
///
/// Normalizes a URI for consistent comparison.
///
/// The URI to normalize.
/// A normalized string representation of the URI.
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();
}
///
/// 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.
///
/// The HTTP response containing the WWW-Authenticate header.
/// The server URL to verify against the resource metadata.
/// The to monitor for cancellation requests. The default is .
/// The resource metadata if the resource matches the server, otherwise throws an exception.
/// 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.
private async Task 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;
}
///
/// Parses the WWW-Authenticate header parameters to extract a specific parameter.
///
/// The parameter string from the WWW-Authenticate header.
/// The name of the parameter to extract.
/// The value of the parameter, or null if not found.
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 key = trimmedPart.AsSpan().Slice(0, equalsIndex).Trim();
if (key.Equals(parameterName, StringComparison.OrdinalIgnoreCase))
{
ReadOnlySpan 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 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);
}