Skip to main content

linger_openai_sdk/
webhooks.rs

1use 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/// EN: Verifies OpenAI webhook signatures before payload parsing.
11/// 中文:在解析载荷前验证 OpenAI webhook 签名。
12#[derive(Clone)]
13#[non_exhaustive]
14pub struct WebhookVerifier {
15    secret: Vec<u8>,
16}
17
18impl WebhookVerifier {
19    /// EN: Creates a verifier from the configured webhook signing secret.
20    /// 中文:使用配置的 webhook 签名密钥创建验证器。
21    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    /// EN: Verifies the signed raw webhook body.
32    /// 中文:验证已签名的原始 webhook 请求体。
33    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    /// EN: Verifies and deserializes a signed webhook JSON body.
55    /// 中文:验证并反序列化已签名的 webhook JSON 请求体。
56    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}