From 3c80857ea34e21f3800b17d864623658bdb9cea0 Mon Sep 17 00:00:00 2001 From: Yunlu Wen Date: Mon, 27 Jul 2026 16:37:30 +0800 Subject: [PATCH] feat: add bearer auth to agent backend (#39622) Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- api/.env.example | 2 + api/clients/agent_backend/factory.py | 6 +- api/configs/extra/agent_backend_config.py | 5 ++ api/core/app/apps/agent_app/app_generator.py | 1 + api/core/workflow/node_factory.py | 1 + .../agent_backend_session_cleanup_task.py | 1 + .../configs/test_env_consistency.py | 2 + dify-agent/.example.env | 6 ++ dify-agent/src/dify_agent/server/app.py | 9 ++- dify-agent/src/dify_agent/server/auth.py | 43 ++++++++++++++ .../src/dify_agent/server/routes/runs.py | 7 ++- dify-agent/src/dify_agent/server/settings.py | 1 + .../local/dify_agent/server/test_auth.py | 57 +++++++++++++++++++ docker/.env.example | 4 ++ docker/docker-compose-template.yaml | 3 + docker/docker-compose.yaml | 3 + 16 files changed, 147 insertions(+), 4 deletions(-) create mode 100644 dify-agent/src/dify_agent/server/auth.py create mode 100644 dify-agent/tests/local/dify_agent/server/test_auth.py diff --git a/api/.env.example b/api/.env.example index 3e600365806..d9fed2d9318 100644 --- a/api/.env.example +++ b/api/.env.example @@ -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 diff --git a/api/clients/agent_backend/factory.py b/api/clients/agent_backend/factory.py index 0fcbf02bf70..2fd9c6faf4a 100644 --- a/api/clients/agent_backend/factory.py +++ b/api/clients/agent_backend/factory.py @@ -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, ) diff --git a/api/configs/extra/agent_backend_config.py b/api/configs/extra/agent_backend_config.py index 7baad3d0b44..d5caf3d2e3c 100644 --- a/api/configs/extra/agent_backend_config.py +++ b/api/configs/extra/agent_backend_config.py @@ -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, diff --git a/api/core/app/apps/agent_app/app_generator.py b/api/core/app/apps/agent_app/app_generator.py index 26e5d0cdcb6..2f343202f4b 100644 --- a/api/core/app/apps/agent_app/app_generator.py +++ b/api/core/app/apps/agent_app/app_generator.py @@ -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, diff --git a/api/core/workflow/node_factory.py b/api/core/workflow/node_factory.py index 3b47e32adf1..02fdb379a45 100644 --- a/api/core/workflow/node_factory.py +++ b/api/core/workflow/node_factory.py @@ -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, diff --git a/api/tasks/agent_backend_session_cleanup_task.py b/api/tasks/agent_backend_session_cleanup_task.py index f1316266db7..0dbc32b45e6 100644 --- a/api/tasks/agent_backend_session_cleanup_task.py +++ b/api/tasks/agent_backend_session_cleanup_task.py @@ -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, diff --git a/api/tests/unit_tests/configs/test_env_consistency.py b/api/tests/unit_tests/configs/test_env_consistency.py index 81e08638145..1afdd307b8c 100644 --- a/api/tests/unit_tests/configs/test_env_consistency.py +++ b/api/tests/unit_tests/configs/test_env_consistency.py @@ -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", diff --git a/dify-agent/.example.env b/dify-agent/.example.env index 26f25293efd..4f11bb9747d 100644 --- a/dify-agent/.example.env +++ b/dify-agent/.example.env @@ -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 diff --git a/dify-agent/src/dify_agent/server/app.py b/dify-agent/src/dify_agent/server/app.py index 1013cf89bb0..1fadd208d57 100644 --- a/dify-agent/src/dify_agent/server/app.py +++ b/dify-agent/src/dify_agent/server/app.py @@ -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( diff --git a/dify-agent/src/dify_agent/server/auth.py b/dify-agent/src/dify_agent/server/auth.py new file mode 100644 index 00000000000..a991639a3ec --- /dev/null +++ b/dify-agent/src/dify_agent/server/auth.py @@ -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"] diff --git a/dify-agent/src/dify_agent/server/routes/runs.py b/dify-agent/src/dify_agent/server/routes/runs.py index f41567648e8..031678789bc 100644 --- a/dify-agent/src/dify_agent/server/routes/runs.py +++ b/dify-agent/src/dify_agent/server/routes/runs.py @@ -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() diff --git a/dify-agent/src/dify_agent/server/settings.py b/dify-agent/src/dify_agent/server/settings.py index d6e8dfbcb4f..c24981d0e73 100644 --- a/dify-agent/src/dify_agent/server/settings.py +++ b/dify-agent/src/dify_agent/server/settings.py @@ -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) diff --git a/dify-agent/tests/local/dify_agent/server/test_auth.py b/dify-agent/tests/local/dify_agent/server/test_auth.py new file mode 100644 index 00000000000..fa561b3bf56 --- /dev/null +++ b/dify-agent/tests/local/dify_agent/server/test_auth.py @@ -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 diff --git a/docker/.env.example b/docker/.env.example index 3b3de9cd976..0bc034d004b 100644 --- a/docker/.env.example +++ b/docker/.env.example @@ -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 diff --git a/docker/docker-compose-template.yaml b/docker/docker-compose-template.yaml index 7049e237ba4..016177008b0 100644 --- a/docker/docker-compose-template.yaml +++ b/docker/docker-compose-template.yaml @@ -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: diff --git a/docker/docker-compose.yaml b/docker/docker-compose.yaml index 5eae7d4d3ca..e95fce741b7 100644 --- a/docker/docker-compose.yaml +++ b/docker/docker-compose.yaml @@ -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: