Skip to main content

systemprompt_agent/services/external_integrations/webhook/service/
mod.rs

1//! In-memory webhook endpoint registry with HMAC-SHA256 signing and delivery.
2//!
3//! [`WebhookService`] registers [`WebhookEndpoint`]s, dispatches inbound
4//! requests after constant-time signature verification, and generates the
5//! `sha256=` HMAC signatures used for outbound delivery. The `delivery` and
6//! `types` submodules hold the retry/delivery logic and the public config and
7//! result types.
8//!
9//! Copyright (c) systemprompt.io — Business Source License 1.1.
10//! See <https://systemprompt.io> for licensing details.
11
12mod delivery;
13mod types;
14
15pub use types::{RetryPolicy, WebhookConfig, WebhookDeliveryResult, WebhookTestResult};
16
17use hmac::{Hmac, KeyInit, Mac};
18use reqwest::Client;
19use serde_json::Value;
20use sha2::Sha256;
21use std::collections::HashMap;
22use systemprompt_identifiers::WebhookEndpointId;
23use tokio::sync::RwLock;
24
25use crate::models::external_integrations::{
26    IntegrationError, IntegrationResult, WebhookEndpoint, WebhookRequest, WebhookResponse,
27};
28
29type HmacSha256 = Hmac<Sha256>;
30
31#[derive(Debug)]
32pub struct WebhookService {
33    pub(super) endpoints: RwLock<HashMap<WebhookEndpointId, WebhookEndpoint>>,
34    pub(super) http_client: Client,
35}
36
37impl WebhookService {
38    pub fn new() -> Self {
39        let http_client = Client::builder()
40            .timeout(std::time::Duration::from_secs(10))
41            .connect_timeout(std::time::Duration::from_secs(5))
42            .build()
43            .unwrap_or_else(|_| Client::new());
44        Self {
45            endpoints: RwLock::new(HashMap::new()),
46            http_client,
47        }
48    }
49
50    pub async fn register_endpoint(
51        &self,
52        mut endpoint: WebhookEndpoint,
53    ) -> IntegrationResult<WebhookEndpointId> {
54        if endpoint.id.as_str().is_empty() {
55            endpoint.id = WebhookEndpointId::generate();
56        }
57
58        let endpoint_id = endpoint.id.clone();
59
60        {
61            let mut endpoints = self.endpoints.write().await;
62            endpoints.insert(endpoint_id.clone(), endpoint);
63        }
64
65        Ok(endpoint_id)
66    }
67
68    pub async fn update_endpoint(&self, endpoint: WebhookEndpoint) -> IntegrationResult<()> {
69        {
70            let mut endpoints = self.endpoints.write().await;
71            endpoints.insert(endpoint.id.clone(), endpoint);
72        }
73        Ok(())
74    }
75
76    pub async fn get_endpoint(
77        &self,
78        endpoint_id: &WebhookEndpointId,
79    ) -> IntegrationResult<Option<WebhookEndpoint>> {
80        let endpoints = self.endpoints.read().await;
81        Ok(endpoints.get(endpoint_id).cloned())
82    }
83
84    pub async fn list_endpoints(&self) -> IntegrationResult<Vec<WebhookEndpoint>> {
85        let endpoints = self.endpoints.read().await;
86        Ok(endpoints.values().cloned().collect())
87    }
88
89    pub async fn remove_endpoint(
90        &self,
91        endpoint_id: &WebhookEndpointId,
92    ) -> IntegrationResult<bool> {
93        let mut endpoints = self.endpoints.write().await;
94        Ok(endpoints.remove(endpoint_id).is_some())
95    }
96
97    pub async fn handle_webhook(
98        &self,
99        endpoint_id: &WebhookEndpointId,
100        request: WebhookRequest,
101    ) -> IntegrationResult<WebhookResponse> {
102        let endpoint = {
103            let endpoints = self.endpoints.read().await;
104            endpoints.get(endpoint_id).cloned().ok_or_else(|| {
105                IntegrationError::Webhook(format!("Endpoint not found: {endpoint_id}"))
106            })?
107        };
108
109        if !endpoint.active {
110            return Ok(WebhookResponse {
111                status: 404,
112                body: Some(serde_json::json!({"error": "Endpoint is inactive"})),
113            });
114        }
115
116        if let (Some(_secret), Some(signature)) = (&endpoint.secret, &request.signature)
117            && !Self::verify_signature_internal(&endpoint, &request.body, signature)?
118        {
119            return Ok(WebhookResponse {
120                status: 401,
121                body: Some(serde_json::json!({"error": "Invalid signature"})),
122            });
123        }
124
125        let event_type = request
126            .headers
127            .get("x-webhook-event")
128            .or_else(|| request.headers.get("x-event-type"))
129            .or_else(|| request.headers.get("x-github-event"))
130            .cloned()
131            .unwrap_or_else(|| "unknown".to_owned());
132
133        if !endpoint.events.is_empty()
134            && !endpoint.events.contains(&event_type)
135            && !endpoint.events.contains(&"*".to_owned())
136        {
137            return Ok(WebhookResponse {
138                status: 200,
139                body: Some(serde_json::json!({"message": "Event type not subscribed"})),
140            });
141        }
142
143        Ok(WebhookResponse {
144            status: 200,
145            body: Some(serde_json::json!({
146                "message": "Webhook processed successfully",
147                "event_type": event_type,
148                "endpoint_id": endpoint_id
149            })),
150        })
151    }
152
153    pub async fn verify_signature(
154        &self,
155        endpoint_id: &WebhookEndpointId,
156        payload: &Value,
157        signature: &str,
158    ) -> IntegrationResult<bool> {
159        let endpoint = {
160            let endpoints = self.endpoints.read().await;
161            endpoints.get(endpoint_id).cloned().ok_or_else(|| {
162                IntegrationError::Webhook(format!("Endpoint not found: {endpoint_id}"))
163            })?
164        };
165
166        Self::verify_signature_internal(&endpoint, payload, signature)
167    }
168
169    pub(super) fn verify_signature_internal(
170        endpoint: &WebhookEndpoint,
171        payload: &Value,
172        signature: &str,
173    ) -> IntegrationResult<bool> {
174        let secret = endpoint.secret.as_ref().ok_or_else(|| {
175            IntegrationError::Webhook("No secret configured for endpoint".to_owned())
176        })?;
177
178        let expected_signature = Self::generate_signature(secret, payload)?;
179
180        Ok(Self::secure_compare(&expected_signature, signature))
181    }
182
183    pub(super) fn generate_signature(secret: &str, payload: &Value) -> IntegrationResult<String> {
184        let payload_bytes = serde_json::to_vec(payload)?;
185
186        let mut mac = HmacSha256::new_from_slice(secret.as_bytes())
187            .map_err(|e| IntegrationError::Webhook(format!("Invalid secret: {e}")))?;
188
189        mac.update(&payload_bytes);
190        let result = mac.finalize();
191        let hex_result = hex::encode(result.into_bytes());
192
193        Ok(format!("sha256={hex_result}"))
194    }
195
196    fn secure_compare(a: &str, b: &str) -> bool {
197        if a.len() != b.len() {
198            return false;
199        }
200
201        let mut result = 0u8;
202        for (byte_a, byte_b) in a.bytes().zip(b.bytes()) {
203            result |= byte_a ^ byte_b;
204        }
205
206        result == 0
207    }
208}
209
210impl Default for WebhookService {
211    fn default() -> Self {
212        Self::new()
213    }
214}