Skip to content

Commit 6e31491

Browse files
jjeonsplunkclaude
andcommitted
feat(auth): make the runtime-token header configurable
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>
1 parent 8154980 commit 6e31491

4 files changed

Lines changed: 413 additions & 17 deletions

File tree

server/src/agent_control_server/auth_framework/config.py

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,10 @@
2121
The ``runtime.token_exchange`` operation continues to flow through
2222
the default authorizer because the exchange itself is shaped like a
2323
management call (forward credential, get grant).
24+
``AGENT_CONTROL_RUNTIME_TOKEN_HEADER`` (default ``Authorization``)
25+
selects which request header the ``jwt`` verifier reads the runtime
26+
token from, so the server can run behind a gateway that reserves
27+
``Authorization`` for its own downstream identity JWT.
2428
"""
2529

2630
from __future__ import annotations
@@ -39,6 +43,7 @@
3943
NoAuthProvider,
4044
)
4145
from .providers.http_upstream import HttpUpstreamConfig
46+
from .providers.local_jwt import DEFAULT_RUNTIME_TOKEN_HEADER
4247

4348
_logger = get_logger(__name__)
4449

@@ -60,6 +65,7 @@
6065
_RUNTIME_MODE_ENV = "AGENT_CONTROL_RUNTIME_AUTH_MODE"
6166
_RUNTIME_TOKEN_SECRET_ENV = "AGENT_CONTROL_RUNTIME_TOKEN_SECRET"
6267
_RUNTIME_TOKEN_TTL_ENV = "AGENT_CONTROL_RUNTIME_TOKEN_TTL_SECONDS"
68+
_RUNTIME_TOKEN_HEADER_ENV = "AGENT_CONTROL_RUNTIME_TOKEN_HEADER"
6369
_DEFAULT_RUNTIME_TOKEN_TTL_SECONDS = 300
6470
# HS256 needs at least 256 bits (32 bytes) of secret material to be safe
6571
# against brute force; reject anything shorter so production deployments
@@ -378,12 +384,30 @@ def _build_runtime_provider(
378384
if mode == "jwt":
379385
if config is None:
380386
raise RuntimeError(f"{_RUNTIME_MODE_ENV}=jwt but runtime auth config is missing.")
381-
return LocalJwtVerifyProvider(secret=config.secret)
387+
return LocalJwtVerifyProvider(
388+
secret=config.secret,
389+
header_name=_resolve_runtime_token_header(),
390+
)
382391
raise RuntimeError(
383392
f"Unknown runtime auth mode {mode!r}; expected 'none', 'api_key', or 'jwt'."
384393
)
385394

386395

396+
def _resolve_runtime_token_header() -> str:
397+
"""Return the header the runtime JWT verifier reads the Bearer token from.
398+
399+
Defaults to ``Authorization``. Deployments behind a gateway that
400+
overwrites ``Authorization`` with its own downstream identity JWT can
401+
point the verifier at a dedicated header (e.g.
402+
``X-Agent-Control-Runtime-Token``) so the two tokens no longer
403+
collide on the hot evaluation path.
404+
"""
405+
raw = os.environ.get(_RUNTIME_TOKEN_HEADER_ENV)
406+
if raw is None or not raw.strip():
407+
return DEFAULT_RUNTIME_TOKEN_HEADER
408+
return raw.strip()
409+
410+
387411
def _load_runtime_auth_config(*, require_secret: bool = False) -> RuntimeAuthConfig | None:
388412
"""Parse, validate, and return the runtime-auth config from env.
389413

server/src/agent_control_server/auth_framework/providers/local_jwt.py

Lines changed: 45 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,17 @@
11
"""Authorizer that verifies a locally-minted runtime token.
22
3-
Wired to the runtime resolution path. Reads a Bearer token from the
4-
``Authorization`` header, verifies the signature against the runtime
5-
secret, checks the token's scope covers the requested operation, and
6-
returns a :class:`Principal` carrying the bound target. When a
7-
``context_builder`` on the dependency must surface matching
8-
``target_type`` / ``target_id`` values for target-bound tokens.
3+
Wired to the runtime resolution path. Reads a Bearer token from a
4+
configurable header (``Authorization`` by default), verifies the
5+
signature against the runtime secret, checks the token's scope covers
6+
the requested operation, and returns a :class:`Principal` carrying the
7+
bound target. When a ``context_builder`` on the dependency must surface
8+
matching ``target_type`` / ``target_id`` values for target-bound tokens.
9+
10+
The header the runtime token arrives on is configurable so the server
11+
can sit behind a gateway that reserves ``Authorization`` for its own
12+
downstream identity JWT. When the token rides a dedicated header (e.g.
13+
``X-Agent-Control-Runtime-Token``), the ``Bearer`` scheme prefix is
14+
optional; on ``Authorization`` it stays required for back-compat.
915
"""
1016

1117
from __future__ import annotations
@@ -19,14 +25,28 @@
1925
from ..core import Operation, Principal, RequestAuthorizer
2026
from ..runtime_token import RuntimeTokenError, verify_runtime_token
2127

28+
DEFAULT_RUNTIME_TOKEN_HEADER = "Authorization"
29+
2230

2331
class LocalJwtVerifyProvider(RequestAuthorizer):
2432
"""Verifies a runtime Bearer token and emits a target-bound :class:`Principal`."""
2533

26-
def __init__(self, *, secret: str) -> None:
34+
def __init__(
35+
self,
36+
*,
37+
secret: str,
38+
header_name: str = DEFAULT_RUNTIME_TOKEN_HEADER,
39+
) -> None:
2740
if not secret:
2841
raise ValueError("LocalJwtVerifyProvider requires a non-empty secret.")
42+
if not header_name or not header_name.strip():
43+
raise ValueError("LocalJwtVerifyProvider requires a non-empty header_name.")
2944
self._secret = secret
45+
self._header_name = header_name.strip()
46+
# The Bearer scheme is mandatory on Authorization (existing SDK
47+
# contract) but optional on a dedicated runtime-token header,
48+
# where the raw token is the whole value.
49+
self._require_bearer = self._header_name.lower() == "authorization"
3050

3151
async def authorize(
3252
self,
@@ -79,18 +99,29 @@ async def authorize(
7999
)
80100

81101
def _extract_bearer_token(self, request: Request) -> str:
82-
header = request.headers.get("Authorization")
102+
header = request.headers.get(self._header_name)
83103
if not header:
84104
raise AuthenticationError(
85105
error_code=ErrorCode.AUTH_MISSING_KEY,
86-
detail="Missing Authorization header.",
106+
detail=f"Missing {self._header_name} header.",
87107
hint="Present a Bearer runtime token.",
88108
)
89-
scheme, _, value = header.partition(" ")
90-
if scheme.lower() != "bearer" or not value:
109+
scheme, sep, value = header.partition(" ")
110+
if sep and scheme.lower() == "bearer":
111+
token = value.strip()
112+
elif self._require_bearer:
91113
raise AuthenticationError(
92114
error_code=ErrorCode.AUTH_MISSING_KEY,
93-
detail="Authorization header must be a Bearer token.",
94-
hint="Format: ``Authorization: Bearer <token>``.",
115+
detail=f"{self._header_name} header must be a Bearer token.",
116+
hint=f"Format: ``{self._header_name}: Bearer <token>``.",
117+
)
118+
else:
119+
# Dedicated runtime-token header: accept the raw token.
120+
token = header.strip()
121+
if not token:
122+
raise AuthenticationError(
123+
error_code=ErrorCode.AUTH_MISSING_KEY,
124+
detail=f"{self._header_name} header is empty.",
125+
hint="Present a Bearer runtime token.",
95126
)
96-
return value.strip()
127+
return token

0 commit comments

Comments
 (0)