feat(auth): query-string token for headerless webhook senders
This commit is contained in:
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user