From 5b3bdc8d711fbe353f4b6c9ca65593dacaab9f01 Mon Sep 17 00:00:00 2001 From: declan-scale Date: Tue, 11 Aug 2026 18:11:49 +0000 Subject: [PATCH] feat(webhooks): accept `Authorization: AgentKey` on forward routes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Some webhook providers can only send a shared secret through the standard `Authorization` header and cannot set a custom header such as `x-agent-api-key`. Today those requests fall through to SGP bearer verification and get rejected with 401, forcing customers to run a proxy that rewrites the header. Add a dedicated `AgentKey` `Authorization` scheme on `/agents/forward/...` that validates the presented value as an external agent API key scoped to the agent named in the URL. Invalid, revoked, and wrong-agent keys return 401 identically to the existing `x-agent-api-key` path — the underlying DB lookup is now shared between the two transports. `Bearer` and every other scheme continue to fall through to the SGP auth gateway, so existing flows are untouched. Docs updated with an example curl for the new header. --- .../docs/docs/development_guides/webhooks.md | 42 ++++- .../use_cases/agent_api_keys_use_case.py | 48 +++++- .../test_agents_api_keys_use_case.py | 161 +++++++++++++++++- 3 files changed, 243 insertions(+), 8 deletions(-) diff --git a/agentex/docs/docs/development_guides/webhooks.md b/agentex/docs/docs/development_guides/webhooks.md index 2ee50ca7..a80a6c98 100644 --- a/agentex/docs/docs/development_guides/webhooks.md +++ b/agentex/docs/docs/development_guides/webhooks.md @@ -34,14 +34,14 @@ A common pattern is to receive the webhook, process the custom JSON payload from async def handle_code_changes(request: Request) -> JSONResponse: # Parse the webhook payload payload = await request.json() - + # Extract relevant information pr_number = payload.get("pull_request", {}).get("number") commit_message = payload.get("commits", [{}])[0].get("message") - + # Find or create the appropriate task for this PR task_id = await get_or_create_task_for_pr(pr_number) - + # Route to your agent via ACP by sending an event await adk.events.send_event( task_id=task_id, @@ -51,7 +51,7 @@ async def handle_code_changes(request: Request) -> JSONResponse: content=f"Code change detected: {commit_message}" ) ) - + return JSONResponse(content={"message": "Success"}) ``` @@ -72,6 +72,26 @@ The forward routes described above rely on Agent API Keys to authenticate the re For event providers that allow custom headers to be included with their requests, please create a new key for the agent (see below) and include its value as the `x-agent-api-key` header. At request time, the backend will verify both that this key still exists and is associated with the agent that is handling the request before passing it through to the handler implementation. +Some providers only permit the standard `Authorization` header for a shared secret and do not allow custom headers such as `x-agent-api-key`. +For these providers, the forward ingress also accepts the agent API key under a dedicated `Authorization` scheme: + +``` +Authorization: AgentKey +``` + +The `AgentKey` scheme is intentionally distinct from `Bearer`, so existing `Authorization: Bearer ...` requests continue to authenticate through the standard auth gateway. +The value is validated exactly like `x-agent-api-key`: the key must exist and belong to the agent named in the URL, and invalid, revoked, or wrong-agent keys are rejected with `401 Unauthorized`. +Use the same key that you would set as `x-agent-api-key` — the `general` API key create flow described below produces a value suitable for either header. + +For example, to send a webhook to an agent using the `Authorization` header: + +```bash +curl -X POST $HOST/agents/forward/name// \ + -H "Content-Type: application/json" \ + -H "Authorization: AgentKey " \ + -d '{"event": "..."}' +``` + Not everyone supports including custom headers when sending webhook events - a common alternative is a configured secret which is used to sign these requests which can then be confirmed by the backend. The way the signature is calculated differs from product to product - we have implemented this for GitHub and Slack as the two most common use cases, but please let us know if you have another use case which require custom logic. @@ -81,7 +101,9 @@ For now, API key management needs to be done by sending the right requests - cUR For configuring API keys in the local environment, it is sufficient to access `localhost:5003` without any authentication. ### Viewing API Keys + You can always check to see all of the existing API keys that have been configured for a given agent by running the following command: + ```bash curl http://localhost:5003/agent_api_keys?agent_name=AGENT_NAME ``` @@ -92,6 +114,7 @@ The examples below will just use $HOST instead of the local or remote version - #### General To create an API key that will be included as the `x-agent-api-key` header, pick a memorable name for it (these will be unique per agent) and run the following command: + ```bash curl -X POST $HOST/agent_api_keys \ -H "Content-Type: application/json" \ @@ -107,6 +130,7 @@ You will not be able to retrieve the API key value after you've created the API #### GitHub To store a GitHub secret that will be used to verify incoming webhook requests, grab the full name of the repository you will be configuring the webhook for and run the following command: + ```bash # Replace with your repository name instead of scaleapi/scale-agentex! curl -X POST $HOST/agent_api_keys \ @@ -117,8 +141,10 @@ curl -X POST $HOST/agent_api_keys \ "api_key_type": "github" }' ``` + The backend will generate an API key value for you, store this in the database, and return the value for you in the response so that you can set it in GitHub as the webhook secret. If you already have a webhook configured with a secret and you don't want to rotate it, you can also pass it as follows: + ```bash # Replace with your repository name instead of scaleapi/scale-agentex curl -X POST $HOST/agent_api_keys \ @@ -132,11 +158,14 @@ curl -X POST $HOST/agent_api_keys \ ``` #### Slack + To store a Slack secret that will be used to verify incoming webhook requests, you will need: + - the App ID you will be configuring the webhook for (you can find this under Your Apps at https://api.slack.com/apps/) - the Signing Secret, available in the app admin panel under Basic Info If these are, for example, `A123ABC456` and `abcdefg12345` respectively, then run the following command: + ```bash curl -X POST $HOST/agent_api_keys \ -H "Content-Type: application/json" \ @@ -149,11 +178,14 @@ curl -X POST $HOST/agent_api_keys \ ``` ### Deleting API Keys + If you'd like to remove an existing API key for whatever reason, first grab its ID by listing the keys for the appropriate agent as above. Once you have it, just send the following command: + ```bash curl -X DELETE $HOST/agent_api_keys/API_KEY_ID ``` + and the key will be removed, allowing you to make a new one with the same name if desired. ## Enterprise @@ -166,4 +198,4 @@ As an example, to view the API keys in a deployed environment with your SGP API curl https://agentex.sgp.scale.com/agent_api_keys?agent_name=AGENT_NAME \ -H "x-api-key: " \ -H "x-selected-account-id: " -``` \ No newline at end of file +``` diff --git a/agentex/src/domain/use_cases/agent_api_keys_use_case.py b/agentex/src/domain/use_cases/agent_api_keys_use_case.py index 8c361de6..046df6ae 100644 --- a/agentex/src/domain/use_cases/agent_api_keys_use_case.py +++ b/agentex/src/domain/use_cases/agent_api_keys_use_case.py @@ -35,6 +35,28 @@ logger = make_logger(__name__) +# Dedicated Authorization scheme so providers that can only send the standard +# ``Authorization`` header (e.g. Ironclad) can present an agent API key +# without needing an intermediary proxy that rewrites headers. Distinct from +# ``Bearer`` so SGP bearer authentication remains unambiguous. +AGENT_KEY_AUTHORIZATION_SCHEME = "AgentKey" + + +def extract_agent_key_from_authorization(header_value: str | None) -> str | None: + """Return the credentials from an ``Authorization: AgentKey `` header. + + Returns ``None`` when the header is absent, uses a different scheme, or + carries no credentials. Scheme matching is case-insensitive per + RFC 9110 § 11.6.1. + """ + if not header_value: + return None + scheme, _, credentials = header_value.strip().partition(" ") + if scheme.lower() != AGENT_KEY_AUTHORIZATION_SCHEME.lower(): + return None + credentials = credentials.strip() + return credentials or None + class AgentAPIKeysUseCase: def __init__( @@ -339,6 +361,19 @@ async def validate_agent_identity_headers( if request.headers.get("X-Agent-API-Key"): return await self.validate_agent_api_key(agent_id, request) + # ``Authorization: AgentKey `` must be checked before the SGP + # auth-gateway fallback so a valid agent-key request from a provider + # that only supports the standard ``Authorization`` header is not + # misrouted to bearer verification. ``Authorization: Bearer ...`` and + # any other scheme return ``None`` here and fall through unchanged. + authorization_agent_key = extract_agent_key_from_authorization( + request.headers.get("Authorization") + ) + if authorization_agent_key is not None: + return await self._verify_external_agent_api_key( + agent_id, authorization_agent_key + ) + if request.headers.get("x-hub-signature-256"): # This is a GitHub webhook, use the API key from the ACP return await self.validate_github_delivery_webhook( @@ -373,16 +408,25 @@ async def validate_agent_api_key( """ agent_api_key = request.headers.get("X-Agent-API-Key") assert agent_api_key, "Missing X-Agent-API-Key header." + return await self._verify_external_agent_api_key(agent_id, agent_api_key) + + async def _verify_external_agent_api_key( + self, agent_id: str, api_key: str + ) -> JSONResponse | None: + """Verify an external agent API key belongs to ``agent_id``. + Shared by the ``X-Agent-API-Key`` header path and the + ``Authorization: AgentKey`` scheme path so invalid, revoked, and + wrong-agent keys produce identical semantics regardless of transport. + """ api_key_entity = await self.agent_api_key_repo.get_external_by_agent_id_and_key( - agent_id=agent_id, api_key=agent_api_key + agent_id=agent_id, api_key=api_key ) if not api_key_entity: return JSONResponse( status_code=401, content={"detail": f"Invalid API key for agent ID {agent_id}."}, ) - return None async def validate_github_delivery_webhook( diff --git a/agentex/tests/unit/use_cases/test_agents_api_keys_use_case.py b/agentex/tests/unit/use_cases/test_agents_api_keys_use_case.py index c93e16d4..4bee562a 100644 --- a/agentex/tests/unit/use_cases/test_agents_api_keys_use_case.py +++ b/agentex/tests/unit/use_cases/test_agents_api_keys_use_case.py @@ -8,7 +8,11 @@ from src.domain.entities.agents import ACPType, AgentEntity, AgentStatus from src.domain.repositories.agent_api_key_repository import AgentAPIKeyRepository from src.domain.repositories.agent_repository import AgentRepository -from src.domain.use_cases.agent_api_keys_use_case import AgentAPIKeysUseCase +from src.domain.use_cases.agent_api_keys_use_case import ( + AgentAPIKeysUseCase, + extract_agent_key_from_authorization, +) +from starlette.datastructures import Headers @pytest.fixture @@ -220,3 +224,158 @@ async def test_create_external_api_key( api_key_type=AgentAPIKeyType.EXTERNAL, ) assert find_after_delete is None + + +class _FakeRequest: + """Minimal Request stand-in exposing the header dict the use case reads. + + Uses Starlette's ``Headers`` so lookups are case-insensitive, matching + real HTTP request semantics for cases like ``Authorization`` vs + ``authorization``. + """ + + def __init__(self, headers: dict[str, str]): + self.headers = Headers(headers) + + +@pytest.mark.unit +class TestExtractAgentKeyFromAuthorization: + """Pure-function tests for the Authorization header parser.""" + + def test_returns_none_for_absent_header(self): + assert extract_agent_key_from_authorization(None) is None + assert extract_agent_key_from_authorization("") is None + + def test_extracts_agent_key(self): + assert extract_agent_key_from_authorization("AgentKey abc123") == "abc123" + + def test_scheme_is_case_insensitive(self): + # RFC 9110 § 11.6.1 requires case-insensitive scheme matching. + assert extract_agent_key_from_authorization("agentkey abc123") == "abc123" + assert extract_agent_key_from_authorization("AGENTKEY abc123") == "abc123" + assert extract_agent_key_from_authorization("aGeNtKeY abc123") == "abc123" + + def test_ignores_bearer_scheme(self): + # Preserves existing ``Authorization: Bearer ...`` semantics for SGP. + assert extract_agent_key_from_authorization("Bearer sgp-token") is None + + def test_ignores_basic_scheme(self): + assert extract_agent_key_from_authorization("Basic dXNlcjpwYXNz") is None + + def test_returns_none_for_empty_credentials(self): + assert extract_agent_key_from_authorization("AgentKey") is None + assert extract_agent_key_from_authorization("AgentKey ") is None + assert extract_agent_key_from_authorization("AgentKey ") is None + + def test_strips_surrounding_whitespace(self): + assert ( + extract_agent_key_from_authorization(" AgentKey abc123 ") == "abc123" + ) + + +@pytest.mark.asyncio +@pytest.mark.unit +class TestValidateAgentIdentityHeadersAuthorizationScheme: + """End-to-end coverage of the AgentKey Authorization scheme. + + Each case exercises ``validate_agent_identity_headers`` so both the + header parsing and the shared DB verification path are covered. + """ + + async def test_authorization_agent_key_valid( + self, agent_api_keys_use_case, agent_repository, sample_agent + ): + await create_or_get_agent(agent_repository, sample_agent) + created = await agent_api_keys_use_case.create( + name="webhook-key", + agent_id=sample_agent.id, + api_key_type=AgentAPIKeyType.EXTERNAL, + api_key="ironclad-secret", + ) + request = _FakeRequest({"Authorization": f"AgentKey {created.api_key}"}) + result = await agent_api_keys_use_case.validate_agent_identity_headers( + sample_agent.id, request, b"" + ) + assert result is None + + async def test_authorization_agent_key_invalid_returns_401( + self, agent_api_keys_use_case, agent_repository, sample_agent + ): + await create_or_get_agent(agent_repository, sample_agent) + request = _FakeRequest({"Authorization": "AgentKey bogus-key"}) + result = await agent_api_keys_use_case.validate_agent_identity_headers( + sample_agent.id, request, b"" + ) + assert result is not None + assert result.status_code == 401 + + async def test_authorization_agent_key_revoked_returns_401( + self, agent_api_keys_use_case, agent_repository, sample_agent + ): + # Revocation = the key row no longer exists; must produce identical + # semantics to ``x-agent-api-key`` (401). + await create_or_get_agent(agent_repository, sample_agent) + created = await agent_api_keys_use_case.create( + name="webhook-key", + agent_id=sample_agent.id, + api_key_type=AgentAPIKeyType.EXTERNAL, + api_key="revoked-secret", + ) + await agent_api_keys_use_case.delete(id=created.id) + request = _FakeRequest({"Authorization": f"AgentKey {created.api_key}"}) + result = await agent_api_keys_use_case.validate_agent_identity_headers( + sample_agent.id, request, b"" + ) + assert result is not None + assert result.status_code == 401 + + async def test_authorization_agent_key_wrong_agent_returns_401( + self, agent_api_keys_use_case, agent_repository, sample_agent + ): + # A key registered under one agent must not authenticate a forward + # request addressed to a different agent. + await create_or_get_agent(agent_repository, sample_agent) + created = await agent_api_keys_use_case.create( + name="webhook-key", + agent_id=sample_agent.id, + api_key_type=AgentAPIKeyType.EXTERNAL, + api_key="scoped-secret", + ) + request = _FakeRequest({"Authorization": f"AgentKey {created.api_key}"}) + result = await agent_api_keys_use_case.validate_agent_identity_headers( + "some-other-agent-id", request, b"" + ) + assert result is not None + assert result.status_code == 401 + + async def test_authorization_bearer_token_does_not_match_agent_key_path( + self, agent_api_keys_use_case, agent_repository, sample_agent + ): + # Regression: an ``Authorization: Bearer ...`` request must NOT be + # consumed by the AgentKey path. With the auth gateway disabled in the + # test harness the fallthrough surfaces as the "missing authentication" + # 403 rather than a 401 from the AgentKey invalid-key branch. + await create_or_get_agent(agent_repository, sample_agent) + request = _FakeRequest({"Authorization": "Bearer sgp-token"}) + result = await agent_api_keys_use_case.validate_agent_identity_headers( + sample_agent.id, request, b"" + ) + assert result is not None + assert result.status_code == 403 + + async def test_x_agent_api_key_header_still_authenticates( + self, agent_api_keys_use_case, agent_repository, sample_agent + ): + # Regression: the pre-existing ``X-Agent-API-Key`` path is unchanged. + await create_or_get_agent(agent_repository, sample_agent) + created = await agent_api_keys_use_case.create( + name="legacy-key", + agent_id=sample_agent.id, + api_key_type=AgentAPIKeyType.EXTERNAL, + api_key="legacy-secret", + ) + request = _FakeRequest({"X-Agent-API-Key": created.api_key}) + result = await agent_api_keys_use_case.validate_agent_identity_headers( + sample_agent.id, request, b"" + ) + assert result is None