Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 37 additions & 5 deletions agentex/docs/docs/development_guides/webhooks.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -51,7 +51,7 @@ async def handle_code_changes(request: Request) -> JSONResponse:
content=f"Code change detected: {commit_message}"
)
)

return JSONResponse(content={"message": "Success"})
```

Expand All @@ -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 <agent-api-key>
```

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/<AGENT_NAME>/<WEBHOOK_PATH> \
-H "Content-Type: application/json" \
-H "Authorization: AgentKey <agent-api-key>" \
-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.

Expand All @@ -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
```
Expand All @@ -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" \
Expand All @@ -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 \
Expand All @@ -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 \
Expand All @@ -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" \
Expand All @@ -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
Expand All @@ -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: <SGP API KEY>" \
-H "x-selected-account-id: <SGP ACCOUNT ID>"
```
```
48 changes: 46 additions & 2 deletions agentex/src/domain/use_cases/agent_api_keys_use_case.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 <key>`` 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__(
Expand Down Expand Up @@ -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 <key>`` 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(
Expand Down Expand Up @@ -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(
Expand Down
161 changes: 160 additions & 1 deletion agentex/tests/unit/use_cases/test_agents_api_keys_use_case.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Loading