From e5744c5d88a04c2f2b868e2884ce582e963276e0 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Thu, 17 Sep 2026 18:13:41 -0700 Subject: [PATCH] fix(azure_ai): mint an oidc Entra token only from the agent's own ids The OIDC branch of the agent token mint handed a missing tenant_id or client_id to the shared helper, which fills them from the host's AZURE_TENANT_ID and AZURE_CLIENT_ID, so an agent carrying only an oidc/ token could be authenticated with the host's identity. The branch now needs both ids on the agent and otherwise fails with the credential help, which names the requirement --- litellm/llms/azure_ai/common_utils.py | 9 ++--- .../llms/azure_ai/test_azure_ai_entra_auth.py | 34 +++++++++++++++++++ 2 files changed, 39 insertions(+), 4 deletions(-) diff --git a/litellm/llms/azure_ai/common_utils.py b/litellm/llms/azure_ai/common_utils.py index 09f94d269ec..d5a05cb8ea5 100644 --- a/litellm/llms/azure_ai/common_utils.py +++ b/litellm/llms/azure_ai/common_utils.py @@ -52,8 +52,8 @@ AZURE_ENTRA_LITELLM_PARAM_KEYS: Final = AZURE_ENTRA_CREDENTIAL_PARAM_KEYS | froz {"tenant_id", "client_id", "azure_username", "azure_scope"} ) AZURE_ENTRA_CREDENTIAL_HELP: Final = ( - "Set `tenant_id` + `client_id` + `client_secret`, `azure_ad_token`, or " - "`client_id` + `azure_username` + `azure_password` in the agent's `litellm_params`" + "Set `tenant_id` + `client_id` + `client_secret`, `azure_ad_token` (an `oidc/` token also needs " + "`tenant_id` + `client_id`), or `client_id` + `azure_username` + `azure_password` in the agent's `litellm_params`" ) @@ -95,11 +95,12 @@ def get_azure_ai_agent_entra_token(litellm_params: Mapping[str, object]) -> str: return get_azure_ad_token_from_username_password( client_id=client_id, azure_username=azure_username, azure_password=azure_password, scope=scope )() - if azure_ad_token and azure_ad_token.startswith("oidc/"): + federated: Final = azure_ad_token is not None and azure_ad_token.startswith("oidc/") + if azure_ad_token and federated and tenant_id and client_id: return get_azure_ad_token_from_oidc( azure_ad_token=azure_ad_token, azure_client_id=client_id, azure_tenant_id=tenant_id, scope=scope ) - if azure_ad_token: + if azure_ad_token and not federated: return azure_ad_token raise ValueError(f"Azure AI agent Entra ID credentials did not resolve to a token. {AZURE_ENTRA_CREDENTIAL_HELP}") diff --git a/tests/test_litellm/llms/azure_ai/test_azure_ai_entra_auth.py b/tests/test_litellm/llms/azure_ai/test_azure_ai_entra_auth.py index 551dc04bdfc..606f398e063 100644 --- a/tests/test_litellm/llms/azure_ai/test_azure_ai_entra_auth.py +++ b/tests/test_litellm/llms/azure_ai/test_azure_ai_entra_auth.py @@ -263,6 +263,40 @@ def test_agent_entra_token_failure_names_the_credential_fields(): get_azure_ai_agent_entra_token({"azure_scope": "https://ai.azure.com/.default"}) +def test_agent_oidc_token_without_agent_ids_never_borrows_the_host_identity(monkeypatch): + """The shared OIDC helper fills a missing client and tenant id from AZURE_CLIENT_ID and AZURE_TENANT_ID, + which would exchange the host's federated token for the host's identity at that agent's URL.""" + monkeypatch.setenv("AZURE_TENANT_ID", "host-tenant") + monkeypatch.setenv("AZURE_CLIENT_ID", "host-client") + + with patch("litellm.llms.azure.common_utils.get_azure_ad_token_from_oidc") as mock_oidc: # test-quality-ok: stubs the OIDC exchange so a host-identity leak would show up as a call instead of a network round trip + mock_oidc.return_value = "host-minted-token" + + with pytest.raises(ValueError, match="oidc/"): + get_azure_ai_agent_entra_token({"azure_ad_token": "oidc/github"}) + with pytest.raises(ValueError, match="oidc/"): + get_azure_ai_agent_entra_token({"azure_ad_token": "oidc/github", "tenant_id": "agent-tenant"}) + + mock_oidc.assert_not_called() + + +def test_agent_oidc_token_exchanges_with_the_agent_ids_and_scope(): + with patch("litellm.llms.azure.common_utils.get_azure_ad_token_from_oidc") as mock_oidc: # test-quality-ok: stubs the OIDC exchange to assert the agent's own ids and the Foundry scope reach it + mock_oidc.return_value = "agent-minted-token" + + token = get_azure_ai_agent_entra_token( + {"azure_ad_token": "oidc/github", "tenant_id": "agent-tenant", "client_id": "agent-client"} + ) + + assert token == "agent-minted-token" + mock_oidc.assert_called_once_with( + azure_ad_token="oidc/github", + azure_client_id="agent-client", + azure_tenant_id="agent-tenant", + scope="https://ai.azure.com/.default", + ) + + @pytest.mark.asyncio async def test_agent_auth_header_is_the_entra_bearer(): headers = await resolve_azure_ai_agent_auth_header({"azure_ad_token": "entra-token"})