Skip to content

Retry OAuth token refresh without resource when the authorization server rejects it (Entra AADSTS9010010) - #1883

Open
1aifanatic wants to merge 1 commit into
modelcontextprotocol:mainfrom
1aifanatic:fix/oauth-refresh-resource-fallback
Open

1aifanatic wants to merge 1 commit into
modelcontextprotocol:mainfrom
1aifanatic:fix/oauth-refresh-resource-fallback

Conversation

@1aifanatic

Copy link
Copy Markdown

Fixes #1587

Problem

Microsoft Entra ID v2.0 rejects the RFC 8707 resource parameter on refresh_token grants with AADSTS9010010 ("The resource parameter provided in the request doesn't match with the requested scopes"). ClientOAuthProvider.RefreshTokensAsync always sends resource and returns null on any failure. So for an Entra-protected MCP server, the silent refresh fails every time the access token expires (about every hour), and the client goes back through interactive authorization.

Why not just drop resource on refresh

The MCP authorization spec ("Resource Parameter Implementation") requires clients to send resource on token requests, and RFC 8707 §2.2 defines it for refresh grants. The Python SDK hit the same bug (modelcontextprotocol/python-sdk#2578). Several PRs there that removed resource from refresh were closed, and review pushed toward keeping the spec-required behavior and adding a targeted fallback (modelcontextprotocol/python-sdk#2853). This PR takes the same approach, so the SDKs behave the same way.

Change

  • The refresh request still sends resource, exactly as before.
  • If the authorization server rejects it with HTTP 400 and an OAuth error other than invalid_grant, the refresh is retried once without resource. Only if that also fails does the client fall back to re-authorization, as it does today.
  • invalid_grant means the refresh token itself is dead, so it's never retried. Dropping resource can't help there.
  • A warning is logged when the fallback runs, so operators can see that their authorization server rejected resource.
  • Conformant authorization servers see no change. For Entra, the cost is one extra request on a path that is already failing, instead of an interactive re-auth every hour.

Tests

TestOAuthServer gets a RejectRefreshWithResourceError switch that simulates Entra (it rejects refresh_token grants that include resource and accepts ones that don't). It also records the resource value of each refresh request. CanAuthenticate_WithTokenRefresh's middleware, which forces a 401, moved into a shared helper, and these tests use it:

  • CanAuthenticate_WithTokenRefresh (existing, now stricter): a conformant server gets exactly one refresh request, it carries resource, and there's no second authorization-code exchange.
  • CanAuthenticate_WithTokenRefresh_WhenAuthServerRejectsResourceOnRefresh: the refresh requests are [resource, null], the refresh succeeds, and there's no interactive re-auth.
  • TokenRefresh_RejectedWithInvalidGrant_IsNotRetriedWithoutResource: there's a single refresh request carrying resource, then the client falls back to a new authorization-code flow.

To check the new test can fail, I reverted only the ClientOAuthProvider change and kept the test infrastructure. The Entra scenario test failed and the other two still passed.

Local results on Windows (.NET SDK 10.0.302). The solution dotnet build is clean (warnings are errors).

  • ModelContextProtocol.AspNetCore.Tests: 637 passed / 0 failed on each of net10.0, net9.0, and net8.0. In an earlier net10.0 run on a loaded machine, TokenCacheTests and AuthEventTests each had one failure. Both classes passed 18/18 on three isolated reruns, and a full rerun passed 637/637.
  • ModelContextProtocol.Tests authentication tests: 21/21 on net10.0, net9.0, net8.0, and net472.

I couldn't test against a live Entra tenant. The simulated server reproduces the documented behavior (400 when resource is present on refresh, success without it).

🤖 Generated with Claude Code

https://claude.ai/code/session_011p29dMDsLnn6KDFmsz2PGr

…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 modelcontextprotocol#1587

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011p29dMDsLnn6KDFmsz2PGr
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

OAuth token refresh fails with AADSTS9010010

1 participant