diff --git a/backend/app/gateway/auth_middleware.py b/backend/app/gateway/auth_middleware.py index 578e8241..7dcaf90c 100644 --- a/backend/app/gateway/auth_middleware.py +++ b/backend/app/gateway/auth_middleware.py @@ -9,6 +9,7 @@ owner filtering works automatically via the sentinel pattern. Fine-grained permission checks remain in authz.py decorators. """ +import logging from collections.abc import Callable from fastapi import HTTPException, Request, Response @@ -24,6 +25,8 @@ from app.gateway.internal_auth import INTERNAL_AUTH_HEADER_NAME, get_internal_us from deerflow.runtime.user_context import reset_current_user, set_current_user from deerflow.runtime.workspace_context import reset_current_workspace, set_current_workspace +logger = logging.getLogger(__name__) + # Paths that never require authentication. _PUBLIC_PATH_PREFIXES: tuple[str, ...] = ( "/health", @@ -87,7 +90,11 @@ class AuthMiddleware(BaseHTTPMiddleware): if auth_header.startswith("Bearer dfk_"): token = auth_header[len("Bearer ") :] backend = build_api_key_backend() - result = await backend.authenticate(token) if backend is not None else None + try: + result = await backend.authenticate(token) if backend is not None else None + except Exception: + logger.exception("API key authentication failed unexpectedly") + return JSONResponse(status_code=503, content={"detail": "Authentication service unavailable"}) if result is None: return JSONResponse( status_code=401, diff --git a/backend/app/gateway/authz.py b/backend/app/gateway/authz.py index 0579f72e..1830f90c 100644 --- a/backend/app/gateway/authz.py +++ b/backend/app/gateway/authz.py @@ -38,6 +38,7 @@ from typing import TYPE_CHECKING, Any, ParamSpec, TypeVar from fastapi import HTTPException, Request if TYPE_CHECKING: + from app.gateway.auth.api_key_backend import ServicePrincipal from app.gateway.auth.models import User P = ParamSpec("P") @@ -65,13 +66,14 @@ class AuthContext: Stored in request.state.auth after require_auth decoration. Attributes: - user: The authenticated user, or None if anonymous + user: The authenticated principal (human ``User`` or + ``ServicePrincipal`` for API-key requests), or None if anonymous permissions: List of permission strings (e.g., "threads:read") """ __slots__ = ("user", "permissions") - def __init__(self, user: User | None = None, permissions: list[str] | None = None): + def __init__(self, user: User | ServicePrincipal | None = None, permissions: list[str] | None = None): self.user = user self.permissions = permissions or [] @@ -93,8 +95,11 @@ class AuthContext: permission = f"{resource}:{action}" return permission in self.permissions - def require_user(self) -> User: - """Get user or raise 401. + def require_user(self) -> User | ServicePrincipal: + """Get the authenticated principal or raise 401. + + Returns the human ``User`` or the ``ServicePrincipal`` backing an + API key, depending on how the request authenticated. Raises: HTTPException 401 if not authenticated diff --git a/backend/tests/test_auth_middleware_api_key.py b/backend/tests/test_auth_middleware_api_key.py index ec56d42b..24206676 100644 --- a/backend/tests/test_auth_middleware_api_key.py +++ b/backend/tests/test_auth_middleware_api_key.py @@ -122,3 +122,13 @@ async def test_non_dfk_bearer_falls_through_to_cookie_path(tmp_path): assert r.json()["detail"]["code"] == "not_authenticated" finally: await _cleanup() + + +async def test_bare_prefix_bearer_returns_401(tmp_path): + await _seed_key(tmp_path) + try: + client = TestClient(_make_app()) + r = client.get("/api/probe", headers={"Authorization": "Bearer dfk_"}) + assert r.status_code == 401 + finally: + await _cleanup()