diff --git a/litellm/proxy/auth/oauth2_check.py b/litellm/proxy/auth/oauth2_check.py index 09bcb7589d1..23716c81fde 100644 --- a/litellm/proxy/auth/oauth2_check.py +++ b/litellm/proxy/auth/oauth2_check.py @@ -143,14 +143,18 @@ class Oauth2Handler: # OAuth2 Token Introspection (RFC 7662) - requires POST with form data verbose_proxy_logger.debug("Using OAuth2 introspection endpoint (POST)") - headers, data = Oauth2Handler._prepare_introspection_request( - token=token, - oauth_client_id=oauth_client_id, - oauth_client_secret=oauth_client_secret, + headers, introspection_body = ( + Oauth2Handler._prepare_introspection_request( + token=token, + oauth_client_id=oauth_client_id, + oauth_client_secret=oauth_client_secret, + ) ) response = await client.post( - token_info_endpoint, headers=headers, data=data + token_info_endpoint, + headers=headers, + content=introspection_body, ) else: # Generic token info endpoint - uses GET with Bearer token diff --git a/tests/test_litellm/proxy/auth/test_oauth2_check.py b/tests/test_litellm/proxy/auth/test_oauth2_check.py index d4f21a01b22..37241ce30df 100644 --- a/tests/test_litellm/proxy/auth/test_oauth2_check.py +++ b/tests/test_litellm/proxy/auth/test_oauth2_check.py @@ -1,8 +1,11 @@ import base64 import os import sys +from unittest.mock import AsyncMock, MagicMock, patch from urllib.parse import parse_qs +import pytest + sys.path.insert(0, os.path.abspath("../../../..")) from litellm.proxy.auth.oauth2_check import Oauth2Handler @@ -47,3 +50,41 @@ def test_introspection_request_without_credentials_only_sends_token(): assert "Authorization" not in headers parsed = parse_qs(body) assert parsed == {"token": ["opaque-token"]} + + +@pytest.mark.asyncio +async def test_check_oauth2_token_sends_introspection_body_as_raw_content(monkeypatch): + """The introspection POST must send the pre-encoded form body via httpx + ``content=``. ``_prepare_introspection_request`` already form-encodes the + body (and Authlib may append client auth), so passing it through ``data=`` + would re-encode it and is deprecated for strings in httpx. + """ + monkeypatch.setenv( + "OAUTH_TOKEN_INFO_ENDPOINT", "https://idp.example.com/introspect" + ) + monkeypatch.setenv("OAUTH_CLIENT_ID", "client-id") + monkeypatch.setenv("OAUTH_CLIENT_SECRET", "client-secret") + + response = MagicMock() + response.raise_for_status = MagicMock() + response.json = MagicMock( + return_value={"active": True, "sub": "u1", "role": "internal_user"} + ) + + client = MagicMock() + client.post = AsyncMock(return_value=response) + + with ( + patch("litellm.proxy.proxy_server.premium_user", True), + patch( + "litellm.proxy.auth.oauth2_check.get_async_httpx_client", + return_value=client, + ), + ): + await Oauth2Handler.check_oauth2_token("opaque-token") + + assert client.post.await_count == 1 + kwargs = client.post.await_args.kwargs + assert "data" not in kwargs + assert isinstance(kwargs["content"], str) + assert "token=opaque-token" in kwargs["content"]