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."""