From 3a6ed33a347d958d064510d69b5553e9a06b8ca7 Mon Sep 17 00:00:00 2001 From: Naveen Chatlapalli Date: Thu, 17 Sep 2026 22:37:19 -0500 Subject: [PATCH] Retry OAuth token refresh without the resource parameter when the AS rejects it Microsoft Entra ID v2.0 rejects the RFC 8707 resource parameter on refresh_token grants (AADSTS9010010), so clients silently lost their session every time the access token expired (~1 hour) and were forced back through interactive authorization. The MCP authorization spec requires the resource parameter on token requests, so the refresh still sends it. When the authorization server rejects that refresh with HTTP 400 and an error other than invalid_grant, the client now retries the refresh once without the resource before falling back to re-authorization. invalid_grant (a dead refresh token) is not retried because dropping the resource cannot help. Conformant authorization servers see no change. Fixes #1587 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_011p29dMDsLnn6KDFmsz2PGr --- .../Authentication/ClientOAuthProvider.cs | 31 ++++++++ .../OAuth/AuthTests.cs | 75 ++++++++++++++----- .../Program.cs | 29 ++++++- 3 files changed, 114 insertions(+), 21 deletions(-) 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