From 3dc660a1353e7ef286f4a050e9c46d0127730d2b Mon Sep 17 00:00:00 2001 From: Yassin Kortam Date: Wed, 10 Jun 2026 18:25:48 -0700 Subject: [PATCH] test(auth_v2): cover OAuth2 token introspection over the cached async client --- .../auth_v2/test_authenticators.py | 71 +++++++++++++++++++ 1 file changed, 71 insertions(+) diff --git a/tests/test_litellm/auth_v2/test_authenticators.py b/tests/test_litellm/auth_v2/test_authenticators.py index ab05e50a896..791c3f07f74 100644 --- a/tests/test_litellm/auth_v2/test_authenticators.py +++ b/tests/test_litellm/auth_v2/test_authenticators.py @@ -2,6 +2,7 @@ from __future__ import annotations import base64 from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch import pytest @@ -19,6 +20,7 @@ from litellm.auth_v2.config import ( AuthConfig, HttpBasicConfig, MutualTlsConfig, + OAuth2IntrospectionConfig, OidcProviderConfig, ) from litellm.auth_v2.errors import AuthError @@ -237,6 +239,75 @@ async def test_oauth2_no_bearer_returns_none(rsa_keypair): assert await _oauth2(public_key).authenticate(make_request()) is None +def _introspecting_oauth2() -> OAuth2Authenticator: + return OAuth2Authenticator( + [], + introspection=OAuth2IntrospectionConfig( + introspection_endpoint="https://idp.example.com/introspect", + client_id="rs-client", + client_secret="rs-secret", + subject_field="sub", + ), + ) + + +def _mock_introspection_post(status_code: int, body: dict) -> AsyncMock: + response = MagicMock() + response.status_code = status_code + response.json.return_value = body + handler = MagicMock() + handler.post = AsyncMock(return_value=response) + factory = MagicMock(return_value=handler) + return factory + + +async def test_oauth2_opaque_token_introspects_active_to_credential(): + factory = _mock_introspection_post( + 200, + {"active": True, "sub": "svc-9", "scope": "models:read tools:run", "aud": "rs"}, + ) + request = make_request(headers={"authorization": "Bearer opaque-xyz"}) + with patch( + "litellm.llms.custom_httpx.http_handler.get_async_httpx_client", factory + ): + credential = await _introspecting_oauth2().authenticate(request) + + assert credential is not None + assert credential.method == AuthMethod.OAUTH2_INTROSPECTION + assert credential.subject == "svc-9" + assert credential.scopes == ["models:read", "tools:run"] + assert credential.audience == ["rs"] + + handler = factory.return_value + _, kwargs = handler.post.call_args + assert kwargs["data"] == {"token": "opaque-xyz"} + expected_basic = base64.b64encode(b"rs-client:rs-secret").decode() + assert kwargs["headers"]["Authorization"] == f"Basic {expected_basic}" + assert handler.post.call_args.args[0] == "https://idp.example.com/introspect" + + +async def test_oauth2_introspection_inactive_token_raises(): + factory = _mock_introspection_post(200, {"active": False}) + request = make_request(headers={"authorization": "Bearer opaque-xyz"}) + with patch( + "litellm.llms.custom_httpx.http_handler.get_async_httpx_client", factory + ): + with pytest.raises(AuthError) as exc: + await _introspecting_oauth2().authenticate(request) + assert exc.value.status_code == 401 + + +async def test_oauth2_introspection_non_200_raises(): + factory = _mock_introspection_post(500, {}) + request = make_request(headers={"authorization": "Bearer opaque-xyz"}) + with patch( + "litellm.llms.custom_httpx.http_handler.get_async_httpx_client", factory + ): + with pytest.raises(AuthError) as exc: + await _introspecting_oauth2().authenticate(request) + assert exc.value.status_code == 401 + + # --------------------------------------------------------------------------- # # OidcAuthenticator # --------------------------------------------------------------------------- #