litellm/tests/unit/proxy/anthropic_endpoints/test_gateway_endpoints.py
devin-ai-integration[bot] 62dee3d730
feat(claude_code_gateway): issue rotating refresh tokens and a revocation endpoint (#44635)
* feat(claude_code_gateway): issue rotating refresh tokens and a revocation endpoint

The Claude Code gateway's device-code grant now returns a refresh token
alongside the session JWT, so a Claude Code session renews itself before
the JWT expires instead of forcing the user back through the browser sign-in.
grant_type=refresh_token re-mints the JWT from the live user row, rotates the
refresh token, and refuses a replayed, foreign, or identity-only token with
invalid_grant. The discovery document now advertises an RFC 7009 revocation
endpoint, which Claude Code's /logout calls with both tokens, so sign-out
burns the refresh token. The refresh token is the MCP gateway's sealed
session refresh token bound to the fixed client id claude_code, so both
front doors share one single-use record.

* fix(claude_code_gateway): mint the whole credential before claiming the device code

A refresh token that failed to mint answered 500 after the device code was
already claimed and the login deleted, so the client could not redeem the
completed sign-in again. The response is now built first, and the code is
claimed only when it is a 200.

* fix(claude-code-gateway): keep device sign-in working when session signing is unusable

* feat(claude-code-gateway): end the whole refresh chain on a replay or a revocation

* fix(mcp-gateway): fail the single-use peek closed on a Redis fault

* fix(mcp-gateway): read the single-use marker under the cache namespace

* fix(mcp-gateway): refuse a replayed refresh token without ending its chain

A refresh token presented a second time is refused as already used and
nothing else happens to the chain it was rotated from. Claude Code renews
from its in-memory copy of the credential, so a second terminal on the
same machine presents the token the first terminal already rotated and
then recovers from the shared credential file; ending the chain there
would sign both terminals out at every expiry. Only a revocation ends a
chain, so the 503-on-unrecorded-chain-ending path and its tests go away.

* fix(mcp-gateway): end the refresh chain before burning the revoked token

---------

Co-authored-by: mateo-berri <277851410+mateo-berri@users.noreply.github.com>
2026-10-06 20:57:50 -07:00

815 lines
36 KiB
Python

"""
Tests for the Claude Code gateway protocol (anthropic_endpoints/gateway_endpoints.py).
Covers the OAuth device-flow surface (RFC 8414 discovery, RFC 8628 device
authorization + token, the rotating refresh grant, RFC 7009 revocation), managed
settings, OTLP ingestion, and the enable flag.
"""
import asyncio
from collections.abc import Iterator, Mapping
from contextlib import ExitStack, contextmanager
from datetime import datetime, timezone
from types import MappingProxyType
from typing import Final
from unittest.mock import AsyncMock, MagicMock, patch
import httpx
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from litellm.caching.dual_cache import DualCache
from litellm.proxy._experimental.mcp_server.gateway_dcr_flow import (
MintedProxyCredential,
ProxyCredentialMintFailure,
)
from litellm.proxy._experimental.mcp_server.outbound_credentials.session_credentials import (
SessionRefreshOpened,
open_session_refresh_bearer,
session_keys_from_master_key,
)
from litellm.proxy._experimental.mcp_server.outbound_credentials.session_token import (
SESSION_REFRESH_PREFIX,
MintedSessionToken,
SessionPrincipal,
mint_session_refresh_token,
)
from litellm.proxy._types import ProxyException
from litellm.proxy.anthropic_endpoints import gateway_endpoints
from litellm.proxy.management_endpoints.ui_sso import (
_get_cli_sso_flow_cache_key,
_hash_cli_sso_secret,
_set_cli_sso_flow,
)
from litellm.proxy.middleware.prometheus_auth_middleware import PrometheusAuthMiddleware
_DEVICE_CODE_GRANT: Final = "urn:ietf:params:oauth:grant-type:device_code"
_MASTER_KEY: Final = "sk-master-key"
_TOKEN_URL: Final = "/claude_code_gateway/oauth/token"
_REVOKE_URL: Final = "/claude_code_gateway/oauth/revoke"
_SHARED_LOGIN_ID: Final = "cli-shared-login-code"
_SHARED_POLL_SECRET: Final = "shared-poll-secret"
_SHARED_DEVICE_CODE: Final = f"{_SHARED_LOGIN_ID}.{_SHARED_POLL_SECRET}"
_MINT: Final = "litellm.proxy.auth.auth_checks.ExperimentalUIJWTToken.get_cli_jwt_auth_token"
_PROTOBUF_BODY: Final = b"\x0a\x05hello\x12\x03{{{"
_COMPLETED_SESSION: Final = MappingProxyType(
{
"user_id": "user-123",
"user_role": "internal_user",
"models": ["claude-sonnet-4-5"],
"teams": ["team-a"],
"team_details": [
{
"team_id": "team-a",
"team_alias": "Team A",
"team_models": ["claude-sonnet-4-5"],
"team_model_aliases": None,
}
],
}
)
class _SharedRedisFake:
"""A Redis double with a configured namespace, prefixing every key its wrapper methods touch the
way ``RedisCache`` does, while its raw client answers only the exact key it is asked for."""
namespace: Final = "qa"
def __init__(self) -> None:
self.values: Mapping[str, object] = MappingProxyType({})
self.counters: Mapping[str, float] = MappingProxyType({})
def check_and_fix_namespace(self, key: str) -> str:
return key if key.startswith(f"{self.namespace}:") else f"{self.namespace}:{key}"
def set_cache(self, key: str, value: object, **kwargs: object) -> None:
self.values = MappingProxyType({**self.values, self.check_and_fix_namespace(key): value})
def get_cache(self, key: str, **kwargs: object) -> object:
return self.values.get(self.check_and_fix_namespace(key))
def delete_cache(self, key: str) -> None:
gone: Final = self.check_and_fix_namespace(key)
self.values = MappingProxyType({name: value for name, value in self.values.items() if name != gone})
async def async_delete_cache(self, key: str) -> None:
self.delete_cache(key)
async def async_increment(self, key: str, value: float, **kwargs: object) -> float:
namespaced: Final = self.check_and_fix_namespace(key)
incremented: Final = self.counters.get(namespaced, 0) + value
self.counters = MappingProxyType({**self.counters, namespaced: incremented})
return incremented
async def async_get_cache(self, key: str, **kwargs: object) -> object:
return self.values.get(self.check_and_fix_namespace(key))
def init_async_client(self) -> "_SharedRedisClientFake":
return _SharedRedisClientFake(self)
class _SharedRedisClientFake:
def __init__(self, store: _SharedRedisFake) -> None:
self._store = store
async def get(self, key: str) -> object:
return self._store.counters.get(key, self._store.values.get(key))
def _replica(redis: _SharedRedisFake) -> DualCache:
return DualCache(redis_cache=redis, default_in_memory_ttl=600) # pyright: ignore[reportArgumentType] # duck-typed Redis double
class _Minter:
def __init__(self, failure: ProxyCredentialMintFailure | None = None) -> None:
self.failure: Final = failure
self.calls: tuple[tuple[str, str | None], ...] = ()
async def __call__(self, user_id: str, team_id: str | None) -> MintedProxyCredential | ProxyCredentialMintFailure:
self.calls = (*self.calls, (user_id, team_id))
if self.failure is not None:
return self.failure
return MintedProxyCredential(key=f"sk-cli-{user_id}", expires_in=7200, user_id=user_id, team_id=team_id)
def _real_auth_proxy_attrs() -> Mapping[str, object]:
proxy_logging_obj: Final = MagicMock()
proxy_logging_obj.internal_usage_cache.dual_cache = AsyncMock()
proxy_logging_obj.post_call_failure_hook = AsyncMock(return_value=None)
return MappingProxyType(
{
"prisma_client": None,
"proxy_logging_obj": proxy_logging_obj,
"llm_router": None,
"llm_model_list": [],
"user_custom_auth": None,
"litellm_proxy_admin_name": "admin",
"jwt_handler": None,
"open_telemetry_logger": None,
"model_max_budget_limiter": MagicMock(),
}
)
@contextmanager
def _gateway_env(
*,
enabled: bool = True,
managed_settings: Mapping[str, object] | None = None,
cache: DualCache | None = None,
claim_cache: DualCache | None = None,
minter: _Minter | None = None,
real_auth: bool = False,
extra_settings: Mapping[str, object] = MappingProxyType({}),
) -> Iterator[tuple[TestClient, DualCache]]:
general_settings: Final = {
"enable_claude_code_gateway": enabled,
**({} if managed_settings is None else {"claude_code_gateway_managed_settings": dict(managed_settings)}),
**extra_settings,
}
session_cache: Final = cache or DualCache(default_in_memory_ttl=600)
app: Final = FastAPI()
app.add_middleware(PrometheusAuthMiddleware)
app.include_router(gateway_endpoints.router)
app.dependency_overrides[gateway_endpoints._proxy_credential_minter] = lambda: minter or _Minter()
async def _fake_auth() -> object:
return object()
with ExitStack() as stack:
stack.enter_context(
patch( # test-quality-ok: the gateway reads this proxy_server module global and has no injection seam
"litellm.proxy.proxy_server.general_settings", general_settings
)
)
stack.enter_context(
patch( # test-quality-ok: the CLI SSO flow cache is this proxy_server module global shared with ui_sso
"litellm.proxy.proxy_server.cli_sso_session_cache", session_cache
)
)
stack.enter_context(
patch( # test-quality-ok: the refresh token is signed under this proxy_server module global
"litellm.proxy.proxy_server.master_key", _MASTER_KEY
)
)
stack.enter_context(
patch( # test-quality-ok: the single-use record lives in this proxy_server module global
"litellm.proxy.proxy_server.user_api_key_cache", claim_cache or DualCache()
)
)
stack.enter_context(
patch( # test-quality-ok: the single-use guard prefers this proxy_server module global when set
"litellm.proxy.proxy_server.redis_usage_cache", None
)
)
if real_auth:
for name, value in _real_auth_proxy_attrs().items():
stack.enter_context(patch(f"litellm.proxy.proxy_server.{name}", value))
else:
app.dependency_overrides[gateway_endpoints.user_api_key_auth] = _fake_auth
with TestClient(app) as client:
yield client, session_cache
def _start_device_flow(client: TestClient) -> str:
return client.post("/claude_code_gateway/oauth/device_authorization").json()["device_code"]
def _request_token(client: TestClient, device_code: str) -> httpx.Response:
return client.post(_TOKEN_URL, data={"grant_type": _DEVICE_CODE_GRANT, "device_code": device_code})
def _refresh(client: TestClient, refresh_token: str) -> httpx.Response:
return client.post(_TOKEN_URL, data={"grant_type": "refresh_token", "refresh_token": refresh_token})
def _revoke(client: TestClient, token: str, hint: str | None = None) -> httpx.Response:
return client.post(_REVOKE_URL, data={"token": token, **({} if hint is None else {"token_type_hint": hint})})
def _signed_in(client: TestClient, cache: DualCache) -> Mapping[str, object]:
device_code: Final = _start_device_flow(client)
_complete_flow(cache, device_code)
with patch(_MINT, return_value="sk-litellm-session-token"):
resp: Final = _request_token(client, device_code)
assert resp.status_code == 200
return MappingProxyType(resp.json())
def _opened_refresh(refresh_token: str, client_id: str = gateway_endpoints.CLAUDE_CODE_CLIENT_ID) -> SessionPrincipal:
opened: Final = open_session_refresh_bearer(
refresh_token,
session_keys_from_master_key(_MASTER_KEY),
datetime.now(timezone.utc),
expected_client_id=client_id,
)
assert isinstance(opened, SessionRefreshOpened)
return opened.principal
def _foreign_refresh_token(principal: SessionPrincipal) -> str:
minted: Final = mint_session_refresh_token(
principal, session_keys_from_master_key(_MASTER_KEY), datetime.now(timezone.utc)
)
assert isinstance(minted, MintedSessionToken)
return minted.token.get_secret_value()
def _completed_flow(session_data: Mapping[str, object] = _COMPLETED_SESSION) -> dict[str, object]:
return {
"poll_secret_hash": _hash_cli_sso_secret(_SHARED_POLL_SECRET),
"user_code_hash": "unused",
"sso_complete": True,
"user_code_verified": True,
"session_data": dict(session_data),
}
def _login_id(device_code: str) -> str:
return device_code.partition(".")[0]
def _complete_flow(cache: DualCache, device_code: str, session_data: Mapping[str, object] = _COMPLETED_SESSION) -> None:
key: Final = _get_cli_sso_flow_cache_key(_login_id(device_code))
flow: Final = cache.get_cache(key=key)
assert isinstance(flow, dict)
completed: Final = {**flow, **_completed_flow(session_data), "poll_secret_hash": flow["poll_secret_hash"]}
cache.set_cache(key=key, value=completed, ttl=600)
def test_discovery_shape():
with _gateway_env() as (client, _):
resp = client.get("/claude_code_gateway/.well-known/oauth-authorization-server")
assert resp.status_code == 200
body = resp.json()
assert body["device_authorization_endpoint"].endswith("/claude_code_gateway/oauth/device_authorization")
assert body["token_endpoint"].endswith("/claude_code_gateway/oauth/token")
assert body["revocation_endpoint"].endswith("/claude_code_gateway/oauth/revoke")
assert body["grant_types_supported"] == [
"urn:ietf:params:oauth:grant-type:device_code",
"refresh_token",
]
# authorization_endpoint is intentionally absent (device flow only).
assert "authorization_endpoint" not in body
# Every endpoint must be same-origin with the issuer, or Claude Code ignores it.
assert body["device_authorization_endpoint"].startswith(body["issuer"])
assert body["token_endpoint"].startswith(body["issuer"])
assert body["revocation_endpoint"].startswith(body["issuer"])
def test_discovery_404_when_disabled():
with _gateway_env(enabled=False) as (client, _):
resp = client.get("/claude_code_gateway/.well-known/oauth-authorization-server")
assert resp.status_code == 404
def test_device_authorization_returns_rfc8628_shape_and_persists_flow():
with _gateway_env() as (client, cache):
resp = client.post("/claude_code_gateway/oauth/device_authorization")
assert resp.status_code == 200
body = resp.json()
device_code = body["device_code"]
login_id, separator, poll_secret = device_code.partition(".")
assert login_id.startswith("cli-")
assert separator == "."
assert len(poll_secret) >= 32
assert body["user_code"]
assert body["expires_in"] == 600
assert body["interval"] == 5
assert "verification_uri_complete" not in body
assert body["verification_uri"].endswith(f"/sso/key/generate?source=litellm-cli&key={login_id}")
assert poll_secret not in body["verification_uri"]
stored = cache.get_cache(key=_get_cli_sso_flow_cache_key(login_id))
assert isinstance(stored, dict)
assert stored["sso_complete"] is False
assert stored["poll_secret_hash"] == _hash_cli_sso_secret(poll_secret)
assert cache.get_cache(key=_get_cli_sso_flow_cache_key(device_code)) is None
@pytest.mark.parametrize("opted_in", [True, False])
def test_verification_uri_complete_carries_the_user_code_only_when_the_operator_opts_in(opted_in: bool):
with _gateway_env(extra_settings={"allow_cli_sso_verification_uri_complete": opted_in}) as (client, _):
body = client.post("/claude_code_gateway/oauth/device_authorization").json()
login_id = _login_id(body["device_code"])
if not opted_in:
assert "verification_uri_complete" not in body
return
assert body["verification_uri_complete"].endswith(
f"/sso/key/generate?source=litellm-cli&key={login_id}&user_code={body['user_code']}"
)
assert "user_code=" not in body["verification_uri"]
def test_token_authorization_pending_before_browser_completes():
with _gateway_env() as (client, _):
resp = _request_token(client, _start_device_flow(client))
assert resp.status_code == 400
assert resp.json()["error"] == "authorization_pending"
@pytest.mark.parametrize("tamper", ["login_id_only", "wrong_secret"])
def test_token_refuses_the_browser_login_id_without_the_client_secret(tamper: str):
with _gateway_env() as (client, cache):
device_code = _start_device_flow(client)
_complete_flow(cache, device_code)
login_id = _login_id(device_code)
presented = login_id if tamper == "login_id_only" else f"{login_id}.not-the-secret"
with patch(_MINT, return_value="sk-session") as mint:
resp = _request_token(client, presented)
assert resp.status_code == 400
assert resp.json()["error"] == "expired_token"
mint.assert_not_called()
with_secret = _request_token(client, device_code)
assert with_secret.status_code == 200
def test_token_success_mints_bearer_and_is_single_use():
with _gateway_env() as (client, cache):
device_code = _start_device_flow(client)
_complete_flow(cache, device_code)
with patch(_MINT, return_value="sk-litellm-session-token") as mint:
resp = _request_token(client, device_code)
assert resp.status_code == 200
assert resp.headers["cache-control"] == "no-store"
body = resp.json()
assert body["access_token"] == "sk-litellm-session-token"
assert body["token_type"] == "Bearer"
assert body["expires_in"] > 0
assert body["refresh_token"].startswith(SESSION_REFRESH_PREFIX)
principal = _opened_refresh(body["refresh_token"])
assert principal.user_id == "user-123"
assert principal.team_id == "team-a"
assert principal.audience == "proxy_api"
called_user = mint.call_args.kwargs["user_info"]
assert called_user.user_id == "user-123"
assert mint.call_args.kwargs["team_id"] == "team-a"
assert mint.call_args.kwargs["team_alias"] == "Team A"
assert mint.call_args.kwargs["team_models"] == ("claude-sonnet-4-5",)
# Single-use: the flow is deleted, so a replay returns expired_token.
replay = _request_token(client, device_code)
assert replay.status_code == 400
assert replay.json()["error"] == "expired_token"
def test_token_teamless_user_mints_without_a_team():
with _gateway_env() as (client, cache):
device_code = _start_device_flow(client)
_complete_flow(cache, device_code, session_data={**_COMPLETED_SESSION, "teams": [], "team_details": []})
with patch(_MINT, return_value="sk-litellm-session-token") as mint:
resp = _request_token(client, device_code)
assert resp.status_code == 200
assert mint.call_args.kwargs["team_id"] is None
assert mint.call_args.kwargs["team_models"] == ()
@pytest.mark.parametrize(
"session_data",
[
{"user_role": "internal_user"},
{**_COMPLETED_SESSION, "user_role": None},
{**_COMPLETED_SESSION, "user_role": "not-a-role"},
],
ids=["missing_user_id", "no_role", "unknown_role"],
)
def test_token_malformed_session_is_invalid_grant_and_does_not_consume_the_login(session_data: Mapping[str, object]):
with _gateway_env() as (client, cache):
device_code = _start_device_flow(client)
_complete_flow(cache, device_code, session_data=session_data)
with patch(_MINT) as mint:
resp = _request_token(client, device_code)
again = _request_token(client, device_code)
assert resp.status_code == 400
assert resp.json()["error"] == "invalid_grant"
assert again.json()["error"] == "invalid_grant"
mint.assert_not_called()
def test_token_mint_failure_leaves_the_login_unconsumed():
with _gateway_env() as (client, cache):
device_code = _start_device_flow(client)
_complete_flow(cache, device_code)
with patch(_MINT, side_effect=RuntimeError("signing key unavailable")), pytest.raises(RuntimeError):
_request_token(client, device_code)
with patch(_MINT, return_value="sk-session"):
retry = _request_token(client, device_code)
assert retry.status_code == 200
assert retry.json()["access_token"] == "sk-session"
def test_token_refresh_mint_failure_leaves_the_login_unconsumed():
oversized_user: Final = {**_COMPLETED_SESSION, "user_id": "u" * 5000}
with _gateway_env() as (client, cache):
device_code = _start_device_flow(client)
_complete_flow(cache, device_code, session_data=oversized_user)
with patch(_MINT, return_value="sk-session"):
failed = _request_token(client, device_code)
_complete_flow(cache, device_code)
with patch(_MINT, return_value="sk-session"):
retry = _request_token(client, device_code)
assert failed.status_code == 500
assert failed.json()["error"] == "server_error"
assert retry.status_code == 200
assert retry.json()["refresh_token"].startswith(SESSION_REFRESH_PREFIX)
def test_token_unknown_team_grants_is_invalid_grant():
with _gateway_env() as (client, cache):
device_code = _start_device_flow(client)
_complete_flow(cache, device_code, session_data={**_COMPLETED_SESSION, "team_details": []})
with patch(_MINT) as mint:
resp = _request_token(client, device_code)
assert resp.status_code == 400
assert resp.json()["error"] == "invalid_grant"
mint.assert_not_called()
def test_token_mints_on_a_replica_that_did_not_start_the_login():
redis: Final = _SharedRedisFake()
_set_cli_sso_flow(login_id=_SHARED_LOGIN_ID, cache=_replica(redis), flow=_completed_flow())
with _gateway_env(cache=_replica(redis)) as (client, _), patch(_MINT, return_value="sk-session") as mint:
resp = _request_token(client, _SHARED_DEVICE_CODE)
assert resp.status_code == 200
assert resp.json()["access_token"] == "sk-session"
assert mint.call_args.kwargs["team_id"] == "team-a"
assert mint.call_args.kwargs["user_info"].user_role == "internal_user"
def test_token_refuses_a_device_code_another_replica_already_claimed():
redis: Final = _SharedRedisFake()
replica_a: Final = _replica(redis)
_set_cli_sso_flow(login_id=_SHARED_LOGIN_ID, cache=replica_a, flow=_completed_flow())
assert asyncio.run(gateway_endpoints._claim_device_code(_SHARED_LOGIN_ID, replica_a)) is True
with _gateway_env(cache=_replica(redis)) as (client, _), patch(_MINT, return_value="sk-session"):
resp = _request_token(client, _SHARED_DEVICE_CODE)
assert resp.status_code == 400
assert resp.json() == {"error": "expired_token"}
def test_token_unknown_device_code_is_expired_token():
with _gateway_env() as (client, _):
resp = _request_token(client, "cli-does-not-exist")
assert resp.status_code == 400
assert resp.json()["error"] == "expired_token"
def test_token_signs_in_without_a_refresh_token_when_session_signing_is_unusable():
"""An ``mcp_session_token_signing`` block whose key reference does not resolve must not turn the device
sign-in that worked before refresh tokens existed into a 500: the login still answers the session token
and consumes the device code, only the refresh token is left out, and the refresh grant stays fail-closed."""
unusable_signing: Final = {"algorithm": "RS256", "kid": "rotated", "private_key": "os.environ/LITELLM_TEST_NO_PEM"}
with _gateway_env(extra_settings={"mcp_session_token_signing": unusable_signing}) as (client, cache):
device_code = _start_device_flow(client)
_complete_flow(cache, device_code)
with patch(_MINT, return_value="sk-litellm-session-token"):
resp = _request_token(client, device_code)
assert resp.status_code == 200
assert resp.headers["cache-control"] == "no-store"
body = resp.json()
assert body["access_token"] == "sk-litellm-session-token"
assert body["token_type"] == "Bearer"
assert body["expires_in"] > 0
assert "refresh_token" not in body
replay = _request_token(client, device_code)
assert replay.status_code == 400
assert replay.json()["error"] == "expired_token"
principal = SessionPrincipal(
user_id="user-123", client_id=gateway_endpoints.CLAUDE_CODE_CLIENT_ID, audience="proxy_api", team_id="team-a"
)
refused = _refresh(client, _foreign_refresh_token(principal))
assert refused.status_code == 500
assert refused.json()["error"] == "server_error"
def test_refresh_grant_mints_a_new_bearer_and_rotates_the_refresh_token():
"""Claude Code refreshes with grant_type and refresh_token alone, no client_id: the gateway
re-mints the session JWT from the live user row for the team the login picked, hands back a
fresh refresh token, and the presented one is dead from then on (rotation). The mint runs
before the single-use claim on every presentation, a replay included, so a transient mint
failure never burns a still-valid token; the chain itself keeps rotating until a replay."""
minter: Final = _Minter()
with _gateway_env(minter=minter) as (client, cache):
signed_in = _signed_in(client, cache)
refreshed = _refresh(client, str(signed_in["refresh_token"]))
assert refreshed.status_code == 200
assert refreshed.headers["cache-control"] == "no-store"
rotated = refreshed.json()
assert minter.calls == (("user-123", "team-a"),)
assert rotated["access_token"] == "sk-cli-user-123"
assert rotated["token_type"] == "Bearer"
assert rotated["expires_in"] == 7200
assert rotated["refresh_token"] != signed_in["refresh_token"]
assert _opened_refresh(rotated["refresh_token"]).team_id == "team-a"
chained = _refresh(client, rotated["refresh_token"])
assert chained.status_code == 200
assert chained.json()["refresh_token"] != rotated["refresh_token"]
replayed = _refresh(client, str(signed_in["refresh_token"]))
assert replayed.status_code == 400
assert replayed.json()["error"] == "invalid_grant"
assert "already used" in replayed.json()["error_description"]
assert "access_token" not in replayed.json()
assert minter.calls == (("user-123", "team-a"),) * 3
def test_refresh_grant_replay_is_refused_on_a_replica_that_did_not_serve_the_rotation():
redis: Final = _SharedRedisFake()
minter: Final = _Minter()
with _gateway_env(claim_cache=_replica(redis), minter=minter) as (client, cache):
refresh_token = str(_signed_in(client, cache)["refresh_token"])
assert _refresh(client, refresh_token).status_code == 200
with _gateway_env(claim_cache=_replica(redis), minter=minter) as (client, _):
replayed = _refresh(client, refresh_token)
assert replayed.status_code == 400
assert replayed.json()["error"] == "invalid_grant"
assert len(minter.calls) == 2
def test_refresh_grant_replay_refuses_only_itself_so_a_second_terminal_recovers_into_a_live_chain():
"""Claude Code renews from its in-memory copy of the credential, so a second terminal on the
same machine presents the refresh token the first one already rotated and then recovers from
the shared credential file. The replay is refused as already used and nothing else happens:
the rotation the first terminal saved keeps renewing, as does its own rotation after it, so
neither terminal is signed out. Only a sign-out ends the chain."""
minter: Final = _Minter()
with _gateway_env(minter=minter) as (client, cache):
first = str(_signed_in(client, cache)["refresh_token"])
rotated = str(_refresh(client, first).json()["refresh_token"])
replayed = _refresh(client, first)
assert replayed.status_code == 400
assert replayed.json()["error"] == "invalid_grant"
assert "already used" in replayed.json()["error_description"]
renewed = _refresh(client, rotated)
assert renewed.status_code == 200
assert "access_token" in renewed.json()
renewed_again = _refresh(client, str(renewed.json()["refresh_token"]))
assert renewed_again.status_code == 200
assert len(minter.calls) == 4
@pytest.mark.parametrize(
"principal",
[
SessionPrincipal(user_id="user-123", client_id="llm_dcrc_other", audience="proxy_api", team_id="team-a"),
SessionPrincipal(user_id="user-123", client_id=gateway_endpoints.CLAUDE_CODE_CLIENT_ID, team_id="team-a"),
],
ids=["dcr_client", "identity_only_audience"],
)
def test_refresh_grant_refuses_a_token_minted_for_another_client_or_audience(principal: SessionPrincipal):
minter: Final = _Minter()
with _gateway_env(minter=minter) as (client, _):
resp = _refresh(client, _foreign_refresh_token(principal))
assert resp.status_code == 400
assert resp.json()["error"] == "invalid_grant"
assert minter.calls == ()
@pytest.mark.parametrize("refresh_token", ["", "not-a-refresh-token"])
def test_refresh_grant_without_a_usable_token_mints_nothing(refresh_token: str):
minter: Final = _Minter()
with _gateway_env(minter=minter) as (client, _):
resp = _refresh(client, refresh_token)
assert resp.status_code == 400
assert resp.json()["error"] == ("invalid_request" if refresh_token == "" else "invalid_grant")
assert minter.calls == ()
@pytest.mark.parametrize(
"failure, status, error",
[
("no_active_key", 400, "invalid_grant"),
("not_a_member", 400, "invalid_grant"),
("unavailable", 503, "temporarily_unavailable"),
],
)
def test_refresh_grant_mint_refusal_leaves_the_refresh_token_usable(
failure: ProxyCredentialMintFailure, status: int, error: str
):
"""A deactivated user, a lost team membership, or a DB outage refuses the renewal without
burning the presented token, so a transient outage never forces a re-login."""
claim_cache: Final = DualCache()
with _gateway_env(claim_cache=claim_cache, minter=_Minter(failure)) as (client, cache):
refresh_token = str(_signed_in(client, cache)["refresh_token"])
refused = _refresh(client, refresh_token)
assert refused.status_code == status
assert refused.json()["error"] == error
assert "refresh_token" not in refused.json()
with _gateway_env(claim_cache=claim_cache, minter=_Minter()) as (client, _):
recovered = _refresh(client, refresh_token)
assert recovered.status_code == 200
def test_revoke_burns_the_refresh_token_and_answers_200_for_every_other_token():
"""Claude Code's /logout posts the session JWT and then the refresh token with a hint, each as
a form, and tolerates nothing but a 2xx: the refresh token is dead afterwards, while the
stateless JWT, an already-dead token, and garbage all answer 200 (RFC 7009 section 2.2)."""
minter: Final = _Minter()
with _gateway_env(minter=minter) as (client, cache):
signed_in = _signed_in(client, cache)
jwt_revoked = _revoke(client, str(signed_in["access_token"]))
assert jwt_revoked.status_code == 200
assert jwt_revoked.json() == {}
assert jwt_revoked.headers["cache-control"] == "no-store"
assert _refresh(client, str(signed_in["refresh_token"])).status_code == 200
rotated = _refresh(client, str(signed_in["refresh_token"]))
assert rotated.status_code == 400
fresh = str(_signed_in(client, cache)["refresh_token"])
revoked = _revoke(client, fresh, hint="refresh_token")
assert revoked.status_code == 200
refused = _refresh(client, fresh)
assert refused.status_code == 400
assert refused.json()["error"] == "invalid_grant"
assert _revoke(client, fresh, hint="refresh_token").status_code == 200
assert _revoke(client, "nonsense").status_code == 200
missing = client.post(_REVOKE_URL, data={"token_type_hint": "refresh_token"})
assert missing.status_code == 400
assert missing.json()["error"] == "invalid_request"
assert len(minter.calls) == 2
def test_revoke_from_another_client_leaves_the_refresh_token_usable():
foreign: Final = SessionPrincipal(
user_id="user-123", client_id="llm_dcrc_other", audience="proxy_api", team_id="team-a"
)
with _gateway_env() as (client, cache):
own = str(_signed_in(client, cache)["refresh_token"])
assert _revoke(client, _foreign_refresh_token(foreign)).status_code == 200
assert _refresh(client, own).status_code == 200
def test_revoke_ends_the_rotation_chain_of_the_presented_token():
"""``/logout`` posts the refresh token Claude Code holds. When a copy of that token already
rotated it, the copy's live descendant must die with the sign-out (RFC 7009 section 2.1 revokes
the grant, not one token), instead of the single-use record only confirming what the copy did."""
with _gateway_env(minter=_Minter()) as (client, cache):
stored = str(_signed_in(client, cache)["refresh_token"])
copied_and_rotated = str(_refresh(client, stored).json()["refresh_token"])
assert _revoke(client, stored).status_code == 200
refused = _refresh(client, copied_and_rotated)
assert refused.status_code == 400
assert refused.json()["error"] == "invalid_grant"
assert "revoked" in refused.json()["error_description"]
def test_revoke_on_one_replica_ends_the_chain_on_another_through_a_namespaced_redis():
"""The sign-out's revocation marker is written through the cache wrapper, which prefixes the key
with the configured ``redis_namespace``; the replica serving the next renewal must look for the
marker under that same prefix, or the revoked chain keeps renewing everywhere but where it signed
out."""
redis: Final = _SharedRedisFake()
minter: Final = _Minter()
with _gateway_env(claim_cache=_replica(redis), minter=minter) as (client, cache):
stored = str(_signed_in(client, cache)["refresh_token"])
copied_and_rotated = str(_refresh(client, stored).json()["refresh_token"])
assert _revoke(client, stored).status_code == 200
with _gateway_env(claim_cache=_replica(redis), minter=minter) as (client, _):
refused = _refresh(client, copied_and_rotated)
assert refused.status_code == 400
assert refused.json()["error"] == "invalid_grant"
assert "revoked" in refused.json()["error_description"]
assert len(minter.calls) == 1
def test_revoke_404_when_gateway_disabled():
with _gateway_env(enabled=False) as (client, _):
resp = _revoke(client, "anything")
assert resp.status_code == 404
def test_unsupported_grant_type():
with _gateway_env() as (client, _):
resp = client.post("/claude_code_gateway/oauth/token", data={"grant_type": "password"})
assert resp.status_code == 400
assert resp.json()["error"] == "unsupported_grant_type"
def test_managed_settings_404_when_unset():
with _gateway_env() as (client, _):
resp = client.get("/claude_code_gateway/managed/settings")
assert resp.status_code == 404
def test_managed_settings_returns_client_envelope_and_304_on_cached_checksum():
settings = {"permissions": {"defaultMode": "acceptEdits"}, "env": {"FOO": "bar"}}
with _gateway_env(managed_settings=settings) as (client, _):
resp = client.get("/claude_code_gateway/managed/settings")
assert resp.status_code == 200
body = resp.json()
assert body["settings"] == settings
checksum = body["checksum"]
assert checksum.startswith("sha256:")
assert body["uuid"] == checksum
assert resp.headers["ETag"] == f'"{checksum}"'
not_modified = client.get("/claude_code_gateway/managed/settings", headers={"If-None-Match": f'"{checksum}"'})
assert not_modified.status_code == 304
assert not_modified.headers["ETag"] == f'"{checksum}"'
stale = client.get("/claude_code_gateway/managed/settings", headers={"If-None-Match": '"sha256:stale"'})
assert stale.status_code == 200
assert stale.json()["checksum"] == checksum
def test_managed_settings_checksum_tracks_policy_content():
with _gateway_env(managed_settings={"env": {"FOO": "bar"}}) as (client, _):
first = client.get("/claude_code_gateway/managed/settings").json()["checksum"]
with _gateway_env(managed_settings={"env": {"FOO": "baz"}}) as (client, _):
second = client.get("/claude_code_gateway/managed/settings").json()["checksum"]
assert first != second
def test_managed_settings_404_when_gateway_disabled():
with _gateway_env(enabled=False, managed_settings={"env": {}}) as (client, _):
resp = client.get("/claude_code_gateway/managed/settings")
assert resp.status_code == 404
@pytest.mark.parametrize("signal", ["metrics", "logs", "traces"])
def test_otlp_endpoints_accept_and_return_200(signal: str):
with _gateway_env() as (client, _):
resp = client.post(f"/claude_code_gateway/v1/{signal}", content=b"\x00\x01binary-otlp")
assert resp.status_code == 200
@pytest.mark.parametrize("signal", ["metrics", "logs", "traces"])
def test_otlp_endpoints_404_when_disabled(signal: str):
with _gateway_env(enabled=False) as (client, _):
resp = client.post(f"/claude_code_gateway/v1/{signal}", content=b"payload")
assert resp.status_code == 404
@pytest.mark.parametrize("signal", ["metrics", "logs", "traces"])
def test_otlp_protobuf_body_is_accepted_through_real_auth(signal: str):
with _gateway_env(real_auth=True) as (client, _):
resp = client.post(
f"/claude_code_gateway/v1/{signal}",
content=_PROTOBUF_BODY,
headers={"Authorization": f"Bearer {_MASTER_KEY}", "Content-Type": "application/x-protobuf"},
)
assert resp.status_code == 200
def test_otlp_without_a_bearer_is_rejected_by_real_auth():
with _gateway_env(real_auth=True) as (client, _), pytest.raises(ProxyException) as exc_info:
client.post(
"/claude_code_gateway/v1/metrics",
content=_PROTOBUF_BODY,
headers={"Content-Type": "application/x-protobuf"},
)
assert exc_info.value.code == "401"
def test_messages_gated_by_enable_flag():
with _gateway_env(enabled=False) as (client, _):
resp = client.post("/claude_code_gateway/v1/messages", json={"model": "claude-sonnet-4-5", "messages": []})
assert resp.status_code == 404