From 7860efec4be996d34b8bcdaa74c59935a151eded Mon Sep 17 00:00:00 2001 From: Stephen Halter Date: Wed, 10 Dec 2025 11:34:59 -0800 Subject: [PATCH 1/5] Support incremental scope consent (SEP-835) - Properly handle path segments in issuer path - Include port in resource comparison --- .../McpAuthenticationHandler.cs | 8 +- .../StreamableHttpHandler.cs | 2 +- .../AuthenticatingMcpHttpClient.cs | 118 ------ .../Authentication/ClientOAuthProvider.cs | 384 +++++++++++------- .../ProtectedResourceMetadata.cs | 14 + .../Client/HttpClientTransport.cs | 3 +- .../OAuth/AuthTests.cs | 275 ++++++++++++- .../OAuth/OAuthTestBase.cs | 1 - .../Program.cs | 92 +++-- 9 files changed, 573 insertions(+), 324 deletions(-) delete mode 100644 src/ModelContextProtocol.Core/Authentication/AuthenticatingMcpHttpClient.cs diff --git a/src/ModelContextProtocol.AspNetCore/Authentication/McpAuthenticationHandler.cs b/src/ModelContextProtocol.AspNetCore/Authentication/McpAuthenticationHandler.cs index 310a9cead..498373fec 100644 --- a/src/ModelContextProtocol.AspNetCore/Authentication/McpAuthenticationHandler.cs +++ b/src/ModelContextProtocol.AspNetCore/Authentication/McpAuthenticationHandler.cs @@ -185,15 +185,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}\""; + string headerValue = $"Bearer resource_metadata=\"{rawPrmDocumentUri}\""; Response.Headers.Append("WWW-Authenticate", 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..52236506e 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; @@ -114,85 +115,129 @@ 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 didRefresh = false; + + if (request.Headers.Authorization is null && request.RequestUri is not null) + { + string? accessToken; + (accessToken, didRefresh) = 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, didRefresh, cancellationToken).ConfigureAwait(false); + } + return response; + } + + private async Task<(string? AccessToken, bool DidRefresh)> 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); + var newTokens = await RefreshAccessTokenAsync(refreshToken, resourceUri, _authServerMetadata, cancellationToken).ConfigureAwait(false); if (newTokens is not null) { - return newTokens.AccessToken; + return (newTokens.AccessToken, true); } } // No valid token - auth handler will trigger the 401 flow - return null; + return (null, 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 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 true; + } + } + + return false; + } + + private async Task HandleUnauthorizedResponseAsync( + HttpRequestMessage originalRequest, + JsonRpcMessage? originalJsonRpcMessage, HttpResponseMessage response, - CancellationToken cancellationToken = default) + bool didRefresh, + 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, didRefresh, cancellationToken).ConfigureAwait(false); + LogOAuthAuthorizationCompleted(); + + 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 HTTP response that triggered the authentication challenge. + /// Indicates whether a token refresh has already been attempted. /// 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) + private async Task GetAccessTokenAsync(HttpResponseMessage response, bool didRefresh, CancellationToken cancellationToken = default) { - // Get available authorization servers from the 401 response + // Get available authorization servers from the 401 or 403 response var protectedResourceMetadata = await ExtractProtectedResourceMetadata(response, _serverUrl, cancellationToken).ConfigureAwait(false); var availableAuthorizationServers = protectedResourceMetadata.AuthorizationServers; @@ -225,13 +270,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 (!didRefresh && + response.StatusCode == System.Net.HttpStatusCode.Unauthorized && + await _tokenCache.GetTokensAsync(cancellationToken).ConfigureAwait(false) is { RefreshToken: { } refreshToken }) { - var refreshedTokens = await RefreshTokenAsync(refreshToken, resourceUri, authServerMetadata, cancellationToken).ConfigureAwait(false); + var refreshedTokens = await RefreshAccessTokenAsync(refreshToken, resourceUri, authServerMetadata, cancellationToken).ConfigureAwait(false); if (refreshedTokens is not null) { // A non-null result indicates the refresh succeeded and the new tokens have been stored. - return; + return refreshedTokens.AccessToken; } } @@ -245,14 +296,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 +323,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 GetWellKnownMetadataUris(authServerUri)) { try { - var wellKnownEndpoint = new Uri(authServerUri, path); - var response = await _httpClient.GetAsync(wellKnownEndpoint, cancellationToken).ConfigureAwait(false); if (!response.IsSuccessStatusCode) { @@ -320,14 +361,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 GetWellKnownMetadataUris(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 RefreshAccessTokenAsync(string refreshToken, Uri resourceUri, AuthorizationServerMetadata authServerMetadata, CancellationToken cancellationToken) { var requestContent = new FormUrlEncodedContent(new Dictionary { @@ -353,7 +413,7 @@ private async Task GetAuthServerMetadataAsync(Uri a return await HandleSuccessfulTokenResponseAsync(httpResponse, cancellationToken).ConfigureAwait(false); } - private async Task InitiateAuthorizationCodeFlowAsync( + private async Task InitiateAuthorizationCodeFlowAsync( ProtectedResourceMetadata protectedResourceMetadata, AuthorizationServerMetadata authServerMetadata, CancellationToken cancellationToken) @@ -369,7 +429,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 +449,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 +475,7 @@ private Uri BuildAuthorizationUrl( return uriBuilder.Uri; } - private async Task ExchangeCodeForTokenAsync( + private async Task ExchangeCodeForTokenAsync( ProtectedResourceMetadata protectedResourceMetadata, AuthorizationServerMetadata authServerMetadata, string authorizationCode, @@ -442,7 +502,8 @@ private async Task ExchangeCodeForTokenAsync( using var httpResponse = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); httpResponse.EnsureSuccessStatusCode(); - await HandleSuccessfulTokenResponseAsync(httpResponse, cancellationToken).ConfigureAwait(false); + var tokenContainer = await HandleSuccessfulTokenResponseAsync(httpResponse, cancellationToken).ConfigureAwait(false); + return tokenContainer.AccessToken; } private async Task HandleSuccessfulTokenResponseAsync(HttpResponseMessage response, CancellationToken cancellationToken) @@ -477,17 +538,11 @@ private async Task HandleSuccessfulTokenResponseAsync(HttpRespon private static Uri BuildProtectedResourceMetadataUri(Uri resourceUri) { - var builder = new UriBuilder(resourceUri) - { - Query = string.Empty, - Fragment = string.Empty, - }; - + var builder = new UriBuilder(resourceUri); var pathSuffix = resourceUri.AbsolutePath; if (pathSuffix.Length > 1) { - pathSuffix = pathSuffix.TrimEnd('/'); - builder.Path = string.Concat(ProtectedResourceMetadataWellKnownPath, pathSuffix); + builder.Path = $"{ProtectedResourceMetadataWellKnownPath}{pathSuffix.AsSpan().TrimEnd('/')}"; } else { @@ -515,10 +570,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 +590,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); @@ -596,7 +649,7 @@ private async Task PerformDynamicClientRegistrationAsync( /// 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 || resourceLocation is null) { return false; } @@ -628,17 +681,25 @@ private static Uri GetRequiredResourceUri(ProtectedResourceMetadata protectedRes /// A normalized string representation of the URI. private static string NormalizeUri(Uri uri) { - var builder = new UriBuilder(uri) + var builder = new StringBuilder(); + builder.Append(uri.Scheme); + builder.Append("://"); + builder.Append(uri.Host); + + if (!uri.IsDefaultPort) { - Port = -1 // Always remove port - }; + builder.Append(':'); + builder.Append(uri.Port); + } + + builder.Append(uri.AbsolutePath.TrimEnd('/')); - if (builder.Path.Length > 0 && builder.Path[builder.Path.Length - 1] == '/') + if (!string.IsNullOrEmpty(uri.Query)) { - builder.Path = builder.Path.TrimEnd('/'); + builder.Append(uri.Query); } - return builder.Uri.ToString(); + return builder.ToString(); } /// @@ -652,48 +713,46 @@ private static string NormalizeUri(Uri uri) /// 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) { - if (response.StatusCode != System.Net.HttpStatusCode.Unauthorized) - { - throw new InvalidOperationException($"Expected a 401 Unauthorized response, but received {(int)response.StatusCode} {response.StatusCode}"); - } - Uri metadataUri; + string? wwwAuthenticateScope = null; + string? resourceMetadataUrl = null; - if (response.Headers.WwwAuthenticate.Count == 0) + // Look for the Bearer authentication scheme with resource_metadata and/or scope parameters. + foreach (var header in response.Headers.WwwAuthenticate) { - metadataUri = BuildProtectedResourceMetadataUri(serverUrl); - LogMissingWwwAuthenticateHeader(metadataUri); - } - else - { - // Look for the Bearer authentication scheme with resource_metadata parameter - string? resourceMetadataUrl = null; - foreach (var header in response.Headers.WwwAuthenticate) + if (string.Equals(header.Scheme, BearerScheme, StringComparison.OrdinalIgnoreCase) && !string.IsNullOrEmpty(header.Parameter)) { - 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) { - resourceMetadataUrl = ParseWwwAuthenticateParameters(header.Parameter, "resource_metadata"); - if (resourceMetadataUrl != null) - { - break; - } + break; } } + } - if (resourceMetadataUrl == null) - { - metadataUri = BuildProtectedResourceMetadataUri(serverUrl); - LogMissingResourceMetadataParameter(metadataUri); - } - else - { - metadataUri = new(resourceMetadataUrl); - } + if (resourceMetadataUrl is null) + { + metadataUri = BuildProtectedResourceMetadataUri(serverUrl); + LogMissingResourceMetadataParameter(metadataUri); + } + else + { + metadataUri = new(resourceMetadataUrl); } var metadata = await FetchProtectedResourceMetadataAsync(metadataUri, cancellationToken).ConfigureAwait(false) ?? throw new McpException($"Failed to fetch resource metadata from {metadataUri}"); + 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); @@ -721,7 +780,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 +788,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 +805,32 @@ private async Task ExtractProtectedResourceMetadata(H return null; } + 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; + } + 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 +842,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 +868,8 @@ 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.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 +880,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..abdba7fa0 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() @@ -222,6 +254,8 @@ public async Task CanAuthenticate_WithTokenRefresh() 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); } @@ -327,6 +361,243 @@ public async Task CanAuthenticate_WithoutResourceInWwwAuthenticateHeader_WithPat transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken); } + [Fact] + public async Task AuthorizationFlow_UsesScopeFromProtectedResourceMetadata() + { + 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 JwtBearerChallenge_DoesNotIncludeResourceMetadata() { diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/OAuthTestBase.cs b/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/OAuthTestBase.cs index 1bdd765a1..544868a8d 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; diff --git a/tests/ModelContextProtocol.TestOAuthServer/Program.cs b/tests/ModelContextProtocol.TestOAuthServer/Program.cs index 54303883d..8b16ea316 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. /// @@ -146,42 +150,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 +588,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 +596,7 @@ private TokenResponse GenerateJwtTokenResponse(string clientId, List sco { { "alg", "RS256" }, { "typ", "JWT" }, - { "kid", _keyId } + { "kid", _keyId }, }; var payload = new Dictionary @@ -593,7 +609,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 From 5d822c49be4098ff7843ed9657c077a43b3d7227 Mon Sep 17 00:00:00 2001 From: Stephen Halter Date: Wed, 10 Dec 2025 12:26:18 -0800 Subject: [PATCH 2/5] Fall back to root when searching well-known protected resource metadata locations --- .../Authentication/ClientOAuthProvider.cs | 170 ++++++++++-------- .../OAuth/AuthTests.cs | 6 +- .../Program.cs | 11 +- 3 files changed, 97 insertions(+), 90 deletions(-) diff --git a/src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs b/src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs index 52236506e..b3be1657f 100644 --- a/src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs +++ b/src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs @@ -103,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) { @@ -117,12 +117,12 @@ public ClientOAuthProvider( internal override async Task SendAsync(HttpRequestMessage request, JsonRpcMessage? message, CancellationToken cancellationToken) { - bool didRefresh = false; + bool attemptedRefresh = false; if (request.Headers.Authorization is null && request.RequestUri is not null) { string? accessToken; - (accessToken, didRefresh) = await GetAccessTokenSilentAsync(request.RequestUri, cancellationToken).ConfigureAwait(false); + (accessToken, attemptedRefresh) = await GetAccessTokenSilentAsync(request.RequestUri, cancellationToken).ConfigureAwait(false); if (!string.IsNullOrEmpty(accessToken)) { @@ -134,13 +134,13 @@ internal override async Task SendAsync(HttpRequestMessage r if (ShouldRetryWithNewAccessToken(response)) { - return await HandleUnauthorizedResponseAsync(request, message, response, didRefresh, cancellationToken).ConfigureAwait(false); + return await HandleUnauthorizedResponseAsync(request, message, response, attemptedRefresh, cancellationToken).ConfigureAwait(false); } return response; } - private async Task<(string? AccessToken, bool DidRefresh)> GetAccessTokenSilentAsync(Uri resourceUri, CancellationToken cancellationToken) + private async Task<(string? AccessToken, bool AttemptedRefresh)> GetAccessTokenSilentAsync(Uri resourceUri, CancellationToken cancellationToken) { var tokens = await _tokenCache.GetTokensAsync(cancellationToken).ConfigureAwait(false); @@ -153,11 +153,8 @@ internal override async Task SendAsync(HttpRequestMessage r // Try to refresh the access token if it is invalid and we have a refresh token. if (_authServerMetadata is not null && tokens?.RefreshToken is { Length: > 0 } refreshToken) { - var newTokens = await RefreshAccessTokenAsync(refreshToken, resourceUri, _authServerMetadata, cancellationToken).ConfigureAwait(false); - if (newTokens is not null) - { - return (newTokens.AccessToken, true); - } + var accessToken = await RefreshTokensAsync(refreshToken, resourceUri, _authServerMetadata, cancellationToken).ConfigureAwait(false); + return (accessToken, true); } // No valid token - auth handler will trigger the 401 flow @@ -199,7 +196,7 @@ private async Task HandleUnauthorizedResponseAsync( HttpRequestMessage originalRequest, JsonRpcMessage? originalJsonRpcMessage, HttpResponseMessage response, - bool didRefresh, + bool attemptedRefresh, CancellationToken cancellationToken) { if (response.Headers.WwwAuthenticate.Count == 0) @@ -212,8 +209,7 @@ private async Task HandleUnauthorizedResponseAsync( throw new McpException($"The server does not support the '{BearerScheme}' authentication scheme. Server supports: [{serverSchemes}]."); } - var accessToken = await GetAccessTokenAsync(response, didRefresh, cancellationToken).ConfigureAwait(false); - LogOAuthAuthorizationCompleted(); + var accessToken = await GetAccessTokenAsync(response, attemptedRefresh, cancellationToken).ConfigureAwait(false); using var retryRequest = new HttpRequestMessage(originalRequest.Method, originalRequest.RequestUri); @@ -233,9 +229,9 @@ private async Task HandleUnauthorizedResponseAsync( /// Handles a 401 Unauthorized or 403 Forbidden response from a resource by completing any required OAuth flows. /// /// The HTTP response that triggered the authentication challenge. - /// Indicates whether a token refresh has already been attempted. - /// The to monitor for cancellation requests. The default is . - private async Task GetAccessTokenAsync(HttpResponseMessage response, bool didRefresh, CancellationToken cancellationToken = default) + /// 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 or 403 response var protectedResourceMetadata = await ExtractProtectedResourceMetadata(response, _serverUrl, cancellationToken).ConfigureAwait(false); @@ -274,15 +270,15 @@ private async Task GetAccessTokenAsync(HttpResponseMessage response, boo // 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 (!didRefresh && + if (!attemptedRefresh && response.StatusCode == System.Net.HttpStatusCode.Unauthorized && - await _tokenCache.GetTokensAsync(cancellationToken).ConfigureAwait(false) is { RefreshToken: { } refreshToken }) + await _tokenCache.GetTokensAsync(cancellationToken).ConfigureAwait(false) is { RefreshToken: { Length: > 0 } refreshToken }) { - var refreshedTokens = await RefreshAccessTokenAsync(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 refreshedTokens.AccessToken; + return accessToken; } } @@ -323,7 +319,7 @@ static bool IsValidClientMetadataDocumentUri(Uri uri) private async Task GetAuthServerMetadataAsync(Uri authServerUri, CancellationToken cancellationToken) { - foreach (var wellKnownEndpoint in GetWellKnownMetadataUris(authServerUri)) + foreach (var wellKnownEndpoint in GetWellKnownAuthorizationServerMetadataUris(authServerUri)) { try { @@ -368,7 +364,7 @@ private async Task GetAuthServerMetadataAsync(Uri a throw new McpException($"Failed to find .well-known/openid-configuration or .well-known/oauth-authorization-server metadata for authorization server: '{authServerUri}'"); } - private static IEnumerable GetWellKnownMetadataUris(Uri issuer) + private static IEnumerable GetWellKnownAuthorizationServerMetadataUris(Uri issuer) { var builder = new UriBuilder(issuer); var hostBase = builder.Uri.GetLeftPart(UriPartial.Authority); @@ -387,7 +383,7 @@ private static IEnumerable GetWellKnownMetadataUris(Uri issuer) } } - private async Task RefreshAccessTokenAsync(string refreshToken, Uri resourceUri, AuthorizationServerMetadata authServerMetadata, CancellationToken cancellationToken) + private async Task RefreshTokensAsync(string refreshToken, Uri resourceUri, AuthorizationServerMetadata authServerMetadata, CancellationToken cancellationToken) { var requestContent = new FormUrlEncodedContent(new Dictionary { @@ -410,7 +406,9 @@ private static IEnumerable GetWellKnownMetadataUris(Uri issuer) 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( @@ -502,8 +500,10 @@ private async Task ExchangeCodeForTokenAsync( using var httpResponse = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); httpResponse.EnsureSuccessStatusCode(); - var tokenContainer = await HandleSuccessfulTokenResponseAsync(httpResponse, cancellationToken).ConfigureAwait(false); - return tokenContainer.AccessToken; + + var tokens = await HandleSuccessfulTokenResponseAsync(httpResponse, cancellationToken).ConfigureAwait(false); + LogOAuthAuthorizationCompleted(); + return tokens.AccessToken; } private async Task HandleSuccessfulTokenResponseAsync(HttpResponseMessage response, CancellationToken cancellationToken) @@ -536,32 +536,20 @@ private async Task HandleSuccessfulTokenResponseAsync(HttpRespon return tokens; } - private static Uri BuildProtectedResourceMetadataUri(Uri resourceUri) - { - var builder = new UriBuilder(resourceUri); - var pathSuffix = resourceUri.AbsolutePath; - if (pathSuffix.Length > 1) - { - builder.Path = $"{ProtectedResourceMetadataWellKnownPath}{pathSuffix.AsSpan().TrimEnd('/')}"; - } - 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); @@ -640,6 +628,30 @@ 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. @@ -664,16 +676,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. /// @@ -708,12 +710,11 @@ private static string NormalizeUri(Uri uri) /// /// 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, Uri serverUrl, CancellationToken cancellationToken) { - Uri metadataUri; string? wwwAuthenticateScope = null; string? resourceMetadataUrl = null; @@ -738,19 +739,33 @@ private async Task ExtractProtectedResourceMetadata(H } } - if (resourceMetadataUrl is null) + ProtectedResourceMetadata? metadata = null; + + if (resourceMetadataUrl is not null) { - metadataUri = BuildProtectedResourceMetadataUri(serverUrl); - LogMissingResourceMetadataParameter(metadataUri); + metadata = await FetchProtectedResourceMetadataAsync(new(resourceMetadataUrl), requireSuccess: true, cancellationToken).ConfigureAwait(false) + ?? throw new McpException($"Failed to fetch resource metadata from {resourceMetadataUrl}"); } else { - metadataUri = new(resourceMetadataUrl); - } + foreach (var wellKnownUri in GetWellKnownResourceMetadataUris(serverUrl)) + { + LogMissingResourceMetadataParameter(wellKnownUri); + metadata = await FetchProtectedResourceMetadataAsync(wellKnownUri, requireSuccess: false, cancellationToken).ConfigureAwait(false); + if (metadata is not null) + { + break; + } + } - var metadata = await FetchProtectedResourceMetadataAsync(metadataUri, cancellationToken).ConfigureAwait(false) - ?? throw new McpException($"Failed to fetch resource metadata from {metadataUri}"); + if (metadata is null) + { + throw new McpException($"Failed to find protected resource metadata at a well-known location for {serverUrl}"); + } + } + // 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 @@ -805,18 +820,18 @@ private async Task ExtractProtectedResourceMetadata(H return null; } - private string? GetScopeParameter(ProtectedResourceMetadata protectedResourceMetadata) + private static IEnumerable GetWellKnownResourceMetadataUris(Uri resourceUri) { - if (!string.IsNullOrEmpty(protectedResourceMetadata.WwwAuthenticateScope)) - { - return protectedResourceMetadata.WwwAuthenticateScope; - } - else if (protectedResourceMetadata.ScopesSupported.Count > 0) + var builder = new UriBuilder(resourceUri); + var hostBase = builder.Uri.GetLeftPart(UriPartial.Authority); + var trimmedPath = builder.Path?.Trim('/') ?? string.Empty; + + if (!string.IsNullOrEmpty(trimmedPath)) { - return string.Join(" ", protectedResourceMetadata.ScopesSupported); + yield return new Uri($"{hostBase}{ProtectedResourceMetadataWellKnownPath}/{trimmedPath}"); } - return _configuredScopes; + yield return new Uri($"{hostBase}{ProtectedResourceMetadataWellKnownPath}"); } private static string GenerateCodeVerifier() @@ -868,6 +883,9 @@ private static void ThrowFailedToHandleUnauthorizedResponse(string message) => [LoggerMessage(Level = LogLevel.Information, Message = "OAuth authorization completed successfully")] partial void LogOAuthAuthorizationCompleted(); + [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); diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/AuthTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/AuthTests.cs index abdba7fa0..f2b6d1da5 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/AuthTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/AuthTests.cs @@ -242,15 +242,13 @@ 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); diff --git a/tests/ModelContextProtocol.TestOAuthServer/Program.cs b/tests/ModelContextProtocol.TestOAuthServer/Program.cs index 8b16ea316..e13c731de 100644 --- a/tests/ModelContextProtocol.TestOAuthServer/Program.cs +++ b/tests/ModelContextProtocol.TestOAuthServer/Program.cs @@ -115,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"], }; From 9737de4cb3f71d5f829833a6f9a6968bb8e6f450 Mon Sep 17 00:00:00 2001 From: Stephen Halter Date: Wed, 10 Dec 2025 15:23:10 -0800 Subject: [PATCH 3/5] WIP --- .../McpAuthenticationHandler.cs | 3 +- .../OAuth/AuthTests.cs | 66 +++++++++++++++---- 2 files changed, 55 insertions(+), 14 deletions(-) diff --git a/src/ModelContextProtocol.AspNetCore/Authentication/McpAuthenticationHandler.cs b/src/ModelContextProtocol.AspNetCore/Authentication/McpAuthenticationHandler.cs index 498373fec..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; @@ -187,7 +188,7 @@ protected override Task HandleChallengeAsync(AuthenticationProperties properties // Add the WWW-Authenticate header with Bearer scheme and resource metadata string headerValue = $"Bearer resource_metadata=\"{rawPrmDocumentUri}\""; - Response.Headers.Append("WWW-Authenticate", headerValue); + Response.Headers.Append(HeaderNames.WWWAuthenticate, headerValue); return base.HandleChallengeAsync(properties); } diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/AuthTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/AuthTests.cs index f2b6d1da5..c364bf0a1 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/AuthTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/AuthTests.cs @@ -597,25 +597,65 @@ public async Task CanAuthenticate_WithAuthorizationServerPathFallbacks() } [Fact] - public async Task JwtBearerChallenge_DoesNotIncludeResourceMetadata() + public async Task CanAuthenticate_WithResourceMetadataPathFallbacks() { - await using var app = await StartMcpServerAsync(authScheme: JwtBearerDefaults.AuthenticationScheme); + const string resourcePath = "/mcp"; + List wellKnownRequests = []; - using var unauthorizedResponse = await HttpClient.GetAsync(McpServerUrl, HttpCompletionOption.ResponseHeadersRead, TestContext.Current.CancellationToken); - Assert.Equal(HttpStatusCode.Unauthorized, unauthorizedResponse.StatusCode); + Builder.Services.Configure(options => options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme); + await using var app = Builder.Build(); - var headerFound = false; - foreach (var header in unauthorizedResponse.Headers.WwwAuthenticate) + var metadata = new ProtectedResourceMetadata { - headerFound = true; - Assert.Equal("Bearer", header.Scheme); - Assert.True(header.Parameter is null || !header.Parameter.Contains("resource_metadata", StringComparison.OrdinalIgnoreCase)); - } + 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); - Assert.True(headerFound); + var endpoint = new Uri(new Uri(McpServerUrl), resourcePath); - using var metadataResponse = await HttpClient.GetAsync(new Uri("/.well-known/oauth-protected-resource", UriKind.Relative), TestContext.Current.CancellationToken); - metadataResponse.EnsureSuccessStatusCode(); + 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] From 4df01bcedc059bbcd3a2d792c04a2296870d9eba Mon Sep 17 00:00:00 2001 From: Stephen Halter Date: Wed, 10 Dec 2025 16:26:43 -0800 Subject: [PATCH 4/5] Allow resource without path when falling back to the root well-known PRM document This is stricter than the TypeScript SDK which only checks that the resource prefix matches: https://github.com/modelcontextprotocol/typescript-sdk/blob/06a4fd2332cd0ba8884e18b21ef4f7d03dea7b0d/src/shared/auth-utils.ts#L25 However RFC 9728 makes it clear that the resource name must be identical. The resource value returned MUST be identical to the protected resource's resource identifier value into which the well-known URI path suffix was inserted to create the URL used to retrieve the metadata. If these values are not identical, the data contained in the response MUST NOT be used. 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 --- .../Authentication/ClientOAuthProvider.cs | 39 ++++---- .../OAuth/AuthTests.cs | 94 +++++++++++++++++++ .../OAuth/OAuthTestBase.cs | 2 +- 3 files changed, 113 insertions(+), 22 deletions(-) diff --git a/src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs b/src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs index b3be1657f..cb115db4d 100644 --- a/src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs +++ b/src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs @@ -234,7 +234,7 @@ private async Task HandleUnauthorizedResponseAsync( private async Task GetAccessTokenAsync(HttpResponseMessage response, bool attemptedRefresh, CancellationToken cancellationToken) { // Get available authorization servers from the 401 or 403 response - var protectedResourceMetadata = await ExtractProtectedResourceMetadata(response, _serverUrl, cancellationToken).ConfigureAwait(false); + var protectedResourceMetadata = await ExtractProtectedResourceMetadata(response, cancellationToken).ConfigureAwait(false); var availableAuthorizationServers = protectedResourceMetadata.AuthorizationServers; if (availableAuthorizationServers.Count == 0) @@ -657,11 +657,14 @@ private static Uri GetRequiredResourceUri(ProtectedResourceMetadata protectedRes /// 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 is null || resourceLocation is null) + if (protectedResourceMetadata.Resource is null) { return false; } @@ -695,12 +698,6 @@ private static string NormalizeUri(Uri uri) } builder.Append(uri.AbsolutePath.TrimEnd('/')); - - if (!string.IsNullOrEmpty(uri.Query)) - { - builder.Append(uri.Query); - } - return builder.ToString(); } @@ -709,12 +706,12 @@ 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 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) + private async Task ExtractProtectedResourceMetadata(HttpResponseMessage response, CancellationToken cancellationToken) { + Uri resourceUri = _serverUrl; string? wwwAuthenticateScope = null; string? resourceMetadataUrl = null; @@ -748,19 +745,20 @@ private async Task ExtractProtectedResourceMetadata(H } else { - foreach (var wellKnownUri in GetWellKnownResourceMetadataUris(serverUrl)) + foreach (var (wellKnownUri, expectedResourceUri) in GetWellKnownResourceMetadataUris(resourceUri)) { LogMissingResourceMetadataParameter(wellKnownUri); metadata = await FetchProtectedResourceMetadataAsync(wellKnownUri, requireSuccess: false, cancellationToken).ConfigureAwait(false); if (metadata is not null) { + resourceUri = expectedResourceUri; break; } } if (metadata is null) { - throw new McpException($"Failed to find protected resource metadata at a well-known location for {serverUrl}"); + throw new McpException($"Failed to find protected resource metadata at a well-known location for {resourceUri}"); } } @@ -768,13 +766,12 @@ private async Task ExtractProtectedResourceMetadata(H // 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; @@ -820,7 +817,7 @@ private async Task ExtractProtectedResourceMetadata(H return null; } - private static IEnumerable GetWellKnownResourceMetadataUris(Uri resourceUri) + private static IEnumerable<(Uri WellKnownUri, Uri ExpectedResourceUri)> GetWellKnownResourceMetadataUris(Uri resourceUri) { var builder = new UriBuilder(resourceUri); var hostBase = builder.Uri.GetLeftPart(UriPartial.Authority); @@ -828,10 +825,10 @@ private static IEnumerable GetWellKnownResourceMetadataUris(Uri resourceUri if (!string.IsNullOrEmpty(trimmedPath)) { - yield return new Uri($"{hostBase}{ProtectedResourceMetadataWellKnownPath}/{trimmedPath}"); + yield return (new Uri($"{hostBase}{ProtectedResourceMetadataWellKnownPath}/{trimmedPath}"), resourceUri); } - yield return new Uri($"{hostBase}{ProtectedResourceMetadataWellKnownPath}"); + yield return (new Uri($"{hostBase}{ProtectedResourceMetadataWellKnownPath}"), new Uri(hostBase)); } private static string GenerateCodeVerifier() diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/AuthTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/AuthTests.cs index c364bf0a1..eee8720f5 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/AuthTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/AuthTests.cs @@ -658,6 +658,100 @@ public async Task CanAuthenticate_WithResourceMetadataPathFallbacks() 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 fallback to root in this case. + // CanAuthenticate_WithResourceMetadataPathFallbacks validates we will fallback 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() + { + 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 void CloneResourceMetadataClonesAllProperties() { diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/OAuthTestBase.cs b/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/OAuthTestBase.cs index 544868a8d..52b5616d0 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/OAuthTestBase.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/OAuthTestBase.cs @@ -83,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}"; }); From 73d0d1cd7b09a6b37c09aa85c74bb6936eed9318 Mon Sep 17 00:00:00 2001 From: Stephen Halter Date: Wed, 10 Dec 2025 17:02:31 -0800 Subject: [PATCH 5/5] Fix whitespace in comments --- .../Authentication/ClientOAuthProvider.cs | 4 ++-- .../ModelContextProtocol.AspNetCore.Tests/OAuth/AuthTests.cs | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs b/src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs index cb115db4d..75126556b 100644 --- a/src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs +++ b/src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs @@ -745,7 +745,7 @@ private async Task ExtractProtectedResourceMetadata(H } else { - foreach (var (wellKnownUri, expectedResourceUri) in GetWellKnownResourceMetadataUris(resourceUri)) + foreach (var (wellKnownUri, expectedResourceUri) in GetWellKnownResourceMetadataUris(_serverUrl)) { LogMissingResourceMetadataParameter(wellKnownUri); metadata = await FetchProtectedResourceMetadataAsync(wellKnownUri, requireSuccess: false, cancellationToken).ConfigureAwait(false); @@ -758,7 +758,7 @@ private async Task ExtractProtectedResourceMetadata(H if (metadata is null) { - throw new McpException($"Failed to find protected resource metadata at a well-known location for {resourceUri}"); + throw new McpException($"Failed to find protected resource metadata at a well-known location for {_serverUrl}"); } } diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/AuthTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/AuthTests.cs index eee8720f5..2301a4983 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/AuthTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/AuthTests.cs @@ -672,8 +672,8 @@ public async Task CannotAuthenticate_WhenResourceMetadataResourceIsNonRootParent // // https://datatracker.ietf.org/doc/html/rfc9728/#section-3.3 // - // CannotAuthenticate_WhenWwwAuthenticateResourceMetadataIsRootPath validates we won't fallback to root in this case. - // CanAuthenticate_WithResourceMetadataPathFallbacks validates we will fallback to root when resource_metadata is missing. + // 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 => {