Skip to content

Commit 69bfdbb

Browse files
committed
fix: retry token refresh without RFC 8707 resource param when the AS rejects it
Keeps the MCP-required resource parameter on refresh_token grants and adds a one-shot fallback: on a 400 whose error is not invalid_grant, the refresh is retried once without the resource param before falling back to full re-auth. Fixes Entra ID v2.0 (AADSTS9010010) interop without violating the MCP authorization profile.
1 parent 57394b0 commit 69bfdbb

2 files changed

Lines changed: 153 additions & 3 deletions

File tree

src/mcp/client/auth/oauth2.py

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
import base64
77
import hashlib
8+
import json
89
import logging
910
import secrets
1011
import string
@@ -81,6 +82,15 @@
8182
)
8283

8384

85+
def _refresh_error_code(body: bytes) -> str | None:
86+
"""Extract the RFC 6749 ``error`` code from a failed token response, if any."""
87+
try:
88+
error = json.loads(body).get("error")
89+
except (json.JSONDecodeError, UnicodeDecodeError, AttributeError):
90+
return None
91+
return error if isinstance(error, str) else None
92+
93+
8494
def check_registration_usable(client_info: OAuthClientInformationFull) -> None:
8595
"""Confirm a registration this flow completed is one it can act on.
8696
@@ -486,8 +496,15 @@ async def _handle_token_response(self, response: httpx2.Response) -> None:
486496
self.context.update_token_expiry(token_response)
487497
await self.context.storage.set_tokens(token_response)
488498

489-
async def _refresh_token(self) -> httpx2.Request:
490-
"""Build token refresh request."""
499+
async def _refresh_token(self, *, include_resource: bool = True) -> httpx2.Request:
500+
"""Build token refresh request.
501+
502+
Args:
503+
include_resource: Whether to attach the RFC 8707 ``resource`` parameter
504+
(when the protocol version calls for it). The retry path passes False
505+
for authorization servers that reject the parameter on
506+
``refresh_token`` grants (e.g. Microsoft Entra ID v2.0, AADSTS9010010).
507+
"""
491508
if not self.context.current_tokens or not self.context.current_tokens.refresh_token:
492509
raise OAuthTokenError("No refresh token available") # pragma: no cover
493510

@@ -507,7 +524,7 @@ async def _refresh_token(self) -> httpx2.Request:
507524
}
508525

509526
# Only include resource param if conditions are met
510-
if self.context.should_include_resource_param(self.context.protocol_version):
527+
if include_resource and self.context.should_include_resource_param(self.context.protocol_version):
511528
refresh_data["resource"] = self.context.get_resource_url() # RFC 8707
512529

513530
# Prepare authentication based on preferred method
@@ -588,9 +605,24 @@ async def async_auth_flow(self, request: httpx2.Request) -> AsyncGenerator[httpx
588605

589606
if not self.context.is_token_valid() and self.context.can_refresh_token():
590607
# Try to refresh token
608+
resource_included = self.context.should_include_resource_param(self.context.protocol_version)
591609
refresh_request = await self._refresh_token()
592610
refresh_response = yield refresh_request
593611

612+
if (
613+
refresh_response.status_code == 400
614+
and resource_included
615+
and _refresh_error_code(await refresh_response.aread()) != "invalid_grant"
616+
):
617+
# Some authorization servers (e.g. Microsoft Entra ID v2.0,
618+
# AADSTS9010010) reject the RFC 8707 resource parameter on
619+
# refresh_token grants. Retry once without it before giving
620+
# up and forcing a full interactive re-authentication.
621+
# `invalid_grant` is excluded: it means the refresh token
622+
# itself is no longer valid, so a retry cannot succeed.
623+
refresh_request = await self._refresh_token(include_resource=False)
624+
refresh_response = yield refresh_request
625+
594626
if not await self._handle_refresh_response(refresh_response):
595627
# Refresh failed, need full re-authentication
596628
self._initialized = False

tests/client/test_auth.py

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -824,6 +824,124 @@ async def test_resource_param_included_with_protected_resource_metadata(self, oa
824824
assert "resource=" in content
825825

826826

827+
class TestRefreshResourceParamFallback:
828+
"""Refresh keeps the RFC 8707 resource param per the MCP spec, but retries once
829+
without it when the authorization server rejects the refresh with a 400 —
830+
some servers (e.g. Microsoft Entra ID v2.0, AADSTS9010010) reject the
831+
parameter on refresh_token grants (#2578)."""
832+
833+
def _prepare_expired_session(self, oauth_provider: OAuthClientProvider) -> httpx2.Request:
834+
oauth_provider._initialized = True
835+
oauth_provider.context.client_info = OAuthClientInformationFull(
836+
client_id="test_client",
837+
client_secret="test_secret",
838+
redirect_uris=[AnyUrl("http://localhost:3030/callback")],
839+
)
840+
oauth_provider.context.current_tokens = OAuthToken(
841+
access_token="expired_access",
842+
token_type="Bearer",
843+
refresh_token="test_refresh_token",
844+
)
845+
oauth_provider.context.token_expiry_time = time.time() - 3600
846+
return httpx2.Request("GET", "https://api.example.com/mcp", headers={"mcp-protocol-version": "2025-06-18"})
847+
848+
@pytest.mark.anyio
849+
async def test_refresh_retries_without_resource_on_400(self, oauth_provider: OAuthClientProvider):
850+
test_request = self._prepare_expired_session(oauth_provider)
851+
auth_flow = oauth_provider.async_auth_flow(test_request)
852+
853+
first_refresh = await auth_flow.__anext__()
854+
first_body = first_refresh.content.decode()
855+
assert "grant_type=refresh_token" in first_body
856+
assert "resource=" in first_body
857+
858+
entra_rejection = httpx2.Response(
859+
400,
860+
content=b'{"error": "invalid_request", "error_description": "AADSTS9010010: ..."}',
861+
request=first_refresh,
862+
)
863+
retry_refresh = await auth_flow.asend(entra_rejection)
864+
retry_body = retry_refresh.content.decode()
865+
assert "grant_type=refresh_token" in retry_body
866+
assert "resource=" not in retry_body
867+
868+
token_response = httpx2.Response(
869+
200,
870+
content=(
871+
b'{"access_token": "new_access_token", "token_type": "Bearer", '
872+
b'"expires_in": 3600, "refresh_token": "new_refresh_token"}'
873+
),
874+
request=retry_refresh,
875+
)
876+
original_request = await auth_flow.asend(token_response)
877+
assert original_request.headers["Authorization"] == "Bearer new_access_token"
878+
879+
with pytest.raises(StopAsyncIteration):
880+
await auth_flow.asend(httpx2.Response(200, request=original_request))
881+
882+
@pytest.mark.anyio
883+
async def test_refresh_falls_back_to_reauth_when_retry_fails(self, oauth_provider: OAuthClientProvider):
884+
test_request = self._prepare_expired_session(oauth_provider)
885+
auth_flow = oauth_provider.async_auth_flow(test_request)
886+
887+
first_refresh = await auth_flow.__anext__()
888+
entra_rejection = httpx2.Response(
889+
400,
890+
content=b'{"error": "invalid_request", "error_description": "AADSTS9010010: ..."}',
891+
request=first_refresh,
892+
)
893+
retry_refresh = await auth_flow.asend(entra_rejection)
894+
assert "resource=" not in retry_refresh.content.decode()
895+
896+
original_request = await auth_flow.asend(httpx2.Response(400, request=retry_refresh))
897+
# Both refresh attempts failed: the original request goes out unauthenticated
898+
# and the provider is flagged for full re-authentication.
899+
assert "Authorization" not in original_request.headers
900+
assert oauth_provider._initialized is False
901+
902+
with pytest.raises(StopAsyncIteration):
903+
await auth_flow.asend(httpx2.Response(200, request=original_request))
904+
905+
@pytest.mark.anyio
906+
async def test_no_retry_on_invalid_grant(self, oauth_provider: OAuthClientProvider):
907+
test_request = self._prepare_expired_session(oauth_provider)
908+
auth_flow = oauth_provider.async_auth_flow(test_request)
909+
910+
first_refresh = await auth_flow.__anext__()
911+
assert "resource=" in first_refresh.content.decode()
912+
913+
# invalid_grant means the refresh token itself is dead: retrying
914+
# without the resource param cannot help, so the flow goes straight
915+
# to full re-authentication.
916+
dead_grant = httpx2.Response(400, content=b'{"error": "invalid_grant"}', request=first_refresh)
917+
original_request = await auth_flow.asend(dead_grant)
918+
assert str(original_request.url) == "https://api.example.com/mcp"
919+
assert "Authorization" not in original_request.headers
920+
assert oauth_provider._initialized is False
921+
922+
with pytest.raises(StopAsyncIteration):
923+
await auth_flow.asend(httpx2.Response(200, request=original_request))
924+
925+
@pytest.mark.anyio
926+
async def test_no_retry_when_resource_was_not_sent(self, oauth_provider: OAuthClientProvider):
927+
test_request = self._prepare_expired_session(oauth_provider)
928+
test_request.headers["mcp-protocol-version"] = "2025-03-26"
929+
auth_flow = oauth_provider.async_auth_flow(test_request)
930+
931+
first_refresh = await auth_flow.__anext__()
932+
assert "resource=" not in first_refresh.content.decode()
933+
934+
# A 400 without the resource param present is a real failure: no retry,
935+
# the next request is the original one, unauthenticated.
936+
original_request = await auth_flow.asend(httpx2.Response(400, request=first_refresh))
937+
assert str(original_request.url) == "https://api.example.com/mcp"
938+
assert "Authorization" not in original_request.headers
939+
assert oauth_provider._initialized is False
940+
941+
with pytest.raises(StopAsyncIteration):
942+
await auth_flow.asend(httpx2.Response(200, request=original_request))
943+
944+
827945
@pytest.mark.parametrize(
828946
("protocol_version", "expected"),
829947
[

0 commit comments

Comments
 (0)