feat: [Option A] configurable runtime-token header (server + SDK) [Need Discussion] #252
feat: [Option A] configurable runtime-token header (server + SDK) [Need Discussion] #252josjeon wants to merge 2 commits into
Conversation
79e7a19 to
28f58f7
Compare
Let the runtime-token JWT verifier read its token from a configurable request header (env AGENT_CONTROL_RUNTIME_TOKEN_HEADER), defaulting to Authorization so existing deployments are unaffected. Why: when Agent Control runs behind an API gateway that reserves the Authorization header for its own downstream identity JWT, the gateway overwrites the runtime token on the hot evaluation path and the verifier fails. Pointing the verifier at a dedicated header (e.g. X-Agent-Control-Runtime-Token) lets the two tokens coexist. - LocalJwtVerifyProvider gains a header_name param. On Authorization the Bearer scheme prefix stays required (back-compat); on a dedicated header the raw token is accepted (Bearer optional). The token is still signature-verified, scope-checked, and target-bound after extraction, so the header choice cannot bypass verification. - config.py resolves the header via _resolve_runtime_token_header(); a whitespace-only env value falls back to the default. - Tests: custom-header raw/Bearer acceptance, case-insensitive lookup, no fallback to Authorization, whitespace/blank handling, and app-level E2E through /api/v1/evaluation (gateway JWT on Authorization coexists with the runtime token on a dedicated header). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Mirror the server change in the Python SDK so Option A works end to end. AgentControlClient gains a runtime_token_header param (same env, default Authorization). The Bearer prefix is applied only on Authorization; a dedicated header carries the raw token. - _merge_runtime_headers sends the token on the configured header; _format_runtime_token owns the Bearer-prefix rule. - The runtime token stays the sole credential on an evaluation request: _AgentControlAuth suppresses X-API-Key when a runtime token is present on its dedicated header. The auto-fallback path (no token minted) and the token-exchange POST both still carry X-API-Key, so nothing becomes unauthenticated. - Blank-header handling matches the server: a whitespace-only env value falls back to the default; an explicit blank param is a hard error. - Tests cover custom-header send (raw, no Bearer; Authorization and X-API-Key both absent), env override, defaults, blank/whitespace handling, custom-header auto-fallback keeping X-API-Key, and exchange auth in custom-header mode. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
28f58f7 to
48b69ac
Compare
| if ( | ||
| self._api_key | ||
| and "Authorization" not in request.headers | ||
| and not runtime_token_on_dedicated_header |
There was a problem hiding this comment.
Could we keep the configured request credential when the runtime token is sent on a dedicated header? In a two-hop setup, the outer credential authenticates the gateway while the runtime token authenticates Agent Control, so both are required on the evaluation request. Please update the custom-header test to assert that both headers are sent.
| api_key: str | None = None, | ||
| api_key_header: str | None = None, | ||
| runtime_auth_mode: RuntimeAuthMode | str | None = None, | ||
| runtime_token_header: str | None = None, |
There was a problem hiding this comment.
Could we preserve the existing positional constructor order? Inserting runtime_token_header before runtime_token_cache changes how existing positional calls bind and can fail when the cache is treated as a header. Making the new option keyword-only after the existing parameters would avoid the compatibility break.
| request auth when the exchange endpoint is unavailable. ``jwt`` | ||
| requires a successful exchange. ``api_key`` and ``none`` keep | ||
| evaluation requests on the normal request-auth path. | ||
| runtime_token_header: HTTP header name to send the runtime token |
There was a problem hiding this comment.
Could we expose runtime_token_header through agent_control.init() as well? The high-level flow creates clients internally, so constructor-only configuration otherwise requires setting a process-wide environment variable.
| response = client.post( | ||
| "/api/v1/evaluation", | ||
| headers={ | ||
| # Simulates the O11y gateway's downstream identity JWT. |
There was a problem hiding this comment.
Small OSS wording suggestion: could we refer to this as an upstream gateway rather than an O11y gateway, to keep the committed test generic?
TL;DR
The runtime-token JWT can now travel on a configurable header instead of always
Authorization. Default is unchanged (Authorization), so existing deployments are unaffected. Set one env var on both the server and the SDK to move it to a dedicated header.The problem
When Agent Control sits behind an API gateway, the gateway often puts its own identity JWT on
Authorization. That overwrites our runtime token on the hot evaluation path, and the verifier rejects the request.Two options for resolving the collision
The collision can be fixed on either side of the boundary. This PR is Option A (agent-control side). A parallel spike sketches Option B (gateway side) for comparison (api-gateway !1351).
AuthenticationFilterX-Agent-Control-Runtime-Token); gatewayAuthorizationis left aloneAuthorizationon an opt-in, path-scoped allow-listOption A keeps the gateway's auth path entirely out of scope — no shared-infrastructure change, no platform/compliance review — which is why it's the front-runner. Option B is captured separately so the "fix it in the gateway" path is a concrete artifact to compare against rather than a hypothetical.
The fix
One knob, wired on both sides:
AGENT_CONTROL_RUNTIME_TOKEN_HEADERenvruntime_token_header=paramRules for the header value:
Authorization(default):Bearerprefix required — fully back-compatible.Bearerprefix optional (nothing else competes for that header).Authorization(server and SDK behave identically). An explicit blank param on the SDK is a hard error.Security: this only chooses which header the opaque token is read from. After extraction the token is still signature-verified, scope-checked, and target-bound (
target_type+target_id) exactly as before — the header choice cannot bypass verification. A runtime token presented onAuthorizationis not accepted when the verifier is configured for a dedicated header (no silent fallback).Single credential: in
jwtmode the runtime token is the only credential on an evaluation request. The SDK suppressesX-API-Keywhen a runtime token is present, whether it ridesAuthorizationor a dedicated header. The auto-fallback path (exchange unavailable, no token minted) and the token-exchange call itself still sendX-API-Key, so nothing silently becomes unauthenticated.What changed
Server
auth_framework/providers/local_jwt.py—LocalJwtVerifyProvidergains aheader_nameparam;_extract_bearer_tokenapplies the Bearer rule above.DEFAULT_RUNTIME_TOKEN_HEADERis the single source of truth.auth_framework/config.py— new env resolved by_resolve_runtime_token_header().SDK (
sdks/python)client.py—AgentControlClientgainsruntime_token_header;_merge_runtime_headerssends on the configured header;_format_runtime_tokenowns the Bearer-prefix rule;_AgentControlAuthsuppressesX-API-Keywhen a runtime token is present.Commits
feat(auth): make the runtime-token header configurable— server verifier + config + testsfeat(sdk): send the runtime token on the configurable header— Python SDK + testsTests
/api/v1/evaluationproving a gateway JWT onAuthorizationand the runtime token on a dedicated header coexist.Bearer;AuthorizationandX-API-Keyboth absent), env override, defaults, blank/whitespace handling, custom-header auto-fallback keepingX-API-Key, and exchange auth in custom-header mode.Test plan
ruff check+mypyclean on all changed filesAuthorization) path is unchanged for existing deploymentsAuthorization" premise; everything above is unit/integration scope. Tracked separately.🤖 Generated with Claude Code