feat: Add webhook user authentication under WEBHOOK_AUTH_ENABLE variable (#9139)
* 📝 (endpoints.py): Add get_webhook_user function to handle webhook user authentication 🔧 (endpoints.py): Update webhook_run_flow endpoint to use get_webhook_user for authentication 🔧 (utils.py): Add get_webhook_user function to handle webhook user authentication in services.auth ✅ (test_webhook.py): Add tests for webhook endpoint authentication and authorization * Update src/backend/base/langflow/services/auth/utils.py Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * [autofix.ci] apply automated fixes * Update src/backend/base/langflow/services/auth/utils.py Co-authored-by: Gabriel Luiz Freitas Almeida <gabriel@langflow.org> * 🔧 (utils.py): refactor authentication logic to use existing api_key_security function for better code reuse and readability * [autofix.ci] apply automated fixes * 📝 (endpoints.py): Update ConfigResponse class to include webhook_auth_enable field and modify from_settings method to accept auth_settings parameter 📝 (endpoints.py): Update get_config function to pass auth_settings to ConfigResponse.from_settings method 📝 (utils.py): Update get_webhook_user function to use WEBHOOK_AUTH_ENABLE setting for authentication logic 📝 (auth.py): Add WEBHOOK_AUTH_ENABLE setting to AuthSettings class 📝 (index.tsx): Add webhookAuthEnable state and setWebhookAuthEnable function to utilityStore 📝 (use-get-config.ts): Update useGetConfig hook to set webhook_auth_enable value from API response 📝 (get-curl-code.tsx): Update getCurlWebhookCode function to use webhookAuthEnable instead of isAuth parameter 📝 (utilityStore.ts): Add webhookAuthEnable state and setWebhookAuthEnable function to utilityStore 📝 (index.ts): Update GetCodeType type to use webhookAuthEnable instead of isAuth parameter * refactor: Simplify error messages in get_webhook_user function - Updated HTTPException messages for flow not found and access denied scenarios to be more concise and user-friendly. - Improved logging for invalid API key validation to enhance clarity. * 🐛 (test_webhook.py): fix test descriptions to accurately reflect the conditions being tested 📝 (test_webhook.py): update test descriptions to improve clarity and consistency with actual test conditions * 🐛 (utils.py): Fix issue where HTTPException was not properly handled when flow owner is not found in get_webhook_user function. Added explicit check and raise HTTPException with appropriate status code and detail message. * 📝 (test_mcp_util.py): add conditional skip for test when DeepWiki server is rate limiting requests --------- Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: Gabriel Luiz Freitas Almeida <gabriel@langflow.org>
This commit is contained in:
parent
ab017bafcd
commit
836faa73d6
12 changed files with 221 additions and 41 deletions
|
|
@ -38,12 +38,11 @@ from langflow.exceptions.serialization import SerializationError
|
||||||
from langflow.graph.graph.base import Graph
|
from langflow.graph.graph.base import Graph
|
||||||
from langflow.graph.schema import RunOutputs
|
from langflow.graph.schema import RunOutputs
|
||||||
from langflow.helpers.flow import get_flow_by_id_or_endpoint_name
|
from langflow.helpers.flow import get_flow_by_id_or_endpoint_name
|
||||||
from langflow.helpers.user import get_user_by_flow_id_or_endpoint_name
|
|
||||||
from langflow.interface.initialize.loading import update_params_with_load_from_db_fields
|
from langflow.interface.initialize.loading import update_params_with_load_from_db_fields
|
||||||
from langflow.logging.logger import logger
|
from langflow.logging.logger import logger
|
||||||
from langflow.processing.process import process_tweaks, run_graph_internal
|
from langflow.processing.process import process_tweaks, run_graph_internal
|
||||||
from langflow.schema.graph import Tweaks
|
from langflow.schema.graph import Tweaks
|
||||||
from langflow.services.auth.utils import api_key_security, get_current_active_user
|
from langflow.services.auth.utils import api_key_security, get_current_active_user, get_webhook_user
|
||||||
from langflow.services.cache.utils import save_uploaded_file
|
from langflow.services.cache.utils import save_uploaded_file
|
||||||
from langflow.services.database.models.flow.model import Flow, FlowRead
|
from langflow.services.database.models.flow.model import Flow, FlowRead
|
||||||
from langflow.services.database.models.flow.utils import get_all_webhook_components_in_flow
|
from langflow.services.database.models.flow.utils import get_all_webhook_components_in_flow
|
||||||
|
|
@ -399,16 +398,16 @@ async def simplified_run_flow(
|
||||||
|
|
||||||
@router.post("/webhook/{flow_id_or_name}", response_model=dict, status_code=HTTPStatus.ACCEPTED) # noqa: RUF100, FAST003
|
@router.post("/webhook/{flow_id_or_name}", response_model=dict, status_code=HTTPStatus.ACCEPTED) # noqa: RUF100, FAST003
|
||||||
async def webhook_run_flow(
|
async def webhook_run_flow(
|
||||||
|
flow_id_or_name: str,
|
||||||
flow: Annotated[Flow, Depends(get_flow_by_id_or_endpoint_name)],
|
flow: Annotated[Flow, Depends(get_flow_by_id_or_endpoint_name)],
|
||||||
user: Annotated[User, Depends(get_user_by_flow_id_or_endpoint_name)],
|
|
||||||
request: Request,
|
request: Request,
|
||||||
background_tasks: BackgroundTasks,
|
background_tasks: BackgroundTasks,
|
||||||
):
|
):
|
||||||
"""Run a flow using a webhook request.
|
"""Run a flow using a webhook request.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
flow (Flow, optional): The flow to be executed. Defaults to Depends(get_flow_by_id).
|
flow_id_or_name (str): The flow ID or endpoint name.
|
||||||
user (User): The flow user.
|
flow (Flow): The flow to be executed.
|
||||||
request (Request): The incoming HTTP request.
|
request (Request): The incoming HTTP request.
|
||||||
background_tasks (BackgroundTasks): The background tasks manager.
|
background_tasks (BackgroundTasks): The background tasks manager.
|
||||||
|
|
||||||
|
|
@ -422,6 +421,10 @@ async def webhook_run_flow(
|
||||||
start_time = time.perf_counter()
|
start_time = time.perf_counter()
|
||||||
await logger.adebug("Received webhook request")
|
await logger.adebug("Received webhook request")
|
||||||
error_msg = ""
|
error_msg = ""
|
||||||
|
|
||||||
|
# Get the appropriate user for webhook execution based on auth settings
|
||||||
|
webhook_user = await get_webhook_user(flow_id_or_name, request)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
try:
|
try:
|
||||||
data = await request.body()
|
data = await request.body()
|
||||||
|
|
@ -453,7 +456,7 @@ async def webhook_run_flow(
|
||||||
simple_run_flow_task,
|
simple_run_flow_task,
|
||||||
flow=flow,
|
flow=flow,
|
||||||
input_request=input_request,
|
input_request=input_request,
|
||||||
api_key_user=user,
|
api_key_user=webhook_user,
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
error_msg = str(exc)
|
error_msg = str(exc)
|
||||||
|
|
@ -762,7 +765,7 @@ async def get_config() -> ConfigResponse:
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
settings_service: SettingsService = get_settings_service()
|
settings_service: SettingsService = get_settings_service()
|
||||||
return ConfigResponse.from_settings(settings_service.settings)
|
return ConfigResponse.from_settings(settings_service.settings, settings_service.auth_settings)
|
||||||
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
||||||
|
|
|
||||||
|
|
@ -407,13 +407,15 @@ class ConfigResponse(BaseModel):
|
||||||
public_flow_cleanup_interval: int
|
public_flow_cleanup_interval: int
|
||||||
public_flow_expiration: int
|
public_flow_expiration: int
|
||||||
event_delivery: Literal["polling", "streaming", "direct"]
|
event_delivery: Literal["polling", "streaming", "direct"]
|
||||||
|
webhook_auth_enable: bool
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_settings(cls, settings: Settings) -> "ConfigResponse":
|
def from_settings(cls, settings: Settings, auth_settings) -> "ConfigResponse":
|
||||||
"""Create a ConfigResponse instance using values from a Settings object and global feature flags.
|
"""Create a ConfigResponse instance using values from a Settings object and AuthSettings.
|
||||||
|
|
||||||
Parameters:
|
Parameters:
|
||||||
settings (Settings): The Settings object containing configuration values.
|
settings (Settings): The Settings object containing configuration values.
|
||||||
|
auth_settings: The AuthSettings object containing authentication configuration values.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
ConfigResponse: An instance populated with configuration and feature flag values.
|
ConfigResponse: An instance populated with configuration and feature flag values.
|
||||||
|
|
@ -431,6 +433,7 @@ class ConfigResponse(BaseModel):
|
||||||
public_flow_cleanup_interval=settings.public_flow_cleanup_interval,
|
public_flow_cleanup_interval=settings.public_flow_cleanup_interval,
|
||||||
public_flow_expiration=settings.public_flow_expiration,
|
public_flow_expiration=settings.public_flow_expiration,
|
||||||
event_delivery=settings.event_delivery,
|
event_delivery=settings.event_delivery,
|
||||||
|
webhook_auth_enable=auth_settings.WEBHOOK_AUTH_ENABLE,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ from typing import TYPE_CHECKING, Annotated
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from cryptography.fernet import Fernet
|
from cryptography.fernet import Fernet
|
||||||
from fastapi import Depends, HTTPException, Security, WebSocketException, status
|
from fastapi import Depends, HTTPException, Request, Security, WebSocketException, status
|
||||||
from fastapi.security import APIKeyHeader, APIKeyQuery, OAuth2PasswordBearer
|
from fastapi.security import APIKeyHeader, APIKeyQuery, OAuth2PasswordBearer
|
||||||
from jose import JWTError, jwt
|
from jose import JWTError, jwt
|
||||||
from sqlalchemy.exc import IntegrityError
|
from sqlalchemy.exc import IntegrityError
|
||||||
|
|
@ -259,6 +259,83 @@ async def get_current_active_superuser(current_user: Annotated[User, Depends(get
|
||||||
return current_user
|
return current_user
|
||||||
|
|
||||||
|
|
||||||
|
async def get_webhook_user(flow_id: str, request: Request) -> UserRead:
|
||||||
|
"""Get the user for webhook execution.
|
||||||
|
|
||||||
|
When WEBHOOK_AUTH_ENABLE=false, allows execution as the flow owner without API key.
|
||||||
|
When WEBHOOK_AUTH_ENABLE=true, requires API key authentication and validates flow ownership.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
flow_id: The ID of the flow being executed
|
||||||
|
request: The FastAPI request object
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
UserRead: The user to execute the webhook as
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException: If authentication fails or user doesn't have permission
|
||||||
|
"""
|
||||||
|
from langflow.helpers.user import get_user_by_flow_id_or_endpoint_name
|
||||||
|
|
||||||
|
settings_service = get_settings_service()
|
||||||
|
|
||||||
|
if not settings_service.auth_settings.WEBHOOK_AUTH_ENABLE:
|
||||||
|
# When webhook auth is disabled, run webhook as the flow owner without requiring API key
|
||||||
|
try:
|
||||||
|
flow_owner = await get_user_by_flow_id_or_endpoint_name(flow_id)
|
||||||
|
if flow_owner is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Flow not found")
|
||||||
|
return flow_owner # noqa: TRY300
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as exc:
|
||||||
|
raise HTTPException(status_code=404, detail="Flow not found") from exc
|
||||||
|
|
||||||
|
# When webhook auth is enabled, require API key authentication
|
||||||
|
api_key_header_val = request.headers.get("x-api-key")
|
||||||
|
api_key_query_val = request.query_params.get("x-api-key")
|
||||||
|
|
||||||
|
# Check if API key is provided
|
||||||
|
if not api_key_header_val and not api_key_query_val:
|
||||||
|
raise HTTPException(status_code=403, detail="API key required when webhook authentication is enabled")
|
||||||
|
|
||||||
|
# Use the provided API key (prefer header over query param)
|
||||||
|
api_key = api_key_header_val or api_key_query_val
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Validate API key directly without AUTO_LOGIN fallback
|
||||||
|
async with get_db_service().with_session() as db:
|
||||||
|
result = await check_key(db, api_key)
|
||||||
|
if not result:
|
||||||
|
logger.warning("Invalid API key provided for webhook")
|
||||||
|
raise HTTPException(status_code=403, detail="Invalid API key")
|
||||||
|
|
||||||
|
authenticated_user = UserRead.model_validate(result, from_attributes=True)
|
||||||
|
logger.info("Webhook API key validated successfully")
|
||||||
|
except HTTPException:
|
||||||
|
# Re-raise HTTP exceptions as-is
|
||||||
|
raise
|
||||||
|
except Exception as exc:
|
||||||
|
# Handle other exceptions
|
||||||
|
logger.error(f"Webhook API key validation error: {exc}")
|
||||||
|
raise HTTPException(status_code=403, detail="API key authentication failed") from exc
|
||||||
|
|
||||||
|
# Get flow owner to check if authenticated user owns this flow
|
||||||
|
try:
|
||||||
|
flow_owner = await get_user_by_flow_id_or_endpoint_name(flow_id)
|
||||||
|
if flow_owner is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Flow not found")
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as exc:
|
||||||
|
raise HTTPException(status_code=404, detail="Flow not found") from exc
|
||||||
|
|
||||||
|
if flow_owner.id != authenticated_user.id:
|
||||||
|
raise HTTPException(status_code=403, detail="Access denied: You can only execute webhooks for flows you own")
|
||||||
|
|
||||||
|
return authenticated_user
|
||||||
|
|
||||||
|
|
||||||
def verify_password(plain_password, hashed_password):
|
def verify_password(plain_password, hashed_password):
|
||||||
settings_service = get_settings_service()
|
settings_service = get_settings_service()
|
||||||
return settings_service.auth_settings.pwd_context.verify(plain_password, hashed_password)
|
return settings_service.auth_settings.pwd_context.verify(plain_password, hashed_password)
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,10 @@ class AuthSettings(BaseSettings):
|
||||||
"""If True, the application will skip authentication when AUTO_LOGIN is enabled.
|
"""If True, the application will skip authentication when AUTO_LOGIN is enabled.
|
||||||
This will be removed in v1.6"""
|
This will be removed in v1.6"""
|
||||||
|
|
||||||
|
WEBHOOK_AUTH_ENABLE: bool = False
|
||||||
|
"""If True, webhook endpoints will require API key authentication.
|
||||||
|
If False, webhooks run as flow owner without authentication."""
|
||||||
|
|
||||||
ENABLE_SUPERUSER_CLI: bool = Field(
|
ENABLE_SUPERUSER_CLI: bool = Field(
|
||||||
default=True,
|
default=True,
|
||||||
description="Allow creation of superusers via CLI. Set to False in production for security.",
|
description="Allow creation of superusers via CLI. Set to False in production for security.",
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import sys
|
||||||
from unittest.mock import AsyncMock, MagicMock, patch
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from langflow.base.mcp import util
|
from langflow.base.mcp import util
|
||||||
from langflow.base.mcp.util import MCPSessionManager, MCPSseClient, MCPStdioClient, _process_headers, validate_headers
|
from langflow.base.mcp.util import MCPSessionManager, MCPSseClient, MCPStdioClient, _process_headers, validate_headers
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,31 +8,67 @@ def _check_openai_api_key_in_environment_variables():
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
async def test_webhook_endpoint(client, added_webhook_test):
|
async def test_webhook_endpoint_requires_api_key_when_auto_login_false(client, added_webhook_test):
|
||||||
# The test is as follows:
|
"""Test that webhook endpoint requires API key when WEBHOOK_AUTH_ENABLE=true."""
|
||||||
# 1. The flow when run will get a "path" from the payload and save a file with the path as the name.
|
# Mock the settings service to enable webhook authentication
|
||||||
# We will create a temporary file path and send it to the webhook endpoint, then check if the file exists.
|
from unittest.mock import patch
|
||||||
# 2. we will delete the file, then send an invalid payload to the webhook endpoint and check if the file exists.
|
|
||||||
|
with patch("langflow.services.auth.utils.get_settings_service") as mock_settings:
|
||||||
|
mock_auth_settings = type("AuthSettings", (), {"WEBHOOK_AUTH_ENABLE": True})()
|
||||||
|
mock_settings_service = type("SettingsService", (), {"auth_settings": mock_auth_settings})()
|
||||||
|
mock_settings.return_value = mock_settings_service
|
||||||
|
|
||||||
|
endpoint_name = added_webhook_test["endpoint_name"]
|
||||||
|
endpoint = f"api/v1/webhook/{endpoint_name}"
|
||||||
|
|
||||||
|
payload = {"path": "/tmp/test_file.txt"} # noqa: S108
|
||||||
|
|
||||||
|
# Should fail without API key when webhook auth is enabled
|
||||||
|
response = await client.post(endpoint, json=payload)
|
||||||
|
assert response.status_code == 403
|
||||||
|
assert "API key required when webhook authentication is enabled" in response.json()["detail"]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_webhook_endpoint_with_valid_api_key(client, added_webhook_test, created_api_key):
|
||||||
|
"""Test that webhook works when valid API key is provided."""
|
||||||
endpoint_name = added_webhook_test["endpoint_name"]
|
endpoint_name = added_webhook_test["endpoint_name"]
|
||||||
endpoint = f"api/v1/webhook/{endpoint_name}"
|
endpoint = f"api/v1/webhook/{endpoint_name}"
|
||||||
|
|
||||||
# Create a temporary file
|
# Create a temporary file
|
||||||
async with aiofiles.tempfile.TemporaryDirectory() as tmp:
|
async with aiofiles.tempfile.TemporaryDirectory() as tmp:
|
||||||
file_path = anyio.Path(tmp) / "test_file.txt"
|
file_path = anyio.Path(tmp) / "test_file.txt"
|
||||||
|
|
||||||
payload = {"path": str(file_path)}
|
payload = {"path": str(file_path)}
|
||||||
|
|
||||||
response = await client.post(endpoint, json=payload)
|
# Should work with valid API key
|
||||||
|
response = await client.post(endpoint, headers={"x-api-key": created_api_key.api_key}, json=payload)
|
||||||
assert response.status_code == 202
|
assert response.status_code == 202
|
||||||
# Wait a few seconds for the file to be created
|
|
||||||
assert await file_path.exists(), f"File {file_path} does not exist"
|
assert await file_path.exists(), f"File {file_path} does not exist"
|
||||||
|
|
||||||
file_does_not_exist = not await file_path.exists()
|
file_does_not_exist = not await file_path.exists()
|
||||||
assert file_does_not_exist, f"File {file_path} still exists"
|
assert file_does_not_exist, f"File {file_path} still exists"
|
||||||
|
|
||||||
# Send an invalid payload
|
|
||||||
payload = {"invalid_key": "invalid_value"}
|
async def test_webhook_endpoint_unauthorized_user_flow(client, added_webhook_test):
|
||||||
response = await client.post(endpoint, json=payload)
|
"""Test that webhook fails when user doesn't own the flow."""
|
||||||
assert response.status_code == 202
|
# Mock the settings service to enable webhook authentication
|
||||||
assert not await file_path.exists(), f"File {file_path} should not exist"
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
with patch("langflow.services.auth.utils.get_settings_service") as mock_settings:
|
||||||
|
mock_auth_settings = type("AuthSettings", (), {"WEBHOOK_AUTH_ENABLE": True})()
|
||||||
|
mock_settings_service = type("SettingsService", (), {"auth_settings": mock_auth_settings})()
|
||||||
|
mock_settings.return_value = mock_settings_service
|
||||||
|
|
||||||
|
# This test would need a different user's API key to test authorization
|
||||||
|
# For now, we'll use an invalid API key to simulate this
|
||||||
|
endpoint_name = added_webhook_test["endpoint_name"]
|
||||||
|
endpoint = f"api/v1/webhook/{endpoint_name}"
|
||||||
|
|
||||||
|
payload = {"path": "/tmp/test_file.txt"} # noqa: S108
|
||||||
|
|
||||||
|
# Should fail with invalid API key
|
||||||
|
response = await client.post(endpoint, headers={"x-api-key": "invalid_key"}, json=payload)
|
||||||
|
assert response.status_code == 403
|
||||||
|
assert "Invalid API key" in response.json()["detail"]
|
||||||
|
|
||||||
|
|
||||||
async def test_webhook_flow_on_run_endpoint(client, added_webhook_test, created_api_key):
|
async def test_webhook_flow_on_run_endpoint(client, added_webhook_test, created_api_key):
|
||||||
|
|
@ -47,12 +83,47 @@ async def test_webhook_flow_on_run_endpoint(client, added_webhook_test, created_
|
||||||
assert response.status_code == 200, response.json()
|
assert response.status_code == 200, response.json()
|
||||||
|
|
||||||
|
|
||||||
async def test_webhook_with_random_payload(client, added_webhook_test):
|
async def test_webhook_with_auto_login_enabled(client, added_webhook_test):
|
||||||
endpoint_name = added_webhook_test["endpoint_name"]
|
"""Test webhook behavior when WEBHOOK_AUTH_ENABLE=false - should work without API key."""
|
||||||
endpoint = f"api/v1/webhook/{endpoint_name}"
|
# Mock the settings service to disable webhook authentication (default behavior)
|
||||||
# Just test that "Random Payload" returns 202
|
from unittest.mock import patch
|
||||||
response = await client.post(
|
|
||||||
endpoint,
|
with patch("langflow.services.auth.utils.get_settings_service") as mock_settings:
|
||||||
json="Random Payload",
|
mock_auth_settings = type("AuthSettings", (), {"WEBHOOK_AUTH_ENABLE": False})()
|
||||||
)
|
mock_settings_service = type("SettingsService", (), {"auth_settings": mock_auth_settings})()
|
||||||
assert response.status_code == 202
|
mock_settings.return_value = mock_settings_service
|
||||||
|
|
||||||
|
endpoint_name = added_webhook_test["endpoint_name"]
|
||||||
|
endpoint = f"api/v1/webhook/{endpoint_name}"
|
||||||
|
|
||||||
|
payload = {"path": "/tmp/test_auto_login.txt"} # noqa: S108
|
||||||
|
|
||||||
|
# Should work without API key when webhook auth is disabled
|
||||||
|
response = await client.post(endpoint, json=payload)
|
||||||
|
assert response.status_code == 202
|
||||||
|
|
||||||
|
|
||||||
|
async def test_webhook_with_random_payload_requires_auth(client, added_webhook_test, created_api_key):
|
||||||
|
"""Test that webhook with random payload still requires authentication."""
|
||||||
|
# Mock the settings service to enable webhook authentication
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
with patch("langflow.services.auth.utils.get_settings_service") as mock_settings:
|
||||||
|
mock_auth_settings = type("AuthSettings", (), {"WEBHOOK_AUTH_ENABLE": True})()
|
||||||
|
mock_settings_service = type("SettingsService", (), {"auth_settings": mock_auth_settings})()
|
||||||
|
mock_settings.return_value = mock_settings_service
|
||||||
|
|
||||||
|
endpoint_name = added_webhook_test["endpoint_name"]
|
||||||
|
endpoint = f"api/v1/webhook/{endpoint_name}"
|
||||||
|
|
||||||
|
# Should fail without API key
|
||||||
|
response = await client.post(endpoint, json="Random Payload")
|
||||||
|
assert response.status_code == 403
|
||||||
|
|
||||||
|
# Should work with API key (even with random payload)
|
||||||
|
response = await client.post(
|
||||||
|
endpoint,
|
||||||
|
headers={"x-api-key": created_api_key.api_key},
|
||||||
|
json="Random Payload",
|
||||||
|
)
|
||||||
|
assert response.status_code == 202
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { GRADIENT_CLASS } from "@/constants/constants";
|
||||||
import { customGetHostProtocol } from "@/customization/utils/custom-get-host-protocol";
|
import { customGetHostProtocol } from "@/customization/utils/custom-get-host-protocol";
|
||||||
import { getCurlWebhookCode } from "@/modals/apiModal/utils/get-curl-code";
|
import { getCurlWebhookCode } from "@/modals/apiModal/utils/get-curl-code";
|
||||||
import ComponentTextModal from "@/modals/textAreaModal";
|
import ComponentTextModal from "@/modals/textAreaModal";
|
||||||
|
import { useUtilityStore } from "@/stores/utilityStore";
|
||||||
import { cn } from "../../../../../utils/utils";
|
import { cn } from "../../../../../utils/utils";
|
||||||
import IconComponent from "../../../../common/genericIconComponent";
|
import IconComponent from "../../../../common/genericIconComponent";
|
||||||
import { Input } from "../../../../ui/input";
|
import { Input } from "../../../../ui/input";
|
||||||
|
|
@ -74,6 +75,7 @@ export default function TextAreaComponent({
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
const [isFocused, setIsFocused] = useState(false);
|
const [isFocused, setIsFocused] = useState(false);
|
||||||
const [passwordVisible, setPasswordVisible] = useState(false);
|
const [passwordVisible, setPasswordVisible] = useState(false);
|
||||||
|
const webhookAuthEnable = useUtilityStore((state) => state.webhookAuthEnable);
|
||||||
const [cursor, setCursor] = useState<number | null>(null);
|
const [cursor, setCursor] = useState<number | null>(null);
|
||||||
|
|
||||||
const isWebhook = useMemo(
|
const isWebhook = useMemo(
|
||||||
|
|
@ -90,7 +92,7 @@ export default function TextAreaComponent({
|
||||||
if (isWebhook && value === WEBHOOK_VALUE) {
|
if (isWebhook && value === WEBHOOK_VALUE) {
|
||||||
const curlWebhookCode = getCurlWebhookCode({
|
const curlWebhookCode = getCurlWebhookCode({
|
||||||
flowId: nodeInformationMetadata?.flowId!,
|
flowId: nodeInformationMetadata?.flowId!,
|
||||||
isAuth: nodeInformationMetadata?.isAuth!,
|
webhookAuthEnable,
|
||||||
flowName: nodeInformationMetadata?.flowName!,
|
flowName: nodeInformationMetadata?.flowName!,
|
||||||
format: "singleline",
|
format: "singleline",
|
||||||
});
|
});
|
||||||
|
|
@ -99,7 +101,13 @@ export default function TextAreaComponent({
|
||||||
const mcpSSEUrl = `${URL_MCP_SSE}`;
|
const mcpSSEUrl = `${URL_MCP_SSE}`;
|
||||||
handleOnNewValue({ value: mcpSSEUrl });
|
handleOnNewValue({ value: mcpSSEUrl });
|
||||||
}
|
}
|
||||||
}, [isWebhook, value, nodeInformationMetadata, handleOnNewValue]);
|
}, [
|
||||||
|
isWebhook,
|
||||||
|
value,
|
||||||
|
nodeInformationMetadata,
|
||||||
|
handleOnNewValue,
|
||||||
|
webhookAuthEnable,
|
||||||
|
]);
|
||||||
|
|
||||||
// Restore cursor position after value changes
|
// Restore cursor position after value changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -127,7 +135,7 @@ export default function TextAreaComponent({
|
||||||
if (isWebhook) {
|
if (isWebhook) {
|
||||||
const curlWebhookCode = getCurlWebhookCode({
|
const curlWebhookCode = getCurlWebhookCode({
|
||||||
flowId: nodeInformationMetadata?.flowId!,
|
flowId: nodeInformationMetadata?.flowId!,
|
||||||
isAuth: nodeInformationMetadata?.isAuth!,
|
webhookAuthEnable,
|
||||||
flowName: nodeInformationMetadata?.flowName!,
|
flowName: nodeInformationMetadata?.flowName!,
|
||||||
format,
|
format,
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ export interface ConfigResponse {
|
||||||
webhook_polling_interval: number;
|
webhook_polling_interval: number;
|
||||||
serialization_max_items_length: number;
|
serialization_max_items_length: number;
|
||||||
event_delivery: EventDeliveryType;
|
event_delivery: EventDeliveryType;
|
||||||
|
webhook_auth_enable: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useGetConfig: useQueryFunctionType<undefined, ConfigResponse> = (
|
export const useGetConfig: useQueryFunctionType<undefined, ConfigResponse> = (
|
||||||
|
|
@ -44,6 +45,9 @@ export const useGetConfig: useQueryFunctionType<undefined, ConfigResponse> = (
|
||||||
(state) => state.setWebhookPollingInterval,
|
(state) => state.setWebhookPollingInterval,
|
||||||
);
|
);
|
||||||
const setEventDelivery = useUtilityStore((state) => state.setEventDelivery);
|
const setEventDelivery = useUtilityStore((state) => state.setEventDelivery);
|
||||||
|
const setWebhookAuthEnable = useUtilityStore(
|
||||||
|
(state) => state.setWebhookAuthEnable,
|
||||||
|
);
|
||||||
|
|
||||||
const { query } = UseRequestProcessor();
|
const { query } = UseRequestProcessor();
|
||||||
|
|
||||||
|
|
@ -66,6 +70,7 @@ export const useGetConfig: useQueryFunctionType<undefined, ConfigResponse> = (
|
||||||
data.webhook_polling_interval ?? DEFAULT_POLLING_INTERVAL,
|
data.webhook_polling_interval ?? DEFAULT_POLLING_INTERVAL,
|
||||||
);
|
);
|
||||||
setEventDelivery(data.event_delivery ?? EventDeliveryType.POLLING);
|
setEventDelivery(data.event_delivery ?? EventDeliveryType.POLLING);
|
||||||
|
setWebhookAuthEnable(data.webhook_auth_enable ?? true);
|
||||||
}
|
}
|
||||||
return data;
|
return data;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -13,21 +13,24 @@ import {
|
||||||
*
|
*
|
||||||
* @param {Object} options - The options for generating the cURL command.
|
* @param {Object} options - The options for generating the cURL command.
|
||||||
* @param {string} options.flowId - The ID of the flow.
|
* @param {string} options.flowId - The ID of the flow.
|
||||||
* @param {boolean} options.isAuth - Indicates whether authentication is required.
|
* @param {boolean} options.webhookAuthEnable - Indicates whether authentication is required for webhooks.
|
||||||
* @param {string} options.endpointName - The name of the webhook endpoint.
|
* @param {string} options.endpointName - The name of the webhook endpoint.
|
||||||
* @returns {string} The cURL command.
|
* @returns {string} The cURL command.
|
||||||
*/
|
*/
|
||||||
export function getCurlWebhookCode({
|
export function getCurlWebhookCode({
|
||||||
flowId,
|
flowId,
|
||||||
isAuth,
|
webhookAuthEnable,
|
||||||
endpointName,
|
endpointName,
|
||||||
format = "multiline",
|
format = "multiline",
|
||||||
}: GetCodeType & { format?: "multiline" | "singleline" }) {
|
}: GetCodeType & {
|
||||||
|
webhookAuthEnable: boolean;
|
||||||
|
format?: "multiline" | "singleline";
|
||||||
|
}) {
|
||||||
const { protocol, host } = customGetHostProtocol();
|
const { protocol, host } = customGetHostProtocol();
|
||||||
const baseUrl = `${protocol}//${host}/api/v1/webhook/${
|
const baseUrl = `${protocol}//${host}/api/v1/webhook/${
|
||||||
endpointName || flowId
|
endpointName || flowId
|
||||||
}`;
|
}`;
|
||||||
const authHeader = !isAuth ? `-H 'x-api-key: <your api key>'` : "";
|
const authHeader = webhookAuthEnable ? `-H 'x-api-key: <your api key>'` : "";
|
||||||
|
|
||||||
if (format === "singleline") {
|
if (format === "singleline") {
|
||||||
return `curl -X POST "${baseUrl}" -H 'Content-Type: application/json' ${authHeader} -d '{"any": "data"}'`.trim();
|
return `curl -X POST "${baseUrl}" -H 'Content-Type: application/json' ${authHeader} -d '{"any": "data"}'`.trim();
|
||||||
|
|
@ -36,7 +39,7 @@ export function getCurlWebhookCode({
|
||||||
return `curl -X POST \\
|
return `curl -X POST \\
|
||||||
"${baseUrl}" \\
|
"${baseUrl}" \\
|
||||||
-H 'Content-Type: application/json' \\${
|
-H 'Content-Type: application/json' \\${
|
||||||
isAuth ? `\n -H 'x-api-key: <your api key>' \\` : ""
|
webhookAuthEnable ? `\n -H 'x-api-key: <your api key>' \\` : ""
|
||||||
}${
|
}${
|
||||||
ENABLE_DATASTAX_LANGFLOW
|
ENABLE_DATASTAX_LANGFLOW
|
||||||
? `\n -H 'Authorization: Bearer <YOUR_APPLICATION_TOKEN>' \\`
|
? `\n -H 'Authorization: Bearer <YOUR_APPLICATION_TOKEN>' \\`
|
||||||
|
|
|
||||||
|
|
@ -48,4 +48,7 @@ export const useUtilityStore = create<UtilityStoreType>((set, get) => ({
|
||||||
eventDelivery: EventDeliveryType.POLLING,
|
eventDelivery: EventDeliveryType.POLLING,
|
||||||
setEventDelivery: (eventDelivery: EventDeliveryType) =>
|
setEventDelivery: (eventDelivery: EventDeliveryType) =>
|
||||||
set({ eventDelivery }),
|
set({ eventDelivery }),
|
||||||
|
webhookAuthEnable: true,
|
||||||
|
setWebhookAuthEnable: (webhookAuthEnable: boolean) =>
|
||||||
|
set({ webhookAuthEnable }),
|
||||||
}));
|
}));
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ export type GetCodesType = {
|
||||||
export type GetCodeType = {
|
export type GetCodeType = {
|
||||||
flowId: string;
|
flowId: string;
|
||||||
flowName: string;
|
flowName: string;
|
||||||
isAuth: boolean;
|
webhookAuthEnable: boolean;
|
||||||
tweaksBuildedObject?: {};
|
tweaksBuildedObject?: {};
|
||||||
endpointName?: string | null;
|
endpointName?: string | null;
|
||||||
activeTweaks?: boolean;
|
activeTweaks?: boolean;
|
||||||
|
|
|
||||||
|
|
@ -28,4 +28,6 @@ export type UtilityStoreType = {
|
||||||
setEventDelivery: (eventDelivery: EventDeliveryType) => void;
|
setEventDelivery: (eventDelivery: EventDeliveryType) => void;
|
||||||
serializationMaxItemsLength: number;
|
serializationMaxItemsLength: number;
|
||||||
setSerializationMaxItemsLength: (serializationMaxItemsLength: number) => void;
|
setSerializationMaxItemsLength: (serializationMaxItemsLength: number) => void;
|
||||||
|
webhookAuthEnable: boolean;
|
||||||
|
setWebhookAuthEnable: (webhookAuthEnable: boolean) => void;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue