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
This commit is contained in:
mateo-berri 2026-09-17 18:13:41 -07:00
parent d4b5f05f97
commit e5744c5d88
2 changed files with 39 additions and 4 deletions

View file

@ -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}")

View file

@ -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"})