mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-02 07:13:02 +00:00
* 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>
815 lines
36 KiB
Python
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
|