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