async_openai/
webhooks.rs

1//! Support for webhook event types, signature verification, and building webhook events from payloads.
2use crate::types::webhooks::WebhookEvent;
3use base64::{engine::general_purpose::STANDARD as BASE64, Engine};
4use hmac::{Hmac, Mac};
5use sha2::Sha256;
6use std::time::{SystemTime, UNIX_EPOCH};
7
8/// Errors that can occur when processing webhooks
9#[derive(Debug, thiserror::Error)]
10pub enum WebhookError {
11    /// Invalid webhook signature or signature verification failed
12    #[error("invalid webhook signature")]
13    InvalidSignature,
14    /// Invalid input (timestamp or secret key)
15    #[error("invalid input ")]
16    Invalid(String),
17    /// Failed to deserialize webhook payload
18    #[error("failed to deserialize webhook payload: error:{0} content:{1}")]
19    Deserialization(serde_json::Error, String),
20}
21
22type HmacSha256 = Hmac<Sha256>;
23
24const DEFAULT_TOLERANCE_SECONDS: i64 = 300;
25
26pub struct Webhooks;
27
28impl Webhooks {
29    pub fn build_event(
30        body: &str,
31        signature: &str,
32        timestamp: &str,
33        webhook_id: &str,
34        secret: &str,
35    ) -> Result<WebhookEvent, WebhookError> {
36        Self::build_event_with_tolerance(
37            body,
38            signature,
39            timestamp,
40            webhook_id,
41            secret,
42            DEFAULT_TOLERANCE_SECONDS,
43        )
44    }
45
46    fn build_event_with_tolerance(
47        body: &str,
48        signature: &str,
49        timestamp: &str,
50        webhook_id: &str,
51        secret: &str,
52        tolerance_seconds: i64,
53    ) -> Result<WebhookEvent, WebhookError> {
54        // Verify the signature and timestamp
55        Self::verify_signature_with_tolerance(
56            body,
57            signature,
58            timestamp,
59            webhook_id,
60            secret,
61            tolerance_seconds,
62        )?;
63
64        // Deserialize the event
65        let event: WebhookEvent = serde_json::from_str(body)
66            .map_err(|e| WebhookError::Deserialization(e, body.to_string()))?;
67
68        Ok(event)
69    }
70
71    pub fn verify_signature(
72        body: &str,
73        signature: &str,
74        timestamp: &str,
75        webhook_id: &str,
76        secret: &str,
77    ) -> Result<(), WebhookError> {
78        Self::verify_signature_with_tolerance(
79            body,
80            signature,
81            timestamp,
82            webhook_id,
83            secret,
84            DEFAULT_TOLERANCE_SECONDS,
85        )
86    }
87
88    fn verify_signature_with_tolerance(
89        body: &str,
90        signature: &str,
91        timestamp: &str,
92        webhook_id: &str,
93        secret: &str,
94        tolerance_seconds: i64,
95    ) -> Result<(), WebhookError> {
96        // Validate timestamp to prevent replay attacks
97        let timestamp_seconds = timestamp
98            .parse::<i64>()
99            .map_err(|_| WebhookError::Invalid("invalid timestamp format".to_string()))?;
100
101        let now = SystemTime::now()
102            .duration_since(UNIX_EPOCH)
103            .unwrap()
104            .as_secs() as i64;
105
106        if now - timestamp_seconds > tolerance_seconds {
107            return Err(WebhookError::Invalid(
108                "webhook timestamp is too old".to_string(),
109            ));
110        }
111
112        if timestamp_seconds > now + tolerance_seconds {
113            return Err(WebhookError::Invalid(
114                "webhook timestamp is too new".to_string(),
115            ));
116        }
117
118        // Construct the signed payload: webhook_id.timestamp.body
119        let signed_payload = format!("{}.{}.{}", webhook_id, timestamp, body);
120
121        // Remove "whsec_" prefix from secret if present
122        let secret_key = secret.strip_prefix("whsec_").unwrap_or(secret);
123
124        // Decode the secret from base64 (Standard Webhooks uses base64-encoded secrets)
125        let secret_bytes = BASE64.decode(secret_key).map_err(|_| {
126            WebhookError::Invalid("failed to decode secret from base64".to_string())
127        })?;
128
129        // Compute HMAC-SHA256
130        let mut mac = HmacSha256::new_from_slice(&secret_bytes)
131            .map_err(|_| WebhookError::Invalid("invalid secret key length".to_string()))?;
132        mac.update(signed_payload.as_bytes());
133
134        // Get the expected signature in base64
135        let expected_signature = BASE64.encode(mac.finalize().into_bytes());
136
137        // Parse the signature header (format: "v1,signature" or just "signature")
138        // Standard Webhooks uses versioned signatures
139        let signature_to_verify = if signature.contains(',') {
140            // Extract signature parts (e.g., "v1,signature1 v1,signature2")
141            signature
142                .split_whitespace()
143                .filter_map(|sig| {
144                    let parts: Vec<&str> = sig.split(',').collect();
145                    if parts.len() == 2 && parts[0] == "v1" {
146                        Some(parts[1])
147                    } else {
148                        None
149                    }
150                })
151                .collect::<Vec<&str>>()
152        } else {
153            vec![signature]
154        };
155
156        // Try to match any of the provided signatures
157        for sig in signature_to_verify {
158            if constant_time_eq(sig.as_bytes(), expected_signature.as_bytes()) {
159                return Ok(());
160            }
161        }
162
163        Err(WebhookError::InvalidSignature)
164    }
165}
166
167fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
168    if a.len() != b.len() {
169        return false;
170    }
171
172    let mut result = 0u8;
173    for (a_byte, b_byte) in a.iter().zip(b.iter()) {
174        result |= a_byte ^ b_byte;
175    }
176
177    result == 0
178}
179
180#[cfg(all(test, feature = "webhook"))]
181mod tests {
182    use super::*;
183
184    fn current_timestamp() -> String {
185        SystemTime::now()
186            .duration_since(UNIX_EPOCH)
187            .unwrap()
188            .as_secs()
189            .to_string()
190    }
191
192    #[test]
193    fn test_constant_time_eq() {
194        assert!(constant_time_eq(b"hello", b"hello"));
195        assert!(!constant_time_eq(b"hello", b"world"));
196        assert!(!constant_time_eq(b"hello", b"hell"));
197        assert!(!constant_time_eq(b"hello", b"helloo"));
198    }
199
200    #[test]
201    fn test_verify_signature_invalid() {
202        let body = r#"{"test":"data"}"#;
203        let signature = "invalid_signature";
204        let timestamp = current_timestamp();
205        let webhook_id = "webhook_test";
206        let secret = BASE64.encode(b"test_secret");
207
208        let result = Webhooks::verify_signature(body, &signature, &timestamp, webhook_id, &secret);
209        assert!(result.is_err());
210        // Could be InvalidSignature or InvalidTimestampFormat
211    }
212
213    #[test]
214    fn test_verify_signature_valid() {
215        let body = r#"{"test":"data"}"#;
216        let timestamp = current_timestamp();
217        let webhook_id = "webhook_test";
218        // Base64-encoded secret (Standard Webhooks format)
219        let secret = BASE64.encode(b"test_secret");
220
221        // Compute the expected signature
222        let signed_payload = format!("{}.{}.{}", webhook_id, timestamp, body);
223        let secret_bytes = BASE64.decode(&secret).unwrap();
224        let mut mac = HmacSha256::new_from_slice(&secret_bytes).unwrap();
225        mac.update(signed_payload.as_bytes());
226        let signature = BASE64.encode(mac.finalize().into_bytes());
227
228        let result = Webhooks::verify_signature(body, &signature, &timestamp, webhook_id, &secret);
229        assert!(result.is_ok());
230    }
231
232    #[test]
233    fn test_verify_signature_with_prefix() {
234        let body = r#"{"test":"data"}"#;
235        let timestamp = current_timestamp();
236        let webhook_id = "webhook_test";
237        let secret = BASE64.encode(b"test_secret");
238        let prefixed_secret = format!("whsec_{}", secret);
239
240        // Compute signature
241        let signed_payload = format!("{}.{}.{}", webhook_id, timestamp, body);
242        let secret_bytes = BASE64.decode(&secret).unwrap();
243        let mut mac = HmacSha256::new_from_slice(&secret_bytes).unwrap();
244        mac.update(signed_payload.as_bytes());
245        let signature = BASE64.encode(mac.finalize().into_bytes());
246
247        // Verify using prefixed secret
248        let result =
249            Webhooks::verify_signature(body, &signature, &timestamp, webhook_id, &prefixed_secret);
250        assert!(result.is_ok());
251    }
252
253    #[test]
254    fn test_verify_signature_with_version() {
255        let body = r#"{"test":"data"}"#;
256        let timestamp = current_timestamp();
257        let webhook_id = "webhook_test";
258        let secret = BASE64.encode(b"test_secret");
259
260        // Compute signature
261        let signed_payload = format!("{}.{}.{}", webhook_id, timestamp, body);
262        let secret_bytes = BASE64.decode(&secret).unwrap();
263        let mut mac = HmacSha256::new_from_slice(&secret_bytes).unwrap();
264        mac.update(signed_payload.as_bytes());
265        let sig_b64 = BASE64.encode(mac.finalize().into_bytes());
266
267        // Standard Webhooks format with version prefix
268        let signature = format!("v1,{}", sig_b64);
269
270        let result = Webhooks::verify_signature(body, &signature, &timestamp, webhook_id, &secret);
271        assert!(result.is_ok());
272    }
273
274    #[test]
275    fn test_timestamp_too_old() {
276        let body = r#"{"test":"data"}"#;
277        let old_timestamp = "1234567890"; // Very old timestamp
278        let webhook_id = "webhook_test";
279        let secret = BASE64.encode(b"test_secret");
280
281        // Compute signature with old timestamp
282        let signed_payload = format!("{}.{}.{}", webhook_id, old_timestamp, body);
283        let secret_bytes = BASE64.decode(&secret).unwrap();
284        let mut mac = HmacSha256::new_from_slice(&secret_bytes).unwrap();
285        mac.update(signed_payload.as_bytes());
286        let signature = BASE64.encode(mac.finalize().into_bytes());
287
288        let result =
289            Webhooks::verify_signature(body, &signature, old_timestamp, webhook_id, &secret);
290        assert!(result.is_err());
291        match result.unwrap_err() {
292            WebhookError::Invalid(msg) => {
293                assert!(msg.contains("too old"));
294            }
295            _ => panic!("Expected InvalidSignature error"),
296        }
297    }
298
299    #[test]
300    fn test_timestamp_too_new() {
301        let body = r#"{"test":"data"}"#;
302        // Timestamp far in the future
303        let future_timestamp = (SystemTime::now()
304            .duration_since(UNIX_EPOCH)
305            .unwrap()
306            .as_secs()
307            + 1000)
308            .to_string();
309        let webhook_id = "webhook_test";
310        let secret = BASE64.encode(b"test_secret");
311
312        // Compute signature with future timestamp
313        let signed_payload = format!("{}.{}.{}", webhook_id, future_timestamp, body);
314        let secret_bytes = BASE64.decode(&secret).unwrap();
315        let mut mac = HmacSha256::new_from_slice(&secret_bytes).unwrap();
316        mac.update(signed_payload.as_bytes());
317        let signature = BASE64.encode(mac.finalize().into_bytes());
318
319        let result =
320            Webhooks::verify_signature(body, &signature, &future_timestamp, webhook_id, &secret);
321        assert!(result.is_err());
322        match result.unwrap_err() {
323            WebhookError::Invalid(msg) => {
324                assert!(msg.contains("too new"));
325            }
326            _ => panic!("Expected InvalidSignature error"),
327        }
328    }
329
330    #[test]
331    fn test_invalid_timestamp_format() {
332        let body = r#"{"test":"data"}"#;
333        let invalid_timestamp = "not_a_number";
334        let webhook_id = "webhook_test";
335        let secret = BASE64.encode(b"test_secret");
336
337        let result = Webhooks::verify_signature(
338            body,
339            "any_signature",
340            invalid_timestamp,
341            webhook_id,
342            &secret,
343        );
344        assert!(result.is_err());
345        match result.unwrap_err() {
346            WebhookError::Invalid(msg) => {
347                assert!(msg.contains("timestamp"));
348            }
349            _ => panic!("Expected InvalidSignature error"),
350        }
351    }
352
353    #[test]
354    fn test_construct_event_invalid_json() {
355        let body = r#"{"invalid json"#;
356        let timestamp = current_timestamp();
357        let webhook_id = "webhook_test";
358        let secret = BASE64.encode(b"test_secret");
359
360        // Compute valid signature for invalid JSON
361        let signed_payload = format!("{}.{}.{}", webhook_id, timestamp, body);
362        let secret_bytes = BASE64.decode(&secret).unwrap();
363        let mut mac = HmacSha256::new_from_slice(&secret_bytes).unwrap();
364        mac.update(signed_payload.as_bytes());
365        let signature = BASE64.encode(mac.finalize().into_bytes());
366
367        let result = Webhooks::build_event(body, &signature, &timestamp, webhook_id, &secret);
368        assert!(result.is_err());
369        assert!(matches!(
370            result.unwrap_err(),
371            WebhookError::Deserialization(..)
372        ));
373    }
374}