mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-08 08:08:37 +00:00
fix(sso): expose full JWT claims and parsed proxy view in /sso/debug/callback
This commit is contained in:
parent
fa6a82ab0f
commit
3240c2c7c8
2 changed files with 273 additions and 50 deletions
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue