feat(auth): query-string token for headerless webhook senders

This commit is contained in:
hh
2026-08-30 18:10:00 +02:00
parent 5d67cfcc9e
commit 064d855382
2 changed files with 59 additions and 9 deletions
+13 -9
View File
@@ -25,20 +25,24 @@ async def require_token(
) -> str: ) -> str:
"""Verify the request's bearer + scope, return the token's audit name. """Verify the request's bearer + scope, return the token's audit name.
Accepts both ``X-Api-Key: <token>`` (Anthropic SDK / LibreChat) and Accepts ``X-Api-Key: <token>`` (Anthropic SDK / LibreChat),
``Authorization: Bearer <token>`` (curl, Cursor). 401 on missing / ``Authorization: Bearer <token>`` (curl, Cursor) and, when neither
header is present, ``?token=<token>`` (Komodo alerters). 401 on missing /
unknown token; 403 on a known token whose scope doesn't cover unknown token; 403 on a known token whose scope doesn't cover
``scope``. Bootstrap tokens implicitly carry ``"*"`` and pass every ``scope``. Bootstrap tokens implicitly carry ``"*"`` and pass every
scope check. scope check.
""" """
api_key = request.headers.get("x-api-key") api_key = request.headers.get("x-api-key")
identity = ( authorization = request.headers.get("authorization")
await runtime.token_store.verify(api_key) if api_key:
if api_key identity = await runtime.token_store.verify(api_key)
else await runtime.token_store.verify_bearer( elif authorization:
request.headers.get("authorization") identity = await runtime.token_store.verify_bearer(authorization)
) else:
) # Webhook senders that cannot set headers (Komodo alerters) put the
# token in the query string; the URL is not logged with it.
qs_token = request.query_params.get("token")
identity = await runtime.token_store.verify(qs_token) if qs_token else None
if identity is None: if identity is None:
raise HTTPException( raise HTTPException(
status.HTTP_401_UNAUTHORIZED, status.HTTP_401_UNAUTHORIZED,
+46
View File
@@ -0,0 +1,46 @@
"""``require_token`` accepts ``?token=`` only when no auth header is present."""
from types import SimpleNamespace
import pytest
from fastapi import HTTPException
from starlette.requests import Request
from beaver_gateway.core.auth import TokenStore
from beaver_gateway.frontends._auth import require_token
def _request(query: str = "", headers: dict[str, str] | None = None) -> Request:
scope = {
"type": "http",
"method": "POST",
"path": "/hooks/komodo",
"query_string": query.encode(),
"headers": [
(k.lower().encode(), v.encode()) for k, v in (headers or {}).items()
],
}
return Request(scope)
@pytest.fixture
def runtime() -> SimpleNamespace:
return SimpleNamespace(token_store=TokenStore(bootstrap={"komodo": "qs-secret"}))
async def test_query_token_authorizes_a_headerless_webhook(runtime) -> None:
name = await require_token(_request("token=qs-secret"), runtime, scope="api")
assert name == "komodo"
async def test_header_wins_over_query(runtime) -> None:
bad_header = _request("token=qs-secret", {"Authorization": "Bearer nope"})
with pytest.raises(HTTPException) as exc:
await require_token(bad_header, runtime, scope="api")
assert exc.value.status_code == 401
async def test_missing_everything_is_401(runtime) -> None:
with pytest.raises(HTTPException) as exc:
await require_token(_request(), runtime, scope="api")
assert exc.value.status_code == 401