Skip to main content

forge_core/webhook/
signature.rs

1use std::time::Duration;
2
3/// Configuration for webhook signature validation.
4#[derive(Debug, Clone)]
5pub struct SignatureConfig {
6    /// Algorithm used for signature verification.
7    pub algorithm: SignatureAlgorithm,
8    /// Header name containing the signature.
9    pub header_name: &'static str,
10    /// Environment variable name containing the secret.
11    pub secret_env: &'static str,
12}
13
14/// Supported signature algorithms.
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum SignatureAlgorithm {
17    /// HMAC-SHA256 (e.g., Stripe, GitHub)
18    HmacSha256,
19    /// HMAC-SHA1 (legacy, e.g., older GitHub)
20    HmacSha1,
21    /// HMAC-SHA512
22    HmacSha512,
23}
24
25impl SignatureAlgorithm {
26    /// Get the algorithm prefix used in signatures (e.g., "sha256=").
27    pub fn prefix(&self) -> &'static str {
28        match self {
29            Self::HmacSha256 => "sha256=",
30            Self::HmacSha1 => "sha1=",
31            Self::HmacSha512 => "sha512=",
32        }
33    }
34}
35
36/// Source for extracting idempotency key.
37#[derive(Debug, Clone)]
38pub enum IdempotencySource {
39    /// Extract from a header (e.g., "X-Request-Id").
40    Header(&'static str),
41    /// Extract from request body using JSONPath (e.g., "$.id").
42    Body(&'static str),
43}
44
45impl IdempotencySource {
46    /// Parse from attribute string (e.g., "header:X-Request-Id" or "body:$.id").
47    ///
48    /// NOTE: This uses `Box::leak` to produce `&'static str` references, so it
49    /// must only be called at startup/registration time (e.g., from proc macros),
50    /// never in request-handling hot paths. Calling this in a loop will leak memory.
51    pub fn parse(s: &str) -> Option<Self> {
52        let (prefix, value) = s.split_once(':')?;
53        // SAFETY: This is intended to be called only at startup during webhook registration.
54        // The leaked strings live for the program's lifetime, matching webhook configurations.
55        match prefix {
56            "header" => Some(Self::Header(Box::leak(value.to_string().into_boxed_str()))),
57            "body" => Some(Self::Body(Box::leak(value.to_string().into_boxed_str()))),
58            _ => None,
59        }
60    }
61}
62
63/// Configuration for webhook idempotency.
64#[derive(Debug, Clone)]
65pub struct IdempotencyConfig {
66    /// Source for the idempotency key.
67    pub source: IdempotencySource,
68    /// TTL for idempotency records (default: 24 hours).
69    pub ttl: Duration,
70}
71
72impl IdempotencyConfig {
73    /// Create a new idempotency config with default TTL.
74    pub fn new(source: IdempotencySource) -> Self {
75        Self {
76            source,
77            ttl: Duration::from_secs(24 * 60 * 60), // 24 hours
78        }
79    }
80
81    /// Set a custom TTL.
82    pub fn with_ttl(mut self, ttl: Duration) -> Self {
83        self.ttl = ttl;
84        self
85    }
86}
87
88/// Helper for constructing signature configurations.
89///
90/// Use in webhook attributes like:
91/// ```ignore
92/// #[forge::webhook(
93///     signature = WebhookSignature::hmac_sha256("X-Stripe-Signature", "STRIPE_SECRET"),
94/// )]
95/// ```
96pub struct WebhookSignature;
97
98impl WebhookSignature {
99    /// Create HMAC-SHA256 signature config.
100    ///
101    /// # Arguments
102    /// * `header` - The HTTP header containing the signature (e.g., "X-Hub-Signature-256")
103    /// * `secret_env` - Environment variable containing the secret
104    pub const fn hmac_sha256(header: &'static str, secret_env: &'static str) -> SignatureConfig {
105        SignatureConfig {
106            algorithm: SignatureAlgorithm::HmacSha256,
107            header_name: header,
108            secret_env,
109        }
110    }
111
112    /// Create HMAC-SHA1 signature config.
113    ///
114    /// # Arguments
115    /// * `header` - The HTTP header containing the signature
116    /// * `secret_env` - Environment variable containing the secret
117    pub const fn hmac_sha1(header: &'static str, secret_env: &'static str) -> SignatureConfig {
118        SignatureConfig {
119            algorithm: SignatureAlgorithm::HmacSha1,
120            header_name: header,
121            secret_env,
122        }
123    }
124
125    /// Create HMAC-SHA512 signature config.
126    ///
127    /// # Arguments
128    /// * `header` - The HTTP header containing the signature
129    /// * `secret_env` - Environment variable containing the secret
130    pub const fn hmac_sha512(header: &'static str, secret_env: &'static str) -> SignatureConfig {
131        SignatureConfig {
132            algorithm: SignatureAlgorithm::HmacSha512,
133            header_name: header,
134            secret_env,
135        }
136    }
137}
138
139#[cfg(test)]
140#[allow(clippy::unwrap_used, clippy::indexing_slicing)]
141mod tests {
142    use super::*;
143
144    #[test]
145    fn test_signature_config_creation() {
146        let config = WebhookSignature::hmac_sha256("X-Hub-Signature-256", "GITHUB_SECRET");
147        assert_eq!(config.algorithm, SignatureAlgorithm::HmacSha256);
148        assert_eq!(config.header_name, "X-Hub-Signature-256");
149        assert_eq!(config.secret_env, "GITHUB_SECRET");
150    }
151
152    #[test]
153    fn test_algorithm_prefix() {
154        assert_eq!(SignatureAlgorithm::HmacSha256.prefix(), "sha256=");
155        assert_eq!(SignatureAlgorithm::HmacSha1.prefix(), "sha1=");
156        assert_eq!(SignatureAlgorithm::HmacSha512.prefix(), "sha512=");
157    }
158
159    #[test]
160    fn test_idempotency_source_parsing() {
161        let header = IdempotencySource::parse("header:X-Request-Id");
162        assert!(matches!(
163            header,
164            Some(IdempotencySource::Header("X-Request-Id"))
165        ));
166
167        let body = IdempotencySource::parse("body:$.id");
168        assert!(matches!(body, Some(IdempotencySource::Body("$.id"))));
169
170        let invalid = IdempotencySource::parse("invalid");
171        assert!(invalid.is_none());
172    }
173
174    #[test]
175    fn test_idempotency_config_default_ttl() {
176        let config = IdempotencyConfig::new(IdempotencySource::Header("X-Id"));
177        assert_eq!(config.ttl, Duration::from_secs(24 * 60 * 60));
178    }
179}