async_openai/
webhooks.rs

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