feat: add bearer auth to agent backend (#39622)

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Yunlu Wen
2026-07-27 16:37:30 +08:00
committed by GitHub
parent 94702efbc2
commit 3c80857ea3
16 changed files with 147 additions and 4 deletions
+2
View File
@@ -677,6 +677,8 @@ INNER_API_KEY_FOR_PLUGIN=QaHbTe77CtuXmsfyhR7+vRjI/+XbV1AaFy691iy+kGDv2Jvy0/eAh8Y
# Dify Agent backend
AGENT_BACKEND_BASE_URL=http://localhost:5050
# Bearer token sent to the Agent backend /runs API. Must match DIFY_AGENT_API_TOKEN on the server side.
AGENT_BACKEND_API_TOKEN=dify-agent-run-token-for-dev-only
AGENT_BACKEND_STREAM_READ_TIMEOUT_SECONDS=30
AGENT_BACKEND_STREAM_MAX_RECONNECTS=3
AGENT_BACKEND_RUN_TIMEOUT_SECONDS=1200
+5 -1
View File
@@ -11,6 +11,7 @@ from clients.agent_backend.fake_client import FakeAgentBackendRunClient, FakeAge
def create_agent_backend_run_client(
*,
base_url: str | None = None,
api_token: str | None = None,
use_fake: bool = False,
fake_scenario: str | FakeAgentBackendScenario = FakeAgentBackendScenario.SUCCESS,
stream_read_timeout_seconds: float = 30,
@@ -22,8 +23,11 @@ def create_agent_backend_run_client(
return FakeAgentBackendRunClient(scenario=FakeAgentBackendScenario(fake_scenario))
if base_url is None:
raise ValueError("base_url is required when creating a real Agent backend client")
headers: dict[str, str] = {}
if api_token:
headers["Authorization"] = f"Bearer {api_token}"
return DifyAgentBackendRunClient(
Client(base_url=base_url, stream_timeout=stream_read_timeout_seconds),
Client(base_url=base_url, stream_timeout=stream_read_timeout_seconds, headers=headers),
stream_max_reconnects=stream_max_reconnects,
stream_timeout_seconds=stream_run_timeout_seconds,
)
@@ -12,6 +12,11 @@ class AgentBackendConfig(BaseSettings):
default=None,
)
AGENT_BACKEND_API_TOKEN: str | None = Field(
description="Bearer token for authenticating with the Agent backend /runs API.",
default=None,
)
AGENT_BACKEND_USE_FAKE: bool = Field(
description="Use the deterministic in-process fake Agent backend client.",
default=False,
@@ -538,6 +538,7 @@ class AgentAppGenerator(MessageBasedAppGenerator):
request_builder=AgentAppRuntimeRequestBuilder(credentials_provider=credentials_provider),
agent_backend_client=create_agent_backend_run_client(
base_url=dify_config.AGENT_BACKEND_BASE_URL,
api_token=dify_config.AGENT_BACKEND_API_TOKEN,
use_fake=dify_config.AGENT_BACKEND_USE_FAKE,
fake_scenario=dify_config.AGENT_BACKEND_FAKE_SCENARIO,
stream_read_timeout_seconds=dify_config.AGENT_BACKEND_STREAM_READ_TIMEOUT_SECONDS,
+1
View File
@@ -497,6 +497,7 @@ class DifyNodeFactory(NodeFactory):
),
"agent_backend_client": create_agent_backend_run_client(
base_url=dify_config.AGENT_BACKEND_BASE_URL,
api_token=dify_config.AGENT_BACKEND_API_TOKEN,
use_fake=dify_config.AGENT_BACKEND_USE_FAKE,
fake_scenario=dify_config.AGENT_BACKEND_FAKE_SCENARIO,
stream_read_timeout_seconds=dify_config.AGENT_BACKEND_STREAM_READ_TIMEOUT_SECONDS,
@@ -22,6 +22,7 @@ def _create_agent_backend_client():
return None
return create_agent_backend_run_client(
base_url=dify_config.AGENT_BACKEND_BASE_URL,
api_token=dify_config.AGENT_BACKEND_API_TOKEN,
use_fake=dify_config.AGENT_BACKEND_USE_FAKE,
fake_scenario=dify_config.AGENT_BACKEND_FAKE_SCENARIO,
stream_read_timeout_seconds=dify_config.AGENT_BACKEND_STREAM_READ_TIMEOUT_SECONDS,
@@ -4,6 +4,7 @@ from dotenv import dotenv_values
BASE_API_AND_DOCKER_CONFIG_SET_DIFF: frozenset[str] = frozenset(
(
"AGENT_BACKEND_API_TOKEN",
"APP_MAX_EXECUTION_TIME",
"BATCH_UPLOAD_LIMIT",
"CELERY_BEAT_SCHEDULER_TIME",
@@ -43,6 +44,7 @@ BASE_API_AND_DOCKER_CONFIG_SET_DIFF: frozenset[str] = frozenset(
BASE_API_AND_DOCKER_COMPOSE_CONFIG_SET_DIFF: frozenset[str] = frozenset(
(
"AGENT_BACKEND_API_TOKEN",
"BATCH_UPLOAD_LIMIT",
"CELERY_BEAT_SCHEDULER_TIME",
"HTTP_REQUEST_MAX_CONNECT_TIMEOUT",
+6
View File
@@ -56,6 +56,12 @@ DIFY_AGENT_STUB_GRPC_BIND_ADDRESS=
# Generate one with: python -c 'import secrets; print(secrets.token_urlsafe(32))'
DIFY_AGENT_SERVER_SECRET_KEY=MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY
# Inbound Bearer token for /runs API authentication.
# Must match AGENT_BACKEND_API_TOKEN on the Dify API side.
# Replace this development default in production.
# Generate one with: python -c 'import secrets; print(secrets.token_urlsafe(32))'
DIFY_AGENT_API_TOKEN=dify-agent-run-token-for-dev-only
# Shared plugin-daemon HTTP client timeouts and limits.
# Plugin-daemon HTTP connect timeout in seconds.
DIFY_AGENT_PLUGIN_DAEMON_CONNECT_TIMEOUT=10
+8 -1
View File
@@ -29,6 +29,7 @@ from dify_agent.agent_stub.server.router import create_agent_stub_router
from dify_agent.layers.execution_context import DifyExecutionContextLayerConfig
from dify_agent.runtime.compositor_factory import create_default_layer_providers
from dify_agent.runtime.run_scheduler import RunScheduler
from dify_agent.server.auth import create_bearer_token_dependency
from dify_agent.server.observability import configure_server_observability
from dify_agent.server.routes.runs import create_runs_router
from dify_agent.server.routes.sandbox_files import create_sandbox_files_router
@@ -123,7 +124,13 @@ def create_app(settings: ServerSettings | None = None) -> FastAPI:
def get_scheduler() -> RunScheduler:
return state["scheduler"] # pyright: ignore[reportReturnType]
app.include_router(create_runs_router(get_store, get_scheduler))
app.include_router(
create_runs_router(
get_store,
get_scheduler,
auth_dependency=create_bearer_token_dependency(resolved_settings.api_token),
)
)
app.include_router(create_sandbox_files_router(lambda: sandbox_file_service))
app.include_router(
create_agent_stub_router(
+43
View File
@@ -0,0 +1,43 @@
import hmac
from fastapi import Depends, Header, HTTPException
def create_bearer_token_dependency(expected_token: str | None):
"""Return a FastAPI dependency that validates Bearer token authentication.
When ``expected_token`` is ``None``, the returned dependency permits all
requests without checking the header, supporting graceful migration for
existing deployments.
"""
async def require_bearer_token(
authorization: str | None = Header(default=None, alias="Authorization"),
) -> None:
if expected_token is None:
return
if authorization is None:
raise HTTPException(
status_code=401,
detail="missing authorization header",
headers={"WWW-Authenticate": "Bearer"},
)
scheme, _, token = authorization.partition(" ")
token = token.strip()
if scheme.lower() != "bearer" or not token:
raise HTTPException(
status_code=401,
detail="invalid authorization scheme",
headers={"WWW-Authenticate": "Bearer"},
)
if not hmac.compare_digest(token.encode(), expected_token.encode()):
raise HTTPException(
status_code=401,
detail="invalid bearer token",
headers={"WWW-Authenticate": "Bearer"},
)
return Depends(require_bearer_token)
__all__ = ["create_bearer_token_dependency"]
@@ -14,6 +14,7 @@ from collections.abc import Callable
from typing import Annotated
from fastapi import APIRouter, Depends, Header, HTTPException, Query
from fastapi.params import Depends as DependsInstance
from fastapi.responses import StreamingResponse
from dify_agent.protocol.schemas import (
@@ -32,9 +33,11 @@ from dify_agent.storage.redis_run_store import RedisRunStore, RunNotFoundError
def create_runs_router(
get_store: Callable[[], RedisRunStore],
get_scheduler: Callable[[], RunScheduler],
*,
auth_dependency: DependsInstance | None = None,
) -> APIRouter:
"""Create routes bound to the application's store dependency provider."""
router = APIRouter(prefix="/runs", tags=["runs"])
dependencies: list[DependsInstance] = [auth_dependency] if auth_dependency is not None else []
router = APIRouter(prefix="/runs", tags=["runs"], dependencies=dependencies)
async def store_dep() -> RedisRunStore:
return get_store()
@@ -52,6 +52,7 @@ class ServerSettings(BaseSettings):
agent_stub_api_base_url: str | None = Field(default=None, validation_alias="DIFY_AGENT_STUB_API_BASE_URL")
agent_stub_grpc_bind_address: str | None = Field(default=None, validation_alias="DIFY_AGENT_STUB_GRPC_BIND_ADDRESS")
server_secret_key: str | None = None
api_token: str | None = None
shell_redact_patterns: str = ""
outbound_http_connect_timeout: float = Field(default=10.0, ge=0)
outbound_http_read_timeout: float = Field(default=600.0, ge=0)
@@ -0,0 +1,57 @@
from fastapi import FastAPI
from fastapi.testclient import TestClient
from dify_agent.server.auth import create_bearer_token_dependency
def _build_app(expected_token: str | None) -> FastAPI:
app = FastAPI()
dep = create_bearer_token_dependency(expected_token)
@app.get("/protected", dependencies=[dep])
async def protected() -> dict[str, str]:
return {"status": "ok"}
return app
class TestBearerTokenAuthEnabled:
"""Auth is enforced when a non-None token is configured."""
def test_missing_header_returns_401(self) -> None:
client = TestClient(_build_app("secret-token"))
response = client.get("/protected")
assert response.status_code == 401
assert "missing" in response.json()["detail"]
def test_invalid_scheme_returns_401(self) -> None:
client = TestClient(_build_app("secret-token"))
response = client.get("/protected", headers={"Authorization": "Basic abc"})
assert response.status_code == 401
assert "scheme" in response.json()["detail"]
def test_wrong_token_returns_401(self) -> None:
client = TestClient(_build_app("secret-token"))
response = client.get("/protected", headers={"Authorization": "Bearer wrong"})
assert response.status_code == 401
assert "invalid bearer token" in response.json()["detail"]
def test_correct_token_passes(self) -> None:
client = TestClient(_build_app("secret-token"))
response = client.get("/protected", headers={"Authorization": "Bearer secret-token"})
assert response.status_code == 200
assert response.json() == {"status": "ok"}
class TestBearerTokenAuthDisabled:
"""Auth is a no-op when expected_token is None (backward compatibility)."""
def test_no_header_passes_when_token_unconfigured(self) -> None:
client = TestClient(_build_app(None))
response = client.get("/protected")
assert response.status_code == 200
def test_any_header_passes_when_token_unconfigured(self) -> None:
client = TestClient(_build_app(None))
response = client.get("/protected", headers={"Authorization": "Bearer anything"})
assert response.status_code == 200
+4
View File
@@ -253,6 +253,10 @@ MARKETPLACE_URL=
# Dify Agent backend
AGENT_BACKEND_BASE_URL=http://agent_backend:5050
# Bearer token for the Agent backend /runs API.
# Replace this development default in production.
# Generate one with: python -c 'import secrets; print(secrets.token_urlsafe(32))'
DIFY_AGENT_API_TOKEN=dify-agent-run-token-for-dev-only
AGENT_BACKEND_STREAM_READ_TIMEOUT_SECONDS=30
AGENT_BACKEND_STREAM_MAX_RECONNECTS=3
AGENT_BACKEND_RUN_TIMEOUT_SECONDS=1200
+3
View File
@@ -232,6 +232,7 @@ services:
PLUGIN_DAEMON_TIMEOUT: ${PLUGIN_DAEMON_TIMEOUT:-600.0}
INNER_API_KEY_FOR_PLUGIN: ${PLUGIN_DIFY_INNER_API_KEY:-QaHbTe77CtuXmsfyhR7+vRjI/+XbV1AaFy691iy+kGDv2Jvy0/eAh8Y1}
AGENT_BACKEND_BASE_URL: ${AGENT_BACKEND_BASE_URL:-http://agent_backend:5050}
AGENT_BACKEND_API_TOKEN: ${DIFY_AGENT_API_TOKEN:-dify-agent-run-token-for-dev-only}
AGENT_BACKEND_STREAM_READ_TIMEOUT_SECONDS: ${AGENT_BACKEND_STREAM_READ_TIMEOUT_SECONDS:-30}
AGENT_BACKEND_STREAM_MAX_RECONNECTS: ${AGENT_BACKEND_STREAM_MAX_RECONNECTS:-3}
AGENT_BACKEND_RUN_TIMEOUT_SECONDS: ${AGENT_BACKEND_RUN_TIMEOUT_SECONDS:-1200}
@@ -305,6 +306,7 @@ services:
PLUGIN_MAX_PACKAGE_SIZE: ${PLUGIN_MAX_PACKAGE_SIZE:-52428800}
INNER_API_KEY_FOR_PLUGIN: ${PLUGIN_DIFY_INNER_API_KEY:-QaHbTe77CtuXmsfyhR7+vRjI/+XbV1AaFy691iy+kGDv2Jvy0/eAh8Y1}
AGENT_BACKEND_BASE_URL: ${AGENT_BACKEND_BASE_URL:-http://agent_backend:5050}
AGENT_BACKEND_API_TOKEN: ${DIFY_AGENT_API_TOKEN:-dify-agent-run-token-for-dev-only}
AGENT_BACKEND_STREAM_READ_TIMEOUT_SECONDS: ${AGENT_BACKEND_STREAM_READ_TIMEOUT_SECONDS:-30}
AGENT_BACKEND_STREAM_MAX_RECONNECTS: ${AGENT_BACKEND_STREAM_MAX_RECONNECTS:-3}
AGENT_BACKEND_RUN_TIMEOUT_SECONDS: ${AGENT_BACKEND_RUN_TIMEOUT_SECONDS:-1200}
@@ -673,6 +675,7 @@ services:
# Replace this development default in production.
# Generate one with: python -c 'import secrets; print(secrets.token_urlsafe(32))'
DIFY_AGENT_SERVER_SECRET_KEY: ${DIFY_AGENT_SERVER_SECRET_KEY:-MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY}
DIFY_AGENT_API_TOKEN: ${DIFY_AGENT_API_TOKEN:-dify-agent-run-token-for-dev-only}
DIFY_AGENT_SHUTDOWN_GRACE_SECONDS: ${DIFY_AGENT_SHUTDOWN_GRACE_SECONDS:-30}
DIFY_AGENT_RUN_RETENTION_SECONDS: ${DIFY_AGENT_RUN_RETENTION_SECONDS:-259200}
depends_on:
+3
View File
@@ -238,6 +238,7 @@ services:
PLUGIN_DAEMON_TIMEOUT: ${PLUGIN_DAEMON_TIMEOUT:-600.0}
INNER_API_KEY_FOR_PLUGIN: ${PLUGIN_DIFY_INNER_API_KEY:-QaHbTe77CtuXmsfyhR7+vRjI/+XbV1AaFy691iy+kGDv2Jvy0/eAh8Y1}
AGENT_BACKEND_BASE_URL: ${AGENT_BACKEND_BASE_URL:-http://agent_backend:5050}
AGENT_BACKEND_API_TOKEN: ${DIFY_AGENT_API_TOKEN:-dify-agent-run-token-for-dev-only}
AGENT_BACKEND_STREAM_READ_TIMEOUT_SECONDS: ${AGENT_BACKEND_STREAM_READ_TIMEOUT_SECONDS:-30}
AGENT_BACKEND_STREAM_MAX_RECONNECTS: ${AGENT_BACKEND_STREAM_MAX_RECONNECTS:-3}
AGENT_BACKEND_RUN_TIMEOUT_SECONDS: ${AGENT_BACKEND_RUN_TIMEOUT_SECONDS:-1200}
@@ -311,6 +312,7 @@ services:
PLUGIN_MAX_PACKAGE_SIZE: ${PLUGIN_MAX_PACKAGE_SIZE:-52428800}
INNER_API_KEY_FOR_PLUGIN: ${PLUGIN_DIFY_INNER_API_KEY:-QaHbTe77CtuXmsfyhR7+vRjI/+XbV1AaFy691iy+kGDv2Jvy0/eAh8Y1}
AGENT_BACKEND_BASE_URL: ${AGENT_BACKEND_BASE_URL:-http://agent_backend:5050}
AGENT_BACKEND_API_TOKEN: ${DIFY_AGENT_API_TOKEN:-dify-agent-run-token-for-dev-only}
AGENT_BACKEND_STREAM_READ_TIMEOUT_SECONDS: ${AGENT_BACKEND_STREAM_READ_TIMEOUT_SECONDS:-30}
AGENT_BACKEND_STREAM_MAX_RECONNECTS: ${AGENT_BACKEND_STREAM_MAX_RECONNECTS:-3}
AGENT_BACKEND_RUN_TIMEOUT_SECONDS: ${AGENT_BACKEND_RUN_TIMEOUT_SECONDS:-1200}
@@ -679,6 +681,7 @@ services:
# Replace this development default in production.
# Generate one with: python -c 'import secrets; print(secrets.token_urlsafe(32))'
DIFY_AGENT_SERVER_SECRET_KEY: ${DIFY_AGENT_SERVER_SECRET_KEY:-MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY}
DIFY_AGENT_API_TOKEN: ${DIFY_AGENT_API_TOKEN:-dify-agent-run-token-for-dev-only}
DIFY_AGENT_SHUTDOWN_GRACE_SECONDS: ${DIFY_AGENT_SHUTDOWN_GRACE_SECONDS:-30}
DIFY_AGENT_RUN_RETENTION_SECONDS: ${DIFY_AGENT_RUN_RETENTION_SECONDS:-259200}
depends_on: