Support incremental scope consent (SEP-835) (#1084)
This commit is contained in:
parent
828d51e9d4
commit
31f8d20a1a
9 changed files with 782 additions and 394 deletions
|
|
@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Authentication;
|
|||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.Net.Http.Headers;
|
||||
using ModelContextProtocol.Authentication;
|
||||
using System.Text.Encodings.Web;
|
||||
|
||||
|
|
@ -185,15 +186,9 @@ public partial class McpAuthenticationHandler : AuthenticationHandler<McpAuthent
|
|||
// Get the absolute URI for the resource metadata
|
||||
string rawPrmDocumentUri = GetAbsoluteResourceMetadataUri();
|
||||
|
||||
properties ??= new AuthenticationProperties();
|
||||
|
||||
// Store the resource_metadata in properties in case other handlers need it
|
||||
properties.Items["resource_metadata"] = rawPrmDocumentUri;
|
||||
|
||||
// Add the WWW-Authenticate header with Bearer scheme and resource metadata
|
||||
string headerValue = $"Bearer realm=\"{Scheme.Name}\", resource_metadata=\"{rawPrmDocumentUri}\"";
|
||||
Response.Headers.Append("WWW-Authenticate", headerValue);
|
||||
|
||||
string headerValue = $"Bearer resource_metadata=\"{rawPrmDocumentUri}\"";
|
||||
Response.Headers.Append(HeaderNames.WWWAuthenticate, headerValue);
|
||||
return base.HandleChallengeAsync(properties);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -275,6 +275,7 @@ internal sealed class StreamableHttpHandler(
|
|||
RandomNumberGenerator.Fill(buffer);
|
||||
return WebEncoders.Base64UrlEncode(buffer);
|
||||
}
|
||||
|
||||
internal static async Task<JsonRpcMessage?> ReadJsonRpcMessageAsync(HttpContext context)
|
||||
{
|
||||
// Implementation for reading a JSON-RPC message from the request body
|
||||
|
|
@ -291,7 +292,6 @@ internal sealed class StreamableHttpHandler(
|
|||
return message;
|
||||
}
|
||||
|
||||
|
||||
internal static Task RunSessionAsync(HttpContext httpContext, McpServer session, CancellationToken requestAborted)
|
||||
=> session.RunAsync(requestAborted);
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,9 +1,16 @@
|
|||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.WebUtilities;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using ModelContextProtocol.AspNetCore.Authentication;
|
||||
using ModelContextProtocol.Authentication;
|
||||
using ModelContextProtocol.Client;
|
||||
using ModelContextProtocol.Server;
|
||||
using System.Net;
|
||||
using System.Reflection;
|
||||
using System.Security.Claims;
|
||||
using Xunit.Sdk;
|
||||
|
||||
namespace ModelContextProtocol.AspNetCore.Tests.OAuth;
|
||||
|
|
@ -88,7 +95,6 @@ public class AuthTests : OAuthTestBase
|
|||
{
|
||||
RedirectUri = new Uri("http://localhost:1179/callback"),
|
||||
AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync,
|
||||
Scopes = ["mcp:tools"],
|
||||
DynamicClientRegistration = new()
|
||||
{
|
||||
ClientName = "Test MCP Client",
|
||||
|
|
@ -139,7 +145,6 @@ public class AuthTests : OAuthTestBase
|
|||
RedirectUri = new Uri("http://localhost:1179/callback"),
|
||||
AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync,
|
||||
ClientMetadataDocumentUri = new Uri("http://invalid-cimd.example.com"),
|
||||
Scopes = ["mcp:tools"],
|
||||
DynamicClientRegistration = new()
|
||||
{
|
||||
ClientName = "Test MCP Client (No CIMD)",
|
||||
|
|
@ -203,6 +208,33 @@ public class AuthTests : OAuthTestBase
|
|||
[Fact]
|
||||
public async Task CanAuthenticate_WithTokenRefresh()
|
||||
{
|
||||
var hasForcedRefresh = false;
|
||||
|
||||
Builder.Services.AddHttpContextAccessor();
|
||||
Builder.Services.AddMcpServer(options =>
|
||||
{
|
||||
options.ToolCollection = new();
|
||||
})
|
||||
.AddListToolsFilter(next =>
|
||||
{
|
||||
return async (mcpContext, cancellationToken) =>
|
||||
{
|
||||
if (!hasForcedRefresh)
|
||||
{
|
||||
hasForcedRefresh = true;
|
||||
|
||||
var httpContext = mcpContext.Services!.GetRequiredService<IHttpContextAccessor>().HttpContext!;
|
||||
await httpContext.ChallengeAsync(JwtBearerDefaults.AuthenticationScheme);
|
||||
await httpContext.Response.CompleteAsync();
|
||||
throw new Exception("This exception will not impact the client because the response has already been completed.");
|
||||
}
|
||||
else
|
||||
{
|
||||
return await next(mcpContext, cancellationToken);
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
await using var app = await StartMcpServerAsync();
|
||||
|
||||
await using var transport = new HttpClientTransport(new()
|
||||
|
|
@ -210,18 +242,18 @@ public class AuthTests : OAuthTestBase
|
|||
Endpoint = new(McpServerUrl),
|
||||
OAuth = new()
|
||||
{
|
||||
ClientId = "test-refresh-client",
|
||||
ClientSecret = "test-refresh-secret",
|
||||
ClientId = "demo-client",
|
||||
ClientSecret = "demo-secret",
|
||||
RedirectUri = new Uri("http://localhost:1179/callback"),
|
||||
AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync,
|
||||
},
|
||||
}, HttpClient, LoggerFactory);
|
||||
|
||||
// The test-refresh-client should get an expired token first,
|
||||
// then automatically refresh it to get a working token
|
||||
await using var client = await McpClient.CreateAsync(
|
||||
transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken);
|
||||
|
||||
await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.True(TestOAuthServer.HasRefreshedToken);
|
||||
}
|
||||
|
||||
|
|
@ -328,25 +360,396 @@ public class AuthTests : OAuthTestBase
|
|||
}
|
||||
|
||||
[Fact]
|
||||
public async Task JwtBearerChallenge_DoesNotIncludeResourceMetadata()
|
||||
public async Task AuthorizationFlow_UsesScopeFromProtectedResourceMetadata()
|
||||
{
|
||||
await using var app = await StartMcpServerAsync(authScheme: JwtBearerDefaults.AuthenticationScheme);
|
||||
|
||||
using var unauthorizedResponse = await HttpClient.GetAsync(McpServerUrl, HttpCompletionOption.ResponseHeadersRead, TestContext.Current.CancellationToken);
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, unauthorizedResponse.StatusCode);
|
||||
|
||||
var headerFound = false;
|
||||
foreach (var header in unauthorizedResponse.Headers.WwwAuthenticate)
|
||||
Builder.Services.Configure<McpAuthenticationOptions>(McpAuthenticationDefaults.AuthenticationScheme, options =>
|
||||
{
|
||||
headerFound = true;
|
||||
Assert.Equal("Bearer", header.Scheme);
|
||||
Assert.True(header.Parameter is null || !header.Parameter.Contains("resource_metadata", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
options.ResourceMetadata!.ScopesSupported = ["mcp:tools", "files:read"];
|
||||
});
|
||||
|
||||
Assert.True(headerFound);
|
||||
await using var app = await StartMcpServerAsync();
|
||||
|
||||
using var metadataResponse = await HttpClient.GetAsync(new Uri("/.well-known/oauth-protected-resource", UriKind.Relative), TestContext.Current.CancellationToken);
|
||||
metadataResponse.EnsureSuccessStatusCode();
|
||||
string? requestedScope = null;
|
||||
|
||||
await using var transport = new HttpClientTransport(new()
|
||||
{
|
||||
Endpoint = new(McpServerUrl),
|
||||
OAuth = new()
|
||||
{
|
||||
ClientId = "demo-client",
|
||||
ClientSecret = "demo-secret",
|
||||
RedirectUri = new Uri("http://localhost:1179/callback"),
|
||||
AuthorizationRedirectDelegate = (uri, redirect, ct) =>
|
||||
{
|
||||
var query = QueryHelpers.ParseQuery(uri.Query);
|
||||
requestedScope = query["scope"].ToString();
|
||||
return HandleAuthorizationUrlAsync(uri, redirect, ct);
|
||||
},
|
||||
},
|
||||
}, HttpClient, LoggerFactory);
|
||||
|
||||
await using var client = await McpClient.CreateAsync(
|
||||
transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal("mcp:tools files:read", requestedScope);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AuthorizationFlow_UsesScopeFromChallengeHeader()
|
||||
{
|
||||
var challengeScopes = "challenge:read challenge:write";
|
||||
|
||||
await using var app = Builder.Build();
|
||||
app.Use(next =>
|
||||
{
|
||||
return async context =>
|
||||
{
|
||||
await next(context);
|
||||
|
||||
if (context.Response.StatusCode != 401)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
context.Response.Headers.WWWAuthenticate = $"Bearer resource_metadata=\"{McpServerUrl}/.well-known/oauth-protected-resource\", scope=\"{challengeScopes}\"";
|
||||
};
|
||||
});
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
|
||||
app.MapMcp().RequireAuthorization();
|
||||
await app.StartAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
string? requestedScope = null;
|
||||
|
||||
await using var transport = new HttpClientTransport(new()
|
||||
{
|
||||
Endpoint = new(McpServerUrl),
|
||||
OAuth = new()
|
||||
{
|
||||
ClientId = "demo-client",
|
||||
ClientSecret = "demo-secret",
|
||||
RedirectUri = new Uri("http://localhost:1179/callback"),
|
||||
AuthorizationRedirectDelegate = (uri, redirect, ct) =>
|
||||
{
|
||||
var query = QueryHelpers.ParseQuery(uri.Query);
|
||||
requestedScope = query["scope"].ToString();
|
||||
return HandleAuthorizationUrlAsync(uri, redirect, ct);
|
||||
},
|
||||
},
|
||||
}, HttpClient, LoggerFactory);
|
||||
|
||||
await using var client = await McpClient.CreateAsync(
|
||||
transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(challengeScopes, requestedScope);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AuthorizationFlow_UsesScopeFromForbiddenHeader()
|
||||
{
|
||||
var adminScopes = "admin:read admin:write";
|
||||
|
||||
Builder.Services.AddHttpContextAccessor();
|
||||
Builder.Services.AddMcpServer()
|
||||
.WithTools([
|
||||
McpServerTool.Create([McpServerTool(Name = "admin-tool")]
|
||||
async (IServiceProvider serviceProvider, ClaimsPrincipal user) =>
|
||||
{
|
||||
if (!user.HasClaim("scope", adminScopes))
|
||||
{
|
||||
var httpContext = serviceProvider.GetRequiredService<IHttpContextAccessor>().HttpContext!;
|
||||
httpContext.Response.StatusCode = StatusCodes.Status403Forbidden;
|
||||
httpContext.Response.Headers.WWWAuthenticate = $"Bearer error=\"insufficient_scope\", resource_metadata=\"{McpServerUrl}/.well-known/oauth-protected-resource\", scope=\"{adminScopes}\"";
|
||||
await httpContext.Response.CompleteAsync();
|
||||
|
||||
throw new Exception("This exception will not impact the client because the response has already been completed.");
|
||||
}
|
||||
|
||||
return "Admin tool executed.";
|
||||
}),
|
||||
]);
|
||||
|
||||
string? requestedScope = null;
|
||||
|
||||
await using var app = await StartMcpServerAsync();
|
||||
|
||||
await using var transport = new HttpClientTransport(new()
|
||||
{
|
||||
Endpoint = new(McpServerUrl),
|
||||
OAuth = new()
|
||||
{
|
||||
ClientId = "demo-client",
|
||||
ClientSecret = "demo-secret",
|
||||
RedirectUri = new Uri("http://localhost:1179/callback"),
|
||||
AuthorizationRedirectDelegate = (uri, redirect, ct) =>
|
||||
{
|
||||
var query = QueryHelpers.ParseQuery(uri.Query);
|
||||
requestedScope = query["scope"].ToString();
|
||||
return HandleAuthorizationUrlAsync(uri, redirect, ct);
|
||||
},
|
||||
},
|
||||
}, HttpClient, LoggerFactory);
|
||||
|
||||
await using var client = await McpClient.CreateAsync(
|
||||
transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal("mcp:tools", requestedScope);
|
||||
|
||||
var adminResult = await client.CallToolAsync("admin-tool", cancellationToken: TestContext.Current.CancellationToken);
|
||||
Assert.Equal("Admin tool executed.", adminResult.Content[0].ToString());
|
||||
|
||||
Assert.Equal(adminScopes, requestedScope);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AuthorizationFails_WhenResourceMetadataPortDiffers()
|
||||
{
|
||||
Builder.Services.Configure<McpAuthenticationOptions>(McpAuthenticationDefaults.AuthenticationScheme, options =>
|
||||
{
|
||||
options.ResourceMetadata!.Resource = new Uri("http://localhost:5999");
|
||||
});
|
||||
|
||||
await using var app = await StartMcpServerAsync();
|
||||
|
||||
await using var transport = new HttpClientTransport(new()
|
||||
{
|
||||
Endpoint = new(McpServerUrl),
|
||||
OAuth = new()
|
||||
{
|
||||
ClientId = "demo-client",
|
||||
ClientSecret = "demo-secret",
|
||||
RedirectUri = new Uri("http://localhost:1179/callback"),
|
||||
AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync,
|
||||
},
|
||||
}, HttpClient, LoggerFactory);
|
||||
|
||||
await Assert.ThrowsAsync<McpException>(() => McpClient.CreateAsync(
|
||||
transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CanAuthenticate_WithAuthorizationServerPathInsertionMetadata()
|
||||
{
|
||||
Builder.Services.Configure<McpAuthenticationOptions>(McpAuthenticationDefaults.AuthenticationScheme, options =>
|
||||
{
|
||||
options.ResourceMetadata!.AuthorizationServers = [new Uri($"{OAuthServerUrl}/tenant1")];
|
||||
});
|
||||
|
||||
await using var app = await StartMcpServerAsync();
|
||||
|
||||
await using var transport = new HttpClientTransport(new()
|
||||
{
|
||||
Endpoint = new(McpServerUrl),
|
||||
OAuth = new()
|
||||
{
|
||||
ClientId = "demo-client",
|
||||
ClientSecret = "demo-secret",
|
||||
RedirectUri = new Uri("http://localhost:1179/callback"),
|
||||
AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync,
|
||||
},
|
||||
}, HttpClient, LoggerFactory);
|
||||
|
||||
await using var client = await McpClient.CreateAsync(
|
||||
transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken);
|
||||
|
||||
var requests = TestOAuthServer.MetadataRequests.ToArray();
|
||||
Assert.Contains("/.well-known/oauth-authorization-server/tenant1", requests);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CanAuthenticate_WithAuthorizationServerPathFallbacks()
|
||||
{
|
||||
const string issuerPath = "/subdir/tenant2";
|
||||
TestOAuthServer.DisabledMetadataPaths.Add($"/.well-known/oauth-authorization-server{issuerPath}");
|
||||
TestOAuthServer.DisabledMetadataPaths.Add($"/.well-known/openid-configuration{issuerPath}");
|
||||
|
||||
Builder.Services.Configure<McpAuthenticationOptions>(McpAuthenticationDefaults.AuthenticationScheme, options =>
|
||||
{
|
||||
options.ResourceMetadata!.AuthorizationServers = [new Uri($"{OAuthServerUrl}{issuerPath}")];
|
||||
});
|
||||
|
||||
await using var app = await StartMcpServerAsync();
|
||||
|
||||
await using var transport = new HttpClientTransport(new()
|
||||
{
|
||||
Endpoint = new(McpServerUrl),
|
||||
OAuth = new()
|
||||
{
|
||||
ClientId = "demo-client",
|
||||
ClientSecret = "demo-secret",
|
||||
RedirectUri = new Uri("http://localhost:1179/callback"),
|
||||
AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync,
|
||||
},
|
||||
}, HttpClient, LoggerFactory);
|
||||
|
||||
await using var client = await McpClient.CreateAsync(
|
||||
transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(
|
||||
[
|
||||
$"/.well-known/oauth-authorization-server{issuerPath}",
|
||||
$"/.well-known/openid-configuration{issuerPath}",
|
||||
$"{issuerPath}/.well-known/openid-configuration",
|
||||
"/.well-known/openid-configuration",
|
||||
],
|
||||
TestOAuthServer.MetadataRequests);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CanAuthenticate_WithResourceMetadataPathFallbacks()
|
||||
{
|
||||
const string resourcePath = "/mcp";
|
||||
List<string> wellKnownRequests = [];
|
||||
|
||||
Builder.Services.Configure<AuthenticationOptions>(options => options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme);
|
||||
await using var app = Builder.Build();
|
||||
|
||||
var metadata = new ProtectedResourceMetadata
|
||||
{
|
||||
Resource = new Uri($"{McpServerUrl}{resourcePath}"),
|
||||
AuthorizationServers = { new Uri(OAuthServerUrl) },
|
||||
};
|
||||
|
||||
app.Use(async (context, next) =>
|
||||
{
|
||||
if (context.Request.Path.StartsWithSegments("/.well-known/oauth-protected-resource", out var remaining))
|
||||
{
|
||||
wellKnownRequests.Add(context.Request.Path);
|
||||
if (remaining.HasValue)
|
||||
{
|
||||
context.Response.StatusCode = StatusCodes.Status404NotFound;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await next();
|
||||
});
|
||||
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
|
||||
app.MapMcp(resourcePath).RequireAuthorization();
|
||||
|
||||
await app.StartAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
var endpoint = new Uri(new Uri(McpServerUrl), resourcePath);
|
||||
|
||||
await using var transport = new HttpClientTransport(new()
|
||||
{
|
||||
Endpoint = endpoint,
|
||||
OAuth = new()
|
||||
{
|
||||
ClientId = "demo-client",
|
||||
ClientSecret = "demo-secret",
|
||||
RedirectUri = new Uri("http://localhost:1179/callback"),
|
||||
AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync,
|
||||
},
|
||||
}, HttpClient, LoggerFactory);
|
||||
|
||||
await using var client = await McpClient.CreateAsync(
|
||||
transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(
|
||||
[
|
||||
$"/.well-known/oauth-protected-resource{resourcePath}",
|
||||
"/.well-known/oauth-protected-resource"
|
||||
],
|
||||
wellKnownRequests);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CannotAuthenticate_WhenResourceMetadataResourceIsNonRootParentPath()
|
||||
{
|
||||
const string configuredResourcePath = "/mcp";
|
||||
const string requestedResourcePath = "/mcp/tools";
|
||||
|
||||
// Remove resource_metadata from the WWW-Authenticate header, because we should only fall back at all (even to root) when it's missing.
|
||||
//
|
||||
// If the protected resource metadata was retrieved from a URL returned by the protected resource via the WWW-Authenticate resource_metadata parameter,
|
||||
// then the resource value returned MUST be identical to the URL that the client used to make the request to the resource server.
|
||||
// If these values are not identical, the data contained in the response MUST NOT be used.
|
||||
//
|
||||
// https://datatracker.ietf.org/doc/html/rfc9728/#section-3.3
|
||||
//
|
||||
// CannotAuthenticate_WhenWwwAuthenticateResourceMetadataIsRootPath validates we won't fall back to root in this case.
|
||||
// CanAuthenticate_WithResourceMetadataPathFallbacks validates we will fall back to root when resource_metadata is missing.
|
||||
Builder.Services.Configure<AuthenticationOptions>(options => options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme);
|
||||
Builder.Services.Configure<McpAuthenticationOptions>(McpAuthenticationDefaults.AuthenticationScheme, options =>
|
||||
{
|
||||
options.ResourceMetadata = new ProtectedResourceMetadata
|
||||
{
|
||||
Resource = new Uri($"{McpServerUrl}{configuredResourcePath}"),
|
||||
AuthorizationServers = { new Uri(OAuthServerUrl) },
|
||||
};
|
||||
});
|
||||
|
||||
await using var app = Builder.Build();
|
||||
|
||||
app.MapMcp(requestedResourcePath).RequireAuthorization();
|
||||
|
||||
await app.StartAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
await using var transport = new HttpClientTransport(new()
|
||||
{
|
||||
Endpoint = new Uri($"{McpServerUrl}{requestedResourcePath}"),
|
||||
OAuth = new()
|
||||
{
|
||||
ClientId = "demo-client",
|
||||
ClientSecret = "demo-secret",
|
||||
RedirectUri = new Uri("http://localhost:1179/callback"),
|
||||
AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync,
|
||||
},
|
||||
}, HttpClient, LoggerFactory);
|
||||
|
||||
var ex = await Assert.ThrowsAsync<McpException>(async () =>
|
||||
{
|
||||
await McpClient.CreateAsync(
|
||||
transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken);
|
||||
});
|
||||
|
||||
Assert.Contains("does not match", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CannotAuthenticate_WhenWwwAuthenticateResourceMetadataIsRootPath()
|
||||
{
|
||||
const string requestedResourcePath = "/mcp/tools";
|
||||
|
||||
Builder.Services.Configure<McpAuthenticationOptions>(McpAuthenticationDefaults.AuthenticationScheme, options =>
|
||||
{
|
||||
options.ResourceMetadata = new ProtectedResourceMetadata
|
||||
{
|
||||
Resource = new Uri($"{McpServerUrl}"),
|
||||
AuthorizationServers = { new Uri(OAuthServerUrl) },
|
||||
};
|
||||
});
|
||||
|
||||
await using var app = Builder.Build();
|
||||
|
||||
app.MapMcp(requestedResourcePath).RequireAuthorization();
|
||||
|
||||
await app.StartAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
await using var transport = new HttpClientTransport(new()
|
||||
{
|
||||
Endpoint = new Uri($"{McpServerUrl}{requestedResourcePath}"),
|
||||
OAuth = new()
|
||||
{
|
||||
ClientId = "demo-client",
|
||||
ClientSecret = "demo-secret",
|
||||
RedirectUri = new Uri("http://localhost:1179/callback"),
|
||||
AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync,
|
||||
},
|
||||
}, HttpClient, LoggerFactory);
|
||||
|
||||
var ex = await Assert.ThrowsAsync<McpException>(async () =>
|
||||
{
|
||||
await McpClient.CreateAsync(
|
||||
transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken);
|
||||
});
|
||||
|
||||
Assert.Contains("does not match", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ using ModelContextProtocol.AspNetCore.Authentication;
|
|||
using ModelContextProtocol.AspNetCore.Tests.Utils;
|
||||
using ModelContextProtocol.Authentication;
|
||||
using System.Net;
|
||||
using Xunit.Sdk;
|
||||
|
||||
namespace ModelContextProtocol.AspNetCore.Tests.OAuth;
|
||||
|
||||
|
|
@ -84,7 +83,7 @@ public abstract class OAuthTestBase : KestrelInMemoryTest, IAsyncDisposable
|
|||
|
||||
protected async Task<WebApplication> StartMcpServerAsync(string path = "", string? authScheme = null)
|
||||
{
|
||||
Builder.Services.PostConfigure<JwtBearerOptions>(JwtBearerDefaults.AuthenticationScheme, options =>
|
||||
Builder.Services.Configure<JwtBearerOptions>(JwtBearerDefaults.AuthenticationScheme, options =>
|
||||
{
|
||||
options.TokenValidationParameters.ValidAudience = $"{McpServerUrl}{path}";
|
||||
});
|
||||
|
|
|
|||
|
|
@ -26,6 +26,8 @@ public sealed class Program
|
|||
private readonly ConcurrentDictionary<string, TokenInfo> _tokens = new();
|
||||
private readonly ConcurrentDictionary<string, ClientInfo> _clients = new();
|
||||
|
||||
private readonly ConcurrentQueue<string> _metadataRequests = new();
|
||||
|
||||
private readonly RSA _rsa;
|
||||
private readonly string _keyId;
|
||||
|
||||
|
|
@ -46,7 +48,6 @@ public sealed class Program
|
|||
}
|
||||
|
||||
// Track if we've already issued an already-expired token for the CanAuthenticate_WithTokenRefresh test which uses the test-refresh-client registration.
|
||||
public bool HasIssuedExpiredToken { get; set; }
|
||||
public bool HasRefreshedToken { get; set; }
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -59,6 +60,9 @@ public sealed class Program
|
|||
/// </remarks>
|
||||
public bool ClientIdMetadataDocumentSupported { get; set; } = true;
|
||||
|
||||
public HashSet<string> DisabledMetadataPaths { get; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
public IReadOnlyCollection<string> MetadataRequests => _metadataRequests.ToArray();
|
||||
|
||||
/// <summary>
|
||||
/// Entry point for the application.
|
||||
/// </summary>
|
||||
|
|
@ -111,24 +115,15 @@ public sealed class Program
|
|||
|
||||
var app = builder.Build();
|
||||
|
||||
// Set up the demo client
|
||||
var clientId = "demo-client";
|
||||
var clientSecret = "demo-secret";
|
||||
|
||||
_clients[clientId] = new ClientInfo
|
||||
{
|
||||
ClientId = clientId,
|
||||
RequiresClientSecret = true,
|
||||
ClientSecret = clientSecret,
|
||||
RedirectUris = ["http://localhost:1179/callback"],
|
||||
};
|
||||
|
||||
// When this client ID is used, the first token issued will already be expired to make
|
||||
// testing the refresh flow easier.
|
||||
_clients["test-refresh-client"] = new ClientInfo
|
||||
{
|
||||
ClientId = "test-refresh-client",
|
||||
RequiresClientSecret = true,
|
||||
ClientSecret = "test-refresh-secret",
|
||||
RedirectUris = ["http://localhost:1179/callback"],
|
||||
};
|
||||
|
||||
|
|
@ -146,42 +141,62 @@ public sealed class Program
|
|||
};
|
||||
|
||||
// The MCP spec tells the client to use /.well-known/oauth-authorization-server but AddJwtBearer looks for
|
||||
// /.well-known/openid-configuration by default. To make things easier, we support both with the same response
|
||||
// which seems to be common. Ex. https://github.com/keycloak/keycloak/pull/29628
|
||||
// /.well-known/openid-configuration by default.
|
||||
//
|
||||
// The requirements for these endpoints are at https://www.rfc-editor.org/rfc/rfc8414 and
|
||||
// https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderMetadata respectively.
|
||||
// They do differ, but it's close enough at least for our current testing to use the same response for both.
|
||||
// See https://gist.github.com/localden/26d8bcf641703c08a5d8741aa9c3336c
|
||||
string[] metadataEndpoints = ["/.well-known/oauth-authorization-server", "/.well-known/openid-configuration"];
|
||||
foreach (var metadataEndpoint in metadataEndpoints)
|
||||
IResult HandleMetadataRequest(HttpContext context, string? issuerPath = null)
|
||||
{
|
||||
// OAuth 2.0 Authorization Server Metadata (RFC 8414)
|
||||
app.MapGet(metadataEndpoint, () =>
|
||||
{
|
||||
var metadata = new OAuthServerMetadata
|
||||
{
|
||||
Issuer = _url,
|
||||
AuthorizationEndpoint = $"{_url}/authorize",
|
||||
TokenEndpoint = $"{_url}/token",
|
||||
JwksUri = $"{_url}/.well-known/jwks.json",
|
||||
ResponseTypesSupported = ["code"],
|
||||
SubjectTypesSupported = ["public"],
|
||||
IdTokenSigningAlgValuesSupported = ["RS256"],
|
||||
ScopesSupported = ["openid", "profile", "email", "mcp:tools"],
|
||||
TokenEndpointAuthMethodsSupported = ["client_secret_post"],
|
||||
ClaimsSupported = ["sub", "iss", "name", "email", "aud"],
|
||||
CodeChallengeMethodsSupported = ["S256"],
|
||||
GrantTypesSupported = ["authorization_code", "refresh_token"],
|
||||
IntrospectionEndpoint = $"{_url}/introspect",
|
||||
RegistrationEndpoint = $"{_url}/register",
|
||||
ClientIdMetadataDocumentSupported = ClientIdMetadataDocumentSupported,
|
||||
};
|
||||
_metadataRequests.Enqueue(context.Request.Path);
|
||||
|
||||
return Results.Ok(metadata);
|
||||
});
|
||||
if (DisabledMetadataPaths.Contains(context.Request.Path))
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(issuerPath))
|
||||
{
|
||||
issuerPath = $"/{issuerPath}";
|
||||
}
|
||||
|
||||
var metadata = new OAuthServerMetadata
|
||||
{
|
||||
Issuer = $"{_url}{issuerPath}",
|
||||
AuthorizationEndpoint = $"{_url}/authorize",
|
||||
TokenEndpoint = $"{_url}/token",
|
||||
JwksUri = $"{_url}/.well-known/jwks.json",
|
||||
ResponseTypesSupported = ["code"],
|
||||
SubjectTypesSupported = ["public"],
|
||||
IdTokenSigningAlgValuesSupported = ["RS256"],
|
||||
ScopesSupported = ["openid", "profile", "email", "mcp:tools"],
|
||||
TokenEndpointAuthMethodsSupported = ["client_secret_post"],
|
||||
ClaimsSupported = ["sub", "iss", "name", "email", "aud"],
|
||||
CodeChallengeMethodsSupported = ["S256"],
|
||||
GrantTypesSupported = ["authorization_code", "refresh_token"],
|
||||
IntrospectionEndpoint = $"{_url}/introspect",
|
||||
RegistrationEndpoint = $"{_url}/register",
|
||||
ClientIdMetadataDocumentSupported = ClientIdMetadataDocumentSupported,
|
||||
};
|
||||
|
||||
return Results.Ok(metadata);
|
||||
}
|
||||
|
||||
app.MapGet("/.well-known/oauth-authorization-server", HandleMetadataRequest);
|
||||
app.MapGet("/.well-known/openid-configuration", HandleMetadataRequest);
|
||||
app.MapGet("/.well-known/oauth-authorization-server/{**issuerPath}", HandleMetadataRequest);
|
||||
app.MapGet("/.well-known/openid-configuration/{**issuerPath}", HandleMetadataRequest);
|
||||
app.MapGet("/{**fullPath}", (HttpContext context, string fullPath) =>
|
||||
{
|
||||
if (fullPath.EndsWith("/.well-known/openid-configuration", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return HandleMetadataRequest(context, fullPath[..^"/.well-known/openid-configuration".Length]);
|
||||
}
|
||||
|
||||
return Results.NotFound();
|
||||
});
|
||||
|
||||
// JWKS endpoint to expose the public key
|
||||
app.MapGet("/.well-known/jwks.json", () =>
|
||||
{
|
||||
|
|
@ -564,14 +579,6 @@ public sealed class Program
|
|||
{
|
||||
var expiresIn = TimeSpan.FromHours(1);
|
||||
var issuedAt = DateTimeOffset.UtcNow;
|
||||
|
||||
// For test-refresh-client, make the first token expired to test refresh functionality.
|
||||
if (clientId == "test-refresh-client" && !HasIssuedExpiredToken)
|
||||
{
|
||||
HasIssuedExpiredToken = true;
|
||||
expiresIn = TimeSpan.FromHours(-1);
|
||||
}
|
||||
|
||||
var expiresAt = issuedAt.Add(expiresIn);
|
||||
var jwtId = Guid.NewGuid().ToString();
|
||||
|
||||
|
|
@ -580,7 +587,7 @@ public sealed class Program
|
|||
{
|
||||
{ "alg", "RS256" },
|
||||
{ "typ", "JWT" },
|
||||
{ "kid", _keyId }
|
||||
{ "kid", _keyId },
|
||||
};
|
||||
|
||||
var payload = new Dictionary<string, string>
|
||||
|
|
@ -593,7 +600,7 @@ public sealed class Program
|
|||
{ "jti", jwtId },
|
||||
{ "iat", issuedAt.ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture) },
|
||||
{ "exp", expiresAt.ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture) },
|
||||
{ "scope", string.Join(" ", scopes) }
|
||||
{ "scope", string.Join(" ", scopes) },
|
||||
};
|
||||
|
||||
// Create JWT token
|
||||
|
|
|
|||
Loading…
Reference in a new issue