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.AspNetCore.Http;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
|
using Microsoft.Net.Http.Headers;
|
||||||
using ModelContextProtocol.Authentication;
|
using ModelContextProtocol.Authentication;
|
||||||
using System.Text.Encodings.Web;
|
using System.Text.Encodings.Web;
|
||||||
|
|
||||||
|
|
@ -185,15 +186,9 @@ public partial class McpAuthenticationHandler : AuthenticationHandler<McpAuthent
|
||||||
// Get the absolute URI for the resource metadata
|
// Get the absolute URI for the resource metadata
|
||||||
string rawPrmDocumentUri = GetAbsoluteResourceMetadataUri();
|
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
|
// Add the WWW-Authenticate header with Bearer scheme and resource metadata
|
||||||
string headerValue = $"Bearer realm=\"{Scheme.Name}\", resource_metadata=\"{rawPrmDocumentUri}\"";
|
string headerValue = $"Bearer resource_metadata=\"{rawPrmDocumentUri}\"";
|
||||||
Response.Headers.Append("WWW-Authenticate", headerValue);
|
Response.Headers.Append(HeaderNames.WWWAuthenticate, headerValue);
|
||||||
|
|
||||||
return base.HandleChallengeAsync(properties);
|
return base.HandleChallengeAsync(properties);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -275,6 +275,7 @@ internal sealed class StreamableHttpHandler(
|
||||||
RandomNumberGenerator.Fill(buffer);
|
RandomNumberGenerator.Fill(buffer);
|
||||||
return WebEncoders.Base64UrlEncode(buffer);
|
return WebEncoders.Base64UrlEncode(buffer);
|
||||||
}
|
}
|
||||||
|
|
||||||
internal static async Task<JsonRpcMessage?> ReadJsonRpcMessageAsync(HttpContext context)
|
internal static async Task<JsonRpcMessage?> ReadJsonRpcMessageAsync(HttpContext context)
|
||||||
{
|
{
|
||||||
// Implementation for reading a JSON-RPC message from the request body
|
// Implementation for reading a JSON-RPC message from the request body
|
||||||
|
|
@ -291,7 +292,6 @@ internal sealed class StreamableHttpHandler(
|
||||||
return message;
|
return message;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
internal static Task RunSessionAsync(HttpContext httpContext, McpServer session, CancellationToken requestAborted)
|
internal static Task RunSessionAsync(HttpContext httpContext, McpServer session, CancellationToken requestAborted)
|
||||||
=> session.RunAsync(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;
|
||||||
using Microsoft.Extensions.Logging.Abstractions;
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
#if NET9_0_OR_GREATER
|
#if NET9_0_OR_GREATER
|
||||||
|
|
@ -15,7 +17,7 @@ namespace ModelContextProtocol.Authentication;
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// A generic implementation of an OAuth authorization provider.
|
/// A generic implementation of an OAuth authorization provider.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
internal sealed partial class ClientOAuthProvider
|
internal sealed partial class ClientOAuthProvider : McpHttpClient
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The Bearer authentication scheme.
|
/// The Bearer authentication scheme.
|
||||||
|
|
@ -23,11 +25,9 @@ internal sealed partial class ClientOAuthProvider
|
||||||
private const string BearerScheme = "Bearer";
|
private const string BearerScheme = "Bearer";
|
||||||
private const string ProtectedResourceMetadataWellKnownPath = "/.well-known/oauth-protected-resource";
|
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 _serverUrl;
|
||||||
private readonly Uri _redirectUri;
|
private readonly Uri _redirectUri;
|
||||||
private readonly string[]? _scopes;
|
private readonly string? _configuredScopes;
|
||||||
private readonly IDictionary<string, string> _additionalAuthorizationParameters;
|
private readonly IDictionary<string, string> _additionalAuthorizationParameters;
|
||||||
private readonly Func<IReadOnlyList<Uri>, Uri?> _authServerSelector;
|
private readonly Func<IReadOnlyList<Uri>, Uri?> _authServerSelector;
|
||||||
private readonly AuthorizationRedirectDelegate _authorizationRedirectDelegate;
|
private readonly AuthorizationRedirectDelegate _authorizationRedirectDelegate;
|
||||||
|
|
@ -60,6 +60,7 @@ internal sealed partial class ClientOAuthProvider
|
||||||
ClientOAuthOptions options,
|
ClientOAuthOptions options,
|
||||||
HttpClient httpClient,
|
HttpClient httpClient,
|
||||||
ILoggerFactory? loggerFactory = null)
|
ILoggerFactory? loggerFactory = null)
|
||||||
|
: base(httpClient)
|
||||||
{
|
{
|
||||||
_serverUrl = serverUrl ?? throw new ArgumentNullException(nameof(serverUrl));
|
_serverUrl = serverUrl ?? throw new ArgumentNullException(nameof(serverUrl));
|
||||||
_httpClient = httpClient;
|
_httpClient = httpClient;
|
||||||
|
|
@ -73,7 +74,7 @@ internal sealed partial class ClientOAuthProvider
|
||||||
_clientId = options.ClientId;
|
_clientId = options.ClientId;
|
||||||
_clientSecret = options.ClientSecret;
|
_clientSecret = options.ClientSecret;
|
||||||
_redirectUri = options.RedirectUri ?? throw new ArgumentException("ClientOAuthOptions.RedirectUri must configured.", nameof(options));
|
_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;
|
_additionalAuthorizationParameters = options.AdditionalAuthorizationParameters;
|
||||||
_clientMetadataDocumentUri = options.ClientMetadataDocumentUri;
|
_clientMetadataDocumentUri = options.ClientMetadataDocumentUri;
|
||||||
|
|
||||||
|
|
@ -102,7 +103,7 @@ internal sealed partial class ClientOAuthProvider
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="authorizationUrl">The authorization URL to handle.</param>
|
/// <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="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>
|
/// <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)
|
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);
|
return Task.FromResult<string?>(authorizationCode);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
internal override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, JsonRpcMessage? message, CancellationToken cancellationToken)
|
||||||
/// Gets the collection of authentication schemes supported by this provider.
|
|
||||||
/// </summary>
|
|
||||||
/// <remarks>
|
|
||||||
/// <para>
|
|
||||||
/// This property returns all authentication schemes that this provider can handle,
|
|
||||||
/// allowing clients to select the appropriate scheme based on server capabilities.
|
|
||||||
/// </para>
|
|
||||||
/// <para>
|
|
||||||
/// Common values include "Bearer" for JWT tokens, "Basic" for username/password authentication,
|
|
||||||
/// and "Negotiate" for integrated Windows authentication.
|
|
||||||
/// </para>
|
|
||||||
/// </remarks>
|
|
||||||
public IEnumerable<string> SupportedSchemes => [BearerScheme];
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets an authentication token or credential for authenticating requests to a resource
|
|
||||||
/// using the specified authentication scheme.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="scheme">The authentication scheme to use.</param>
|
|
||||||
/// <param name="resourceUri">The URI of the resource requiring authentication.</param>
|
|
||||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
|
|
||||||
/// <returns>An authentication token string, or null if no token could be obtained for the specified scheme.</returns>
|
|
||||||
public async Task<string?> GetCredentialAsync(string scheme, Uri resourceUri, CancellationToken cancellationToken = default)
|
|
||||||
{
|
{
|
||||||
ThrowIfNotBearerScheme(scheme);
|
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);
|
var tokens = await _tokenCache.GetTokensAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
// Return the token if it's valid
|
// Return the token if it's valid
|
||||||
if (tokens is not null && !tokens.IsExpired)
|
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.
|
// 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);
|
var accessToken = await RefreshTokensAsync(refreshToken, resourceUri, _authServerMetadata, cancellationToken).ConfigureAwait(false);
|
||||||
if (newTokens is not null)
|
return (accessToken, true);
|
||||||
{
|
|
||||||
return newTokens.AccessToken;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// No valid token - auth handler will trigger the 401 flow
|
// No valid token - auth handler will trigger the 401 flow
|
||||||
return null;
|
return (null, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
private static bool ShouldRetryWithNewAccessToken(HttpResponseMessage response)
|
||||||
/// Handles a 401 Unauthorized response from a resource.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="scheme">The authentication scheme that was used when the unauthorized response was received.</param>
|
|
||||||
/// <param name="response">The HTTP response that contained the 401 status code.</param>
|
|
||||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
|
|
||||||
/// <returns>
|
|
||||||
/// A result object indicating if the provider was able to handle the unauthorized response,
|
|
||||||
/// and the authentication scheme that should be used for the next attempt, if any.
|
|
||||||
/// </returns>
|
|
||||||
public async Task HandleUnauthorizedResponseAsync(
|
|
||||||
string scheme,
|
|
||||||
HttpResponseMessage response,
|
|
||||||
CancellationToken cancellationToken = default)
|
|
||||||
{
|
{
|
||||||
ThrowIfNotBearerScheme(scheme);
|
if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized)
|
||||||
await PerformOAuthAuthorizationAsync(response, cancellationToken).ConfigureAwait(false);
|
{
|
||||||
|
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>
|
private async Task<HttpResponseMessage> HandleUnauthorizedResponseAsync(
|
||||||
/// Performs OAuth authorization by selecting an appropriate authorization server and completing the OAuth flow.
|
HttpRequestMessage originalRequest,
|
||||||
/// </summary>
|
JsonRpcMessage? originalJsonRpcMessage,
|
||||||
/// <param name="response">The 401 Unauthorized response containing authentication challenge.</param>
|
|
||||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
|
|
||||||
/// <returns>A result object indicating whether authorization was successful.</returns>
|
|
||||||
private async Task PerformOAuthAuthorizationAsync(
|
|
||||||
HttpResponseMessage response,
|
HttpResponseMessage response,
|
||||||
|
bool attemptedRefresh,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
// Get available authorization servers from the 401 response
|
if (response.Headers.WwwAuthenticate.Count == 0)
|
||||||
var protectedResourceMetadata = await ExtractProtectedResourceMetadata(response, _serverUrl, cancellationToken).ConfigureAwait(false);
|
{
|
||||||
|
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;
|
var availableAuthorizationServers = protectedResourceMetadata.AuthorizationServers;
|
||||||
|
|
||||||
if (availableAuthorizationServers.Count == 0)
|
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.
|
// The existing access token must be invalid to have resulted in a 401 response, but refresh might still work.
|
||||||
var resourceUri = GetRequiredResourceUri(protectedResourceMetadata);
|
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);
|
var accessToken = await RefreshTokensAsync(refreshToken, resourceUri, authServerMetadata, cancellationToken).ConfigureAwait(false);
|
||||||
if (refreshedTokens is not null)
|
if (accessToken is not null)
|
||||||
{
|
{
|
||||||
// A non-null result indicates the refresh succeeded and the new tokens have been stored.
|
// 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
|
else
|
||||||
{
|
{
|
||||||
await PerformDynamicClientRegistrationAsync(authServerMetadata, cancellationToken).ConfigureAwait(false);
|
await PerformDynamicClientRegistrationAsync(protectedResourceMetadata, authServerMetadata, cancellationToken).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Perform the OAuth flow
|
// Perform the OAuth flow
|
||||||
await InitiateAuthorizationCodeFlowAsync(protectedResourceMetadata, authServerMetadata, cancellationToken).ConfigureAwait(false);
|
return await InitiateAuthorizationCodeFlowAsync(protectedResourceMetadata, authServerMetadata, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
LogOAuthAuthorizationCompleted();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void ApplyClientIdMetadataDocument(Uri metadataUri)
|
private void ApplyClientIdMetadataDocument(Uri metadataUri)
|
||||||
|
|
@ -274,18 +319,10 @@ internal sealed partial class ClientOAuthProvider
|
||||||
|
|
||||||
private async Task<AuthorizationServerMetadata> GetAuthServerMetadataAsync(Uri authServerUri, CancellationToken cancellationToken)
|
private async Task<AuthorizationServerMetadata> GetAuthServerMetadataAsync(Uri authServerUri, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
if (authServerUri.OriginalString.Length == 0 ||
|
foreach (var wellKnownEndpoint in GetWellKnownAuthorizationServerMetadataUris(authServerUri))
|
||||||
authServerUri.OriginalString[authServerUri.OriginalString.Length - 1] != '/')
|
|
||||||
{
|
|
||||||
authServerUri = new Uri($"{authServerUri.OriginalString}/");
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (var path in s_wellKnownPaths)
|
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var wellKnownEndpoint = new Uri(authServerUri, path);
|
|
||||||
|
|
||||||
var response = await _httpClient.GetAsync(wellKnownEndpoint, cancellationToken).ConfigureAwait(false);
|
var response = await _httpClient.GetAsync(wellKnownEndpoint, cancellationToken).ConfigureAwait(false);
|
||||||
if (!response.IsSuccessStatusCode)
|
if (!response.IsSuccessStatusCode)
|
||||||
{
|
{
|
||||||
|
|
@ -320,14 +357,33 @@ internal sealed partial class ClientOAuthProvider
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
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}'");
|
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>
|
var requestContent = new FormUrlEncodedContent(new Dictionary<string, string>
|
||||||
{
|
{
|
||||||
|
|
@ -350,10 +406,12 @@ internal sealed partial class ClientOAuthProvider
|
||||||
return null;
|
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,
|
ProtectedResourceMetadata protectedResourceMetadata,
|
||||||
AuthorizationServerMetadata authServerMetadata,
|
AuthorizationServerMetadata authServerMetadata,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
|
|
@ -369,7 +427,7 @@ internal sealed partial class ClientOAuthProvider
|
||||||
ThrowFailedToHandleUnauthorizedResponse($"The {nameof(AuthorizationRedirectDelegate)} returned a null or empty authorization code.");
|
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(
|
private Uri BuildAuthorizationUrl(
|
||||||
|
|
@ -389,10 +447,10 @@ internal sealed partial class ClientOAuthProvider
|
||||||
["resource"] = resourceUri.ToString(),
|
["resource"] = resourceUri.ToString(),
|
||||||
};
|
};
|
||||||
|
|
||||||
var scopesSupported = protectedResourceMetadata.ScopesSupported;
|
var scope = GetScopeParameter(protectedResourceMetadata);
|
||||||
if (_scopes is not null || scopesSupported.Count > 0)
|
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.
|
// 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;
|
return uriBuilder.Uri;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task ExchangeCodeForTokenAsync(
|
private async Task<string> ExchangeCodeForTokenAsync(
|
||||||
ProtectedResourceMetadata protectedResourceMetadata,
|
ProtectedResourceMetadata protectedResourceMetadata,
|
||||||
AuthorizationServerMetadata authServerMetadata,
|
AuthorizationServerMetadata authServerMetadata,
|
||||||
string authorizationCode,
|
string authorizationCode,
|
||||||
|
|
@ -442,7 +500,10 @@ internal sealed partial class ClientOAuthProvider
|
||||||
|
|
||||||
using var httpResponse = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
using var httpResponse = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||||
httpResponse.EnsureSuccessStatusCode();
|
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)
|
private async Task<TokenContainer> HandleSuccessfulTokenResponseAsync(HttpResponseMessage response, CancellationToken cancellationToken)
|
||||||
|
|
@ -475,38 +536,20 @@ internal sealed partial class ClientOAuthProvider
|
||||||
return tokens;
|
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>
|
/// <summary>
|
||||||
/// Fetches the protected resource metadata from the provided URL.
|
/// Fetches the protected resource metadata from the provided URL.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="metadataUrl">The URL to fetch the metadata from.</param>
|
private async Task<ProtectedResourceMetadata?> FetchProtectedResourceMetadataAsync(Uri metadataUrl, bool requireSuccess, CancellationToken cancellationToken)
|
||||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
|
|
||||||
/// <returns>The fetched ProtectedResourceMetadata, or null if it couldn't be fetched.</returns>
|
|
||||||
private async Task<ProtectedResourceMetadata?> FetchProtectedResourceMetadataAsync(Uri metadataUrl, CancellationToken cancellationToken = default)
|
|
||||||
{
|
{
|
||||||
using var httpResponse = await _httpClient.GetAsync(metadataUrl, cancellationToken).ConfigureAwait(false);
|
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);
|
using var stream = await httpResponse.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
|
||||||
return await JsonSerializer.DeserializeAsync(stream, McpJsonUtilities.JsonContext.Default.ProtectedResourceMetadata, 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>
|
/// <summary>
|
||||||
/// Performs dynamic client registration with the authorization server.
|
/// Performs dynamic client registration with the authorization server.
|
||||||
/// </summary>
|
/// </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(
|
private async Task PerformDynamicClientRegistrationAsync(
|
||||||
|
ProtectedResourceMetadata protectedResourceMetadata,
|
||||||
AuthorizationServerMetadata authServerMetadata,
|
AuthorizationServerMetadata authServerMetadata,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
|
@ -537,7 +578,7 @@ internal sealed partial class ClientOAuthProvider
|
||||||
TokenEndpointAuthMethod = "client_secret_post",
|
TokenEndpointAuthMethod = "client_secret_post",
|
||||||
ClientName = _dcrClientName,
|
ClientName = _dcrClientName,
|
||||||
ClientUri = _dcrClientUri?.ToString(),
|
ClientUri = _dcrClientUri?.ToString(),
|
||||||
Scope = _scopes is not null ? string.Join(" ", _scopes) : null
|
Scope = GetScopeParameter(protectedResourceMetadata),
|
||||||
};
|
};
|
||||||
|
|
||||||
var requestJson = JsonSerializer.Serialize(registrationRequest, McpJsonUtilities.JsonContext.Default.DynamicClientRegistrationRequest);
|
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>
|
/// <summary>
|
||||||
/// Verifies that the resource URI in the metadata exactly matches the original request URL as required by the RFC.
|
/// 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.
|
/// Per RFC: The resource value must be identical to the URL that the client used to make the request to the resource server.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="protectedResourceMetadata">The metadata to verify.</param>
|
/// <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>
|
/// <returns>True if the resource URI exactly matches the original request URL, otherwise false.</returns>
|
||||||
private static bool VerifyResourceMatch(ProtectedResourceMetadata protectedResourceMetadata, Uri resourceLocation)
|
private static bool VerifyResourceMatch(ProtectedResourceMetadata protectedResourceMetadata, Uri resourceLocation)
|
||||||
{
|
{
|
||||||
if (protectedResourceMetadata.Resource == null || resourceLocation == null)
|
if (protectedResourceMetadata.Resource is null)
|
||||||
{
|
{
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
@ -611,16 +679,6 @@ internal sealed partial class ClientOAuthProvider
|
||||||
return string.Equals(normalizedMetadataResource, normalizedResourceLocation, StringComparison.OrdinalIgnoreCase);
|
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>
|
/// <summary>
|
||||||
/// Normalizes a URI for consistent comparison.
|
/// Normalizes a URI for consistent comparison.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|
@ -628,17 +686,19 @@ internal sealed partial class ClientOAuthProvider
|
||||||
/// <returns>A normalized string representation of the URI.</returns>
|
/// <returns>A normalized string representation of the URI.</returns>
|
||||||
private static string NormalizeUri(Uri uri)
|
private static string NormalizeUri(Uri uri)
|
||||||
{
|
{
|
||||||
var builder = new UriBuilder(uri)
|
var builder = new StringBuilder();
|
||||||
{
|
builder.Append(uri.Scheme);
|
||||||
Port = -1 // Always remove port
|
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>
|
/// <summary>
|
||||||
|
|
@ -646,61 +706,72 @@ internal sealed partial class ClientOAuthProvider
|
||||||
/// verifying the resource match, and returning the metadata if valid.
|
/// verifying the resource match, and returning the metadata if valid.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="response">The HTTP response containing the WWW-Authenticate header.</param>
|
/// <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.</param>
|
||||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
|
|
||||||
/// <returns>The resource metadata if the resource matches the server, otherwise throws an exception.</returns>
|
/// <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>
|
/// <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);
|
metadata = await FetchProtectedResourceMetadataAsync(new(resourceMetadataUrl), requireSuccess: true, cancellationToken).ConfigureAwait(false)
|
||||||
LogMissingWwwAuthenticateHeader(metadataUri);
|
?? throw new McpException($"Failed to fetch resource metadata from {resourceMetadataUrl}");
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
// Look for the Bearer authentication scheme with resource_metadata parameter
|
foreach (var (wellKnownUri, expectedResourceUri) in GetWellKnownResourceMetadataUris(_serverUrl))
|
||||||
string? resourceMetadataUrl = null;
|
|
||||||
foreach (var header in response.Headers.WwwAuthenticate)
|
|
||||||
{
|
{
|
||||||
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");
|
resourceUri = expectedResourceUri;
|
||||||
if (resourceMetadataUrl != null)
|
break;
|
||||||
{
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (resourceMetadataUrl == null)
|
if (metadata is null)
|
||||||
{
|
{
|
||||||
metadataUri = BuildProtectedResourceMetadataUri(serverUrl);
|
throw new McpException($"Failed to find protected resource metadata at a well-known location for {_serverUrl}");
|
||||||
LogMissingResourceMetadataParameter(metadataUri);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
metadataUri = new(resourceMetadataUrl);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var metadata = await FetchProtectedResourceMetadataAsync(metadataUri, cancellationToken).ConfigureAwait(false)
|
// The WWW-Authenticate header parameter should be preferred over using the scopes_supported metadata property.
|
||||||
?? throw new McpException($"Failed to fetch resource metadata from {metadataUri}");
|
// 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
|
// Per RFC: The resource value must be identical to the URL that the client used to make the request to the resource server
|
||||||
// to make the request to the resource server
|
LogValidatingResourceMetadata(resourceUri);
|
||||||
LogValidatingResourceMetadata(serverUrl);
|
|
||||||
|
|
||||||
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;
|
return metadata;
|
||||||
|
|
@ -721,7 +792,7 @@ internal sealed partial class ClientOAuthProvider
|
||||||
|
|
||||||
foreach (var part in parameters.Split(','))
|
foreach (var part in parameters.Split(','))
|
||||||
{
|
{
|
||||||
string trimmedPart = part.Trim();
|
var trimmedPart = part.AsSpan().Trim();
|
||||||
int equalsIndex = trimmedPart.IndexOf('=');
|
int equalsIndex = trimmedPart.IndexOf('=');
|
||||||
|
|
||||||
if (equalsIndex <= 0)
|
if (equalsIndex <= 0)
|
||||||
|
|
@ -729,15 +800,14 @@ internal sealed partial class ClientOAuthProvider
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
ReadOnlySpan<char> key = trimmedPart.AsSpan().Slice(0, equalsIndex).Trim();
|
var key = trimmedPart[..equalsIndex].Trim();
|
||||||
|
|
||||||
if (key.Equals(parameterName, StringComparison.OrdinalIgnoreCase))
|
if (key.Equals(parameterName, StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
ReadOnlySpan<char> value = trimmedPart.AsSpan(equalsIndex + 1).Trim();
|
var value = trimmedPart[(equalsIndex + 1)..].Trim();
|
||||||
|
if (value.Length > 0 && value[0] == '"' && value[^1] == '"')
|
||||||
if (value.Length > 0 && value[0] == '"' && value[value.Length - 1] == '"')
|
|
||||||
{
|
{
|
||||||
value = value.Slice(1, value.Length - 2);
|
value = value[1..^1];
|
||||||
}
|
}
|
||||||
|
|
||||||
return value.ToString();
|
return value.ToString();
|
||||||
|
|
@ -747,15 +817,32 @@ internal sealed partial class ClientOAuthProvider
|
||||||
return null;
|
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()
|
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];
|
var bytes = new byte[32];
|
||||||
using var rng = RandomNumberGenerator.Create();
|
using var rng = RandomNumberGenerator.Create();
|
||||||
rng.GetBytes(bytes);
|
rng.GetBytes(bytes);
|
||||||
return Convert.ToBase64String(bytes)
|
return ToBase64UrlString(bytes);
|
||||||
.TrimEnd('=')
|
#endif
|
||||||
.Replace('+', '-')
|
|
||||||
.Replace('/', '_');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string GenerateCodeChallenge(string codeVerifier)
|
private static string GenerateCodeChallenge(string codeVerifier)
|
||||||
|
|
@ -767,22 +854,21 @@ internal sealed partial class ClientOAuthProvider
|
||||||
#else
|
#else
|
||||||
using var sha256 = SHA256.Create();
|
using var sha256 = SHA256.Create();
|
||||||
var challengeBytes = sha256.ComputeHash(Encoding.UTF8.GetBytes(codeVerifier));
|
var challengeBytes = sha256.ComputeHash(Encoding.UTF8.GetBytes(codeVerifier));
|
||||||
return Convert.ToBase64String(challengeBytes)
|
return ToBase64UrlString(challengeBytes);
|
||||||
.TrimEnd('=')
|
|
||||||
.Replace('+', '-')
|
|
||||||
.Replace('/', '_');
|
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
private string GetClientIdOrThrow() => _clientId ?? throw new InvalidOperationException("Client ID is not available. This may indicate an issue with dynamic client registration.");
|
#if !NET9_0_OR_GREATER
|
||||||
|
private static string ToBase64UrlString(byte[] bytes)
|
||||||
private static void ThrowIfNotBearerScheme(string scheme)
|
|
||||||
{
|
{
|
||||||
if (!string.Equals(scheme, BearerScheme, StringComparison.OrdinalIgnoreCase))
|
return Convert.ToBase64String(bytes)
|
||||||
{
|
.TrimEnd('=')
|
||||||
throw new InvalidOperationException($"The '{scheme}' is not supported. This credential provider only supports the '{BearerScheme}' scheme");
|
.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]
|
[DoesNotReturn]
|
||||||
private static void ThrowFailedToHandleUnauthorizedResponse(string message) =>
|
private static void ThrowFailedToHandleUnauthorizedResponse(string message) =>
|
||||||
|
|
@ -794,8 +880,11 @@ internal sealed partial class ClientOAuthProvider
|
||||||
[LoggerMessage(Level = LogLevel.Information, Message = "OAuth authorization completed successfully")]
|
[LoggerMessage(Level = LogLevel.Information, Message = "OAuth authorization completed successfully")]
|
||||||
partial void LogOAuthAuthorizationCompleted();
|
partial void LogOAuthAuthorizationCompleted();
|
||||||
|
|
||||||
[LoggerMessage(Level = LogLevel.Error, Message = "Error fetching auth server metadata from {Path}")]
|
[LoggerMessage(Level = LogLevel.Information, Message = "OAuth token refresh completed successfully")]
|
||||||
partial void LogErrorFetchingAuthServerMetadata(Exception ex, string path);
|
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}")]
|
[LoggerMessage(Level = LogLevel.Information, Message = "Performing dynamic client registration with {RegistrationEndpoint}")]
|
||||||
partial void LogPerformingDynamicClientRegistration(Uri 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}")]
|
[LoggerMessage(Level = LogLevel.Debug, Message = "Validating resource metadata against original server URL: {ServerUrl}")]
|
||||||
partial void LogValidatingResourceMetadata(Uri serverUrl);
|
partial void LogValidatingResourceMetadata(Uri serverUrl);
|
||||||
|
|
||||||
[LoggerMessage(Level = LogLevel.Debug, Message = "WWW-Authenticate header missing. Falling back to resource metadata at {MetadataUri}")]
|
[LoggerMessage(Level = LogLevel.Warning, Message = "WWW-Authenticate header missing.")]
|
||||||
partial void LogMissingWwwAuthenticateHeader(Uri metadataUri);
|
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);
|
partial void LogMissingResourceMetadataParameter(Uri metadataUri);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -177,4 +177,18 @@ public sealed class ProtectedResourceMetadata
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
[JsonPropertyName("dpop_bound_access_tokens_required")]
|
[JsonPropertyName("dpop_bound_access_tokens_required")]
|
||||||
public bool? DpopBoundAccessTokensRequired { get; set; }
|
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)
|
if (transportOptions.OAuth is { } clientOAuthOptions)
|
||||||
{
|
{
|
||||||
var oAuthProvider = new ClientOAuthProvider(_options.Endpoint, clientOAuthOptions, httpClient, loggerFactory);
|
_mcpHttpClient = new ClientOAuthProvider(_options.Endpoint, clientOAuthOptions, httpClient, loggerFactory);
|
||||||
_mcpHttpClient = new AuthenticatingMcpHttpClient(httpClient, oAuthProvider);
|
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,16 @@
|
||||||
|
using Microsoft.AspNetCore.Authentication;
|
||||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
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.AspNetCore.Authentication;
|
||||||
using ModelContextProtocol.Authentication;
|
using ModelContextProtocol.Authentication;
|
||||||
using ModelContextProtocol.Client;
|
using ModelContextProtocol.Client;
|
||||||
|
using ModelContextProtocol.Server;
|
||||||
using System.Net;
|
using System.Net;
|
||||||
using System.Reflection;
|
using System.Reflection;
|
||||||
|
using System.Security.Claims;
|
||||||
using Xunit.Sdk;
|
using Xunit.Sdk;
|
||||||
|
|
||||||
namespace ModelContextProtocol.AspNetCore.Tests.OAuth;
|
namespace ModelContextProtocol.AspNetCore.Tests.OAuth;
|
||||||
|
|
@ -88,7 +95,6 @@ public class AuthTests : OAuthTestBase
|
||||||
{
|
{
|
||||||
RedirectUri = new Uri("http://localhost:1179/callback"),
|
RedirectUri = new Uri("http://localhost:1179/callback"),
|
||||||
AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync,
|
AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync,
|
||||||
Scopes = ["mcp:tools"],
|
|
||||||
DynamicClientRegistration = new()
|
DynamicClientRegistration = new()
|
||||||
{
|
{
|
||||||
ClientName = "Test MCP Client",
|
ClientName = "Test MCP Client",
|
||||||
|
|
@ -139,7 +145,6 @@ public class AuthTests : OAuthTestBase
|
||||||
RedirectUri = new Uri("http://localhost:1179/callback"),
|
RedirectUri = new Uri("http://localhost:1179/callback"),
|
||||||
AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync,
|
AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync,
|
||||||
ClientMetadataDocumentUri = new Uri("http://invalid-cimd.example.com"),
|
ClientMetadataDocumentUri = new Uri("http://invalid-cimd.example.com"),
|
||||||
Scopes = ["mcp:tools"],
|
|
||||||
DynamicClientRegistration = new()
|
DynamicClientRegistration = new()
|
||||||
{
|
{
|
||||||
ClientName = "Test MCP Client (No CIMD)",
|
ClientName = "Test MCP Client (No CIMD)",
|
||||||
|
|
@ -203,6 +208,33 @@ public class AuthTests : OAuthTestBase
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task CanAuthenticate_WithTokenRefresh()
|
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 app = await StartMcpServerAsync();
|
||||||
|
|
||||||
await using var transport = new HttpClientTransport(new()
|
await using var transport = new HttpClientTransport(new()
|
||||||
|
|
@ -210,18 +242,18 @@ public class AuthTests : OAuthTestBase
|
||||||
Endpoint = new(McpServerUrl),
|
Endpoint = new(McpServerUrl),
|
||||||
OAuth = new()
|
OAuth = new()
|
||||||
{
|
{
|
||||||
ClientId = "test-refresh-client",
|
ClientId = "demo-client",
|
||||||
ClientSecret = "test-refresh-secret",
|
ClientSecret = "demo-secret",
|
||||||
RedirectUri = new Uri("http://localhost:1179/callback"),
|
RedirectUri = new Uri("http://localhost:1179/callback"),
|
||||||
AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync,
|
AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync,
|
||||||
},
|
},
|
||||||
}, HttpClient, LoggerFactory);
|
}, 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(
|
await using var client = await McpClient.CreateAsync(
|
||||||
transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken);
|
transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
Assert.True(TestOAuthServer.HasRefreshedToken);
|
Assert.True(TestOAuthServer.HasRefreshedToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -328,25 +360,396 @@ public class AuthTests : OAuthTestBase
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task JwtBearerChallenge_DoesNotIncludeResourceMetadata()
|
public async Task AuthorizationFlow_UsesScopeFromProtectedResourceMetadata()
|
||||||
{
|
{
|
||||||
await using var app = await StartMcpServerAsync(authScheme: JwtBearerDefaults.AuthenticationScheme);
|
Builder.Services.Configure<McpAuthenticationOptions>(McpAuthenticationDefaults.AuthenticationScheme, options =>
|
||||||
|
|
||||||
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)
|
|
||||||
{
|
{
|
||||||
headerFound = true;
|
options.ResourceMetadata!.ScopesSupported = ["mcp:tools", "files:read"];
|
||||||
Assert.Equal("Bearer", header.Scheme);
|
});
|
||||||
Assert.True(header.Parameter is null || !header.Parameter.Contains("resource_metadata", StringComparison.OrdinalIgnoreCase));
|
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
string? requestedScope = null;
|
||||||
metadataResponse.EnsureSuccessStatusCode();
|
|
||||||
|
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]
|
[Fact]
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,6 @@ using ModelContextProtocol.AspNetCore.Authentication;
|
||||||
using ModelContextProtocol.AspNetCore.Tests.Utils;
|
using ModelContextProtocol.AspNetCore.Tests.Utils;
|
||||||
using ModelContextProtocol.Authentication;
|
using ModelContextProtocol.Authentication;
|
||||||
using System.Net;
|
using System.Net;
|
||||||
using Xunit.Sdk;
|
|
||||||
|
|
||||||
namespace ModelContextProtocol.AspNetCore.Tests.OAuth;
|
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)
|
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}";
|
options.TokenValidationParameters.ValidAudience = $"{McpServerUrl}{path}";
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,8 @@ public sealed class Program
|
||||||
private readonly ConcurrentDictionary<string, TokenInfo> _tokens = new();
|
private readonly ConcurrentDictionary<string, TokenInfo> _tokens = new();
|
||||||
private readonly ConcurrentDictionary<string, ClientInfo> _clients = new();
|
private readonly ConcurrentDictionary<string, ClientInfo> _clients = new();
|
||||||
|
|
||||||
|
private readonly ConcurrentQueue<string> _metadataRequests = new();
|
||||||
|
|
||||||
private readonly RSA _rsa;
|
private readonly RSA _rsa;
|
||||||
private readonly string _keyId;
|
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.
|
// 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; }
|
public bool HasRefreshedToken { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
@ -59,6 +60,9 @@ public sealed class Program
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
public bool ClientIdMetadataDocumentSupported { get; set; } = true;
|
public bool ClientIdMetadataDocumentSupported { get; set; } = true;
|
||||||
|
|
||||||
|
public HashSet<string> DisabledMetadataPaths { get; } = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
public IReadOnlyCollection<string> MetadataRequests => _metadataRequests.ToArray();
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Entry point for the application.
|
/// Entry point for the application.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|
@ -111,24 +115,15 @@ public sealed class Program
|
||||||
|
|
||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
|
|
||||||
// Set up the demo client
|
|
||||||
var clientId = "demo-client";
|
var clientId = "demo-client";
|
||||||
var clientSecret = "demo-secret";
|
var clientSecret = "demo-secret";
|
||||||
|
|
||||||
_clients[clientId] = new ClientInfo
|
_clients[clientId] = new ClientInfo
|
||||||
{
|
{
|
||||||
ClientId = clientId,
|
ClientId = clientId,
|
||||||
RequiresClientSecret = true,
|
|
||||||
ClientSecret = clientSecret,
|
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,
|
RequiresClientSecret = true,
|
||||||
ClientSecret = "test-refresh-secret",
|
|
||||||
RedirectUris = ["http://localhost:1179/callback"],
|
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
|
// 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
|
// /.well-known/openid-configuration by default.
|
||||||
// which seems to be common. Ex. https://github.com/keycloak/keycloak/pull/29628
|
|
||||||
//
|
//
|
||||||
// The requirements for these endpoints are at https://www.rfc-editor.org/rfc/rfc8414 and
|
// 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.
|
// 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.
|
// 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
|
// See https://gist.github.com/localden/26d8bcf641703c08a5d8741aa9c3336c
|
||||||
string[] metadataEndpoints = ["/.well-known/oauth-authorization-server", "/.well-known/openid-configuration"];
|
IResult HandleMetadataRequest(HttpContext context, string? issuerPath = null)
|
||||||
foreach (var metadataEndpoint in metadataEndpoints)
|
|
||||||
{
|
{
|
||||||
// OAuth 2.0 Authorization Server Metadata (RFC 8414)
|
_metadataRequests.Enqueue(context.Request.Path);
|
||||||
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,
|
|
||||||
};
|
|
||||||
|
|
||||||
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
|
// JWKS endpoint to expose the public key
|
||||||
app.MapGet("/.well-known/jwks.json", () =>
|
app.MapGet("/.well-known/jwks.json", () =>
|
||||||
{
|
{
|
||||||
|
|
@ -564,14 +579,6 @@ public sealed class Program
|
||||||
{
|
{
|
||||||
var expiresIn = TimeSpan.FromHours(1);
|
var expiresIn = TimeSpan.FromHours(1);
|
||||||
var issuedAt = DateTimeOffset.UtcNow;
|
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 expiresAt = issuedAt.Add(expiresIn);
|
||||||
var jwtId = Guid.NewGuid().ToString();
|
var jwtId = Guid.NewGuid().ToString();
|
||||||
|
|
||||||
|
|
@ -580,7 +587,7 @@ public sealed class Program
|
||||||
{
|
{
|
||||||
{ "alg", "RS256" },
|
{ "alg", "RS256" },
|
||||||
{ "typ", "JWT" },
|
{ "typ", "JWT" },
|
||||||
{ "kid", _keyId }
|
{ "kid", _keyId },
|
||||||
};
|
};
|
||||||
|
|
||||||
var payload = new Dictionary<string, string>
|
var payload = new Dictionary<string, string>
|
||||||
|
|
@ -593,7 +600,7 @@ public sealed class Program
|
||||||
{ "jti", jwtId },
|
{ "jti", jwtId },
|
||||||
{ "iat", issuedAt.ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture) },
|
{ "iat", issuedAt.ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture) },
|
||||||
{ "exp", expiresAt.ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture) },
|
{ "exp", expiresAt.ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture) },
|
||||||
{ "scope", string.Join(" ", scopes) }
|
{ "scope", string.Join(" ", scopes) },
|
||||||
};
|
};
|
||||||
|
|
||||||
// Create JWT token
|
// Create JWT token
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue