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:
Hyeonsang Cho 2026-10-26 23:11:48 +09:00 committed by GitHub
parent 4f6ee59535
commit 0479934fe2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 179 additions and 15 deletions

View File

@ -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

View File

@ -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

View File

@ -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`.

View File

@ -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:

View 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()