mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-27 05:30:39 +00:00
fix(auth): read the live config off the event loop on public auth routes (#7576)
* fix(auth): read the live config off the event loop on public auth routes
/register, /setup-status, /providers, /oauth/{provider} and
/callback/{provider} read the live auth config through get_app_config()
inline. That call stats and hashes config.yaml and extensions_config.json
on every request and reparses them after an edit, and these routes are
unauthenticated, so any client could keep that file IO on the event
loop. /login/local already reads its throttle policy in a worker.
Read the registration gate through asyncio.to_thread, and the SSO
routes' auth.oidc section through a shared _oidc_config() helper that
does the same.
* docs(changelog): reference #7576 for the off-loop auth route config reads
---------
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
This commit is contained in:
parent
4f6ee59535
commit
0479934fe2
@ -1397,6 +1397,14 @@ As of 2026-10-21, the milestone includes **829 merged pull requests**.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **auth:** The public auth routes (`/register`, `/setup-status`,
|
||||
`/providers`, `/oauth/{provider}` and `/callback/{provider}`) no longer read
|
||||
`config.yaml` and `extensions_config.json` on the event loop. Each request
|
||||
checked both files (a `stat` and a full sha256 of each), and reparsed
|
||||
`config.yaml` there after an edit, stalling other requests and streams
|
||||
meanwhile. The read now runs in a worker, as `/login/local` already did.
|
||||
([#7576])
|
||||
|
||||
- **sandbox:** Stopping the Gateway no longer destroys AIO or E2B sandboxes
|
||||
before in-flight runs drain. When the first sandbox use built the provider on
|
||||
the event-loop thread, the provider's own SIGTERM/SIGINT handler destroyed
|
||||
@ -13136,3 +13144,4 @@ with **180 merged pull requests** since the first 2.0 milestone tag.
|
||||
[#7530]: https://github.com/bytedance/deer-flow/pull/7530
|
||||
[#7557]: https://github.com/bytedance/deer-flow/pull/7557
|
||||
[#7560]: https://github.com/bytedance/deer-flow/pull/7560
|
||||
[#7576]: https://github.com/bytedance/deer-flow/pull/7576
|
||||
|
||||
@ -1112,6 +1112,12 @@
|
||||
|
||||
### 修复
|
||||
|
||||
- **鉴权:** 公开的认证路由(`/register`、`/setup-status`、`/providers`、
|
||||
`/oauth/{provider}` 和 `/callback/{provider}`)不再在事件循环上读取 `config.yaml` 和
|
||||
`extensions_config.json`。每个请求都会检查这两个文件(各做一次 `stat` 和完整的
|
||||
sha256),编辑后还会在循环上重新解析 `config.yaml`,期间其他请求和流式输出都会
|
||||
停顿。现在这次读取与 `/login/local` 一样在工作线程中执行。([#7576])
|
||||
|
||||
- **沙箱:** 停止 Gateway 时,AIO 和 E2B 沙箱不再在进行中的运行收尾之前被销毁。此前
|
||||
如果第一次使用沙箱时在事件循环线程上构造了提供方,提供方自己的 SIGTERM/SIGINT
|
||||
处理器会在信号到达时同步销毁所有沙箱:整个销毁期间事件循环冻结,正在执行的工具
|
||||
@ -10485,3 +10491,4 @@ DeerFlow 2.0 是围绕"超级智能体"框架的彻底重写,核心包含子
|
||||
[#7530]: https://github.com/bytedance/deer-flow/pull/7530
|
||||
[#7557]: https://github.com/bytedance/deer-flow/pull/7557
|
||||
[#7560]: https://github.com/bytedance/deer-flow/pull/7560
|
||||
[#7576]: https://github.com/bytedance/deer-flow/pull/7576
|
||||
|
||||
@ -88,3 +88,8 @@ State updates synchronize titles into thread metadata only for the root checkpoi
|
||||
namespace. Nonroot writes preserve the conversation title, branch-title sequence
|
||||
and activity timestamp. `tests/test_threads_router.py` checks metadata/search
|
||||
isolation and root title synchronization in full and delta modes.
|
||||
|
||||
Unauthenticated auth routes (`/register`, `/setup-status`, `/providers`,
|
||||
`/oauth/{provider}`, `/callback/{provider}`, the `/login/local` throttle) read
|
||||
`get_app_config()` through `asyncio.to_thread`: each read stats and hashes the
|
||||
config files. Coverage: `tests/blocking_io/test_auth_router_config_reads.py`.
|
||||
|
||||
@ -39,7 +39,7 @@ from app.gateway.auth.user_provisioning import get_or_provision_oidc_user
|
||||
from app.gateway.csrf_middleware import CSRF_COOKIE_NAME, _request_origin, auth_csrf_cookie_settings, generate_csrf_token, is_secure_request
|
||||
from app.gateway.deps import get_current_user_from_request, get_local_provider
|
||||
from app.gateway.utils import constant_time_equals
|
||||
from deerflow.config.auth_config import OIDCProviderConfig
|
||||
from deerflow.config.auth_config import OIDCAuthConfig, OIDCProviderConfig
|
||||
from deerflow.persistence.login_throttle import LoginThrottleStore
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@ -474,6 +474,8 @@ def _local_registration_enabled() -> bool:
|
||||
|
||||
``/register`` reads this fresh on every request (``get_app_config`` reloads on file
|
||||
change); ``/setup-status`` may serve it up to 60s stale via its per-IP result cache.
|
||||
Both are unauthenticated and ``get_app_config`` stats and re-hashes the config
|
||||
files on every call, so they run this through ``asyncio.to_thread``.
|
||||
"""
|
||||
from deerflow.config.app_config import get_app_config
|
||||
|
||||
@ -492,7 +494,7 @@ async def register(request: Request, response: Response, body: RegisterRequest):
|
||||
|
||||
Returns 403 when ``auth.local.allow_registration`` is false.
|
||||
"""
|
||||
if not _local_registration_enabled():
|
||||
if not await asyncio.to_thread(_local_registration_enabled):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=AuthErrorResponse(code=AuthErrorCode.REGISTRATION_DISABLED, message="Self-registration is disabled on this deployment").model_dump(),
|
||||
@ -784,7 +786,8 @@ async def setup_status(request: Request):
|
||||
|
||||
async def _compute_setup_status() -> dict:
|
||||
admin_count = await get_local_provider().count_admin_users()
|
||||
return {"needs_setup": admin_count == 0, "registration_enabled": _local_registration_enabled()}
|
||||
registration_enabled = await asyncio.to_thread(_local_registration_enabled)
|
||||
return {"needs_setup": admin_count == 0, "registration_enabled": registration_enabled}
|
||||
|
||||
task = asyncio.create_task(_compute_setup_status())
|
||||
_SETUP_STATUS_INFLIGHT[client_ip] = task
|
||||
@ -890,6 +893,21 @@ def _set_csrf_cookie(response: Response, request: Request) -> None:
|
||||
)
|
||||
|
||||
|
||||
def _live_oidc_config() -> OIDCAuthConfig:
|
||||
from deerflow.config.app_config import get_app_config
|
||||
|
||||
return get_app_config().auth.oidc
|
||||
|
||||
|
||||
async def _oidc_config() -> OIDCAuthConfig:
|
||||
"""The live ``auth.oidc`` config, read off the event loop.
|
||||
|
||||
The SSO routes are unauthenticated, and ``get_app_config`` stats and
|
||||
re-hashes the config files on every call.
|
||||
"""
|
||||
return await asyncio.to_thread(_live_oidc_config)
|
||||
|
||||
|
||||
def _resolve_oidc_redirect_uri(request: Request, provider_id: str, provider_config: OIDCProviderConfig) -> str:
|
||||
"""Resolve the redirect URI for an OIDC provider.
|
||||
|
||||
@ -916,10 +934,7 @@ async def list_auth_providers():
|
||||
Returns only safe frontend metadata — no secrets, endpoints, or
|
||||
internal configuration.
|
||||
"""
|
||||
from deerflow.config.app_config import get_app_config
|
||||
|
||||
app_config = get_app_config()
|
||||
oidc_config = app_config.auth.oidc
|
||||
oidc_config = await _oidc_config()
|
||||
|
||||
if not oidc_config.enabled:
|
||||
return {"providers": []}
|
||||
@ -949,10 +964,7 @@ async def oauth_login(
|
||||
and PKCE parameters. The ``next`` query parameter specifies where to
|
||||
redirect after successful login (default: /workspace).
|
||||
"""
|
||||
from deerflow.config.app_config import get_app_config
|
||||
|
||||
app_config = get_app_config()
|
||||
oidc_config = app_config.auth.oidc
|
||||
oidc_config = await _oidc_config()
|
||||
|
||||
if not oidc_config.enabled:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="SSO authentication is not enabled")
|
||||
@ -1031,10 +1043,7 @@ async def oauth_callback(
|
||||
the ID token, provisions/links the DeerFlow user, and sets the
|
||||
session cookie.
|
||||
"""
|
||||
from deerflow.config.app_config import get_app_config
|
||||
|
||||
app_config = get_app_config()
|
||||
oidc_config = app_config.auth.oidc
|
||||
oidc_config = await _oidc_config()
|
||||
|
||||
# ── Provider error ───────────────────────────────────────────────
|
||||
if error:
|
||||
|
||||
134
backend/tests/blocking_io/test_auth_router_config_reads.py
Normal file
134
backend/tests/blocking_io/test_auth_router_config_reads.py
Normal file
@ -0,0 +1,134 @@
|
||||
"""Regression anchors: the public auth routes must read the live config off the loop.
|
||||
|
||||
``get_app_config()`` stats and re-hashes ``config.yaml`` and
|
||||
``extensions_config.json`` on every call, and reparses them after an edit.
|
||||
``/register``, ``/setup-status``, ``/providers``, ``/oauth/{provider}`` and
|
||||
``/callback/{provider}`` are unauthenticated and each read the live ``auth``
|
||||
section, so the read runs in a worker, as ``/login/local``'s throttle policy
|
||||
does (``test_login_local_throttle.py``).
|
||||
|
||||
Each request runs against real config files and asserts an answer only the
|
||||
file on disk produces (registration closed, the configured SSO provider), so an
|
||||
anchor cannot pass because the route stopped reading the config.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from collections.abc import AsyncIterator
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from blockbuster import BlockingError
|
||||
from fastapi import FastAPI
|
||||
|
||||
from app.gateway.routers import auth as auth_router
|
||||
from deerflow.config.app_config import get_app_config, reset_app_config
|
||||
from deerflow.config.extensions_config import reset_extensions_config
|
||||
|
||||
pytestmark = pytest.mark.asyncio
|
||||
|
||||
_CONFIG_YAML = """sandbox:
|
||||
use: deerflow.sandbox.local:LocalSandboxProvider
|
||||
auth:
|
||||
local:
|
||||
allow_registration: false
|
||||
oidc:
|
||||
enabled: true
|
||||
frontend_base_url: https://deerflow.example.com
|
||||
providers:
|
||||
corp:
|
||||
display_name: Corp SSO
|
||||
issuer: https://sso.example.com
|
||||
client_id: deerflow
|
||||
"""
|
||||
|
||||
|
||||
def _write_configs(directory: Path) -> tuple[Path, Path]:
|
||||
config_path = directory / "config.yaml"
|
||||
extensions_path = directory / "extensions_config.json"
|
||||
config_path.write_text(_CONFIG_YAML, encoding="utf-8")
|
||||
extensions_path.write_text(json.dumps({"mcpServers": {}, "skills": {}}), encoding="utf-8")
|
||||
return config_path, extensions_path
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def local_provider(monkeypatch: pytest.MonkeyPatch) -> AsyncMock:
|
||||
provider = AsyncMock()
|
||||
provider.count_admin_users.return_value = 0
|
||||
monkeypatch.setattr(auth_router, "get_local_provider", lambda: provider)
|
||||
return provider
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def client(tmp_path: Path, monkeypatch: pytest.MonkeyPatch, local_provider: AsyncMock) -> AsyncIterator[httpx.AsyncClient]:
|
||||
config_path, extensions_path = await asyncio.to_thread(_write_configs, tmp_path)
|
||||
monkeypatch.setenv("DEER_FLOW_CONFIG_PATH", str(config_path))
|
||||
monkeypatch.setenv("DEER_FLOW_EXTENSIONS_CONFIG_PATH", str(extensions_path))
|
||||
monkeypatch.delenv("AUTH_TRUSTED_PROXIES", raising=False)
|
||||
monkeypatch.setattr(auth_router, "_SETUP_STATUS_CACHE", {})
|
||||
monkeypatch.setattr(auth_router, "_SETUP_STATUS_INFLIGHT", {})
|
||||
reset_app_config()
|
||||
reset_extensions_config()
|
||||
# Load once off the loop: each request then only pays the per-call
|
||||
# revalidation, which is the cost every unauthenticated request carries.
|
||||
await asyncio.to_thread(get_app_config)
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(auth_router.router)
|
||||
transport = httpx.ASGITransport(app=app)
|
||||
try:
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://gateway.test") as http_client:
|
||||
yield http_client
|
||||
finally:
|
||||
reset_app_config()
|
||||
reset_extensions_config()
|
||||
|
||||
|
||||
async def test_register_reads_registration_gate_off_the_loop(client: httpx.AsyncClient, local_provider: AsyncMock) -> None:
|
||||
response = await client.post("/api/v1/auth/register", json={"email": "new@example.com", "password": "Str0ng!Passw0rd#2026"})
|
||||
|
||||
assert response.status_code == 403
|
||||
assert response.json()["detail"]["code"] == "registration_disabled"
|
||||
local_provider.create_user.assert_not_called()
|
||||
|
||||
|
||||
async def test_setup_status_reads_registration_gate_off_the_loop(client: httpx.AsyncClient) -> None:
|
||||
response = await client.get("/api/v1/auth/setup-status")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"needs_setup": True, "registration_enabled": False}
|
||||
|
||||
|
||||
async def test_providers_reads_oidc_config_off_the_loop(client: httpx.AsyncClient) -> None:
|
||||
response = await client.get("/api/v1/auth/providers")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"providers": [{"id": "corp", "display_name": "Corp SSO", "type": "oidc"}]}
|
||||
|
||||
|
||||
async def test_oauth_login_reads_oidc_config_off_the_loop(client: httpx.AsyncClient) -> None:
|
||||
# An unconfigured provider is rejected right after the config read, before
|
||||
# any discovery request to the IdP.
|
||||
response = await client.get("/api/v1/auth/oauth/other")
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json()["detail"] == "Unknown SSO provider: other"
|
||||
|
||||
|
||||
async def test_oauth_callback_reads_oidc_config_off_the_loop(client: httpx.AsyncClient) -> None:
|
||||
response = await client.get("/api/v1/auth/callback/corp", params={"error": "access_denied"})
|
||||
|
||||
assert response.status_code == 302
|
||||
assert response.headers["location"].startswith("https://deerflow.example.com/")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("read", [auth_router._local_registration_enabled, get_app_config], ids=["registration", "app-config"])
|
||||
async def test_blocking_probe_inline_config_read_actually_trips_the_gate(client: httpx.AsyncClient, read) -> None:
|
||||
"""Meta-check: the same reads inline on the loop are caught by the gate."""
|
||||
with pytest.raises(BlockingError):
|
||||
read()
|
||||
Loading…
x
Reference in New Issue
Block a user