From ba9d63667ebe71c63ffdc60f757b390048559454 Mon Sep 17 00:00:00 2001 From: h Date: Sun, 30 Aug 2026 18:10:00 +0200 Subject: [PATCH] feat(auth): query-string token for headerless webhook senders --- src/beaver_gateway/frontends/_auth.py | 22 +++++++------ tests/test_auth_query_token.py | 46 +++++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 9 deletions(-) create mode 100644 tests/test_auth_query_token.py diff --git a/src/beaver_gateway/frontends/_auth.py b/src/beaver_gateway/frontends/_auth.py index 1be5cb2..05b81c0 100644 --- a/src/beaver_gateway/frontends/_auth.py +++ b/src/beaver_gateway/frontends/_auth.py @@ -25,20 +25,24 @@ async def require_token( ) -> str: """Verify the request's bearer + scope, return the token's audit name. - Accepts both ``X-Api-Key: `` (Anthropic SDK / LibreChat) and - ``Authorization: Bearer `` (curl, Cursor). 401 on missing / + Accepts ``X-Api-Key: `` (Anthropic SDK / LibreChat), + ``Authorization: Bearer `` (curl, Cursor) and, when neither + header is present, ``?token=`` (Komodo alerters). 401 on missing / unknown token; 403 on a known token whose scope doesn't cover ``scope``. Bootstrap tokens implicitly carry ``"*"`` and pass every scope check. """ api_key = request.headers.get("x-api-key") - identity = ( - await runtime.token_store.verify(api_key) - if api_key - else await runtime.token_store.verify_bearer( - request.headers.get("authorization") - ) - ) + authorization = request.headers.get("authorization") + if api_key: + identity = await runtime.token_store.verify(api_key) + elif 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: raise HTTPException( status.HTTP_401_UNAUTHORIZED, diff --git a/tests/test_auth_query_token.py b/tests/test_auth_query_token.py new file mode 100644 index 0000000..a3a814f --- /dev/null +++ b/tests/test_auth_query_token.py @@ -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