diff --git a/litellm/proxy/management_endpoints/mcp_management_endpoints.py b/litellm/proxy/management_endpoints/mcp_management_endpoints.py index dcc4dc36d83..bf361c7c3ab 100644 --- a/litellm/proxy/management_endpoints/mcp_management_endpoints.py +++ b/litellm/proxy/management_endpoints/mcp_management_endpoints.py @@ -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 diff --git a/litellm/proxy/management_endpoints/sso/id_jag_assertion_capture.py b/litellm/proxy/management_endpoints/sso/id_jag_assertion_capture.py new file mode 100644 index 00000000000..e0cb0d3e775 --- /dev/null +++ b/litellm/proxy/management_endpoints/sso/id_jag_assertion_capture.py @@ -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) diff --git a/litellm/proxy/management_endpoints/ui_sso.py b/litellm/proxy/management_endpoints/ui_sso.py index 8682b61f910..3155a0da73b 100644 --- a/litellm/proxy/management_endpoints/ui_sso.py +++ b/litellm/proxy/management_endpoints/ui_sso.py @@ -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/") diff --git a/tests/test_litellm/proxy/management_endpoints/test_id_jag_assertion_capture.py b/tests/test_litellm/proxy/management_endpoints/test_id_jag_assertion_capture.py new file mode 100644 index 00000000000..91bd8dfb2a6 --- /dev/null +++ b/tests/test_litellm/proxy/management_endpoints/test_id_jag_assertion_capture.py @@ -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 diff --git a/tests/test_litellm/proxy/management_endpoints/test_mcp_management_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_mcp_management_endpoints.py index bf119c4fb2f..2108319eb2a 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_mcp_management_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_mcp_management_endpoints.py @@ -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""" diff --git a/tests/test_litellm/proxy/management_endpoints/test_ui_sso.py b/tests/test_litellm/proxy/management_endpoints/test_ui_sso.py index 795b7cd5a9e..4a1f5e1f33e 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_ui_sso.py +++ b/tests/test_litellm/proxy/management_endpoints/test_ui_sso.py @@ -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