Skip to content

Commit 857a752

Browse files
Kowserclaude
andcommitted
Consolidate agent API auth onto Configuration token machinery
AgentApiClient now stores minted tokens via Configuration.update_token() and reuses them until auth_token_ttl_msec elapses — the same rule and the same cache as every generated client built from that Configuration. This replaces the private JWT-exp mint cache (and its opaque-token re-mint-per-request special case: with fixed-TTL renewal a stale token can never be served indefinitely, so the caching test now pins the TTL behavior instead). conductor.ai.agents._internal.token_utils is untouched — the framework adapters' event-push paths still use it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 0418c23 commit 857a752

2 files changed

Lines changed: 28 additions & 37 deletions

File tree

src/conductor/client/ai/agent_api_client.py

Lines changed: 15 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,6 @@
2323

2424
from __future__ import annotations
2525

26-
import base64
2726
import json
2827
import logging
2928
import time
@@ -42,23 +41,6 @@
4241
_SSE_NO_EVENT_TIMEOUT = 15 # seconds to wait for first real event before fallback
4342

4443

45-
def _decode_jwt_exp(token: str) -> float:
46-
"""Best-effort decode of a JWT's ``exp`` claim (unix seconds).
47-
48-
Returns 0.0 for opaque tokens, malformed JWTs, or tokens without ``exp`` —
49-
callers treat 0 as "expiry unknown, re-mint rather than cache".
50-
"""
51-
try:
52-
parts = token.split(".")
53-
if len(parts) < 2:
54-
return 0.0
55-
seg = parts[1] + "=" * (-len(parts[1]) % 4)
56-
payload = json.loads(base64.urlsafe_b64decode(seg))
57-
return float(payload.get("exp", 0) or 0)
58-
except Exception:
59-
return 0.0
60-
61-
6244
class SSEUnavailableError(Exception):
6345
"""Raised when the server doesn't support SSE streaming."""
6446

@@ -71,7 +53,9 @@ class AgentApiClient:
7153
1. an explicit ``token`` passed to the constructor — sent as-is;
7254
2. otherwise a JWT minted via ``POST {host}/token`` from the
7355
``Configuration``'s ``authentication_settings`` (requires both key id
74-
and secret), cached until ~expiry;
56+
and secret), stored via ``Configuration.update_token()`` and reused
57+
until the configuration's token TTL elapses — the same cache every
58+
generated client built from that ``Configuration`` shares;
7559
3. no credentials → no header (anonymous / security-disabled servers).
7660
"""
7761

@@ -84,8 +68,6 @@ def __init__(
8468
self._server_url = (configuration.host or "").rstrip("/")
8569
self._token_static = token or ""
8670
self._client: Optional[httpx.AsyncClient] = None
87-
self._token: str = ""
88-
self._token_exp: float = 0.0
8971

9072
def _mint_credentials(self) -> Optional[tuple]:
9173
"""(key_id, key_secret) when the configuration can mint, else None."""
@@ -95,12 +77,16 @@ def _mint_credentials(self) -> Optional[tuple]:
9577
return settings.key_id, settings.key_secret
9678

9779
def _cached_token_header(self) -> Optional[Dict[str, str]]:
98-
# Reuse the cached token only if it has a decodable expiry and isn't near
99-
# it. A token with no decodable exp (_token_exp == 0.0) is NOT cached —
100-
# re-mint it (matches the C# SDK; avoids serving a stale token forever).
101-
if self._token and self._token_exp != 0.0 and time.time() < self._token_exp - 30:
102-
return {"X-Authorization": self._token}
103-
return None
80+
# Same TTL rule as the generated ApiClient's auth headers: a token
81+
# stored on the Configuration is reused until auth_token_ttl_msec
82+
# elapses, then re-minted.
83+
token = self._configuration.AUTH_TOKEN
84+
if not token:
85+
return None
86+
now = round(time.time() * 1000)
87+
if now - self._configuration.token_update_time > self._configuration.auth_token_ttl_msec:
88+
return None
89+
return {"X-Authorization": token}
10490

10591
async def _auth_headers(self) -> Dict[str, str]:
10692
"""``X-Authorization`` header for secured hosts (orkes); {} when anonymous."""
@@ -126,8 +112,7 @@ async def _auth_headers(self) -> Dict[str, str]:
126112
return {}
127113
if not token:
128114
return {}
129-
self._token = token
130-
self._token_exp = _decode_jwt_exp(token)
115+
self._configuration.update_token(token)
131116
return {"X-Authorization": token}
132117

133118
def _sync_headers(self) -> Dict[str, str]:
@@ -156,8 +141,7 @@ def _sync_headers(self) -> Dict[str, str]:
156141
return {}
157142
if not token:
158143
return {}
159-
self._token = token
160-
self._token_exp = _decode_jwt_exp(token)
144+
self._configuration.update_token(token)
161145
return {"X-Authorization": token}
162146

163147
async def _get_client(self) -> httpx.AsyncClient:

tests/unit/ai/test_http_client.py

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -210,8 +210,8 @@ def b64url(d: dict) -> str:
210210

211211
@pytest.mark.asyncio
212212
async def test_auth_key_mints_token_and_caches_it():
213-
"""A minted token WITH a decodable (future) exp is cached across requests —
214-
minted exactly once."""
213+
"""A minted token is stored on the Configuration and reused across
214+
requests until its TTL elapses — minted exactly once."""
215215
token_calls = {"count": 0}
216216
jwt = _jwt_with_exp(4102444800) # ~2100 → far future
217217

@@ -232,9 +232,11 @@ async def handler(request: httpx.Request) -> httpx.Response:
232232

233233

234234
@pytest.mark.asyncio
235-
async def test_auth_key_opaque_token_is_reminted_not_cached_forever():
236-
"""A token with no decodable exp must NOT be cached indefinitely — it is
237-
re-minted on each request (matches the C# SDK; avoids serving a stale token)."""
235+
async def test_opaque_token_cached_for_configuration_ttl():
236+
"""An opaque (non-JWT) token is cached like any other: stored on the
237+
Configuration and reused until auth_token_ttl_min elapses — the same
238+
fixed-TTL renewal rule every generated client uses, so a stale token can
239+
never be served indefinitely."""
238240
token_calls = {"count": 0}
239241

240242
async def handler(request: httpx.Request) -> httpx.Response:
@@ -246,7 +248,12 @@ async def handler(request: httpx.Request) -> httpx.Response:
246248
client = _make_client(handler, auth_key="key1", auth_secret="secret1")
247249
await client.start_agent({"prompt": "one"})
248250
await client.start_agent({"prompt": "two"})
249-
assert token_calls["count"] == 2 # no exp → not cached → minted each call
251+
assert token_calls["count"] == 1 # cached on the Configuration for its TTL
252+
253+
# Force the TTL to lapse → the next request re-mints.
254+
client._api._configuration.token_update_time = 0
255+
await client.start_agent({"prompt": "three"})
256+
assert token_calls["count"] == 2
250257
await client.close()
251258

252259

0 commit comments

Comments
 (0)