Skip to main content

outfox_openai/
webhooks.rs

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