@@ -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