fix(sso): expose full JWT claims and parsed proxy view in /sso/debug/callback

This commit is contained in:
Ishaan Jaffer 2026-05-05 14:57:52 -07:00
parent fa6a82ab0f
commit 3240c2c7c8
2 changed files with 273 additions and 50 deletions

View file

@ -235,36 +235,166 @@ jwt_display_template = """
<script>
// This will be populated with the actual data from the server
const userData = SSO_DATA;
// Top-level sections we expect from /sso/debug/callback. Rendered in
// this order. Anything else (e.g. simple "provider" string) is shown
// as a plain row above the sections.
const SECTIONS = [
{
key: "litellm_parsed_openid",
title: "LiteLLM parsed (what the proxy stored)",
description: "Fields the proxy extracted from the SSO response (id, email, display_name, team_ids, user_role, team_alias, extra_fields, ...).",
},
{
key: "sso_provider_response",
title: "Full SSO provider response",
description: "Raw response returned by the identity provider (id_token / userinfo claims, group memberships, app roles, etc.).",
},
{
key: "decoded_jwt_access_token_claims",
title: "Decoded JWT access-token claims",
description: "Decoded JWT payload from the access token (generic SSO only — populated when the access token is a JWT).",
},
];
function isPlainObject(v) {
return v !== null && typeof v === 'object' && !Array.isArray(v);
}
function formatPrimitive(v) {
if (v === null || v === undefined) return 'null';
if (typeof v === 'object') return JSON.stringify(v);
return String(v);
}
function appendRow(parent, key, value) {
const row = document.createElement('div');
row.className = 'data-row';
const label = document.createElement('div');
label.className = 'data-label';
label.textContent = key;
const dataValue = document.createElement('div');
dataValue.className = 'data-value';
if (value !== null && typeof value === 'object') {
const pre = document.createElement('pre');
pre.className = 'jwt-text';
pre.style.margin = '0';
pre.textContent = JSON.stringify(value, null, 2);
dataValue.appendChild(pre);
} else {
dataValue.textContent = formatPrimitive(value);
}
row.appendChild(label);
row.appendChild(dataValue);
parent.appendChild(row);
}
function renderSection(parent, section, value) {
const wrapper = document.createElement('div');
wrapper.className = 'info-box';
wrapper.style.marginBottom = '20px';
const header = document.createElement('div');
header.className = 'info-header';
header.textContent = section.title;
wrapper.appendChild(header);
const desc = document.createElement('p');
desc.style.margin = '0 0 12px';
desc.style.color = '#475569';
desc.style.fontSize = '14px';
desc.textContent = section.description;
wrapper.appendChild(desc);
if (value === null || value === undefined) {
const empty = document.createElement('div');
empty.style.color = '#94a3b8';
empty.style.fontStyle = 'italic';
empty.textContent = '(not available for this provider)';
wrapper.appendChild(empty);
parent.appendChild(wrapper);
return;
}
// Flat key/value table for primitive entries; nested objects/arrays
// are pretty-printed inline so nothing is silently dropped.
if (isPlainObject(value)) {
const table = document.createElement('div');
table.className = 'data-container';
const entries = Object.entries(value);
if (entries.length === 0) {
const empty = document.createElement('div');
empty.style.color = '#94a3b8';
empty.style.fontStyle = 'italic';
empty.textContent = '(empty)';
table.appendChild(empty);
} else {
for (const [k, v] of entries) {
appendRow(table, k, v);
}
}
wrapper.appendChild(table);
} else {
// Array or primitive — show as JSON.
const pre = document.createElement('pre');
pre.className = 'jwt-text';
pre.textContent = JSON.stringify(value, null, 2);
wrapper.appendChild(pre);
}
// Per-section copy button so each blob can be copied independently.
const sectionId = 'section-json-' + section.key;
const hidden = document.createElement('pre');
hidden.id = sectionId;
hidden.style.display = 'none';
hidden.textContent = JSON.stringify(value, null, 2);
wrapper.appendChild(hidden);
const buttons = document.createElement('div');
buttons.className = 'buttons';
const copyBtn = document.createElement('button');
copyBtn.className = 'copy-button';
copyBtn.textContent = 'Copy this section';
copyBtn.onclick = function () { copyToClipboard(sectionId); };
buttons.appendChild(copyBtn);
wrapper.appendChild(buttons);
parent.appendChild(wrapper);
}
function renderUserData() {
const container = document.getElementById('userData');
const jsonDisplay = document.getElementById('jsonData');
// Format JSON with indentation for display
// Full JSON view at the bottom for easy copy/paste.
jsonDisplay.textContent = JSON.stringify(userData, null, 2);
// Clear container
container.innerHTML = '';
// Add each key-value pair to the UI
for (const [key, value] of Object.entries(userData)) {
if (typeof value !== 'object' || value === null) {
const row = document.createElement('div');
row.className = 'data-row';
const label = document.createElement('div');
label.className = 'data-label';
label.textContent = key;
const dataValue = document.createElement('div');
dataValue.className = 'data-value';
dataValue.textContent = value !== null ? value : 'null';
row.appendChild(label);
row.appendChild(dataValue);
container.appendChild(row);
// Surface any top-level primitive metadata (e.g. "provider": "generic")
// first as flat rows for quick scanning.
const sectionKeys = new Set(SECTIONS.map(s => s.key));
const meta = document.createElement('div');
meta.className = 'data-container';
let metaCount = 0;
for (const [key, value] of Object.entries(userData || {})) {
if (sectionKeys.has(key)) continue;
if (value === null || typeof value !== 'object') {
appendRow(meta, key, value);
metaCount++;
}
}
if (metaCount > 0) container.appendChild(meta);
// Then render each detailed section.
for (const section of SECTIONS) {
const value = (userData || {})[section.key];
renderSection(container, section, value === undefined ? null : value);
}
}
function copyToClipboard(elementId) {

View file

@ -4034,10 +4034,76 @@ async def debug_sso_login(request: Request):
)
def _make_sso_debug_json_serializable(value: Any) -> Any:
"""Recursively convert SSO/JWT values into JSON-serializable Python types.
Handles primitives, dicts, lists/tuples/sets, Enums, datetime-like values,
Pydantic models (v1 and v2), dataclasses, and arbitrary objects with a
``__dict__``. Anything that still cannot be converted falls back to
``str(value)``.
Used by the ``/sso/debug/callback`` endpoint so we can surface the FULL
SSO provider response and decoded JWT claims in the debug UI rather than
silently dropping nested objects.
"""
import datetime
from enum import Enum
if value is None or isinstance(value, (bool, int, float, str)):
return value
if isinstance(value, Enum):
return value.value
if isinstance(value, (datetime.datetime, datetime.date)):
return value.isoformat()
if isinstance(value, dict):
return {
str(k): _make_sso_debug_json_serializable(v) for k, v in value.items()
}
if isinstance(value, (list, tuple, set)):
return [_make_sso_debug_json_serializable(v) for v in value]
# Pydantic v2
model_dump = getattr(value, "model_dump", None)
if callable(model_dump):
try:
return _make_sso_debug_json_serializable(model_dump())
except Exception:
pass
# Pydantic v1
pydantic_dict = getattr(value, "dict", None)
if callable(pydantic_dict) and hasattr(value, "__fields__"):
try:
return _make_sso_debug_json_serializable(pydantic_dict())
except Exception:
pass
if hasattr(value, "__dict__"):
return {
str(k): _make_sso_debug_json_serializable(v)
for k, v in vars(value).items()
if not k.startswith("_")
}
try:
return str(value)
except Exception:
return repr(value)
@router.get("/sso/debug/callback", tags=["experimental"], include_in_schema=False)
async def debug_sso_callback(request: Request):
"""
Returns the OpenID object returned by the SSO provider
Returns full SSO debug information so operators can see exactly what the
identity provider returned and how LiteLLM parsed it.
The response now includes three sections instead of just the OpenID-converted
fields:
* ``sso_provider_response`` — full raw response from the SSO provider
(id_token / userinfo claims, group memberships, app roles, etc.)
* ``litellm_parsed_openid`` — the fields the proxy actually parsed
(id, email, display_name, team_ids, user_role, plus any
``GENERIC_USER_EXTRA_ATTRIBUTES`` extra_fields and team_alias when
available)
* ``decoded_jwt_access_token_claims`` — decoded JWT access-token claims
(generic SSO only; populated when the access token is a JWT)
"""
import json
@ -4077,24 +4143,61 @@ async def debug_sso_callback(request: Request):
else:
redirect_url += "/sso/debug/callback"
result = None
parsed_openid: Any = None
raw_sso_response: Any = None
access_token_payload: Optional[dict] = None
provider_label: Optional[str] = None
if google_client_id is not None:
result = await GoogleSSOHandler.get_google_callback_response(
provider_label = "google"
# Single call only — OAuth authorization codes are single-use, so we
# cannot fetch both raw + parsed via separate verify_and_process calls.
# The raw response is the authoritative provider payload; we surface
# it as both the "raw" view and (for backwards compat) the "parsed"
# view since Google does not have a separate proxy-side parser.
raw_sso_response = await GoogleSSOHandler.get_google_callback_response(
request=request,
google_client_id=google_client_id,
redirect_url=redirect_url,
return_raw_sso_response=True,
)
parsed_openid = raw_sso_response
elif microsoft_client_id is not None:
result = await MicrosoftSSOHandler.get_microsoft_callback_response(
provider_label = "microsoft"
raw_sso_response = await MicrosoftSSOHandler.get_microsoft_callback_response(
request=request,
microsoft_client_id=microsoft_client_id,
redirect_url=redirect_url,
return_raw_sso_response=True,
)
# Re-derive the parsed CustomOpenID from the raw response so operators
# see exactly what LiteLLM would have stored in the user record.
try:
user_team_ids: List[str] = []
if isinstance(raw_sso_response, dict):
user_team_ids = list(
raw_sso_response.get(
MicrosoftSSOHandler.GRAPH_API_RESPONSE_KEY, []
)
or []
)
parsed_openid = MicrosoftSSOHandler.openid_from_response(
response=raw_sso_response if isinstance(raw_sso_response, dict) else {},
team_ids=user_team_ids,
user_role=None,
)
except Exception as parse_err:
verbose_proxy_logger.debug(
f"debug_sso_callback: could not derive parsed Microsoft OpenID: {parse_err}"
)
parsed_openid = None
elif generic_client_id is not None:
result, _, _ = await get_generic_sso_response(
provider_label = "generic"
(
parsed_openid,
raw_sso_response,
access_token_payload,
) = await get_generic_sso_response(
request=request,
jwt_handler=jwt_handler,
generic_client_id=generic_client_id,
@ -4102,36 +4205,26 @@ async def debug_sso_callback(request: Request):
sso_jwt_handler=sso_jwt_handler,
)
# If result is None, return a basic error message
if result is None:
# If nothing was returned at all, surface the original error page.
if parsed_openid is None and raw_sso_response is None:
return HTMLResponse(
content="<h1>SSO Authentication Failed</h1><p>No data was returned from the SSO provider.</p>",
status_code=400,
)
# Convert the OpenID object to a dictionary
if hasattr(result, "__dict__"):
result_dict = result.__dict__
else:
result_dict = dict(result)
# Filter out any None values and convert to JSON serializable format
filtered_result = {}
for key, value in result_dict.items():
if value is not None and not key.startswith("_"):
if isinstance(value, (str, int, float, bool)) or value is None:
filtered_result[key] = value
else:
try:
# Try to convert to string or another JSON serializable format
filtered_result[key] = str(value)
except Exception as e:
filtered_result[key] = f"Complex value (not displayable): {str(e)}"
sso_data: Dict[str, Any] = {
"provider": provider_label,
"sso_provider_response": _make_sso_debug_json_serializable(raw_sso_response),
"litellm_parsed_openid": _make_sso_debug_json_serializable(parsed_openid),
"decoded_jwt_access_token_claims": _make_sso_debug_json_serializable(
access_token_payload
),
}
# Replace the placeholder in the template with the actual data
html_content = jwt_display_template.replace(
"const userData = SSO_DATA;",
f"const userData = {json.dumps(filtered_result, indent=2)};",
f"const userData = {json.dumps(sso_data, indent=2, default=str)};",
)
return HTMLResponse(content=html_content)