feat(mcp): warn when an oauth2_id_jag server outruns the SSO provider's assertion capture

Only the generic OIDC login path captures the IdP id_token that an oauth2_id_jag MCP
server spends as its RFC 8693 subject token. Under Google, Microsoft, SAML or no SSO at
all, registration succeeds and then every ID-JAG credential resolution fails for every
user, with nothing in the logs, the config or the API response to say why.

Report the mismatch from the two places it is knowable: when an oauth2_id_jag server is
created or updated through the management endpoint, and at SSO callback time when a login
hands the arm nothing while such a server is registered. Provider selection mirrors the
callback's precedence, so a generic client id sitting behind GOOGLE_CLIENT_ID does not
clear the warning.
This commit is contained in:
Yassin Kortam 2026-07-31 12:10:36 -07:00
parent c943d9a952
commit f67f7ca495
6 changed files with 643 additions and 0 deletions

View file

@ -62,6 +62,9 @@ from litellm.proxy.common_utils.encrypt_decrypt_utils import (
decrypt_value_helper,
encrypt_value_helper,
)
from litellm.proxy.management_endpoints.sso.id_jag_assertion_capture import (
id_jag_assertion_capture_gap,
)
from litellm.proxy.management_helpers.audit_logs import get_audit_log_changed_by
from litellm.repositories.table_repositories import (
MCPServerRepository,
@ -227,6 +230,22 @@ if MCP_AVAILABLE:
_base_validate_and_normalize_mcp_server_payload(payload)
_validate_mcp_server_name_fields(payload)
def warn_if_id_jag_server_outruns_sso(server_id: str | None, auth_type: MCPAuth | str | None) -> None:
"""Registering an ``oauth2_id_jag`` server under an SSO provider that captures no IdP
identity assertion is a dead configuration: nothing here fails, and then every ID-JAG call
fails for every user with a message that only ever tells them to sign in again. Say it once,
at the moment the admin can still act on it."""
if auth_type != MCPAuth.oauth2_id_jag:
return
gap = id_jag_assertion_capture_gap()
if gap is None:
return
verbose_proxy_logger.warning(
"MCP server %s is registered with auth_type=oauth2_id_jag, but %s.",
server_id,
gap,
)
def stamp_omitted_oauth2_flow(payload: NewMCPServerRequest) -> None:
"""Fallback only: fill in oauth2_flow when an oauth2 create omits it.
@ -1489,6 +1508,8 @@ if MCP_AVAILABLE:
detail={"error": f"Error creating mcp server: {str(e)}"},
)
warn_if_id_jag_server_outruns_sso(new_mcp_server.server_id, new_mcp_server.auth_type)
# Registry refresh is best-effort: the row is already committed, so a
# failure here (e.g. an unrelated malformed row in the table) must not
# surface as a 500 and orphan the created server, which would push the
@ -2476,6 +2497,7 @@ if MCP_AVAILABLE:
status_code=status.HTTP_404_NOT_FOUND,
detail={"error": f"MCP Server not found, passed server_id={payload.server_id}"},
)
warn_if_id_jag_server_outruns_sso(mcp_server_record_updated.server_id, mcp_server_record_updated.auth_type)
await global_mcp_server_manager.update_server(mcp_server_record_updated)
# Ensure registry is up to date by reloading from database

View file

@ -0,0 +1,71 @@
"""Whether the SSO provider the login callback dispatches to can capture an IdP identity assertion.
An ``oauth2_id_jag`` MCP server spends the ``id_token`` captured at SSO login as its RFC 8693
subject token. Only the generic OIDC login path reaches a token response the gateway retains one
from, so a deployment whose SSO runs through Google, Microsoft or SAML never stores an assertion
and every store-sourced ID-JAG exchange fails for every user, however many times they sign in.
Neither side can see that alone: the MCP registration knows nothing about SSO and the login knows
nothing about MCP. This module is the one shared answer both warn from.
"""
from __future__ import annotations
import os
from enum import Enum
from typing import assert_never
from litellm.proxy.management_endpoints.sso.saml_sso import SAMLAuthHandler
_GENERIC_OIDC_REMEDY = (
"Point SSO at the generic OIDC provider (GENERIC_CLIENT_ID), the one login path whose token "
"response the gateway retains an id_token from"
)
class ActiveSSOProvider(str, Enum):
google = "google"
microsoft = "microsoft"
generic = "generic"
saml = "saml"
none = "none"
def active_sso_provider() -> ActiveSSOProvider:
"""The provider the SSO callback will dispatch to.
Mirrors the callback's precedence rather than reporting everything configured: an environment
carrying both GOOGLE_CLIENT_ID and GENERIC_CLIENT_ID runs the Google branch, so it must report
Google. Presence is judged the way the callback judges it, so a client id set to the empty
string still selects that branch here.
"""
if os.getenv("GOOGLE_CLIENT_ID") is not None:
return ActiveSSOProvider.google
if os.getenv("MICROSOFT_CLIENT_ID") is not None:
return ActiveSSOProvider.microsoft
if os.getenv("GENERIC_CLIENT_ID") is not None:
return ActiveSSOProvider.generic
if SAMLAuthHandler.is_saml_configured():
return ActiveSSOProvider.saml
return ActiveSSOProvider.none
def id_jag_assertion_capture_gap() -> str | None:
"""Why ID-JAG cannot work under the active SSO provider, phrased for an operator reading a log,
or ``None`` when that provider does capture an assertion."""
provider = active_sso_provider()
match provider:
case ActiveSSOProvider.generic:
return None
case ActiveSSOProvider.none:
return (
"no SSO provider is configured, so no IdP identity assertion is ever captured and "
f"ID-JAG credential resolution fails for every user. {_GENERIC_OIDC_REMEDY}"
)
case ActiveSSOProvider.google | ActiveSSOProvider.microsoft | ActiveSSOProvider.saml:
return (
f"the active SSO provider ({provider.value}) has no identity-assertion capture path, so no "
"IdP id_token is ever stored and ID-JAG credential resolution fails for every user no matter "
f"how often they sign in. {_GENERIC_OIDC_REMEDY}"
)
case _:
assert_never(provider)

View file

@ -66,6 +66,7 @@ from litellm.llms.custom_httpx.http_handler import (
from litellm.proxy._experimental.mcp_server.outbound_credentials.sso_assertion_store import (
SSOIdentityAssertion,
assertion_from_sso_login,
ema_assertion_retention_enabled,
retain_sso_identity_assertion_for_ema,
)
from litellm.proxy._types import (
@ -100,6 +101,9 @@ from litellm.proxy.common_utils.html_forms.ui_login import build_ui_login_form
from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache
from litellm.proxy.management_endpoints.internal_user_endpoints import new_user
from litellm.proxy.management_endpoints.sso import CustomMicrosoftSSO
from litellm.proxy.management_endpoints.sso.id_jag_assertion_capture import (
id_jag_assertion_capture_gap,
)
from litellm.proxy.management_endpoints.sso.saml_sso import SAMLAuthHandler
from litellm.proxy.management_endpoints.sso_helper_utils import (
check_is_admin_only_access,
@ -1529,6 +1533,26 @@ async def get_generic_sso_response(
return result or {}, received_response, access_token_payload, sso_assertion
async def warn_if_id_jag_assertion_uncaptured(assertion: SSOIdentityAssertion | None) -> None:
"""Say, at the one moment it is knowable, that this login gave an ``oauth2_id_jag`` server
nothing to spend. Without it the operator only ever sees the per-request failure, which cannot
tell a user who has never signed in from a provider that will never capture. Kept strictly
diagnostic: a store outage is swallowed, since a login must not fail over a log line."""
if assertion is not None:
return
try:
if not await ema_assertion_retention_enabled():
return
except Exception as exc: # noqa: BLE001 # diagnostics must never break the login
verbose_proxy_logger.debug("Could not check for oauth2_id_jag MCP servers after SSO login: %s", exc)
return
gap = id_jag_assertion_capture_gap()
verbose_proxy_logger.warning(
"SSO login captured no IdP identity assertion while an oauth2_id_jag MCP server is registered: %s",
gap if gap is not None else "the identity provider's token response carried no usable id_token",
)
async def create_team_member_add_task(team_id, user_info):
"""Create a task for adding a member to a team."""
try:
@ -2090,6 +2114,7 @@ async def _complete_cli_sso_callback_session(
raise HTTPException(status_code=500, detail="Failed to retrieve user information from SSO")
await retain_sso_identity_assertion_for_ema(user_id=user_info.user_id, assertion=sso_assertion)
await warn_if_id_jag_assertion_uncaptured(sso_assertion)
teams: List[str] = []
if hasattr(user_info, "teams") and user_info.teams:
@ -3369,6 +3394,7 @@ class SSOAuthenticationHandler:
if isinstance(user_id, str) and user_id:
await retain_sso_identity_assertion_for_ema(user_id=user_id, assertion=sso_assertion)
await warn_if_id_jag_assertion_uncaptured(sso_assertion)
disabled_non_admin_personal_key_creation = get_disabled_non_admin_personal_key_creation()
litellm_dashboard_ui = get_custom_url(request_base_url=str(request.base_url), route="ui/")

View file

@ -0,0 +1,100 @@
import pytest
from litellm.proxy.management_endpoints.sso.id_jag_assertion_capture import (
ActiveSSOProvider,
active_sso_provider,
id_jag_assertion_capture_gap,
)
_SSO_ENV_VARS = (
"GOOGLE_CLIENT_ID",
"MICROSOFT_CLIENT_ID",
"GENERIC_CLIENT_ID",
"SAML_IDP_METADATA_URL",
"SAML_IDP_METADATA_XML",
)
@pytest.fixture(autouse=True)
def _isolated_sso_env(monkeypatch):
"""Every SSO selector is read from the process environment, so a value left behind by
another test would silently decide this one's answer."""
for name in _SSO_ENV_VARS:
monkeypatch.delenv(name, raising=False)
class TestActiveSSOProviderMirrorsTheCallback:
"""The gap warning is only as good as its agreement with the branch the login callback
actually takes, so provider selection is asserted branch by branch, including the
precedence that makes a co-configured generic client unreachable."""
def test_google_client_id_selects_google(self, monkeypatch):
monkeypatch.setenv("GOOGLE_CLIENT_ID", "google-cid")
assert active_sso_provider() is ActiveSSOProvider.google
def test_microsoft_client_id_selects_microsoft(self, monkeypatch):
monkeypatch.setenv("MICROSOFT_CLIENT_ID", "ms-cid")
assert active_sso_provider() is ActiveSSOProvider.microsoft
def test_generic_client_id_selects_generic(self, monkeypatch):
monkeypatch.setenv("GENERIC_CLIENT_ID", "generic-cid")
assert active_sso_provider() is ActiveSSOProvider.generic
def test_saml_metadata_selects_saml(self, monkeypatch):
monkeypatch.setenv("SAML_IDP_METADATA_URL", "https://idp.example.com/metadata")
assert active_sso_provider() is ActiveSSOProvider.saml
def test_nothing_configured_selects_none(self):
assert active_sso_provider() is ActiveSSOProvider.none
def test_google_outranks_a_co_configured_generic_client(self, monkeypatch):
"""The callback tests GOOGLE_CLIENT_ID first, so the generic arm never runs here and
no assertion is captured; reporting generic would clear a gap that is still open."""
monkeypatch.setenv("GOOGLE_CLIENT_ID", "google-cid")
monkeypatch.setenv("GENERIC_CLIENT_ID", "generic-cid")
assert active_sso_provider() is ActiveSSOProvider.google
def test_microsoft_outranks_a_co_configured_generic_client(self, monkeypatch):
monkeypatch.setenv("MICROSOFT_CLIENT_ID", "ms-cid")
monkeypatch.setenv("GENERIC_CLIENT_ID", "generic-cid")
assert active_sso_provider() is ActiveSSOProvider.microsoft
def test_generic_outranks_saml(self, monkeypatch):
monkeypatch.setenv("GENERIC_CLIENT_ID", "generic-cid")
monkeypatch.setenv("SAML_IDP_METADATA_URL", "https://idp.example.com/metadata")
assert active_sso_provider() is ActiveSSOProvider.generic
class TestIdJagAssertionCaptureGap:
def test_generic_oidc_has_no_gap(self, monkeypatch):
monkeypatch.setenv("GENERIC_CLIENT_ID", "generic-cid")
assert id_jag_assertion_capture_gap() is None
@pytest.mark.parametrize(
"env_var, provider_label",
[
("GOOGLE_CLIENT_ID", "google"),
("MICROSOFT_CLIENT_ID", "microsoft"),
("SAML_IDP_METADATA_URL", "saml"),
],
)
def test_non_capturing_provider_is_named_with_the_remedy(self, monkeypatch, env_var, provider_label):
monkeypatch.setenv(env_var, "configured")
gap = id_jag_assertion_capture_gap()
assert gap is not None
assert provider_label in gap
assert "GENERIC_CLIENT_ID" in gap
def test_no_sso_configured_reports_a_gap(self):
gap = id_jag_assertion_capture_gap()
assert gap is not None
assert "no SSO provider is configured" in gap
def test_google_beside_generic_still_reports_a_gap(self, monkeypatch):
"""The precedence trap in operator terms: adding a generic client id without removing
GOOGLE_CLIENT_ID does not fix the deployment, so the gap must not clear."""
monkeypatch.setenv("GOOGLE_CLIENT_ID", "google-cid")
monkeypatch.setenv("GENERIC_CLIENT_ID", "generic-cid")
gap = id_jag_assertion_capture_gap()
assert gap is not None
assert "google" in gap

View file

@ -3437,6 +3437,162 @@ class TestAddMCPServerAtomicity:
mock_manager.reload_servers_from_database.assert_not_awaited()
class TestIdJagRegistrationWarnsAboutTheSSOGap:
"""An `oauth2_id_jag` server only ever works when the login path captures an IdP identity
assertion, and only the generic OIDC arm does. Registering one under Google or Microsoft
succeeds and then fails for every user on every call, so the mismatch has to be said at
registration time, while the admin is still looking at the configuration."""
@staticmethod
def _clear_sso_env(monkeypatch):
for name in (
"GOOGLE_CLIENT_ID",
"MICROSOFT_CLIENT_ID",
"GENERIC_CLIENT_ID",
"SAML_IDP_METADATA_URL",
"SAML_IDP_METADATA_XML",
):
monkeypatch.delenv(name, raising=False)
@staticmethod
def _id_jag_warnings(logger_mock) -> list:
return [call for call in logger_mock.warning.call_args_list if "oauth2_id_jag" in str(call)]
@staticmethod
def _server_record(auth_type) -> LiteLLM_MCPServerTable:
record = generate_mock_mcp_server_db_record(server_id="ema-1", alias="ema")
record.auth_type = auth_type
return record
async def _run_create(self, monkeypatch, provider_env, auth_type, logger_mock):
from litellm.proxy.management_endpoints.mcp_management_endpoints import (
add_mcp_server,
)
self._clear_sso_env(monkeypatch)
for name, value in provider_env.items():
monkeypatch.setenv(name, value)
mock_manager = MagicMock()
mock_manager.add_server = AsyncMock()
mock_manager.reload_servers_from_database = AsyncMock()
with (
patch(
"litellm.proxy.management_endpoints.mcp_management_endpoints.get_prisma_client_or_throw",
return_value=MagicMock(),
),
patch(
"litellm.proxy.management_endpoints.mcp_management_endpoints.create_mcp_server",
AsyncMock(return_value=self._server_record(auth_type)),
),
patch(
"litellm.proxy.management_endpoints.mcp_management_endpoints.global_mcp_server_manager",
mock_manager,
),
patch(
"litellm.proxy.management_endpoints.mcp_management_endpoints.verbose_proxy_logger",
logger_mock,
),
):
await add_mcp_server(
payload=NewMCPServerRequest(
alias="ema",
url="https://ema.example.com/mcp",
transport=MCPTransport.http,
),
user_api_key_dict=generate_mock_user_api_key_auth(
user_role=LitellmUserRoles.PROXY_ADMIN, user_id="admin-user"
),
)
@pytest.mark.asyncio
@pytest.mark.parametrize(
"provider_env, expected_fragment",
[
({"GOOGLE_CLIENT_ID": "cid"}, "google"),
({"MICROSOFT_CLIENT_ID": "cid"}, "microsoft"),
({"SAML_IDP_METADATA_URL": "https://idp.example.com/metadata"}, "saml"),
({}, "no SSO provider is configured"),
],
)
async def test_create_warns_under_a_provider_that_captures_nothing(
self, monkeypatch, provider_env, expected_fragment
):
logger_mock = MagicMock()
await self._run_create(monkeypatch, provider_env, MCPAuth.oauth2_id_jag, logger_mock)
warnings = self._id_jag_warnings(logger_mock)
assert len(warnings) == 1
assert expected_fragment in str(warnings[0])
assert "ema-1" in str(warnings[0])
@pytest.mark.asyncio
async def test_create_is_silent_under_generic_oidc(self, monkeypatch):
logger_mock = MagicMock()
await self._run_create(monkeypatch, {"GENERIC_CLIENT_ID": "cid"}, MCPAuth.oauth2_id_jag, logger_mock)
assert self._id_jag_warnings(logger_mock) == []
@pytest.mark.asyncio
async def test_create_is_silent_for_other_auth_types(self, monkeypatch):
"""Nothing but the id_jag arm sources credentials from a stored SSO assertion, so no
other server registered under Google has anything to warn about."""
logger_mock = MagicMock()
await self._run_create(monkeypatch, {"GOOGLE_CLIENT_ID": "cid"}, MCPAuth.api_key, logger_mock)
assert self._id_jag_warnings(logger_mock) == []
@pytest.mark.asyncio
async def test_update_to_id_jag_warns(self, monkeypatch):
"""Switching an existing server onto id_jag opens the same gap a create does."""
from litellm.proxy.management_endpoints.mcp_management_endpoints import (
edit_mcp_server,
)
self._clear_sso_env(monkeypatch)
monkeypatch.setenv("GOOGLE_CLIENT_ID", "cid")
mock_manager = MagicMock()
mock_manager.update_server = AsyncMock()
mock_manager.reload_servers_from_database = AsyncMock()
logger_mock = MagicMock()
with (
patch(
"litellm.proxy.management_endpoints.mcp_management_endpoints.get_prisma_client_or_throw",
return_value=MagicMock(),
),
patch(
"litellm.proxy.management_endpoints.mcp_management_endpoints.get_mcp_server",
AsyncMock(return_value=self._server_record(MCPAuth.api_key)),
),
patch(
"litellm.proxy.management_endpoints.mcp_management_endpoints.update_mcp_server",
AsyncMock(return_value=self._server_record(MCPAuth.oauth2_id_jag)),
),
patch(
"litellm.proxy.management_endpoints.mcp_management_endpoints.purge_user_oauth_credentials_for_server",
AsyncMock(return_value=0),
),
patch(
"litellm.proxy.management_endpoints.mcp_management_endpoints.global_mcp_server_manager",
mock_manager,
),
patch(
"litellm.proxy.management_endpoints.mcp_management_endpoints.verbose_proxy_logger",
logger_mock,
),
):
await edit_mcp_server(
payload=UpdateMCPServerRequest(server_id="ema-1", auth_type=MCPAuth.oauth2_id_jag),
user_api_key_dict=generate_mock_user_api_key_auth(
user_role=LitellmUserRoles.PROXY_ADMIN, user_id="admin-user"
),
)
warnings = self._id_jag_warnings(logger_mock)
assert len(warnings) == 1
assert "google" in str(warnings[0])
class TestHealthCheckServers:
"""Test suite for health check servers endpoint"""

View file

@ -7830,6 +7830,274 @@ async def test_cli_completion_persists_assertion_under_db_user_id():
assert response.status_code == 200
# ── Diagnosing a login that captured nothing an id_jag server can spend ───────
def _id_jag_gap_warnings(logger_mock) -> list:
return [call for call in logger_mock.warning.call_args_list if "oauth2_id_jag" in str(call)]
@pytest.mark.asyncio
@pytest.mark.parametrize(
"provider_env, expected_fragment",
[
({"GOOGLE_CLIENT_ID": "cid"}, "google"),
({"MICROSOFT_CLIENT_ID": "cid", "MICROSOFT_TENANT": "t"}, "microsoft"),
({}, "no SSO provider is configured"),
],
)
async def test_uncaptured_assertion_warns_when_an_id_jag_server_is_registered(
monkeypatch, provider_env, expected_fragment
):
"""A provider with no capture path leaves ID-JAG permanently broken, and the only place
that is knowable is the login itself; without this line the operator sees nothing at all."""
from litellm.proxy.management_endpoints.ui_sso import (
warn_if_id_jag_assertion_uncaptured,
)
for name in ("GOOGLE_CLIENT_ID", "MICROSOFT_CLIENT_ID", "GENERIC_CLIENT_ID", "SAML_IDP_METADATA_URL"):
monkeypatch.delenv(name, raising=False)
for name, value in provider_env.items():
monkeypatch.setenv(name, value)
logger_mock = MagicMock()
with (
patch(
"litellm.proxy.management_endpoints.ui_sso.ema_assertion_retention_enabled",
AsyncMock(return_value=True),
),
patch("litellm.proxy.management_endpoints.ui_sso.verbose_proxy_logger", logger_mock),
):
await warn_if_id_jag_assertion_uncaptured(None)
warnings = _id_jag_gap_warnings(logger_mock)
assert len(warnings) == 1
assert expected_fragment in str(warnings[0])
@pytest.mark.asyncio
async def test_generic_provider_that_returned_no_id_token_still_warns(monkeypatch):
"""Generic OIDC has a capture path, so there is no configuration gap to report; the login
still handed the id_jag arm nothing, and that must not pass silently."""
from litellm.proxy.management_endpoints.ui_sso import (
warn_if_id_jag_assertion_uncaptured,
)
for name in ("GOOGLE_CLIENT_ID", "MICROSOFT_CLIENT_ID", "SAML_IDP_METADATA_URL"):
monkeypatch.delenv(name, raising=False)
monkeypatch.setenv("GENERIC_CLIENT_ID", "cid")
logger_mock = MagicMock()
with (
patch(
"litellm.proxy.management_endpoints.ui_sso.ema_assertion_retention_enabled",
AsyncMock(return_value=True),
),
patch("litellm.proxy.management_endpoints.ui_sso.verbose_proxy_logger", logger_mock),
):
await warn_if_id_jag_assertion_uncaptured(None)
warnings = _id_jag_gap_warnings(logger_mock)
assert len(warnings) == 1
assert "no usable id_token" in str(warnings[0])
@pytest.mark.asyncio
async def test_no_warning_when_the_assertion_was_captured(monkeypatch):
from litellm.proxy._experimental.mcp_server.outbound_credentials.sso_assertion_store import (
assertion_from_sso_login,
)
from litellm.proxy.management_endpoints.ui_sso import (
warn_if_id_jag_assertion_uncaptured,
)
monkeypatch.setenv("GOOGLE_CLIENT_ID", "cid")
assertion = assertion_from_sso_login(_ema_id_token(), None)
assert assertion is not None
retention_mock = AsyncMock(return_value=True)
logger_mock = MagicMock()
with (
patch(
"litellm.proxy.management_endpoints.ui_sso.ema_assertion_retention_enabled",
retention_mock,
),
patch("litellm.proxy.management_endpoints.ui_sso.verbose_proxy_logger", logger_mock),
):
await warn_if_id_jag_assertion_uncaptured(assertion)
assert _id_jag_gap_warnings(logger_mock) == []
retention_mock.assert_not_awaited()
@pytest.mark.asyncio
async def test_no_warning_when_no_id_jag_server_is_registered(monkeypatch):
"""Most deployments never register one; a warning about ID-JAG on every login there would
be pure noise and would train operators to ignore it."""
from litellm.proxy.management_endpoints.ui_sso import (
warn_if_id_jag_assertion_uncaptured,
)
monkeypatch.setenv("GOOGLE_CLIENT_ID", "cid")
logger_mock = MagicMock()
with (
patch(
"litellm.proxy.management_endpoints.ui_sso.ema_assertion_retention_enabled",
AsyncMock(return_value=False),
),
patch("litellm.proxy.management_endpoints.ui_sso.verbose_proxy_logger", logger_mock),
):
await warn_if_id_jag_assertion_uncaptured(None)
assert _id_jag_gap_warnings(logger_mock) == []
@pytest.mark.asyncio
async def test_store_outage_does_not_break_the_login(monkeypatch):
from litellm.proxy.management_endpoints.ui_sso import (
warn_if_id_jag_assertion_uncaptured,
)
monkeypatch.setenv("GOOGLE_CLIENT_ID", "cid")
with (
patch(
"litellm.proxy.management_endpoints.ui_sso.ema_assertion_retention_enabled",
AsyncMock(side_effect=Exception("db down")),
),
patch("litellm.proxy.management_endpoints.ui_sso.verbose_proxy_logger", MagicMock()),
):
await warn_if_id_jag_assertion_uncaptured(None)
@pytest.mark.asyncio
async def test_browser_funnel_reports_an_uncaptured_assertion(monkeypatch):
"""Wiring: the browser login path must reach the diagnostic, not just define it."""
monkeypatch.setenv("GOOGLE_CLIENT_ID", "cid")
mock_request = MagicMock(spec=Request)
mock_request.base_url = "http://localhost:4000/"
mock_request.cookies = {}
logger_mock = MagicMock()
with (
patch("litellm.proxy.utils.get_prisma_client_or_throw", return_value=MagicMock()),
patch("litellm.proxy.proxy_server.master_key", "sk-master"),
patch("litellm.proxy.proxy_server.general_settings", {}),
patch("litellm.proxy.proxy_server.premium_user", False),
patch("litellm.proxy.proxy_server.user_custom_sso", None),
patch("litellm.proxy.proxy_server.proxy_logging_obj", MagicMock()),
patch("litellm.proxy.proxy_server.redis_usage_cache", None),
patch("litellm.proxy.proxy_server.user_api_key_cache", MagicMock()),
patch(
"litellm.proxy.proxy_server.generate_key_helper_fn",
AsyncMock(return_value={"token": "sk-ui-key", "user_id": "canonical-user-id"}),
),
patch(
"litellm.proxy.management_endpoints.ui_sso.get_user_info_from_db",
AsyncMock(return_value=None),
),
patch(
"litellm.proxy.management_endpoints.ui_sso.check_and_update_if_proxy_admin_id",
AsyncMock(return_value="internal_user"),
),
patch(
"litellm.proxy.management_endpoints.ui_sso.retain_sso_identity_assertion_for_ema",
AsyncMock(),
),
patch(
"litellm.proxy.management_endpoints.ui_sso.ema_assertion_retention_enabled",
AsyncMock(return_value=True),
),
patch("litellm.proxy.management_endpoints.ui_sso.verbose_proxy_logger", logger_mock),
):
await SSOAuthenticationHandler.get_redirect_response_from_openid(
result=CustomOpenID(
id="raw-idp-subject",
email="u@example.com",
first_name="U",
last_name="Ser",
display_name="U Ser",
provider="google",
team_ids=[],
user_role=None,
),
request=mock_request,
received_response=None,
generic_client_id=None,
ui_access_mode=None,
access_token_payload=None,
jwt_handler=None,
sso_assertion=None,
)
warnings = _id_jag_gap_warnings(logger_mock)
assert len(warnings) == 1
assert "google" in str(warnings[0])
@pytest.mark.asyncio
async def test_cli_funnel_reports_an_uncaptured_assertion(monkeypatch):
"""Wiring: the CLI login path shares the gap, so it must share the diagnostic."""
from litellm.proxy.management_endpoints.ui_sso import (
_complete_cli_sso_callback_session,
)
monkeypatch.setenv("MICROSOFT_CLIENT_ID", "cid")
mock_request = MagicMock(spec=Request)
mock_request.base_url = "http://localhost:4000/"
user_info = MagicMock()
user_info.user_id = "cli-user-id"
user_info.user_role = "internal_user"
user_info.models = []
user_info.teams = []
logger_mock = MagicMock()
with (
patch(
"litellm.proxy.management_endpoints.ui_sso.get_user_info_from_db",
AsyncMock(return_value=user_info),
),
patch(
"litellm.proxy.management_endpoints.ui_sso._fetch_cli_sso_team_details",
AsyncMock(return_value=[]),
),
patch(
"litellm.proxy.management_endpoints.ui_sso.build_cli_sso_attribution_metadata",
return_value={},
),
patch(
"litellm.proxy.management_endpoints.ui_sso.retain_sso_identity_assertion_for_ema",
AsyncMock(),
),
patch(
"litellm.proxy.management_endpoints.ui_sso.ema_assertion_retention_enabled",
AsyncMock(return_value=True),
),
patch("litellm.proxy.management_endpoints.ui_sso.verbose_proxy_logger", logger_mock),
):
await _complete_cli_sso_callback_session(
request=mock_request,
key="cli-login-id",
flow={},
result={"sub": "raw-idp-subject"},
parsed_openid_result={
"user_id": "raw-idp-subject",
"user_email": "u@example.com",
"user_role": None,
},
user_defined_values=None,
prisma_client=MagicMock(),
user_api_key_cache=MagicMock(),
cli_sso_session_cache=MagicMock(),
proxy_logging_obj=MagicMock(),
sso_assertion=None,
)
warnings = _id_jag_gap_warnings(logger_mock)
assert len(warnings) == 1
assert "microsoft" in str(warnings[0])
class TestSameOriginReturnPath:
"""The same-origin relative return_to arm added for the MCP gateway DCR authorize
round-trip: only strictly relative paths qualify, so login can never redirect the