763 lines
35 KiB
C#
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);
|
|
}
|