fix(sso): replace httpx.AsyncClient() with get_async_httpx_client

Use the cached SSO_HANDLER client instead of creating a new
httpx.AsyncClient per request in PKCE token exchange and userinfo
fetch. Converts httpx.BasicAuth to a manual Authorization header
since AsyncHTTPHandler.post() does not accept an auth param.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
yuneng-jiang 2026-03-12 23:48:34 -07:00
parent 06681ddfcc
commit 2a997993d4

View File

@ -17,7 +17,6 @@ import secrets
from copy import deepcopy from copy import deepcopy
from typing import TYPE_CHECKING, Any, Dict, List, Literal, Optional, Tuple, Union, cast from typing import TYPE_CHECKING, Any, Dict, List, Literal, Optional, Tuple, Union, cast
import httpx
import jwt import jwt
from fastapi import APIRouter, Depends, HTTPException, Request, status from fastapi import APIRouter, Depends, HTTPException, Request, status
from fastapi.responses import RedirectResponse from fastapi.responses import RedirectResponse
@ -2801,20 +2800,19 @@ class SSOAuthenticationHandler:
if redirect_url: if redirect_url:
token_data["redirect_uri"] = redirect_url token_data["redirect_uri"] = redirect_url
post_kwargs: Dict[str, Any] = { request_headers = {
"data": token_data, **additional_headers,
"headers": { "Content-Type": "application/x-www-form-urlencoded", # must not be overridden
**additional_headers, "Accept": "application/json",
"Content-Type": "application/x-www-form-urlencoded", # must not be overridden
"Accept": "application/json",
},
"timeout": 30.0,
} }
if not include_client_id: if not include_client_id:
# Use Basic Auth only when a secret is available; public PKCE clients omit it. # Use Basic Auth only when a secret is available; public PKCE clients omit it.
if client_secret: if client_secret:
post_kwargs["auth"] = httpx.BasicAuth(client_id, client_secret) credentials = base64.b64encode(
f"{client_id}:{client_secret}".encode()
).decode()
request_headers["Authorization"] = f"Basic {credentials}"
else: else:
token_data["client_id"] = client_id token_data["client_id"] = client_id
else: else:
@ -2822,27 +2820,27 @@ class SSOAuthenticationHandler:
if client_secret: if client_secret:
token_data["client_secret"] = client_secret token_data["client_secret"] = client_secret
# The try/except is INSIDE the async with so that TLS teardown exceptions http_client = get_async_httpx_client(
# from __aexit__ propagate as-is and are NOT mis-labelled as "Token endpoint llm_provider=httpxSpecialProvider.SSO_HANDLER
# request failed". httpx buffers the full response body before __aexit__, )
# so status_code / text / json() remain valid after the context exits. try:
async with httpx.AsyncClient() as http_client: response = await http_client.post(
try: url=token_endpoint,
response = await http_client.post(token_endpoint, **post_kwargs) data=token_data,
except Exception as exc: headers=request_headers,
# Catch network-level errors (SSL, DNS, TCP, timeout, etc.) and timeout=30.0,
# wrap them as a clean ProxyException rather than leaking raw )
# httpx or OS exceptions to callers. except Exception as exc:
verbose_proxy_logger.error("PKCE token endpoint unreachable: %s", exc) # Catch network-level errors (SSL, DNS, TCP, timeout, etc.) and
raise ProxyException( # wrap them as a clean ProxyException rather than leaking raw
message=f"Token endpoint request failed: {exc}", # httpx or OS exceptions to callers.
type=ProxyErrorTypes.auth_error, verbose_proxy_logger.error("PKCE token endpoint unreachable: %s", exc)
param="token_exchange", raise ProxyException(
code=status.HTTP_401_UNAUTHORIZED, message=f"Token endpoint request failed: {exc}",
) from exc type=ProxyErrorTypes.auth_error,
param="token_exchange",
# Response processing outside the async with — httpx buffers the full code=status.HTTP_401_UNAUTHORIZED,
# response body so status_code / text / json() remain valid after __aexit__. ) from exc
if response.status_code != 200: if response.status_code != 200:
verbose_proxy_logger.error( verbose_proxy_logger.error(
"PKCE token exchange failed. status=%s body=%s", "PKCE token exchange failed. status=%s body=%s",
@ -2970,41 +2968,43 @@ class SSOAuthenticationHandler:
if userinfo_endpoint: if userinfo_endpoint:
try: try:
async with httpx.AsyncClient() as client: client = get_async_httpx_client(
resp = await client.get( llm_provider=httpxSpecialProvider.SSO_HANDLER
userinfo_endpoint, )
headers={ resp = await client.get(
**additional_headers, url=userinfo_endpoint,
"Authorization": f"Bearer {access_token}", # must not be overridden headers={
}, **additional_headers,
timeout=30.0, "Authorization": f"Bearer {access_token}", # must not be overridden
) },
if resp.status_code == 200: timeout=30.0,
try: )
userinfo_raw = resp.json() if resp.status_code == 200:
if not userinfo_raw: try:
# JSON null (None) or empty dict ({}) — no identity claims. userinfo_raw = resp.json()
# Treat as failure so id_token fallback can be attempted. if not userinfo_raw:
verbose_proxy_logger.warning( # JSON null (None) or empty dict ({}) — no identity claims.
"Userinfo endpoint returned an empty or null response " # Treat as failure so id_token fallback can be attempted.
"(type=%s); treating as failure and attempting id_token fallback. "
"Check your provider's userinfo endpoint configuration.",
type(userinfo_raw).__name__,
)
userinfo = None
else:
userinfo = userinfo_raw
except Exception as json_err:
verbose_proxy_logger.warning( verbose_proxy_logger.warning(
"Userinfo endpoint returned non-JSON response (status 200): %s", "Userinfo endpoint returned an empty or null response "
json_err, "(type=%s); treating as failure and attempting id_token fallback. "
"Check your provider's userinfo endpoint configuration.",
type(userinfo_raw).__name__,
) )
else: userinfo = None
else:
userinfo = userinfo_raw
except Exception as json_err:
verbose_proxy_logger.warning( verbose_proxy_logger.warning(
"Userinfo endpoint returned %s (body: %s), falling back to id_token", "Userinfo endpoint returned non-JSON response (status 200): %s",
resp.status_code, json_err,
resp.text[:500],
) )
else:
verbose_proxy_logger.warning(
"Userinfo endpoint returned %s (body: %s), falling back to id_token",
resp.status_code,
resp.text[:500],
)
except Exception as e: except Exception as e:
verbose_proxy_logger.warning( verbose_proxy_logger.warning(
"Userinfo endpoint error: %s, falling back to id_token", e "Userinfo endpoint error: %s, falling back to id_token", e