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:
@@ -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,
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user