linger_openai_sdk/
webhooks.rs1use crate::error::{HeaderMap, LingerError};
2use base64::prelude::{Engine as _, BASE64_STANDARD};
3use hmac::{Hmac, Mac};
4use serde::de::DeserializeOwned;
5use sha2::Sha256;
6use std::fmt;
7
8type HmacSha256 = Hmac<Sha256>;
9
10#[derive(Clone)]
13#[non_exhaustive]
14pub struct WebhookVerifier {
15 secret: Vec<u8>,
16}
17
18impl WebhookVerifier {
19 pub fn new(secret: impl AsRef<str>) -> Result<Self, LingerError> {
22 let secret = secret.as_ref().trim();
23 if secret.is_empty() {
24 return Err(LingerError::invalid_config("webhook secret is required"));
25 }
26 Ok(Self {
27 secret: decode_secret(secret),
28 })
29 }
30
31 pub fn verify(&self, headers: &HeaderMap, body: &[u8]) -> Result<(), LingerError> {
34 let webhook_id = required_header(headers, "webhook-id")?;
35 let timestamp = required_header(headers, "webhook-timestamp")?;
36 let signature = required_header(headers, "webhook-signature")?;
37 let expected = signed_payload(webhook_id, timestamp, body);
38 for candidate in signature_candidates(signature) {
39 let decoded = BASE64_STANDARD
40 .decode(candidate)
41 .map_err(|_| LingerError::invalid_config("webhook signature is invalid"))?;
42 let mut mac = HmacSha256::new_from_slice(&self.secret)
43 .map_err(|_| LingerError::invalid_config("webhook secret is invalid"))?;
44 mac.update(&expected);
45 if mac.verify_slice(&decoded).is_ok() {
46 return Ok(());
47 }
48 }
49 Err(LingerError::invalid_config(
50 "webhook signature verification failed",
51 ))
52 }
53
54 pub fn parse<T>(&self, headers: &HeaderMap, body: &[u8]) -> Result<T, LingerError>
57 where
58 T: DeserializeOwned,
59 {
60 self.verify(headers, body)?;
61 Ok(serde_json::from_slice(body)?)
62 }
63}
64
65impl fmt::Debug for WebhookVerifier {
66 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
67 f.debug_struct("WebhookVerifier")
68 .field("secret", &"<redacted>")
69 .finish()
70 }
71}
72
73fn decode_secret(secret: &str) -> Vec<u8> {
74 secret
75 .strip_prefix("whsec_")
76 .and_then(|encoded| BASE64_STANDARD.decode(encoded).ok())
77 .unwrap_or_else(|| secret.as_bytes().to_vec())
78}
79
80fn required_header<'a>(headers: &'a HeaderMap, name: &str) -> Result<&'a str, LingerError> {
81 headers
82 .get(name)
83 .filter(|value| !value.trim().is_empty())
84 .ok_or_else(|| LingerError::invalid_config(format!("{name} header is required")))
85}
86
87fn signed_payload(webhook_id: &str, timestamp: &str, body: &[u8]) -> Vec<u8> {
88 let mut payload = Vec::with_capacity(webhook_id.len() + timestamp.len() + body.len() + 2);
89 payload.extend_from_slice(webhook_id.as_bytes());
90 payload.push(b'.');
91 payload.extend_from_slice(timestamp.as_bytes());
92 payload.push(b'.');
93 payload.extend_from_slice(body);
94 payload
95}
96
97fn signature_candidates(header: &str) -> impl Iterator<Item = &str> {
98 header
99 .split(',')
100 .filter_map(|part| part.trim().strip_prefix("v1,").or(Some(part.trim())))
101 .filter(|part| !part.is_empty() && *part != "v1")
102}