Support incremental scope consent (SEP-835) (#1084)

This commit is contained in:
Stephen Halter 2025-12-11 19:51:47 -08:00 • committed by GitHub
commit 31f8d20a1a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 782 additions and 394 deletions

View file

@ -1,118 +0,0 @@
using ModelContextProtocol.Client;
using ModelContextProtocol.Protocol;
using System.Net.Http.Headers;
namespace ModelContextProtocol.Authentication;
/// <summary>
/// A delegating handler that adds authentication tokens to requests and handles 401 responses.
/// </summary>
internal sealed class AuthenticatingMcpHttpClient(HttpClient httpClient, ClientOAuthProvider credentialProvider) : McpHttpClient(httpClient)
{
// Select first supported scheme as the default
private string _currentScheme = credentialProvider.SupportedSchemes.FirstOrDefault() ??
throw new ArgumentException("Authorization provider must support at least one authentication scheme.", nameof(credentialProvider));
/// <summary>
/// Sends an HTTP request with authentication handling.
/// </summary>
internal override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, JsonRpcMessage? message, CancellationToken cancellationToken)
{
if (request.Headers.Authorization == null)
{
await AddAuthorizationHeaderAsync(request, _currentScheme, cancellationToken).ConfigureAwait(false);
}
var response = await base.SendAsync(request, message, cancellationToken).ConfigureAwait(false);
if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized)
{
return await HandleUnauthorizedResponseAsync(request, message, response, cancellationToken).ConfigureAwait(false);
}
return response;
}
/// <summary>
/// Handles a 401 Unauthorized response by attempting to authenticate and retry the request.
/// </summary>
private async Task<HttpResponseMessage> HandleUnauthorizedResponseAsync(
HttpRequestMessage originalRequest,
JsonRpcMessage? originalJsonRpcMessage,
HttpResponseMessage response,
CancellationToken cancellationToken)
{
// Gather the schemes the server wants us to use from WWW-Authenticate headers
var serverSchemes = ExtractServerSupportedSchemes(response);
if (!serverSchemes.Contains(_currentScheme))
{
// Find the first server scheme that's in our supported set
var bestSchemeMatch = serverSchemes.Intersect(credentialProvider.SupportedSchemes, StringComparer.OrdinalIgnoreCase).FirstOrDefault();
if (bestSchemeMatch is not null)
{
_currentScheme = bestSchemeMatch;
}
else if (serverSchemes.Count > 0)
{
// If no match was found, either throw an exception or use default
throw new McpException(
$"The server does not support any of the provided authentication schemes." +
$"Server supports: [{string.Join(", ", serverSchemes)}], " +
$"Provider supports: [{string.Join(", ", credentialProvider.SupportedSchemes)}].");
}
}
// Try to handle the 401 response with the selected scheme
await credentialProvider.HandleUnauthorizedResponseAsync(_currentScheme, response, cancellationToken).ConfigureAwait(false);
using var retryRequest = new HttpRequestMessage(originalRequest.Method, originalRequest.RequestUri);
// Copy headers except Authorization which we'll set separately
foreach (var header in originalRequest.Headers)
{
if (!header.Key.Equals("Authorization", StringComparison.OrdinalIgnoreCase))
{
retryRequest.Headers.TryAddWithoutValidation(header.Key, header.Value);
}
}
await AddAuthorizationHeaderAsync(retryRequest, _currentScheme, cancellationToken).ConfigureAwait(false);
return await base.SendAsync(retryRequest, originalJsonRpcMessage, cancellationToken).ConfigureAwait(false);
}
/// <summary>
/// Extracts the authentication schemes that the server supports from the WWW-Authenticate headers.
/// </summary>
private static HashSet<string> ExtractServerSupportedSchemes(HttpResponseMessage response)
{
var serverSchemes = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var header in response.Headers.WwwAuthenticate)
{
serverSchemes.Add(header.Scheme);
}
return serverSchemes;
}
/// <summary>
/// Adds an authorization header to the request.
/// </summary>
private async Task AddAuthorizationHeaderAsync(HttpRequestMessage request, string scheme, CancellationToken cancellationToken)
{
if (request.RequestUri is null)
{
return;
}
var token = await credentialProvider.GetCredentialAsync(scheme, request.RequestUri, cancellationToken).ConfigureAwait(false);
if (string.IsNullOrEmpty(token))
{
return;
}
request.Headers.Authorization = new AuthenticationHeaderValue(scheme, token);
}
}

View file

@ -1,3 +1,5 @@
using ModelContextProtocol.Client;
using ModelContextProtocol.Protocol;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
#if NET9_0_OR_GREATER
@ -15,7 +17,7 @@ namespace ModelContextProtocol.Authentication;
/// <summary>
/// A generic implementation of an OAuth authorization provider.
/// </summary>
internal sealed partial class ClientOAuthProvider
internal sealed partial class ClientOAuthProvider : McpHttpClient
{
/// <summary>
/// The Bearer authentication scheme.
@ -23,11 +25,9 @@ internal sealed partial class ClientOAuthProvider
private const string BearerScheme = "Bearer";
private const string ProtectedResourceMetadataWellKnownPath = "/.well-known/oauth-protected-resource";
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 string? _configuredScopes;
private readonly IDictionary<string, string> _additionalAuthorizationParameters;
private readonly Func<IReadOnlyList<Uri>, Uri?> _authServerSelector;
private readonly AuthorizationRedirectDelegate _authorizationRedirectDelegate;
@ -60,6 +60,7 @@ internal sealed partial class ClientOAuthProvider
ClientOAuthOptions options,
HttpClient httpClient,
ILoggerFactory? loggerFactory = null)
: base(httpClient)
{
_serverUrl = serverUrl ?? throw new ArgumentNullException(nameof(serverUrl));
_httpClient = httpClient;
@ -73,7 +74,7 @@ internal sealed partial class ClientOAuthProvider
_clientId = options.ClientId;
_clientSecret = options.ClientSecret;
_redirectUri = options.RedirectUri ?? throw new ArgumentException("ClientOAuthOptions.RedirectUri must configured.", nameof(options));
_scopes = options.Scopes?.ToArray();
_configuredScopes = options.Scopes is null ? null : string.Join(" ", options.Scopes);
_additionalAuthorizationParameters = options.AdditionalAuthorizationParameters;
_clientMetadataDocumentUri = options.ClientMetadataDocumentUri;
@ -102,7 +103,7 @@ internal sealed partial class ClientOAuthProvider
/// </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>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests.</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)
{
@ -114,86 +115,126 @@ internal sealed partial class ClientOAuthProvider
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)
internal override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, JsonRpcMessage? message, CancellationToken cancellationToken)
{
ThrowIfNotBearerScheme(scheme);
bool attemptedRefresh = false;
if (request.Headers.Authorization is null && request.RequestUri is not null)
{
string? accessToken;
(accessToken, attemptedRefresh) = await GetAccessTokenSilentAsync(request.RequestUri, cancellationToken).ConfigureAwait(false);
if (!string.IsNullOrEmpty(accessToken))
{
request.Headers.Authorization = new AuthenticationHeaderValue(BearerScheme, accessToken);
}
}
var response = await base.SendAsync(request, message, cancellationToken).ConfigureAwait(false);
if (ShouldRetryWithNewAccessToken(response))
{
return await HandleUnauthorizedResponseAsync(request, message, response, attemptedRefresh, cancellationToken).ConfigureAwait(false);
}
return response;
}
private async Task<(string? AccessToken, bool AttemptedRefresh)> GetAccessTokenSilentAsync(Uri resourceUri, CancellationToken cancellationToken)
{
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;
return (tokens.AccessToken, false);
}
// Try to refresh the access token if it is invalid and we have a refresh token.
if (tokens?.RefreshToken != null && _authServerMetadata != null)
if (_authServerMetadata is not null && tokens?.RefreshToken is { Length: > 0 } refreshToken)
{
var newTokens = await RefreshTokenAsync(tokens.RefreshToken, resourceUri, _authServerMetadata, cancellationToken).ConfigureAwait(false);
if (newTokens is not null)
{
return newTokens.AccessToken;
}
var accessToken = await RefreshTokensAsync(refreshToken, resourceUri, _authServerMetadata, cancellationToken).ConfigureAwait(false);
return (accessToken, true);
}
// No valid token - auth handler will trigger the 401 flow
return null;
return (null, false);
}
/// <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)
private static bool ShouldRetryWithNewAccessToken(HttpResponseMessage response)
{
ThrowIfNotBearerScheme(scheme);
await PerformOAuthAuthorizationAsync(response, cancellationToken).ConfigureAwait(false);
if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized)
{
return true;
}
// Only retry 403 Forbidden if it contains an insufficient_scope error as described in Section 10.1.1 of the MCP specification
// https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#runtime-insufficient-scope-errors
if (response.StatusCode != System.Net.HttpStatusCode.Forbidden)
{
return false;
}
foreach (var header in response.Headers.WwwAuthenticate)
{
if (!string.Equals(header.Scheme, BearerScheme, StringComparison.OrdinalIgnoreCase) || string.IsNullOrEmpty(header.Parameter))
{
continue;
}
var error = ParseWwwAuthenticateParameters(header.Parameter, "error");
if (string.Equals(error, "insufficient_scope", StringComparison.OrdinalIgnoreCase))
{
return true;
}
}
return 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(
private async Task<HttpResponseMessage> HandleUnauthorizedResponseAsync(
HttpRequestMessage originalRequest,
JsonRpcMessage? originalJsonRpcMessage,
HttpResponseMessage response,
bool attemptedRefresh,
CancellationToken cancellationToken)
{
// Get available authorization servers from the 401 response
var protectedResourceMetadata = await ExtractProtectedResourceMetadata(response, _serverUrl, cancellationToken).ConfigureAwait(false);
if (response.Headers.WwwAuthenticate.Count == 0)
{
LogMissingWwwAuthenticateHeader();
}
else if (!response.Headers.WwwAuthenticate.Any(static header => string.Equals(header.Scheme, BearerScheme, StringComparison.OrdinalIgnoreCase)))
{
var serverSchemes = string.Join(", ", response.Headers.WwwAuthenticate.Select(static header => header.Scheme));
throw new McpException($"The server does not support the '{BearerScheme}' authentication scheme. Server supports: [{serverSchemes}].");
}
var accessToken = await GetAccessTokenAsync(response, attemptedRefresh, cancellationToken).ConfigureAwait(false);
using var retryRequest = new HttpRequestMessage(originalRequest.Method, originalRequest.RequestUri);
foreach (var header in originalRequest.Headers)
{
if (!header.Key.Equals("Authorization", StringComparison.OrdinalIgnoreCase))
{
retryRequest.Headers.TryAddWithoutValidation(header.Key, header.Value);
}
}
retryRequest.Headers.Authorization = new AuthenticationHeaderValue(BearerScheme, accessToken);
return await base.SendAsync(retryRequest, originalJsonRpcMessage, cancellationToken).ConfigureAwait(false);
}
/// <summary>
/// Handles a 401 Unauthorized or 403 Forbidden response from a resource by completing any required OAuth flows.
/// </summary>
/// <param name="response">The HTTP response that triggered the authentication challenge.</param>
/// <param name="attemptedRefresh">Indicates whether a token refresh has already been attempted.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests.</param>
private async Task<string> GetAccessTokenAsync(HttpResponseMessage response, bool attemptedRefresh, CancellationToken cancellationToken)
{
// Get available authorization servers from the 401 or 403 response
var protectedResourceMetadata = await ExtractProtectedResourceMetadata(response, cancellationToken).ConfigureAwait(false);
var availableAuthorizationServers = protectedResourceMetadata.AuthorizationServers;
if (availableAuthorizationServers.Count == 0)
@ -225,13 +266,19 @@ internal sealed partial class ClientOAuthProvider
// The existing access token must be invalid to have resulted in a 401 response, but refresh might still work.
var resourceUri = GetRequiredResourceUri(protectedResourceMetadata);
if (await _tokenCache.GetTokensAsync(cancellationToken).ConfigureAwait(false) is { RefreshToken: { } refreshToken })
// Only attempt a token refresh if we haven't attempted to already for this request.
// Also only attempt a token refresh for a 401 Unauthorized responses. Other response status codes
// should not be used for expired access tokens. This is important because 403 forbiden responses can
// be used for incremental consent which cannot be acheived with a simple refresh.
if (!attemptedRefresh &&
response.StatusCode == System.Net.HttpStatusCode.Unauthorized &&
await _tokenCache.GetTokensAsync(cancellationToken).ConfigureAwait(false) is { RefreshToken: { Length: > 0 } refreshToken })
{
var refreshedTokens = await RefreshTokenAsync(refreshToken, resourceUri, authServerMetadata, cancellationToken).ConfigureAwait(false);
if (refreshedTokens is not null)
var accessToken = await RefreshTokensAsync(refreshToken, resourceUri, authServerMetadata, cancellationToken).ConfigureAwait(false);
if (accessToken is not null)
{
// A non-null result indicates the refresh succeeded and the new tokens have been stored.
return;
return accessToken;
}
}
@ -245,14 +292,12 @@ internal sealed partial class ClientOAuthProvider
}
else
{
await PerformDynamicClientRegistrationAsync(authServerMetadata, cancellationToken).ConfigureAwait(false);
await PerformDynamicClientRegistrationAsync(protectedResourceMetadata, authServerMetadata, cancellationToken).ConfigureAwait(false);
}
}
// Perform the OAuth flow
await InitiateAuthorizationCodeFlowAsync(protectedResourceMetadata, authServerMetadata, cancellationToken).ConfigureAwait(false);
LogOAuthAuthorizationCompleted();
return await InitiateAuthorizationCodeFlowAsync(protectedResourceMetadata, authServerMetadata, cancellationToken).ConfigureAwait(false);
}
private void ApplyClientIdMetadataDocument(Uri metadataUri)
@ -274,18 +319,10 @@ internal sealed partial class ClientOAuthProvider
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)
foreach (var wellKnownEndpoint in GetWellKnownAuthorizationServerMetadataUris(authServerUri))
{
try
{
var wellKnownEndpoint = new Uri(authServerUri, path);
var response = await _httpClient.GetAsync(wellKnownEndpoint, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
@ -320,14 +357,33 @@ internal sealed partial class ClientOAuthProvider
}
catch (Exception ex)
{
LogErrorFetchingAuthServerMetadata(ex, path);
LogErrorFetchingAuthServerMetadata(ex, wellKnownEndpoint);
}
}
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)
private static IEnumerable<Uri> GetWellKnownAuthorizationServerMetadataUris(Uri issuer)
{
var builder = new UriBuilder(issuer);
var hostBase = builder.Uri.GetLeftPart(UriPartial.Authority);
var trimmedPath = builder.Path?.Trim('/') ?? string.Empty;
if (string.IsNullOrEmpty(trimmedPath))
{
yield return new Uri($"{hostBase}/.well-known/oauth-authorization-server");
yield return new Uri($"{hostBase}/.well-known/openid-configuration");
}
else
{
yield return new Uri($"{hostBase}/.well-known/oauth-authorization-server/{trimmedPath}");
yield return new Uri($"{hostBase}/.well-known/openid-configuration/{trimmedPath}");
yield return new Uri($"{hostBase}/{trimmedPath}/.well-known/openid-configuration");
}
}
private async Task<string?> RefreshTokensAsync(string refreshToken, Uri resourceUri, AuthorizationServerMetadata authServerMetadata, CancellationToken cancellationToken)
{
var requestContent = new FormUrlEncodedContent(new Dictionary<string, string>
{
@ -350,10 +406,12 @@ internal sealed partial class ClientOAuthProvider
return null;
}
return await HandleSuccessfulTokenResponseAsync(httpResponse, cancellationToken).ConfigureAwait(false);
var tokens = await HandleSuccessfulTokenResponseAsync(httpResponse, cancellationToken).ConfigureAwait(false);
LogOAuthTokenRefreshCompleted();
return tokens.AccessToken;
}
private async Task InitiateAuthorizationCodeFlowAsync(
private async Task<string> InitiateAuthorizationCodeFlowAsync(
ProtectedResourceMetadata protectedResourceMetadata,
AuthorizationServerMetadata authServerMetadata,
CancellationToken cancellationToken)
@ -369,7 +427,7 @@ internal sealed partial class ClientOAuthProvider
ThrowFailedToHandleUnauthorizedResponse($"The {nameof(AuthorizationRedirectDelegate)} returned a null or empty authorization code.");
}
await ExchangeCodeForTokenAsync(protectedResourceMetadata, authServerMetadata, authCode!, codeVerifier, cancellationToken).ConfigureAwait(false);
return await ExchangeCodeForTokenAsync(protectedResourceMetadata, authServerMetadata, authCode!, codeVerifier, cancellationToken).ConfigureAwait(false);
}
private Uri BuildAuthorizationUrl(
@ -389,10 +447,10 @@ internal sealed partial class ClientOAuthProvider
["resource"] = resourceUri.ToString(),
};
var scopesSupported = protectedResourceMetadata.ScopesSupported;
if (_scopes is not null || scopesSupported.Count > 0)
var scope = GetScopeParameter(protectedResourceMetadata);
if (!string.IsNullOrEmpty(scope))
{
queryParamsDictionary["scope"] = string.Join(" ", _scopes ?? scopesSupported.ToArray());
queryParamsDictionary["scope"] = scope!;
}
// Add extra parameters if provided. Load into a dictionary before constructing to avoid overwiting values.
@ -415,7 +473,7 @@ internal sealed partial class ClientOAuthProvider
return uriBuilder.Uri;
}
private async Task ExchangeCodeForTokenAsync(
private async Task<string> ExchangeCodeForTokenAsync(
ProtectedResourceMetadata protectedResourceMetadata,
AuthorizationServerMetadata authServerMetadata,
string authorizationCode,
@ -442,7 +500,10 @@ internal sealed partial class ClientOAuthProvider
using var httpResponse = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
httpResponse.EnsureSuccessStatusCode();
await HandleSuccessfulTokenResponseAsync(httpResponse, cancellationToken).ConfigureAwait(false);
var tokens = await HandleSuccessfulTokenResponseAsync(httpResponse, cancellationToken).ConfigureAwait(false);
LogOAuthAuthorizationCompleted();
return tokens.AccessToken;
}
private async Task<TokenContainer> HandleSuccessfulTokenResponseAsync(HttpResponseMessage response, CancellationToken cancellationToken)
@ -475,38 +536,20 @@ internal sealed partial class ClientOAuthProvider
return tokens;
}
private static Uri BuildProtectedResourceMetadataUri(Uri resourceUri)
{
var builder = new UriBuilder(resourceUri)
{
Query = string.Empty,
Fragment = string.Empty,
};
var pathSuffix = resourceUri.AbsolutePath;
if (pathSuffix.Length > 1)
{
pathSuffix = pathSuffix.TrimEnd('/');
builder.Path = string.Concat(ProtectedResourceMetadataWellKnownPath, pathSuffix);
}
else
{
builder.Path = ProtectedResourceMetadataWellKnownPath;
}
return builder.Uri;
}
/// <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)
private async Task<ProtectedResourceMetadata?> FetchProtectedResourceMetadataAsync(Uri metadataUrl, bool requireSuccess, CancellationToken cancellationToken)
{
using var httpResponse = await _httpClient.GetAsync(metadataUrl, cancellationToken).ConfigureAwait(false);
httpResponse.EnsureSuccessStatusCode();
if (requireSuccess)
{
httpResponse.EnsureSuccessStatusCode();
}
else if (!httpResponse.IsSuccessStatusCode)
{
return null;
}
using var stream = await httpResponse.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
return await JsonSerializer.DeserializeAsync(stream, McpJsonUtilities.JsonContext.Default.ProtectedResourceMetadata, cancellationToken).ConfigureAwait(false);
@ -515,10 +558,8 @@ internal sealed partial class ClientOAuthProvider
/// <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(
ProtectedResourceMetadata protectedResourceMetadata,
AuthorizationServerMetadata authServerMetadata,
CancellationToken cancellationToken)
{
@ -537,7 +578,7 @@ internal sealed partial class ClientOAuthProvider
TokenEndpointAuthMethod = "client_secret_post",
ClientName = _dcrClientName,
ClientUri = _dcrClientUri?.ToString(),
Scope = _scopes is not null ? string.Join(" ", _scopes) : null
Scope = GetScopeParameter(protectedResourceMetadata),
};
var requestJson = JsonSerializer.Serialize(registrationRequest, McpJsonUtilities.JsonContext.Default.DynamicClientRegistrationRequest);
@ -587,16 +628,43 @@ internal sealed partial class ClientOAuthProvider
}
}
private static Uri GetRequiredResourceUri(ProtectedResourceMetadata protectedResourceMetadata)
{
if (protectedResourceMetadata.Resource is null)
{
ThrowFailedToHandleUnauthorizedResponse("Protected resource metadata did not include a 'resource' value.");
}
return protectedResourceMetadata.Resource;
}
private string? GetScopeParameter(ProtectedResourceMetadata protectedResourceMetadata)
{
if (!string.IsNullOrEmpty(protectedResourceMetadata.WwwAuthenticateScope))
{
return protectedResourceMetadata.WwwAuthenticateScope;
}
else if (protectedResourceMetadata.ScopesSupported.Count > 0)
{
return string.Join(" ", protectedResourceMetadata.ScopesSupported);
}
return _configuredScopes;
}
/// <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>
/// <param name="resourceLocation">
/// The original URL the client used to make the request to the resource server or the root Uri for the resource server
/// if the metadata was automatically requested from the root well-known location.
/// </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)
if (protectedResourceMetadata.Resource is null)
{
return false;
}
@ -611,16 +679,6 @@ internal sealed partial class ClientOAuthProvider
return string.Equals(normalizedMetadataResource, normalizedResourceLocation, StringComparison.OrdinalIgnoreCase);
}
private static Uri GetRequiredResourceUri(ProtectedResourceMetadata protectedResourceMetadata)
{
if (protectedResourceMetadata.Resource is null)
{
ThrowFailedToHandleUnauthorizedResponse("Protected resource metadata did not include a 'resource' value.");
}
return protectedResourceMetadata.Resource;
}
/// <summary>
/// Normalizes a URI for consistent comparison.
/// </summary>
@ -628,17 +686,19 @@ internal sealed partial class ClientOAuthProvider
/// <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
};
var builder = new StringBuilder();
builder.Append(uri.Scheme);
builder.Append("://");
builder.Append(uri.Host);
if (builder.Path.Length > 0 && builder.Path[builder.Path.Length - 1] == '/')
if (!uri.IsDefaultPort)
{
builder.Path = builder.Path.TrimEnd('/');
builder.Append(':');
builder.Append(uri.Port);
}
return builder.Uri.ToString();
builder.Append(uri.AbsolutePath.TrimEnd('/'));
return builder.ToString();
}
/// <summary>
@ -646,61 +706,72 @@ internal sealed partial class ClientOAuthProvider
/// 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>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests.</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, 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)
private async Task<ProtectedResourceMetadata> ExtractProtectedResourceMetadata(HttpResponseMessage response, CancellationToken cancellationToken)
{
if (response.StatusCode != System.Net.HttpStatusCode.Unauthorized)
Uri resourceUri = _serverUrl;
string? wwwAuthenticateScope = null;
string? resourceMetadataUrl = null;
// Look for the Bearer authentication scheme with resource_metadata and/or scope parameters.
foreach (var header in response.Headers.WwwAuthenticate)
{
throw new InvalidOperationException($"Expected a 401 Unauthorized response, but received {(int)response.StatusCode} {response.StatusCode}");
if (string.Equals(header.Scheme, BearerScheme, StringComparison.OrdinalIgnoreCase) && !string.IsNullOrEmpty(header.Parameter))
{
resourceMetadataUrl = ParseWwwAuthenticateParameters(header.Parameter, "resource_metadata");
// "Use scope parameter from the initial WWW-Authenticate header in the 401 response, if provided."
// https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#scope-selection-strategy
//
// We use the scope even if resource_metadata is not present so long as it's for the Bearer scheme,
// since we do not require a resource_metadata parameter.
wwwAuthenticateScope ??= ParseWwwAuthenticateParameters(header.Parameter, "scope");
if (resourceMetadataUrl is not null)
{
break;
}
}
}
Uri metadataUri;
ProtectedResourceMetadata? metadata = null;
if (response.Headers.WwwAuthenticate.Count == 0)
if (resourceMetadataUrl is not null)
{
metadataUri = BuildProtectedResourceMetadataUri(serverUrl);
LogMissingWwwAuthenticateHeader(metadataUri);
metadata = await FetchProtectedResourceMetadataAsync(new(resourceMetadataUrl), requireSuccess: true, cancellationToken).ConfigureAwait(false)
?? throw new McpException($"Failed to fetch resource metadata from {resourceMetadataUrl}");
}
else
{
// Look for the Bearer authentication scheme with resource_metadata parameter
string? resourceMetadataUrl = null;
foreach (var header in response.Headers.WwwAuthenticate)
foreach (var (wellKnownUri, expectedResourceUri) in GetWellKnownResourceMetadataUris(_serverUrl))
{
if (string.Equals(header.Scheme, BearerScheme, StringComparison.OrdinalIgnoreCase) && !string.IsNullOrEmpty(header.Parameter))
LogMissingResourceMetadataParameter(wellKnownUri);
metadata = await FetchProtectedResourceMetadataAsync(wellKnownUri, requireSuccess: false, cancellationToken).ConfigureAwait(false);
if (metadata is not null)
{
resourceMetadataUrl = ParseWwwAuthenticateParameters(header.Parameter, "resource_metadata");
if (resourceMetadataUrl != null)
{
break;
}
resourceUri = expectedResourceUri;
break;
}
}
if (resourceMetadataUrl == null)
if (metadata is null)
{
metadataUri = BuildProtectedResourceMetadataUri(serverUrl);
LogMissingResourceMetadataParameter(metadataUri);
}
else
{
metadataUri = new(resourceMetadataUrl);
throw new McpException($"Failed to find protected resource metadata at a well-known location for {_serverUrl}");
}
}
var metadata = await FetchProtectedResourceMetadataAsync(metadataUri, cancellationToken).ConfigureAwait(false)
?? throw new McpException($"Failed to fetch resource metadata from {metadataUri}");
// The WWW-Authenticate header parameter should be preferred over using the scopes_supported metadata property.
// https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#protected-resource-metadata-discovery-requirements
metadata.WwwAuthenticateScope = wwwAuthenticateScope;
// 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);
// Per RFC: The resource value must be identical to the URL that the client used to make the request to the resource server
LogValidatingResourceMetadata(resourceUri);
if (!VerifyResourceMatch(metadata, serverUrl))
if (!VerifyResourceMatch(metadata, resourceUri))
{
throw new McpException($"Resource URI in metadata ({metadata.Resource}) does not match the expected URI ({serverUrl})");
throw new McpException($"Resource URI in metadata ({metadata.Resource}) does not match the expected URI ({resourceUri})");
}
return metadata;
@ -721,7 +792,7 @@ internal sealed partial class ClientOAuthProvider
foreach (var part in parameters.Split(','))
{
string trimmedPart = part.Trim();
var trimmedPart = part.AsSpan().Trim();
int equalsIndex = trimmedPart.IndexOf('=');
if (equalsIndex <= 0)
@ -729,15 +800,14 @@ internal sealed partial class ClientOAuthProvider
continue;
}
ReadOnlySpan<char> key = trimmedPart.AsSpan().Slice(0, equalsIndex).Trim();
var key = trimmedPart[..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] == '"')
var value = trimmedPart[(equalsIndex + 1)..].Trim();
if (value.Length > 0 && value[0] == '"' && value[^1] == '"')
{
value = value.Slice(1, value.Length - 2);
value = value[1..^1];
}
return value.ToString();
@ -747,15 +817,32 @@ internal sealed partial class ClientOAuthProvider
return null;
}
private static IEnumerable<(Uri WellKnownUri, Uri ExpectedResourceUri)> GetWellKnownResourceMetadataUris(Uri resourceUri)
{
var builder = new UriBuilder(resourceUri);
var hostBase = builder.Uri.GetLeftPart(UriPartial.Authority);
var trimmedPath = builder.Path?.Trim('/') ?? string.Empty;
if (!string.IsNullOrEmpty(trimmedPath))
{
yield return (new Uri($"{hostBase}{ProtectedResourceMetadataWellKnownPath}/{trimmedPath}"), resourceUri);
}
yield return (new Uri($"{hostBase}{ProtectedResourceMetadataWellKnownPath}"), new Uri(hostBase));
}
private static string GenerateCodeVerifier()
{
#if NET9_0_OR_GREATER
Span<byte> bytes = stackalloc byte[32];
RandomNumberGenerator.Fill(bytes);
return Base64Url.EncodeToString(bytes);
#else
var bytes = new byte[32];
using var rng = RandomNumberGenerator.Create();
rng.GetBytes(bytes);
return Convert.ToBase64String(bytes)
.TrimEnd('=')
.Replace('+', '-')
.Replace('/', '_');
return ToBase64UrlString(bytes);
#endif
}
private static string GenerateCodeChallenge(string codeVerifier)
@ -767,22 +854,21 @@ internal sealed partial class ClientOAuthProvider
#else
using var sha256 = SHA256.Create();
var challengeBytes = sha256.ComputeHash(Encoding.UTF8.GetBytes(codeVerifier));
return Convert.ToBase64String(challengeBytes)
.TrimEnd('=')
.Replace('+', '-')
.Replace('/', '_');
return ToBase64UrlString(challengeBytes);
#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 !NET9_0_OR_GREATER
private static string ToBase64UrlString(byte[] bytes)
{
if (!string.Equals(scheme, BearerScheme, StringComparison.OrdinalIgnoreCase))
{
throw new InvalidOperationException($"The '{scheme}' is not supported. This credential provider only supports the '{BearerScheme}' scheme");
}
return Convert.ToBase64String(bytes)
.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.");
[DoesNotReturn]
private static void ThrowFailedToHandleUnauthorizedResponse(string message) =>
@ -794,8 +880,11 @@ internal sealed partial class ClientOAuthProvider
[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 = "OAuth token refresh completed successfully")]
partial void LogOAuthTokenRefreshCompleted();
[LoggerMessage(Level = LogLevel.Error, Message = "Error fetching auth server metadata from {Endpoint}")]
partial void LogErrorFetchingAuthServerMetadata(Exception ex, Uri endpoint);
[LoggerMessage(Level = LogLevel.Information, Message = "Performing dynamic client registration with {RegistrationEndpoint}")]
partial void LogPerformingDynamicClientRegistration(Uri registrationEndpoint);
@ -806,9 +895,9 @@ internal sealed partial class ClientOAuthProvider
[LoggerMessage(Level = LogLevel.Debug, Message = "Validating resource metadata against original server URL: {ServerUrl}")]
partial void LogValidatingResourceMetadata(Uri serverUrl);
[LoggerMessage(Level = LogLevel.Debug, Message = "WWW-Authenticate header missing. Falling back to resource metadata at {MetadataUri}")]
partial void LogMissingWwwAuthenticateHeader(Uri metadataUri);
[LoggerMessage(Level = LogLevel.Warning, Message = "WWW-Authenticate header missing.")]
partial void LogMissingWwwAuthenticateHeader();
[LoggerMessage(Level = LogLevel.Debug, Message = "WWW-Authenticate header missing resource_metadata parameter. Falling back to {MetadataUri}")]
[LoggerMessage(Level = LogLevel.Debug, Message = "Missing resource_metadata parameter from WWW-Authenticate header. Falling back to {MetadataUri}")]
partial void LogMissingResourceMetadataParameter(Uri metadataUri);
}

View file

@ -177,4 +177,18 @@ public sealed class ProtectedResourceMetadata
/// </remarks>
[JsonPropertyName("dpop_bound_access_tokens_required")]
public bool? DpopBoundAccessTokensRequired { get; set; }
/// <summary>
/// Used internally by the client to get or set the scope specified as a WWW-Authenticate header parameter.
/// This should be preferred over using the ScopesSupported property.
///
/// The scopes included in the WWW-Authenticate challenge MAY match scopes_supported, be a subset or superset of it,
/// or an alternative collection that is neither a strict subset nor superset. Clients MUST NOT assume any particular
/// set relationship between the challenged scope set and scopes_supported. Clients MUST treat the scopes provided
/// in the challenge as authoritative for satisfying the current request.
///
/// https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#protected-resource-metadata-discovery-requirements
/// </summary>
[JsonIgnore]
internal string? WwwAuthenticateScope { get; set; }
}

View file

@ -53,8 +53,7 @@ public sealed class HttpClientTransport : IClientTransport, IAsyncDisposable
if (transportOptions.OAuth is { } clientOAuthOptions)
{
var oAuthProvider = new ClientOAuthProvider(_options.Endpoint, clientOAuthOptions, httpClient, loggerFactory);
_mcpHttpClient = new AuthenticatingMcpHttpClient(httpClient, oAuthProvider);
_mcpHttpClient = new ClientOAuthProvider(_options.Endpoint, clientOAuthOptions, httpClient, loggerFactory);
}
else
{