diff --git a/src/ModelContextProtocol.AspNetCore/Authentication/McpAuthenticationHandler.cs b/src/ModelContextProtocol.AspNetCore/Authentication/McpAuthenticationHandler.cs index 310a9cead..a3139333c 100644 --- a/src/ModelContextProtocol.AspNetCore/Authentication/McpAuthenticationHandler.cs +++ b/src/ModelContextProtocol.AspNetCore/Authentication/McpAuthenticationHandler.cs @@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; +using Microsoft.Net.Http.Headers; using ModelContextProtocol.Authentication; using System.Text.Encodings.Web; @@ -185,15 +186,9 @@ protected override Task HandleChallengeAsync(AuthenticationProperties properties // Get the absolute URI for the resource metadata string rawPrmDocumentUri = GetAbsoluteResourceMetadataUri(); - properties ??= new AuthenticationProperties(); - - // Store the resource_metadata in properties in case other handlers need it - properties.Items["resource_metadata"] = rawPrmDocumentUri; - // Add the WWW-Authenticate header with Bearer scheme and resource metadata - string headerValue = $"Bearer realm=\"{Scheme.Name}\", resource_metadata=\"{rawPrmDocumentUri}\""; - Response.Headers.Append("WWW-Authenticate", headerValue); - + string headerValue = $"Bearer resource_metadata=\"{rawPrmDocumentUri}\""; + Response.Headers.Append(HeaderNames.WWWAuthenticate, headerValue); return base.HandleChallengeAsync(properties); } diff --git a/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs b/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs index 9f4af7ea5..c0f59363a 100644 --- a/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs +++ b/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs @@ -275,6 +275,7 @@ internal static string MakeNewSessionId() RandomNumberGenerator.Fill(buffer); return WebEncoders.Base64UrlEncode(buffer); } + internal static async Task ReadJsonRpcMessageAsync(HttpContext context) { // Implementation for reading a JSON-RPC message from the request body @@ -291,7 +292,6 @@ internal static string MakeNewSessionId() return message; } - internal static Task RunSessionAsync(HttpContext httpContext, McpServer session, CancellationToken requestAborted) => session.RunAsync(requestAborted); diff --git a/src/ModelContextProtocol.Core/Authentication/AuthenticatingMcpHttpClient.cs b/src/ModelContextProtocol.Core/Authentication/AuthenticatingMcpHttpClient.cs deleted file mode 100644 index 1cc081895..000000000 --- a/src/ModelContextProtocol.Core/Authentication/AuthenticatingMcpHttpClient.cs +++ /dev/null @@ -1,118 +0,0 @@ -using ModelContextProtocol.Client; -using ModelContextProtocol.Protocol; -using System.Net.Http.Headers; - -namespace ModelContextProtocol.Authentication; - -/// -/// A delegating handler that adds authentication tokens to requests and handles 401 responses. -/// -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)); - - /// - /// Sends an HTTP request with authentication handling. - /// - internal override async Task 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; - } - - /// - /// Handles a 401 Unauthorized response by attempting to authenticate and retry the request. - /// - private async Task 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); - } - - /// - /// Extracts the authentication schemes that the server supports from the WWW-Authenticate headers. - /// - private static HashSet ExtractServerSupportedSchemes(HttpResponseMessage response) - { - var serverSchemes = new HashSet(StringComparer.OrdinalIgnoreCase); - - foreach (var header in response.Headers.WwwAuthenticate) - { - serverSchemes.Add(header.Scheme); - } - - return serverSchemes; - } - - /// - /// Adds an authorization header to the request. - /// - 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); - } -} \ No newline at end of file diff --git a/src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs b/src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs index 7e68caf01..75126556b 100644 --- a/src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs +++ b/src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs @@ -1,3 +1,5 @@ +using ModelContextProtocol.Client; +using ModelContextProtocol.Protocol; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; #if NET9_0_OR_GREATER @@ -15,7 +17,7 @@ namespace ModelContextProtocol.Authentication; /// /// A generic implementation of an OAuth authorization provider. /// -internal sealed partial class ClientOAuthProvider +internal sealed partial class ClientOAuthProvider : McpHttpClient { /// /// The Bearer authentication scheme. @@ -23,11 +25,9 @@ internal sealed partial class ClientOAuthProvider private const string BearerScheme = "Bearer"; private const string ProtectedResourceMetadataWellKnownPath = "/.well-known/oauth-protected-resource"; - private static readonly string[] s_wellKnownPaths = [".well-known/openid-configuration", ".well-known/oauth-authorization-server"]; - private readonly Uri _serverUrl; private readonly Uri _redirectUri; - private readonly string[]? _scopes; + private readonly string? _configuredScopes; private readonly IDictionary _additionalAuthorizationParameters; private readonly Func, Uri?> _authServerSelector; private readonly AuthorizationRedirectDelegate _authorizationRedirectDelegate; @@ -60,6 +60,7 @@ public ClientOAuthProvider( ClientOAuthOptions options, HttpClient httpClient, ILoggerFactory? loggerFactory = null) + : base(httpClient) { _serverUrl = serverUrl ?? throw new ArgumentNullException(nameof(serverUrl)); _httpClient = httpClient; @@ -73,7 +74,7 @@ public ClientOAuthProvider( _clientId = options.ClientId; _clientSecret = options.ClientSecret; _redirectUri = options.RedirectUri ?? throw new ArgumentException("ClientOAuthOptions.RedirectUri must configured.", nameof(options)); - _scopes = options.Scopes?.ToArray(); + _configuredScopes = options.Scopes is null ? null : string.Join(" ", options.Scopes); _additionalAuthorizationParameters = options.AdditionalAuthorizationParameters; _clientMetadataDocumentUri = options.ClientMetadataDocumentUri; @@ -102,7 +103,7 @@ public ClientOAuthProvider( /// /// The authorization URL to handle. /// The redirect URI where the authorization code will be sent. - /// The to monitor for cancellation requests. The default is . + /// The to monitor for cancellation requests. /// The authorization code entered by the user, or null if none was provided. private static Task DefaultAuthorizationUrlHandler(Uri authorizationUrl, Uri redirectUri, CancellationToken cancellationToken) { @@ -114,86 +115,126 @@ public ClientOAuthProvider( return Task.FromResult(authorizationCode); } - /// - /// Gets the collection of authentication schemes supported by this provider. - /// - /// - /// - /// This property returns all authentication schemes that this provider can handle, - /// allowing clients to select the appropriate scheme based on server capabilities. - /// - /// - /// Common values include "Bearer" for JWT tokens, "Basic" for username/password authentication, - /// and "Negotiate" for integrated Windows authentication. - /// - /// - public IEnumerable SupportedSchemes => [BearerScheme]; - - /// - /// Gets an authentication token or credential for authenticating requests to a resource - /// using the specified authentication scheme. - /// - /// The authentication scheme to use. - /// The URI of the resource requiring authentication. - /// The to monitor for cancellation requests. The default is . - /// An authentication token string, or null if no token could be obtained for the specified scheme. - public async Task GetCredentialAsync(string scheme, Uri resourceUri, CancellationToken cancellationToken = default) + internal override async Task SendAsync(HttpRequestMessage request, JsonRpcMessage? message, CancellationToken cancellationToken) { - ThrowIfNotBearerScheme(scheme); + bool attemptedRefresh = false; + if (request.Headers.Authorization is null && request.RequestUri is not null) + { + string? accessToken; + (accessToken, attemptedRefresh) = await GetAccessTokenSilentAsync(request.RequestUri, cancellationToken).ConfigureAwait(false); + + if (!string.IsNullOrEmpty(accessToken)) + { + request.Headers.Authorization = new AuthenticationHeaderValue(BearerScheme, accessToken); + } + } + + var response = await base.SendAsync(request, message, cancellationToken).ConfigureAwait(false); + + if (ShouldRetryWithNewAccessToken(response)) + { + return await HandleUnauthorizedResponseAsync(request, message, response, attemptedRefresh, cancellationToken).ConfigureAwait(false); + } + + return response; + } + + private async Task<(string? AccessToken, bool AttemptedRefresh)> GetAccessTokenSilentAsync(Uri resourceUri, CancellationToken cancellationToken) + { var tokens = await _tokenCache.GetTokensAsync(cancellationToken).ConfigureAwait(false); // Return the token if it's valid if (tokens is not null && !tokens.IsExpired) { - return tokens.AccessToken; + return (tokens.AccessToken, false); } // Try to refresh the access token if it is invalid and we have a refresh token. - if (tokens?.RefreshToken != null && _authServerMetadata != null) + if (_authServerMetadata is not null && tokens?.RefreshToken is { Length: > 0 } refreshToken) { - var newTokens = await RefreshTokenAsync(tokens.RefreshToken, resourceUri, _authServerMetadata, cancellationToken).ConfigureAwait(false); - if (newTokens is not null) + var accessToken = await RefreshTokensAsync(refreshToken, resourceUri, _authServerMetadata, cancellationToken).ConfigureAwait(false); + return (accessToken, true); + } + + // No valid token - auth handler will trigger the 401 flow + return (null, false); + } + + private static bool ShouldRetryWithNewAccessToken(HttpResponseMessage response) + { + if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized) + { + return true; + } + + // Only retry 403 Forbidden if it contains an insufficient_scope error as described in Section 10.1.1 of the MCP specification + // https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#runtime-insufficient-scope-errors + if (response.StatusCode != System.Net.HttpStatusCode.Forbidden) + { + return false; + } + + foreach (var header in response.Headers.WwwAuthenticate) + { + if (!string.Equals(header.Scheme, BearerScheme, StringComparison.OrdinalIgnoreCase) || string.IsNullOrEmpty(header.Parameter)) + { + continue; + } + + var error = ParseWwwAuthenticateParameters(header.Parameter, "error"); + if (string.Equals(error, "insufficient_scope", StringComparison.OrdinalIgnoreCase)) { - return newTokens.AccessToken; + return true; } } - // No valid token - auth handler will trigger the 401 flow - return null; + return false; } - /// - /// Handles a 401 Unauthorized response from a resource. - /// - /// The authentication scheme that was used when the unauthorized response was received. - /// The HTTP response that contained the 401 status code. - /// The to monitor for cancellation requests. The default is . - /// - /// A result object indicating if the provider was able to handle the unauthorized response, - /// and the authentication scheme that should be used for the next attempt, if any. - /// - public async Task HandleUnauthorizedResponseAsync( - string scheme, + private async Task HandleUnauthorizedResponseAsync( + HttpRequestMessage originalRequest, + JsonRpcMessage? originalJsonRpcMessage, HttpResponseMessage response, - CancellationToken cancellationToken = default) + bool attemptedRefresh, + CancellationToken cancellationToken) { - ThrowIfNotBearerScheme(scheme); - await PerformOAuthAuthorizationAsync(response, cancellationToken).ConfigureAwait(false); + if (response.Headers.WwwAuthenticate.Count == 0) + { + LogMissingWwwAuthenticateHeader(); + } + else if (!response.Headers.WwwAuthenticate.Any(static header => string.Equals(header.Scheme, BearerScheme, StringComparison.OrdinalIgnoreCase))) + { + var serverSchemes = string.Join(", ", response.Headers.WwwAuthenticate.Select(static header => header.Scheme)); + throw new McpException($"The server does not support the '{BearerScheme}' authentication scheme. Server supports: [{serverSchemes}]."); + } + + var accessToken = await GetAccessTokenAsync(response, attemptedRefresh, cancellationToken).ConfigureAwait(false); + + using var retryRequest = new HttpRequestMessage(originalRequest.Method, originalRequest.RequestUri); + + foreach (var header in originalRequest.Headers) + { + if (!header.Key.Equals("Authorization", StringComparison.OrdinalIgnoreCase)) + { + retryRequest.Headers.TryAddWithoutValidation(header.Key, header.Value); + } + } + + retryRequest.Headers.Authorization = new AuthenticationHeaderValue(BearerScheme, accessToken); + return await base.SendAsync(retryRequest, originalJsonRpcMessage, cancellationToken).ConfigureAwait(false); } /// - /// Performs OAuth authorization by selecting an appropriate authorization server and completing the OAuth flow. + /// Handles a 401 Unauthorized or 403 Forbidden response from a resource by completing any required OAuth flows. /// - /// The 401 Unauthorized response containing authentication challenge. - /// The to monitor for cancellation requests. The default is . - /// A result object indicating whether authorization was successful. - private async Task PerformOAuthAuthorizationAsync( - HttpResponseMessage response, - CancellationToken cancellationToken) + /// The HTTP response that triggered the authentication challenge. + /// Indicates whether a token refresh has already been attempted. + /// The to monitor for cancellation requests. + private async Task GetAccessTokenAsync(HttpResponseMessage response, bool attemptedRefresh, CancellationToken cancellationToken) { - // Get available authorization servers from the 401 response - var protectedResourceMetadata = await ExtractProtectedResourceMetadata(response, _serverUrl, cancellationToken).ConfigureAwait(false); + // Get available authorization servers from the 401 or 403 response + var protectedResourceMetadata = await ExtractProtectedResourceMetadata(response, cancellationToken).ConfigureAwait(false); var availableAuthorizationServers = protectedResourceMetadata.AuthorizationServers; if (availableAuthorizationServers.Count == 0) @@ -225,13 +266,19 @@ private async Task PerformOAuthAuthorizationAsync( // The existing access token must be invalid to have resulted in a 401 response, but refresh might still work. var resourceUri = GetRequiredResourceUri(protectedResourceMetadata); - if (await _tokenCache.GetTokensAsync(cancellationToken).ConfigureAwait(false) is { RefreshToken: { } refreshToken }) + // Only attempt a token refresh if we haven't attempted to already for this request. + // Also only attempt a token refresh for a 401 Unauthorized responses. Other response status codes + // should not be used for expired access tokens. This is important because 403 forbiden responses can + // be used for incremental consent which cannot be acheived with a simple refresh. + if (!attemptedRefresh && + response.StatusCode == System.Net.HttpStatusCode.Unauthorized && + await _tokenCache.GetTokensAsync(cancellationToken).ConfigureAwait(false) is { RefreshToken: { Length: > 0 } refreshToken }) { - var refreshedTokens = await RefreshTokenAsync(refreshToken, resourceUri, authServerMetadata, cancellationToken).ConfigureAwait(false); - if (refreshedTokens is not null) + var accessToken = await RefreshTokensAsync(refreshToken, resourceUri, authServerMetadata, cancellationToken).ConfigureAwait(false); + if (accessToken is not null) { // A non-null result indicates the refresh succeeded and the new tokens have been stored. - return; + return accessToken; } } @@ -245,14 +292,12 @@ private async Task PerformOAuthAuthorizationAsync( } else { - await PerformDynamicClientRegistrationAsync(authServerMetadata, cancellationToken).ConfigureAwait(false); + await PerformDynamicClientRegistrationAsync(protectedResourceMetadata, authServerMetadata, cancellationToken).ConfigureAwait(false); } } // Perform the OAuth flow - await InitiateAuthorizationCodeFlowAsync(protectedResourceMetadata, authServerMetadata, cancellationToken).ConfigureAwait(false); - - LogOAuthAuthorizationCompleted(); + return await InitiateAuthorizationCodeFlowAsync(protectedResourceMetadata, authServerMetadata, cancellationToken).ConfigureAwait(false); } private void ApplyClientIdMetadataDocument(Uri metadataUri) @@ -274,18 +319,10 @@ static bool IsValidClientMetadataDocumentUri(Uri uri) private async Task GetAuthServerMetadataAsync(Uri authServerUri, CancellationToken cancellationToken) { - if (authServerUri.OriginalString.Length == 0 || - authServerUri.OriginalString[authServerUri.OriginalString.Length - 1] != '/') - { - authServerUri = new Uri($"{authServerUri.OriginalString}/"); - } - - foreach (var path in s_wellKnownPaths) + foreach (var wellKnownEndpoint in GetWellKnownAuthorizationServerMetadataUris(authServerUri)) { try { - var wellKnownEndpoint = new Uri(authServerUri, path); - var response = await _httpClient.GetAsync(wellKnownEndpoint, cancellationToken).ConfigureAwait(false); if (!response.IsSuccessStatusCode) { @@ -320,14 +357,33 @@ private async Task GetAuthServerMetadataAsync(Uri a } catch (Exception ex) { - LogErrorFetchingAuthServerMetadata(ex, path); + LogErrorFetchingAuthServerMetadata(ex, wellKnownEndpoint); } } throw new McpException($"Failed to find .well-known/openid-configuration or .well-known/oauth-authorization-server metadata for authorization server: '{authServerUri}'"); } - private async Task RefreshTokenAsync(string refreshToken, Uri resourceUri, AuthorizationServerMetadata authServerMetadata, CancellationToken cancellationToken) + private static IEnumerable 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 RefreshTokensAsync(string refreshToken, Uri resourceUri, AuthorizationServerMetadata authServerMetadata, CancellationToken cancellationToken) { var requestContent = new FormUrlEncodedContent(new Dictionary { @@ -350,10 +406,12 @@ private async Task GetAuthServerMetadataAsync(Uri a 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 InitiateAuthorizationCodeFlowAsync( ProtectedResourceMetadata protectedResourceMetadata, AuthorizationServerMetadata authServerMetadata, CancellationToken cancellationToken) @@ -369,7 +427,7 @@ private async Task InitiateAuthorizationCodeFlowAsync( ThrowFailedToHandleUnauthorizedResponse($"The {nameof(AuthorizationRedirectDelegate)} returned a null or empty authorization code."); } - await ExchangeCodeForTokenAsync(protectedResourceMetadata, authServerMetadata, authCode!, codeVerifier, cancellationToken).ConfigureAwait(false); + return await ExchangeCodeForTokenAsync(protectedResourceMetadata, authServerMetadata, authCode!, codeVerifier, cancellationToken).ConfigureAwait(false); } private Uri BuildAuthorizationUrl( @@ -389,10 +447,10 @@ private Uri BuildAuthorizationUrl( ["resource"] = resourceUri.ToString(), }; - var scopesSupported = protectedResourceMetadata.ScopesSupported; - if (_scopes is not null || scopesSupported.Count > 0) + var scope = GetScopeParameter(protectedResourceMetadata); + if (!string.IsNullOrEmpty(scope)) { - queryParamsDictionary["scope"] = string.Join(" ", _scopes ?? scopesSupported.ToArray()); + queryParamsDictionary["scope"] = scope!; } // Add extra parameters if provided. Load into a dictionary before constructing to avoid overwiting values. @@ -415,7 +473,7 @@ private Uri BuildAuthorizationUrl( return uriBuilder.Uri; } - private async Task ExchangeCodeForTokenAsync( + private async Task ExchangeCodeForTokenAsync( ProtectedResourceMetadata protectedResourceMetadata, AuthorizationServerMetadata authServerMetadata, string authorizationCode, @@ -442,7 +500,10 @@ private async Task ExchangeCodeForTokenAsync( using var httpResponse = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); httpResponse.EnsureSuccessStatusCode(); - await HandleSuccessfulTokenResponseAsync(httpResponse, cancellationToken).ConfigureAwait(false); + + var tokens = await HandleSuccessfulTokenResponseAsync(httpResponse, cancellationToken).ConfigureAwait(false); + LogOAuthAuthorizationCompleted(); + return tokens.AccessToken; } private async Task HandleSuccessfulTokenResponseAsync(HttpResponseMessage response, CancellationToken cancellationToken) @@ -475,38 +536,20 @@ private async Task HandleSuccessfulTokenResponseAsync(HttpRespon 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; - } - /// /// Fetches the protected resource metadata from the provided URL. /// - /// The URL to fetch the metadata from. - /// The to monitor for cancellation requests. The default is . - /// The fetched ProtectedResourceMetadata, or null if it couldn't be fetched. - private async Task FetchProtectedResourceMetadataAsync(Uri metadataUrl, CancellationToken cancellationToken = default) + private async Task FetchProtectedResourceMetadataAsync(Uri metadataUrl, bool requireSuccess, CancellationToken cancellationToken) { using var httpResponse = await _httpClient.GetAsync(metadataUrl, cancellationToken).ConfigureAwait(false); - httpResponse.EnsureSuccessStatusCode(); + if (requireSuccess) + { + httpResponse.EnsureSuccessStatusCode(); + } + else if (!httpResponse.IsSuccessStatusCode) + { + return null; + } using var stream = await httpResponse.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); return await JsonSerializer.DeserializeAsync(stream, McpJsonUtilities.JsonContext.Default.ProtectedResourceMetadata, cancellationToken).ConfigureAwait(false); @@ -515,10 +558,8 @@ private static Uri BuildProtectedResourceMetadataUri(Uri resourceUri) /// /// Performs dynamic client registration with the authorization server. /// - /// The authorization server metadata. - /// The to monitor for cancellation requests. The default is . - /// A task representing the asynchronous operation. private async Task PerformDynamicClientRegistrationAsync( + ProtectedResourceMetadata protectedResourceMetadata, AuthorizationServerMetadata authServerMetadata, CancellationToken cancellationToken) { @@ -537,7 +578,7 @@ private async Task PerformDynamicClientRegistrationAsync( TokenEndpointAuthMethod = "client_secret_post", ClientName = _dcrClientName, ClientUri = _dcrClientUri?.ToString(), - Scope = _scopes is not null ? string.Join(" ", _scopes) : null + Scope = GetScopeParameter(protectedResourceMetadata), }; var requestJson = JsonSerializer.Serialize(registrationRequest, McpJsonUtilities.JsonContext.Default.DynamicClientRegistrationRequest); @@ -587,16 +628,43 @@ private async Task PerformDynamicClientRegistrationAsync( } } + 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; + } + /// /// Verifies that the resource URI in the metadata exactly matches the original request URL as required by the RFC. /// Per RFC: The resource value must be identical to the URL that the client used to make the request to the resource server. /// /// The metadata to verify. - /// The original URL the client used to make the request to the resource server. + /// + /// 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. + /// /// True if the resource URI exactly matches the original request URL, otherwise false. private static bool VerifyResourceMatch(ProtectedResourceMetadata protectedResourceMetadata, Uri resourceLocation) { - if (protectedResourceMetadata.Resource == null || resourceLocation == null) + if (protectedResourceMetadata.Resource is null) { return false; } @@ -611,16 +679,6 @@ private static bool VerifyResourceMatch(ProtectedResourceMetadata protectedResou 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; - } - /// /// Normalizes a URI for consistent comparison. /// @@ -628,17 +686,19 @@ private static Uri GetRequiredResourceUri(ProtectedResourceMetadata protectedRes /// A normalized string representation of the URI. private static string NormalizeUri(Uri uri) { - var builder = new UriBuilder(uri) - { - Port = -1 // Always remove port - }; + var builder = new StringBuilder(); + builder.Append(uri.Scheme); + builder.Append("://"); + builder.Append(uri.Host); - if (builder.Path.Length > 0 && builder.Path[builder.Path.Length - 1] == '/') + if (!uri.IsDefaultPort) { - builder.Path = builder.Path.TrimEnd('/'); + builder.Append(':'); + builder.Append(uri.Port); } - return builder.Uri.ToString(); + builder.Append(uri.AbsolutePath.TrimEnd('/')); + return builder.ToString(); } /// @@ -646,61 +706,72 @@ private static string NormalizeUri(Uri uri) /// verifying the resource match, and returning the metadata if valid. /// /// The HTTP response containing the WWW-Authenticate header. - /// The server URL to verify against the resource metadata. - /// The to monitor for cancellation requests. The default is . + /// The to monitor for cancellation requests. /// The resource metadata if the resource matches the server, otherwise throws an exception. /// Thrown when the response is not a 401, the metadata can't be fetched, or the resource URI doesn't match the server URL. - private async Task ExtractProtectedResourceMetadata(HttpResponseMessage response, Uri serverUrl, CancellationToken cancellationToken = default) + private async Task ExtractProtectedResourceMetadata(HttpResponseMessage response, CancellationToken cancellationToken) { - if (response.StatusCode != System.Net.HttpStatusCode.Unauthorized) + Uri resourceUri = _serverUrl; + string? wwwAuthenticateScope = null; + string? resourceMetadataUrl = null; + + // Look for the Bearer authentication scheme with resource_metadata and/or scope parameters. + foreach (var header in response.Headers.WwwAuthenticate) { - throw new InvalidOperationException($"Expected a 401 Unauthorized response, but received {(int)response.StatusCode} {response.StatusCode}"); + if (string.Equals(header.Scheme, BearerScheme, StringComparison.OrdinalIgnoreCase) && !string.IsNullOrEmpty(header.Parameter)) + { + resourceMetadataUrl = ParseWwwAuthenticateParameters(header.Parameter, "resource_metadata"); + + // "Use scope parameter from the initial WWW-Authenticate header in the 401 response, if provided." + // https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#scope-selection-strategy + // + // We use the scope even if resource_metadata is not present so long as it's for the Bearer scheme, + // since we do not require a resource_metadata parameter. + wwwAuthenticateScope ??= ParseWwwAuthenticateParameters(header.Parameter, "scope"); + + if (resourceMetadataUrl is not null) + { + break; + } + } } - Uri metadataUri; + ProtectedResourceMetadata? metadata = null; - if (response.Headers.WwwAuthenticate.Count == 0) + if (resourceMetadataUrl is not null) { - metadataUri = BuildProtectedResourceMetadataUri(serverUrl); - LogMissingWwwAuthenticateHeader(metadataUri); + metadata = await FetchProtectedResourceMetadataAsync(new(resourceMetadataUrl), requireSuccess: true, cancellationToken).ConfigureAwait(false) + ?? throw new McpException($"Failed to fetch resource metadata from {resourceMetadataUrl}"); } else { - // Look for the Bearer authentication scheme with resource_metadata parameter - string? resourceMetadataUrl = null; - foreach (var header in response.Headers.WwwAuthenticate) + foreach (var (wellKnownUri, expectedResourceUri) in GetWellKnownResourceMetadataUris(_serverUrl)) { - if (string.Equals(header.Scheme, BearerScheme, StringComparison.OrdinalIgnoreCase) && !string.IsNullOrEmpty(header.Parameter)) + LogMissingResourceMetadataParameter(wellKnownUri); + metadata = await FetchProtectedResourceMetadataAsync(wellKnownUri, requireSuccess: false, cancellationToken).ConfigureAwait(false); + if (metadata is not null) { - resourceMetadataUrl = ParseWwwAuthenticateParameters(header.Parameter, "resource_metadata"); - if (resourceMetadataUrl != null) - { - break; - } + resourceUri = expectedResourceUri; + break; } } - if (resourceMetadataUrl == null) + if (metadata is null) { - metadataUri = BuildProtectedResourceMetadataUri(serverUrl); - LogMissingResourceMetadataParameter(metadataUri); - } - else - { - metadataUri = new(resourceMetadataUrl); + throw new McpException($"Failed to find protected resource metadata at a well-known location for {_serverUrl}"); } } - var metadata = await FetchProtectedResourceMetadataAsync(metadataUri, cancellationToken).ConfigureAwait(false) - ?? throw new McpException($"Failed to fetch resource metadata from {metadataUri}"); + // The WWW-Authenticate header parameter should be preferred over using the scopes_supported metadata property. + // https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#protected-resource-metadata-discovery-requirements + metadata.WwwAuthenticateScope = wwwAuthenticateScope; - // Per RFC: The resource value must be identical to the URL that the client used - // to make the request to the resource server - LogValidatingResourceMetadata(serverUrl); + // Per RFC: The resource value must be identical to the URL that the client used to make the request to the resource server + LogValidatingResourceMetadata(resourceUri); - if (!VerifyResourceMatch(metadata, serverUrl)) + if (!VerifyResourceMatch(metadata, resourceUri)) { - throw new McpException($"Resource URI in metadata ({metadata.Resource}) does not match the expected URI ({serverUrl})"); + throw new McpException($"Resource URI in metadata ({metadata.Resource}) does not match the expected URI ({resourceUri})"); } return metadata; @@ -721,7 +792,7 @@ private async Task ExtractProtectedResourceMetadata(H foreach (var part in parameters.Split(',')) { - string trimmedPart = part.Trim(); + var trimmedPart = part.AsSpan().Trim(); int equalsIndex = trimmedPart.IndexOf('='); if (equalsIndex <= 0) @@ -729,15 +800,14 @@ private async Task ExtractProtectedResourceMetadata(H continue; } - ReadOnlySpan key = trimmedPart.AsSpan().Slice(0, equalsIndex).Trim(); + var key = trimmedPart[..equalsIndex].Trim(); if (key.Equals(parameterName, StringComparison.OrdinalIgnoreCase)) { - ReadOnlySpan value = trimmedPart.AsSpan(equalsIndex + 1).Trim(); - - if (value.Length > 0 && value[0] == '"' && value[value.Length - 1] == '"') + var value = trimmedPart[(equalsIndex + 1)..].Trim(); + if (value.Length > 0 && value[0] == '"' && value[^1] == '"') { - value = value.Slice(1, value.Length - 2); + value = value[1..^1]; } return value.ToString(); @@ -747,15 +817,32 @@ private async Task ExtractProtectedResourceMetadata(H return null; } + private static IEnumerable<(Uri WellKnownUri, Uri ExpectedResourceUri)> GetWellKnownResourceMetadataUris(Uri resourceUri) + { + var builder = new UriBuilder(resourceUri); + var hostBase = builder.Uri.GetLeftPart(UriPartial.Authority); + var trimmedPath = builder.Path?.Trim('/') ?? string.Empty; + + if (!string.IsNullOrEmpty(trimmedPath)) + { + yield return (new Uri($"{hostBase}{ProtectedResourceMetadataWellKnownPath}/{trimmedPath}"), resourceUri); + } + + yield return (new Uri($"{hostBase}{ProtectedResourceMetadataWellKnownPath}"), new Uri(hostBase)); + } + private static string GenerateCodeVerifier() { +#if NET9_0_OR_GREATER + Span bytes = stackalloc byte[32]; + RandomNumberGenerator.Fill(bytes); + return Base64Url.EncodeToString(bytes); +#else var bytes = new byte[32]; using var rng = RandomNumberGenerator.Create(); rng.GetBytes(bytes); - return Convert.ToBase64String(bytes) - .TrimEnd('=') - .Replace('+', '-') - .Replace('/', '_'); + return ToBase64UrlString(bytes); +#endif } private static string GenerateCodeChallenge(string codeVerifier) @@ -767,23 +854,22 @@ private static string GenerateCodeChallenge(string codeVerifier) #else using var sha256 = SHA256.Create(); var challengeBytes = sha256.ComputeHash(Encoding.UTF8.GetBytes(codeVerifier)); - return Convert.ToBase64String(challengeBytes) + return ToBase64UrlString(challengeBytes); +#endif + } + +#if !NET9_0_OR_GREATER + private static string ToBase64UrlString(byte[] bytes) + { + return Convert.ToBase64String(bytes) .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."); - private static void ThrowIfNotBearerScheme(string scheme) - { - if (!string.Equals(scheme, BearerScheme, StringComparison.OrdinalIgnoreCase)) - { - throw new InvalidOperationException($"The '{scheme}' is not supported. This credential provider only supports the '{BearerScheme}' scheme"); - } - } - [DoesNotReturn] private static void ThrowFailedToHandleUnauthorizedResponse(string message) => throw new McpException($"Failed to handle unauthorized response with 'Bearer' scheme. {message}"); @@ -794,8 +880,11 @@ private static void ThrowFailedToHandleUnauthorizedResponse(string message) => [LoggerMessage(Level = LogLevel.Information, Message = "OAuth authorization completed successfully")] partial void LogOAuthAuthorizationCompleted(); - [LoggerMessage(Level = LogLevel.Error, Message = "Error fetching auth server metadata from {Path}")] - partial void LogErrorFetchingAuthServerMetadata(Exception ex, string path); + [LoggerMessage(Level = LogLevel.Information, Message = "OAuth token refresh completed successfully")] + partial void LogOAuthTokenRefreshCompleted(); + + [LoggerMessage(Level = LogLevel.Error, Message = "Error fetching auth server metadata from {Endpoint}")] + partial void LogErrorFetchingAuthServerMetadata(Exception ex, Uri endpoint); [LoggerMessage(Level = LogLevel.Information, Message = "Performing dynamic client registration with {RegistrationEndpoint}")] partial void LogPerformingDynamicClientRegistration(Uri registrationEndpoint); @@ -806,9 +895,9 @@ private static void ThrowFailedToHandleUnauthorizedResponse(string message) => [LoggerMessage(Level = LogLevel.Debug, Message = "Validating resource metadata against original server URL: {ServerUrl}")] partial void LogValidatingResourceMetadata(Uri serverUrl); - [LoggerMessage(Level = LogLevel.Debug, Message = "WWW-Authenticate header missing. Falling back to resource metadata at {MetadataUri}")] - partial void LogMissingWwwAuthenticateHeader(Uri metadataUri); + [LoggerMessage(Level = LogLevel.Warning, Message = "WWW-Authenticate header missing.")] + partial void LogMissingWwwAuthenticateHeader(); - [LoggerMessage(Level = LogLevel.Debug, Message = "WWW-Authenticate header missing resource_metadata parameter. Falling back to {MetadataUri}")] + [LoggerMessage(Level = LogLevel.Debug, Message = "Missing resource_metadata parameter from WWW-Authenticate header. Falling back to {MetadataUri}")] partial void LogMissingResourceMetadataParameter(Uri metadataUri); } diff --git a/src/ModelContextProtocol.Core/Authentication/ProtectedResourceMetadata.cs b/src/ModelContextProtocol.Core/Authentication/ProtectedResourceMetadata.cs index 46cf630d5..4b21227c8 100644 --- a/src/ModelContextProtocol.Core/Authentication/ProtectedResourceMetadata.cs +++ b/src/ModelContextProtocol.Core/Authentication/ProtectedResourceMetadata.cs @@ -177,4 +177,18 @@ public sealed class ProtectedResourceMetadata /// [JsonPropertyName("dpop_bound_access_tokens_required")] public bool? DpopBoundAccessTokensRequired { get; set; } + + /// + /// 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 + /// + [JsonIgnore] + internal string? WwwAuthenticateScope { get; set; } } diff --git a/src/ModelContextProtocol.Core/Client/HttpClientTransport.cs b/src/ModelContextProtocol.Core/Client/HttpClientTransport.cs index 48f8c5af1..14044d2d7 100644 --- a/src/ModelContextProtocol.Core/Client/HttpClientTransport.cs +++ b/src/ModelContextProtocol.Core/Client/HttpClientTransport.cs @@ -53,8 +53,7 @@ public HttpClientTransport(HttpClientTransportOptions transportOptions, HttpClie if (transportOptions.OAuth is { } clientOAuthOptions) { - var oAuthProvider = new ClientOAuthProvider(_options.Endpoint, clientOAuthOptions, httpClient, loggerFactory); - _mcpHttpClient = new AuthenticatingMcpHttpClient(httpClient, oAuthProvider); + _mcpHttpClient = new ClientOAuthProvider(_options.Endpoint, clientOAuthOptions, httpClient, loggerFactory); } else { diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/AuthTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/AuthTests.cs index 9db004310..2301a4983 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/AuthTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/AuthTests.cs @@ -1,9 +1,16 @@ +using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.WebUtilities; +using Microsoft.Extensions.DependencyInjection; using ModelContextProtocol.AspNetCore.Authentication; using ModelContextProtocol.Authentication; using ModelContextProtocol.Client; +using ModelContextProtocol.Server; using System.Net; using System.Reflection; +using System.Security.Claims; using Xunit.Sdk; namespace ModelContextProtocol.AspNetCore.Tests.OAuth; @@ -88,7 +95,6 @@ public async Task CanAuthenticate_WithDynamicClientRegistration() { RedirectUri = new Uri("http://localhost:1179/callback"), AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync, - Scopes = ["mcp:tools"], DynamicClientRegistration = new() { ClientName = "Test MCP Client", @@ -139,7 +145,6 @@ public async Task UsesDynamicClientRegistration_WhenCimdNotSupported() RedirectUri = new Uri("http://localhost:1179/callback"), AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync, ClientMetadataDocumentUri = new Uri("http://invalid-cimd.example.com"), - Scopes = ["mcp:tools"], DynamicClientRegistration = new() { ClientName = "Test MCP Client (No CIMD)", @@ -203,6 +208,33 @@ public async Task CannotAuthenticate_WithInvalidClientMetadataDocument(string ur [Fact] public async Task CanAuthenticate_WithTokenRefresh() { + var hasForcedRefresh = false; + + Builder.Services.AddHttpContextAccessor(); + Builder.Services.AddMcpServer(options => + { + options.ToolCollection = new(); + }) + .AddListToolsFilter(next => + { + return async (mcpContext, cancellationToken) => + { + if (!hasForcedRefresh) + { + hasForcedRefresh = true; + + var httpContext = mcpContext.Services!.GetRequiredService().HttpContext!; + await httpContext.ChallengeAsync(JwtBearerDefaults.AuthenticationScheme); + await httpContext.Response.CompleteAsync(); + throw new Exception("This exception will not impact the client because the response has already been completed."); + } + else + { + return await next(mcpContext, cancellationToken); + } + }; + }); + await using var app = await StartMcpServerAsync(); await using var transport = new HttpClientTransport(new() @@ -210,18 +242,18 @@ public async Task CanAuthenticate_WithTokenRefresh() Endpoint = new(McpServerUrl), OAuth = new() { - ClientId = "test-refresh-client", - ClientSecret = "test-refresh-secret", + ClientId = "demo-client", + ClientSecret = "demo-secret", RedirectUri = new Uri("http://localhost:1179/callback"), AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync, }, }, HttpClient, LoggerFactory); - // The test-refresh-client should get an expired token first, - // then automatically refresh it to get a working token await using var client = await McpClient.CreateAsync( transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken); + await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); + Assert.True(TestOAuthServer.HasRefreshedToken); } @@ -328,25 +360,396 @@ public async Task CanAuthenticate_WithoutResourceInWwwAuthenticateHeader_WithPat } [Fact] - public async Task JwtBearerChallenge_DoesNotIncludeResourceMetadata() + public async Task AuthorizationFlow_UsesScopeFromProtectedResourceMetadata() { - await using var app = await StartMcpServerAsync(authScheme: JwtBearerDefaults.AuthenticationScheme); + Builder.Services.Configure(McpAuthenticationDefaults.AuthenticationScheme, options => + { + options.ResourceMetadata!.ScopesSupported = ["mcp:tools", "files:read"]; + }); + + await using var app = await StartMcpServerAsync(); + + string? requestedScope = null; + + await using var transport = new HttpClientTransport(new() + { + Endpoint = new(McpServerUrl), + OAuth = new() + { + ClientId = "demo-client", + ClientSecret = "demo-secret", + RedirectUri = new Uri("http://localhost:1179/callback"), + AuthorizationRedirectDelegate = (uri, redirect, ct) => + { + var query = QueryHelpers.ParseQuery(uri.Query); + requestedScope = query["scope"].ToString(); + return HandleAuthorizationUrlAsync(uri, redirect, ct); + }, + }, + }, HttpClient, LoggerFactory); + + await using var client = await McpClient.CreateAsync( + transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken); + + Assert.Equal("mcp:tools files:read", requestedScope); + } + + [Fact] + public async Task AuthorizationFlow_UsesScopeFromChallengeHeader() + { + var challengeScopes = "challenge:read challenge:write"; + + await using var app = Builder.Build(); + app.Use(next => + { + return async context => + { + await next(context); + + if (context.Response.StatusCode != 401) + { + return; + } + + context.Response.Headers.WWWAuthenticate = $"Bearer resource_metadata=\"{McpServerUrl}/.well-known/oauth-protected-resource\", scope=\"{challengeScopes}\""; + }; + }); + app.UseAuthentication(); + app.UseAuthorization(); + + app.MapMcp().RequireAuthorization(); + await app.StartAsync(TestContext.Current.CancellationToken); + + string? requestedScope = null; + + await using var transport = new HttpClientTransport(new() + { + Endpoint = new(McpServerUrl), + OAuth = new() + { + ClientId = "demo-client", + ClientSecret = "demo-secret", + RedirectUri = new Uri("http://localhost:1179/callback"), + AuthorizationRedirectDelegate = (uri, redirect, ct) => + { + var query = QueryHelpers.ParseQuery(uri.Query); + requestedScope = query["scope"].ToString(); + return HandleAuthorizationUrlAsync(uri, redirect, ct); + }, + }, + }, HttpClient, LoggerFactory); + + await using var client = await McpClient.CreateAsync( + transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken); + + Assert.Equal(challengeScopes, requestedScope); + } + + [Fact] + public async Task AuthorizationFlow_UsesScopeFromForbiddenHeader() + { + var adminScopes = "admin:read admin:write"; + + Builder.Services.AddHttpContextAccessor(); + Builder.Services.AddMcpServer() + .WithTools([ + McpServerTool.Create([McpServerTool(Name = "admin-tool")] + async (IServiceProvider serviceProvider, ClaimsPrincipal user) => + { + if (!user.HasClaim("scope", adminScopes)) + { + var httpContext = serviceProvider.GetRequiredService().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(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(() => McpClient.CreateAsync( + transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task CanAuthenticate_WithAuthorizationServerPathInsertionMetadata() + { + Builder.Services.Configure(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(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 wellKnownRequests = []; + + Builder.Services.Configure(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(); - using var unauthorizedResponse = await HttpClient.GetAsync(McpServerUrl, HttpCompletionOption.ResponseHeadersRead, TestContext.Current.CancellationToken); - Assert.Equal(HttpStatusCode.Unauthorized, unauthorizedResponse.StatusCode); + app.MapMcp(resourcePath).RequireAuthorization(); - var headerFound = false; - foreach (var header in unauthorizedResponse.Headers.WwwAuthenticate) + 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(options => options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme); + Builder.Services.Configure(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(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(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() { - headerFound = true; - Assert.Equal("Bearer", header.Scheme); - Assert.True(header.Parameter is null || !header.Parameter.Contains("resource_metadata", StringComparison.OrdinalIgnoreCase)); - } + Endpoint = new Uri($"{McpServerUrl}{requestedResourcePath}"), + OAuth = new() + { + ClientId = "demo-client", + ClientSecret = "demo-secret", + RedirectUri = new Uri("http://localhost:1179/callback"), + AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync, + }, + }, HttpClient, LoggerFactory); - Assert.True(headerFound); + var ex = await Assert.ThrowsAsync(async () => + { + await McpClient.CreateAsync( + transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken); + }); - using var metadataResponse = await HttpClient.GetAsync(new Uri("/.well-known/oauth-protected-resource", UriKind.Relative), TestContext.Current.CancellationToken); - metadataResponse.EnsureSuccessStatusCode(); + Assert.Contains("does not match", ex.Message); } [Fact] diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/OAuthTestBase.cs b/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/OAuthTestBase.cs index 1bdd765a1..52b5616d0 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/OAuthTestBase.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/OAuthTestBase.cs @@ -8,7 +8,6 @@ using ModelContextProtocol.AspNetCore.Tests.Utils; using ModelContextProtocol.Authentication; using System.Net; -using Xunit.Sdk; namespace ModelContextProtocol.AspNetCore.Tests.OAuth; @@ -84,7 +83,7 @@ public async ValueTask DisposeAsync() protected async Task StartMcpServerAsync(string path = "", string? authScheme = null) { - Builder.Services.PostConfigure(JwtBearerDefaults.AuthenticationScheme, options => + Builder.Services.Configure(JwtBearerDefaults.AuthenticationScheme, options => { options.TokenValidationParameters.ValidAudience = $"{McpServerUrl}{path}"; }); diff --git a/tests/ModelContextProtocol.TestOAuthServer/Program.cs b/tests/ModelContextProtocol.TestOAuthServer/Program.cs index 54303883d..e13c731de 100644 --- a/tests/ModelContextProtocol.TestOAuthServer/Program.cs +++ b/tests/ModelContextProtocol.TestOAuthServer/Program.cs @@ -26,6 +26,8 @@ public sealed class Program private readonly ConcurrentDictionary _tokens = new(); private readonly ConcurrentDictionary _clients = new(); + private readonly ConcurrentQueue _metadataRequests = new(); + private readonly RSA _rsa; private readonly string _keyId; @@ -46,7 +48,6 @@ public Program(ILoggerProvider? loggerProvider = null, IConnectionListenerFactor } // 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; } /// @@ -59,6 +60,9 @@ public Program(ILoggerProvider? loggerProvider = null, IConnectionListenerFactor /// public bool ClientIdMetadataDocumentSupported { get; set; } = true; + public HashSet DisabledMetadataPaths { get; } = new(StringComparer.OrdinalIgnoreCase); + public IReadOnlyCollection MetadataRequests => _metadataRequests.ToArray(); + /// /// Entry point for the application. /// @@ -111,24 +115,15 @@ public async Task RunServerAsync(string[]? args = null, CancellationToken cancel var app = builder.Build(); - // Set up the demo client var clientId = "demo-client"; var clientSecret = "demo-secret"; + _clients[clientId] = new ClientInfo { ClientId = clientId, - RequiresClientSecret = true, ClientSecret = clientSecret, - RedirectUris = ["http://localhost:1179/callback"], - }; - // When this client ID is used, the first token issued will already be expired to make - // testing the refresh flow easier. - _clients["test-refresh-client"] = new ClientInfo - { - ClientId = "test-refresh-client", RequiresClientSecret = true, - ClientSecret = "test-refresh-secret", RedirectUris = ["http://localhost:1179/callback"], }; @@ -146,42 +141,62 @@ public async Task RunServerAsync(string[]? args = null, CancellationToken cancel }; // The MCP spec tells the client to use /.well-known/oauth-authorization-server but AddJwtBearer looks for - // /.well-known/openid-configuration by default. To make things easier, we support both with the same response - // which seems to be common. Ex. https://github.com/keycloak/keycloak/pull/29628 + // /.well-known/openid-configuration by default. // // The requirements for these endpoints are at https://www.rfc-editor.org/rfc/rfc8414 and // https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderMetadata respectively. // They do differ, but it's close enough at least for our current testing to use the same response for both. // See https://gist.github.com/localden/26d8bcf641703c08a5d8741aa9c3336c - string[] metadataEndpoints = ["/.well-known/oauth-authorization-server", "/.well-known/openid-configuration"]; - foreach (var metadataEndpoint in metadataEndpoints) + IResult HandleMetadataRequest(HttpContext context, string? issuerPath = null) { - // OAuth 2.0 Authorization Server Metadata (RFC 8414) - app.MapGet(metadataEndpoint, () => + _metadataRequests.Enqueue(context.Request.Path); + + if (DisabledMetadataPaths.Contains(context.Request.Path)) { - 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); - }); + return Results.NotFound(); + } + + if (!string.IsNullOrEmpty(issuerPath)) + { + issuerPath = $"/{issuerPath}"; + } + + var metadata = new OAuthServerMetadata + { + Issuer = $"{_url}{issuerPath}", + AuthorizationEndpoint = $"{_url}/authorize", + TokenEndpoint = $"{_url}/token", + JwksUri = $"{_url}/.well-known/jwks.json", + ResponseTypesSupported = ["code"], + SubjectTypesSupported = ["public"], + IdTokenSigningAlgValuesSupported = ["RS256"], + ScopesSupported = ["openid", "profile", "email", "mcp:tools"], + TokenEndpointAuthMethodsSupported = ["client_secret_post"], + ClaimsSupported = ["sub", "iss", "name", "email", "aud"], + CodeChallengeMethodsSupported = ["S256"], + GrantTypesSupported = ["authorization_code", "refresh_token"], + IntrospectionEndpoint = $"{_url}/introspect", + RegistrationEndpoint = $"{_url}/register", + ClientIdMetadataDocumentSupported = ClientIdMetadataDocumentSupported, + }; + + return Results.Ok(metadata); } + app.MapGet("/.well-known/oauth-authorization-server", HandleMetadataRequest); + app.MapGet("/.well-known/openid-configuration", HandleMetadataRequest); + app.MapGet("/.well-known/oauth-authorization-server/{**issuerPath}", HandleMetadataRequest); + app.MapGet("/.well-known/openid-configuration/{**issuerPath}", HandleMetadataRequest); + app.MapGet("/{**fullPath}", (HttpContext context, string fullPath) => + { + if (fullPath.EndsWith("/.well-known/openid-configuration", StringComparison.OrdinalIgnoreCase)) + { + return HandleMetadataRequest(context, fullPath[..^"/.well-known/openid-configuration".Length]); + } + + return Results.NotFound(); + }); + // JWKS endpoint to expose the public key app.MapGet("/.well-known/jwks.json", () => { @@ -564,14 +579,6 @@ private TokenResponse GenerateJwtTokenResponse(string clientId, List sco { var expiresIn = TimeSpan.FromHours(1); var issuedAt = DateTimeOffset.UtcNow; - - // For test-refresh-client, make the first token expired to test refresh functionality. - if (clientId == "test-refresh-client" && !HasIssuedExpiredToken) - { - HasIssuedExpiredToken = true; - expiresIn = TimeSpan.FromHours(-1); - } - var expiresAt = issuedAt.Add(expiresIn); var jwtId = Guid.NewGuid().ToString(); @@ -580,7 +587,7 @@ private TokenResponse GenerateJwtTokenResponse(string clientId, List sco { { "alg", "RS256" }, { "typ", "JWT" }, - { "kid", _keyId } + { "kid", _keyId }, }; var payload = new Dictionary @@ -593,7 +600,7 @@ private TokenResponse GenerateJwtTokenResponse(string clientId, List sco { "jti", jwtId }, { "iat", issuedAt.ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture) }, { "exp", expiresAt.ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture) }, - { "scope", string.Join(" ", scopes) } + { "scope", string.Join(" ", scopes) }, }; // Create JWT token