harden(auth): guard bearer auth errors as 503; widen AuthContext to ServicePrincipal (Stage 1 PR2)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
1445043649
2026-06-28 12:04:19 +08:00
parent 3ec4fb8537
commit 78359c3fd8
3 changed files with 27 additions and 5 deletions
+8 -1
View File
@@ -9,6 +9,7 @@ owner filtering works automatically via the sentinel pattern.
Fine-grained permission checks remain in authz.py decorators. Fine-grained permission checks remain in authz.py decorators.
""" """
import logging
from collections.abc import Callable from collections.abc import Callable
from fastapi import HTTPException, Request, Response 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.user_context import reset_current_user, set_current_user
from deerflow.runtime.workspace_context import reset_current_workspace, set_current_workspace from deerflow.runtime.workspace_context import reset_current_workspace, set_current_workspace
logger = logging.getLogger(__name__)
# Paths that never require authentication. # Paths that never require authentication.
_PUBLIC_PATH_PREFIXES: tuple[str, ...] = ( _PUBLIC_PATH_PREFIXES: tuple[str, ...] = (
"/health", "/health",
@@ -87,7 +90,11 @@ class AuthMiddleware(BaseHTTPMiddleware):
if auth_header.startswith("Bearer dfk_"): if auth_header.startswith("Bearer dfk_"):
token = auth_header[len("Bearer ") :] token = auth_header[len("Bearer ") :]
backend = build_api_key_backend() 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: if result is None:
return JSONResponse( return JSONResponse(
status_code=401, status_code=401,
+9 -4
View File
@@ -38,6 +38,7 @@ from typing import TYPE_CHECKING, Any, ParamSpec, TypeVar
from fastapi import HTTPException, Request from fastapi import HTTPException, Request
if TYPE_CHECKING: if TYPE_CHECKING:
from app.gateway.auth.api_key_backend import ServicePrincipal
from app.gateway.auth.models import User from app.gateway.auth.models import User
P = ParamSpec("P") P = ParamSpec("P")
@@ -65,13 +66,14 @@ class AuthContext:
Stored in request.state.auth after require_auth decoration. Stored in request.state.auth after require_auth decoration.
Attributes: 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") permissions: List of permission strings (e.g., "threads:read")
""" """
__slots__ = ("user", "permissions") __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.user = user
self.permissions = permissions or [] self.permissions = permissions or []
@@ -93,8 +95,11 @@ class AuthContext:
permission = f"{resource}:{action}" permission = f"{resource}:{action}"
return permission in self.permissions return permission in self.permissions
def require_user(self) -> User: def require_user(self) -> User | ServicePrincipal:
"""Get user or raise 401. """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: Raises:
HTTPException 401 if not authenticated HTTPException 401 if not authenticated
@@ -122,3 +122,13 @@ async def test_non_dfk_bearer_falls_through_to_cookie_path(tmp_path):
assert r.json()["detail"]["code"] == "not_authenticated" assert r.json()["detail"]["code"] == "not_authenticated"
finally: finally:
await _cleanup() 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()