diff --git a/src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs b/src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs index 785e3cc2e..92beb9a0f 100644 --- a/src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs +++ b/src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs @@ -690,6 +690,21 @@ private static IEnumerable GetWellKnownAuthorizationServerMetadataUris(Uri if (!httpResponse.IsSuccessStatusCode) { + // The MCP authorization spec requires the RFC 8707 resource parameter on token requests, but some + // authorization servers reject it on refresh_token grants (Microsoft Entra ID v2.0 fails with + // AADSTS9010010). Rather than forcing interactive re-authorization every time the access token + // expires, retry the refresh once without it. A dead refresh token (invalid_grant) can't be fixed + // by dropping the resource, so that falls through to re-authorization as before. + if (resourceUri is not null && httpResponse.StatusCode == System.Net.HttpStatusCode.BadRequest) + { + var error = await ReadOAuthErrorCodeAsync(httpResponse, cancellationToken).ConfigureAwait(false); + if (error != "invalid_grant") + { + LogOAuthTokenRefreshRetryingWithoutResource(resourceUri, error); + return await RefreshTokensAsync(refreshToken, resourceUri: null, authServerMetadata, cancellationToken).ConfigureAwait(false); + } + } + return null; } @@ -698,6 +713,19 @@ private static IEnumerable GetWellKnownAuthorizationServerMetadataUris(Uri return tokens.AccessToken; } + private static async Task ReadOAuthErrorCodeAsync(HttpResponseMessage response, CancellationToken cancellationToken) + { + try + { + var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + return JsonSerializer.Deserialize(body, McpJsonUtilities.JsonContext.Default.OAuthErrorResponse)?.Error; + } + catch (JsonException) + { + return null; + } + } + private async Task InitiateAuthorizationCodeFlowAsync( ProtectedResourceMetadata protectedResourceMetadata, AuthorizationServerMetadata authServerMetadata, @@ -1572,6 +1600,9 @@ private static void ThrowFailedToHandleUnauthorizedResponse(string message) => [LoggerMessage(Level = LogLevel.Information, Message = "OAuth token refresh completed successfully")] partial void LogOAuthTokenRefreshCompleted(); + [LoggerMessage(Level = LogLevel.Warning, Message = "OAuth token refresh with resource '{Resource}' was rejected with error '{Error}'. Retrying without the resource parameter.")] + partial void LogOAuthTokenRefreshRetryingWithoutResource(string resource, string? error); + [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 693c77943..c5cb8aa9b 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/AuthTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/AuthTests.cs @@ -523,6 +523,60 @@ public async Task CannotAuthenticate_WithInvalidClientMetadataDocument(string ur [Fact] public async Task CanAuthenticate_WithTokenRefresh() + { + await using var app = await StartMcpServerThatForcesTokenRefreshAsync(); + await using var transport = CreateOAuthTransport(); + 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); + + // Conformant authorization servers only see the spec-required refresh request that includes the resource. + Assert.Equal([McpServerUrl], TestOAuthServer.RefreshTokenRequestResources); + Assert.Equal(1, TestOAuthServer.AuthorizationCodeTokenRequestCount); + } + + [Fact] + public async Task CanAuthenticate_WithTokenRefresh_WhenAuthServerRejectsResourceOnRefresh() + { + // Simulates Microsoft Entra ID v2.0, which rejects the RFC 8707 resource parameter on refresh_token grants (AADSTS9010010). + TestOAuthServer.RejectRefreshWithResourceError = "invalid_target"; + + await using var app = await StartMcpServerThatForcesTokenRefreshAsync(); + await using var transport = CreateOAuthTransport(); + await using var client = await McpClient.CreateAsync( + transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken); + + await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); + + // The rejected refresh is retried once without the resource instead of forcing interactive re-authorization. + Assert.True(TestOAuthServer.HasRefreshedToken); + Assert.Equal([McpServerUrl, null], TestOAuthServer.RefreshTokenRequestResources); + Assert.Equal(1, TestOAuthServer.AuthorizationCodeTokenRequestCount); + } + + [Fact] + public async Task TokenRefresh_RejectedWithInvalidGrant_IsNotRetriedWithoutResource() + { + // invalid_grant means the refresh token itself is dead, so dropping the resource can't help. + TestOAuthServer.RejectRefreshWithResourceError = "invalid_grant"; + + await using var app = await StartMcpServerThatForcesTokenRefreshAsync(); + await using var transport = CreateOAuthTransport(); + await using var client = await McpClient.CreateAsync( + transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken); + + await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); + + // The client falls back to a new authorization-code flow instead. + Assert.False(TestOAuthServer.HasRefreshedToken); + Assert.Equal([McpServerUrl], TestOAuthServer.RefreshTokenRequestResources); + Assert.Equal(2, TestOAuthServer.AuthorizationCodeTokenRequestCount); + } + + private async Task StartMcpServerThatForcesTokenRefreshAsync() { var hasForcedRefresh = false; @@ -531,7 +585,7 @@ public async Task CanAuthenticate_WithTokenRefresh() options.ToolCollection = new(); }); - await using var app = await StartMcpServerAsync(configureMiddleware: app => + return await StartMcpServerAsync(configureMiddleware: app => { // Add middleware to intercept list tools requests and force a token refresh on the first call app.Use(async (context, next) => @@ -566,25 +620,6 @@ public async Task CanAuthenticate_WithTokenRefresh() await next(context); }); }); - - 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"), - AuthorizationCallbackHandler = HandleAuthorizationUrlAsync, - }, - }, HttpClient, LoggerFactory); - - 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); } [Fact] diff --git a/tests/ModelContextProtocol.TestOAuthServer/Program.cs b/tests/ModelContextProtocol.TestOAuthServer/Program.cs index 73663dc94..c1cc98a1c 100644 --- a/tests/ModelContextProtocol.TestOAuthServer/Program.cs +++ b/tests/ModelContextProtocol.TestOAuthServer/Program.cs @@ -29,6 +29,7 @@ public sealed class Program private readonly ConcurrentQueue _metadataRequests = new(); private int _authorizationCodeTokenRequestCount; + private readonly ConcurrentQueue _refreshTokenRequestResources = new(); private readonly RSA _rsa; private readonly string _keyId; @@ -91,6 +92,13 @@ public Program(ILoggerProvider? loggerProvider = null, IConnectionListenerFactor /// public bool ExpectResource { get; set; } = true; + /// + /// Gets or sets the OAuth error code returned for refresh_token grants that include a resource parameter. + /// When set, such grants are rejected and grants without a resource parameter are accepted, simulating + /// authorization servers like Microsoft Entra ID v2.0 that reject RFC 8707 resource indicators on refresh (AADSTS9010010). + /// + public string? RejectRefreshWithResourceError { get; set; } + /// /// Gets or sets a value indicating whether the authorization server advertises support for /// offline_access in its scopes_supported metadata. This simulates an OIDC-flavored @@ -141,6 +149,9 @@ public Program(ILoggerProvider? loggerProvider = null, IConnectionListenerFactor /// Gets the number of authorization-code token exchange requests received. public int AuthorizationCodeTokenRequestCount => Volatile.Read(ref _authorizationCodeTokenRequestCount); + /// Gets the resource field of each refresh_token grant received, or where it was absent. + public IReadOnlyCollection RefreshTokenRequestResources => _refreshTokenRequestResources.ToArray(); + /// Gets the scope field from the most recent Dynamic Client Registration request. public string? LastRegistrationScope { get; private set; } @@ -452,7 +463,23 @@ IResult HandleMetadataRequest(HttpContext context, string? issuerPath = null) // RFC 7523 JWT-bearer assertions carry the target resource inside the JWT itself, // so we skip the form-level resource check for that grant type. var resource = form["resource"].ToString(); - if (grant_type != "urn:ietf:params:oauth:grant-type:jwt-bearer" && + if (grant_type == "refresh_token") + { + _refreshTokenRequestResources.Enqueue(string.IsNullOrEmpty(resource) ? null : resource); + } + + if (grant_type == "refresh_token" && RejectRefreshWithResourceError is { } refreshResourceError) + { + if (!string.IsNullOrEmpty(resource)) + { + return Results.BadRequest(new OAuthErrorResponse + { + Error = refreshResourceError, + ErrorDescription = "AADSTS9010010: The resource parameter provided in the request doesn't match with the requested scopes." + }); + } + } + else if (grant_type != "urn:ietf:params:oauth:grant-type:jwt-bearer" && (ExpectResource ? (string.IsNullOrEmpty(resource) || !ValidResources.Contains(resource)) : !string.IsNullOrEmpty(resource))) { return Results.BadRequest(new OAuthErrorResponse