From 33bf08a6e11afc9e45a2779baf3d5c3b0b302eca Mon Sep 17 00:00:00 2001 From: Yassin Kortam Date: Mon, 8 Jun 2026 14:53:11 -0700 Subject: [PATCH] refactor(oauth2): use Authlib for token introspection --- litellm/proxy/auth/oauth2_check.py | 36 ++++++-------- pyproject.toml | 1 + .../proxy/auth/test_oauth2_check.py | 49 +++++++++++++++++++ uv.lock | 29 ++++++++++- 4 files changed, 94 insertions(+), 21 deletions(-) create mode 100644 tests/test_litellm/proxy/auth/test_oauth2_check.py diff --git a/litellm/proxy/auth/oauth2_check.py b/litellm/proxy/auth/oauth2_check.py index 860f85e8959..09bcb7589d1 100644 --- a/litellm/proxy/auth/oauth2_check.py +++ b/litellm/proxy/auth/oauth2_check.py @@ -1,8 +1,9 @@ -import base64 import os from typing import Dict, Optional, Tuple +from urllib.parse import urlencode import httpx +from authlib.oauth2.auth import ClientAuth from litellm._logging import verbose_proxy_logger from litellm.identity import build_user_api_key_auth_from_oauth2_response @@ -46,33 +47,28 @@ class Oauth2Handler: token: str, oauth_client_id: Optional[str], oauth_client_secret: Optional[str], - ) -> Tuple[Dict[str, str], Dict[str, str]]: + ) -> Tuple[Dict[str, str], str]: """ - Prepare headers and data for OAuth2 introspection endpoint (RFC 7662). + Prepare headers and form body for OAuth2 introspection (RFC 7662). - Args: - token: The OAuth2 token to validate - oauth_client_id: OAuth2 client ID - oauth_client_secret: OAuth2 client secret + Confidential clients (id + secret) authenticate via HTTP Basic; + public clients (id only) carry ``client_id`` in the body. Both are + delegated to Authlib's RFC 6749 client-auth encoders. Returns: - Tuple of (headers, data) for the introspection request + Tuple of (headers, form-encoded body string). """ headers = {"Content-Type": "application/x-www-form-urlencoded"} - data = {"token": token} + body = urlencode({"token": token}) - # Add client authentication if credentials are provided - if oauth_client_id and oauth_client_secret: - # Use HTTP Basic authentication for client credentials - credentials = base64.b64encode( - f"{oauth_client_id}:{oauth_client_secret}".encode() - ).decode() - headers["Authorization"] = f"Basic {credentials}" - elif oauth_client_id: - # For public clients, include client_id in the request body - data["client_id"] = oauth_client_id + if not oauth_client_id: + return headers, body - return headers, data + auth_method = "client_secret_basic" if oauth_client_secret else "none" + _, headers, body = ClientAuth( + oauth_client_id, oauth_client_secret, auth_method + ).prepare("POST", "", headers, body) + return headers, body @staticmethod def _prepare_token_info_request(token: str) -> Dict[str, str]: diff --git a/pyproject.toml b/pyproject.toml index 577e800d79b..8c6d22bae1e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -52,6 +52,7 @@ proxy = [ "rq>=2.7.0,<3.0", "orjson>=3.11.6,<4.0", "apscheduler>=3.11.2,<4.0", + "authlib>=1.3.0,<2.0", "fastapi-sso>=0.19.0,<1.0", "PyJWT>=2.12.0,<3.0", "python-multipart>=0.0.27,<1.0", diff --git a/tests/test_litellm/proxy/auth/test_oauth2_check.py b/tests/test_litellm/proxy/auth/test_oauth2_check.py new file mode 100644 index 00000000000..d4f21a01b22 --- /dev/null +++ b/tests/test_litellm/proxy/auth/test_oauth2_check.py @@ -0,0 +1,49 @@ +import base64 +import os +import sys +from urllib.parse import parse_qs + +sys.path.insert(0, os.path.abspath("../../../..")) + +from litellm.proxy.auth.oauth2_check import Oauth2Handler + + +def test_introspection_request_confidential_client_uses_basic_auth(): + headers, body = Oauth2Handler._prepare_introspection_request( + token="opaque-token", + oauth_client_id="client-id", + oauth_client_secret="client-secret", + ) + + assert headers["Content-Type"] == "application/x-www-form-urlencoded" + expected = base64.b64encode(b"client-id:client-secret").decode() + assert headers["Authorization"] == f"Basic {expected}" + + parsed = parse_qs(body) + assert parsed["token"] == ["opaque-token"] + assert "client_secret" not in parsed + + +def test_introspection_request_public_client_puts_client_id_in_body(): + headers, body = Oauth2Handler._prepare_introspection_request( + token="opaque-token", + oauth_client_id="client-id", + oauth_client_secret=None, + ) + + assert "Authorization" not in headers + parsed = parse_qs(body) + assert parsed["token"] == ["opaque-token"] + assert parsed["client_id"] == ["client-id"] + + +def test_introspection_request_without_credentials_only_sends_token(): + headers, body = Oauth2Handler._prepare_introspection_request( + token="opaque-token", + oauth_client_id=None, + oauth_client_secret=None, + ) + + assert "Authorization" not in headers + parsed = parse_qs(body) + assert parsed == {"token": ["opaque-token"]} diff --git a/uv.lock b/uv.lock index 4386fce1b4d..362ed0c0502 100644 --- a/uv.lock +++ b/uv.lock @@ -9,7 +9,7 @@ resolution-markers = [ ] [options] -exclude-newer = "2026-06-03T21:40:52.018333Z" +exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values. exclude-newer-span = "P3D" [manifest] @@ -423,6 +423,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a2/1f/aa74b23b6eea4cf9b79ace914df59123c4c8e7e4bd32dd22d09c126422d9/aurelio_sdk-0.0.19-py3-none-any.whl", hash = "sha256:390c0212b59ce99116df8722d3badced88c5ef0bb742a6222d479ceed0ed3948", size = 17322, upload-time = "2025-03-24T14:37:31.305Z" }, ] +[[package]] +name = "authlib" +version = "1.7.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "joserfc" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/36/98/7d93f30d029643c0275dbc0bd6d5a6f670661ee6c9a94d93af7ab4887600/authlib-1.7.2.tar.gz", hash = "sha256:2cea25fefcd4e7173bdf1372c0afc265c8034b23a8cd5dcb6a9164b826c64231", size = 176511, upload-time = "2026-05-06T08:10:23.116Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/95/adcb68e20c34162e9135f370d6e31737719c2b6f94bc953fe7ed1f10fe21/authlib-1.7.2-py2.py3-none-any.whl", hash = "sha256:3e1faedc9d87e7d56a164eca3ccb6ace0d61b94abe83e92242f8dc8bba9b4a9f", size = 259548, upload-time = "2026-05-06T08:10:21.436Z" }, +] + [[package]] name = "azure-ai-contentsafety" version = "1.0.0" @@ -2735,6 +2748,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7b/91/984aca2ec129e2757d1e4e3c81c3fcda9d0f85b74670a094cc443d9ee949/joblib-1.5.3-py3-none-any.whl", hash = "sha256:5fc3c5039fc5ca8c0276333a188bbd59d6b7ab37fe6632daa76bc7f9ec18e713", size = 309071, upload-time = "2025-12-15T08:41:44.973Z" }, ] +[[package]] +name = "joserfc" +version = "1.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d3/c3/2f590052b55cbdd0ace470ee7ee1f685f6882051be93a9374891005623e2/joserfc-1.7.0.tar.gz", hash = "sha256:4aced6ab0c47846f0a531402aec2419a874b91e918df9c4c9da8a82fb559d6c4", size = 232967, upload-time = "2026-06-02T09:59:34.506Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5b/83/b6b62a66a06ce872d9429a5eb5ee20b2002fd9c331b953c94381c1f7c9f9/joserfc-1.7.0-py3-none-any.whl", hash = "sha256:17e5d7a5a35e65442b05efc435a3d5d46696ffa2c8a2ed0eea6f63fc268e3224", size = 70387, upload-time = "2026-06-02T09:59:33.264Z" }, +] + [[package]] name = "jsonlines" version = "4.0.0" @@ -3318,6 +3343,7 @@ mlflow = [ ] proxy = [ { name = "apscheduler" }, + { name = "authlib" }, { name = "azure-identity" }, { name = "azure-storage-blob" }, { name = "backoff" }, @@ -3472,6 +3498,7 @@ requires-dist = [ { name = "apscheduler", marker = "extra == 'proxy'", specifier = ">=3.11.2,<4.0" }, { name = "audioread", marker = "extra == 'stt-nvidia-riva'", specifier = ">=3.0.1" }, { name = "aurelio-sdk", marker = "python_full_version < '3.14' and extra == 'semantic-router'", specifier = ">=0.0.19,<1.0" }, + { name = "authlib", marker = "extra == 'proxy'", specifier = ">=1.3.0,<2.0" }, { name = "azure-ai-contentsafety", marker = "extra == 'proxy-runtime'", specifier = ">=1.0.0,<2.0" }, { name = "azure-identity", marker = "extra == 'extra-proxy'", specifier = ">=1.25.2,<2.0" }, { name = "azure-identity", marker = "extra == 'proxy'", specifier = ">=1.25.2,<2.0" },