"""Global authentication middleware — fail-closed safety net. Rejects unauthenticated requests to non-public paths with 401. When a request passes the cookie check, resolves the JWT payload to a real ``User`` object and stamps it into both ``request.state.user`` and the ``deerflow.runtime.user_context`` contextvar so that repository-layer 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 from starlette.middleware.base import BaseHTTPMiddleware from starlette.responses import JSONResponse from starlette.types import ASGIApp from app.gateway.auth.api_key_backend import build_api_key_backend from app.gateway.auth.errors import AuthErrorCode, AuthErrorResponse from app.gateway.auth.models import ActiveWorkspace from app.gateway.authz import _ALL_PERMISSIONS, AuthContext from app.gateway.internal_auth import INTERNAL_AUTH_HEADER_NAME, get_internal_user, is_valid_internal_auth_token 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", "/docs", "/redoc", "/openapi.json", ) # Exact auth paths that are public (login/register/status check). # /api/v1/auth/me, /api/v1/auth/change-password etc. are NOT public. _PUBLIC_EXACT_PATHS: frozenset[str] = frozenset( { "/api/v1/auth/login/local", "/api/v1/auth/register", "/api/v1/auth/logout", "/api/v1/auth/setup-status", "/api/v1/auth/initialize", } ) def _is_public(path: str) -> bool: stripped = path.rstrip("/") if stripped in _PUBLIC_EXACT_PATHS: return True return any(path.startswith(prefix) for prefix in _PUBLIC_PATH_PREFIXES) # Data-plane / SDK route prefixes a service principal (API key) may reach. # Everything else (global control plane: models/mcp/memory/skills/channels/ # agents, plus management/auth endpoints) is denied by default for API keys. # NOTE: nginx rewrites /api/langgraph/(.*) -> /api/$1 before the gateway, so # AuthMiddleware never sees /api/langgraph; the SDK surface arrives as # /api/threads, /api/runs, /api/assistants. assistants.search()/get() is # required for langgraph-sdk client init, so /api/assistants is allowed. _DATAPLANE_PREFIXES: tuple[str, ...] = ( "/api/threads", "/api/v1/threads", "/api/runs", "/api/v1/runs", "/api/assistants", ) def _is_dataplane_path(path: str) -> bool: """True if an API key request may reach this path. Reusable by a future Pattern B service-token branch. Matches a prefix only at a path-segment boundary (exact match, or the prefix immediately followed by ``/``), so the allowlist can't be silently widened by a similarly-named route — e.g. ``/api/threads-export`` shares the ``/api/threads`` prefix but crosses no segment boundary, so it stays denied.""" return any(path == prefix or path.startswith(prefix + "/") for prefix in _DATAPLANE_PREFIXES) class AuthMiddleware(BaseHTTPMiddleware): """Strict auth gate: reject requests without a valid session. Two-stage check for non-public paths: 1. Cookie presence — return 401 NOT_AUTHENTICATED if missing 2. JWT validation via ``get_optional_user_from_request`` — return 401 TOKEN_INVALID if the token is absent, malformed, expired, or the signed user does not exist / is stale On success, stamps ``request.state.user`` and the ``deerflow.runtime.user_context`` contextvar so that repository-layer owner filters work downstream without every route needing a ``@require_auth`` decorator. Routes that need per-resource authorization (e.g. "user A cannot read user B's thread by guessing the URL") should additionally use ``@require_permission(..., owner_check=True)`` for explicit enforcement — but authentication itself is fully handled here. """ def __init__(self, app: ASGIApp) -> None: super().__init__(app) async def dispatch(self, request: Request, call_next: Callable) -> Response: if _is_public(request.url.path): return await call_next(request) # API key path: "Authorization: Bearer dfk_..." authenticates a # service account. Resolved principal is mapped to the same # (user_id, workspace_id) contextvars a human would set (spec D1), # so all downstream isolation works unchanged. auth_header = request.headers.get("authorization", "") if auth_header.startswith("Bearer dfk_"): token = auth_header[len("Bearer ") :] backend = build_api_key_backend() 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, content={"detail": AuthErrorResponse(code=AuthErrorCode.TOKEN_INVALID, message="Invalid API key").model_dump()}, ) # Default-deny: a service principal may only reach the data plane # (threads/runs/assistants). Control-plane routes (mcp/skills/ # channels/models/agents/memory + management/auth) are global, # un-partitioned config — never reachable by an API key. New # control-plane routes are denied automatically (allowlist, not # blocklist). Humans (cookie path) never enter this branch. if not _is_dataplane_path(request.url.path): return JSONResponse( status_code=403, content={"detail": AuthErrorResponse(code=AuthErrorCode.INSUFFICIENT_SCOPE, message="API keys cannot access this endpoint").model_dump()}, ) request.state.user = result.principal request.state.auth = AuthContext(user=result.principal, permissions=result.permissions) user_token = set_current_user(result.principal) ws_token = set_current_workspace(ActiveWorkspace(id=result.workspace_id, role=result.role)) try: return await call_next(request) finally: reset_current_workspace(ws_token) reset_current_user(user_token) internal_user = None if is_valid_internal_auth_token(request.headers.get(INTERNAL_AUTH_HEADER_NAME)): internal_user = get_internal_user() # Non-public path: require session cookie if internal_user is None and not request.cookies.get("access_token"): return JSONResponse( status_code=401, content={ "detail": AuthErrorResponse( code=AuthErrorCode.NOT_AUTHENTICATED, message="Authentication required", ).model_dump() }, ) # Strict JWT validation: reject junk/expired tokens with 401 # right here instead of silently passing through. This closes # the "junk cookie bypass" gap (AUTH_TEST_PLAN test 7.5.8): # without this, non-isolation routes like /api/models would # accept any cookie-shaped string as authentication. # # We call the *strict* resolver so that fine-grained error # codes (token_expired, token_invalid, user_not_found, …) # propagate from AuthErrorCode, not get flattened into one # generic code. BaseHTTPMiddleware doesn't let HTTPException # bubble up, so we catch and render it as JSONResponse here. from app.gateway.deps import get_current_user_from_request if internal_user is not None: user = internal_user else: try: user = await get_current_user_from_request(request) except HTTPException as exc: return JSONResponse(status_code=exc.status_code, content={"detail": exc.detail}) # Stamp both request.state.user (for the contextvar pattern) # and request.state.auth (so @require_permission's "auth is # None" branch short-circuits instead of running the entire # JWT-decode + DB-lookup pipeline a second time per request). request.state.user = user request.state.auth = AuthContext(user=user, permissions=_ALL_PERMISSIONS) user_token = set_current_user(user) # Inject workspace contextvar from the JWT's wid/role claims. # decode_token has already rejected legacy no-wid tokens upstream, # so by the time we get here payload.wid is guaranteed non-None # for cookie-authenticated requests. Internal-auth requests skip # the workspace contextvar (they don't have a workspace scope — # the internal user is a system actor). ws_token = None payload = getattr(request.state, "auth_payload", None) if payload is not None and payload.wid is not None: ws_token = set_current_workspace(ActiveWorkspace(id=payload.wid, role=payload.role or "owner")) try: return await call_next(request) finally: if ws_token is not None: reset_current_workspace(ws_token) reset_current_user(user_token)