refactor(oauth2): use Authlib for token introspection

This commit is contained in:
Yassin Kortam 2026-06-08 14:53:11 -07:00
parent 70b5ea878e
commit 33bf08a6e1
4 changed files with 94 additions and 21 deletions

View file

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

View file

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

View file

@ -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"]}

29
uv.lock generated
View file

@ -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" },