From 432efc9b2d13afade389f4138a9d03f2fdc59d2f Mon Sep 17 00:00:00 2001 From: Kerry Lu Date: Sat, 7 Nov 2026 16:48:48 -0800 Subject: [PATCH] fix(anthropic): exchange Google identity tokens again on refresh and read oidc/ references out of ANTHROPIC_IDENTITY_TOKEN (#50583) A Google metadata-server identity token carries no jti, so Anthropic lets it be exchanged more than once. The shared on-disk token store still answered a mandatory refresh with the stored token minted from that same assertion, which was the almost expired token the refresh existed to replace. Google references now skip the store, so every refresh is a real exchange and a Kubernetes token file keeps today's cross-worker reuse. ANTHROPIC_IDENTITY_TOKEN was always wrapped as oidc/env/ANTHROPIC_IDENTITY_TOKEN, so a value that was itself an oidc/google/ reference was posted to Anthropic as the literal assertion text. A value starting with oidc/ is now the reference, with oidc/env_path/ still rejected, and a raw token in the variable resolves exactly as before. Fixes #50553 Co-authored-by: devin-ai-integration[bot] <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/llms/anthropic/wif.py | 9 +++-- litellm/llms/base_llm/auth/token_exchange.py | 8 +++- .../unit/llms/anthropic/test_anthropic_wif.py | 25 ++++++++++++ .../base_llm/auth/test_shared_token_store.py | 38 +++++++++++++++++++ 4 files changed, 75 insertions(+), 5 deletions(-) diff --git a/litellm/llms/anthropic/wif.py b/litellm/llms/anthropic/wif.py index ccb81c1244f..d2bf3e893af 100644 --- a/litellm/llms/anthropic/wif.py +++ b/litellm/llms/anthropic/wif.py @@ -552,9 +552,12 @@ def _resolve_assertion_ref(litellm_params: Mapping[str, object] | None) -> str | file_env: Final = _env_str("ANTHROPIC_IDENTITY_TOKEN_FILE") if file_env is not None: return f"oidc/file/{file_env}" - if _env_str(_INLINE_ENV_VAR) is not None: - return f"oidc/env/{_INLINE_ENV_VAR}" - return None + inline_env: Final = _env_str(_INLINE_ENV_VAR) + if inline_env is None: + return None + if inline_env.startswith(_ACCEPTED_REF_PREFIX): + return _validated_inline_ref(inline_env) + return f"oidc/env/{_INLINE_ENV_VAR}" def _validated_inline_ref(value: str) -> str: diff --git a/litellm/llms/base_llm/auth/token_exchange.py b/litellm/llms/base_llm/auth/token_exchange.py index ce0e3c45639..5b2ab86dd53 100644 --- a/litellm/llms/base_llm/auth/token_exchange.py +++ b/litellm/llms/base_llm/auth/token_exchange.py @@ -292,8 +292,12 @@ def _cache_key(spec: TokenExchangeSpec) -> str: def _shares_one_assertion_across_workers(spec: TokenExchangeSpec) -> bool: """The store exists so the workers reading one projected token file don't each spend that file's single-use ``jti``. A source that mints its own assertion per exchange shares nothing with another - worker, so it never reads the store, never finds a hit there, and keeps its minted token off disk.""" - return spec.assertion_source is None + worker, and a Google metadata-server token carries no ``jti`` (Anthropic only enforces single use on + assertions that do), so a stored token would only hand a mandatory refresh back the token it is + replacing. Neither reads the store, finds a hit there, or puts its minted token on disk.""" + from litellm.secret_managers.main import GOOGLE_OIDC_SECRET_PREFIX + + return spec.assertion_source is None and not spec.assertion_ref.startswith(GOOGLE_OIDC_SECRET_PREFIX) def _assertion_digest(assertion: SecretStr) -> str: diff --git a/tests/unit/llms/anthropic/test_anthropic_wif.py b/tests/unit/llms/anthropic/test_anthropic_wif.py index 733e1472a8a..acaa7c7c8bf 100644 --- a/tests/unit/llms/anthropic/test_anthropic_wif.py +++ b/tests/unit/llms/anthropic/test_anthropic_wif.py @@ -452,6 +452,31 @@ class TestResolutionMatrix: assert params.service_account_id is None assert params.workspace_id is None + def test_env_inline_oidc_reference_resolves_like_the_param(self, monkeypatch: pytest.MonkeyPatch): + monkeypatch.setenv("ANTHROPIC_FEDERATION_RULE_ID", "fdrl_env") + monkeypatch.setenv("ANTHROPIC_ORGANIZATION_ID", "org-env") + monkeypatch.setenv("ANTHROPIC_IDENTITY_TOKEN", "oidc/google/https://api.anthropic.com") + poster = ScriptedPoster([token_response()]) + engine = JwtBearerTokenExchangeEngine( + poster=poster, + assertion_reader={"oidc/google/https://api.anthropic.com": "google-id-token"}.get, + clock=FakeClock(), + refresh_executor=ManualExecutor(), + ) + + token = get_anthropic_wif_token(None, None, "claude-opus-4-6", engine) + + assert token == "sk-ant-oat01-minted" + assert [request.json_body()["assertion"] for request in poster.requests] == ["google-id-token"] + + def test_env_inline_env_path_reference_is_rejected(self, monkeypatch: pytest.MonkeyPatch): + monkeypatch.setenv("ANTHROPIC_FEDERATION_RULE_ID", "fdrl_env") + monkeypatch.setenv("ANTHROPIC_ORGANIZATION_ID", "org-env") + monkeypatch.setenv("ANTHROPIC_IDENTITY_TOKEN", "oidc/env_path/ANTHROPIC_TOKEN_PATH") + + with pytest.raises(litellm.AuthenticationError): + resolve_anthropic_wif_params(None) + def test_file_param_beats_inline_param(self): params = resolve_anthropic_wif_params( { diff --git a/tests/unit/llms/base_llm/auth/test_shared_token_store.py b/tests/unit/llms/base_llm/auth/test_shared_token_store.py index 298e61c4c79..5acea09d296 100644 --- a/tests/unit/llms/base_llm/auth/test_shared_token_store.py +++ b/tests/unit/llms/base_llm/auth/test_shared_token_store.py @@ -111,6 +111,44 @@ def test_a_minted_assertion_never_reaches_the_shared_store(tmp_path: Path): assert second.access_token.get_secret_value() != first.access_token.get_secret_value() +def test_a_google_metadata_assertion_is_exchanged_again_inside_the_mandatory_window(tmp_path: Path): + """A Google metadata-server token carries no ``jti``, so Anthropic exchanges it as often as asked. The + store used to answer a mandatory refresh with the stored token minted from that same assertion, which + was the 20-seconds-left token the refresh existed to replace.""" + poster = ScriptedPoster([token_response("sk-ant-oat01-first", expires_in=600), token_response("sk-ant-oat01-second")]) + clock = FakeClock() + google_ref = "oidc/google/https://api.anthropic.com" + engine = store_engine( + poster, FileTokenStore(tmp_path), reader={google_ref: "google-id-token"}, clock=clock, wall_clock=clock + ) + spec = make_spec(assertion_ref=google_ref) + + first = minted(engine.get_token(spec)) + clock.advance(580.0) + refreshed = minted(engine.get_token(spec)) + + assert refreshed.access_token.get_secret_value() == "sk-ant-oat01-second" + assert refreshed.expires_at - clock.now == pytest.approx(3600.0), (first.expires_at, refreshed.expires_at) + assert [request.json_body()["assertion"] for request in poster.requests] == ["google-id-token", "google-id-token"] + assert stored_files(tmp_path) == [], "a re-exchangeable assertion has no token to share across workers" + + +def test_a_token_file_assertion_still_serves_the_stored_token_inside_the_mandatory_window(tmp_path: Path): + """The projected token file is the case the store was built for: with the file unrotated, a second + exchange would only be refused as ``jti_reused``, so the stored token keeps serving until it dies.""" + poster = SingleUsePoster(expires_in=600) + clock = FakeClock() + store = FileTokenStore(tmp_path) + engine = store_engine(poster, store, clock=clock, wall_clock=clock) + + first = minted(engine.get_token(make_spec())) + clock.advance(580.0) + served = minted(store_engine(poster, store, clock=clock, wall_clock=clock).get_token(make_spec())) + + assert served.access_token.get_secret_value() == first.access_token.get_secret_value() + assert len(poster.requests) == 1 + + def test_a_failed_write_leaves_no_staging_file_holding_a_live_token(tmp_path: Path): """Nothing ever sweeps this directory, so a staging file a failed write leaves behind would keep a working token readable on disk for as long as the pod lives."""