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>
This commit is contained in:
Kerry Lu 2026-11-07 16:48:48 -08:00 • committed by GitHub
parent c22dc25316
commit 432efc9b2d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 75 additions and 5 deletions

View file

@ -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:

View file

@ -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:

View file

@ -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(
{

View file

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