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    pub fn parse(s: &str) -> Option<Self> {
48        let (prefix, value) = s.split_once(':')?;
49        match prefix {
50            "header" => Some(Self::Header(Box::leak(value.to_string().into_boxed_str()))),
51            "body" => Some(Self::Body(Box::leak(value.to_string().into_boxed_str()))),
52            _ => None,
53        }
54    }
55}
56
57/// Configuration for webhook idempotency.
58#[derive(Debug, Clone)]
59pub struct IdempotencyConfig {
60    /// Source for the idempotency key.
61    pub source: IdempotencySource,
62    /// TTL for idempotency records (default: 24 hours).
63    pub ttl: Duration,
64}
65
66impl IdempotencyConfig {
67    /// Create a new idempotency config with default TTL.
68    pub fn new(source: IdempotencySource) -> Self {
69        Self {
70            source,
71            ttl: Duration::from_secs(24 * 60 * 60), // 24 hours
72        }
73    }
74
75    /// Set a custom TTL.
76    pub fn with_ttl(mut self, ttl: Duration) -> Self {
77        self.ttl = ttl;
78        self
79    }
80}
81
82/// Helper for constructing signature configurations.
83///
84/// Use in webhook attributes like:
85/// ```ignore
86/// #[forge::webhook(
87///     signature = WebhookSignature::hmac_sha256("X-Stripe-Signature", "STRIPE_SECRET"),
88/// )]
89/// ```
90pub struct WebhookSignature;
91
92impl WebhookSignature {
93    /// Create HMAC-SHA256 signature config.
94    ///
95    /// # Arguments
96    /// * `header` - The HTTP header containing the signature (e.g., "X-Hub-Signature-256")
97    /// * `secret_env` - Environment variable containing the secret
98    pub const fn hmac_sha256(header: &'static str, secret_env: &'static str) -> SignatureConfig {
99        SignatureConfig {
100            algorithm: SignatureAlgorithm::HmacSha256,
101            header_name: header,
102            secret_env,
103        }
104    }
105
106    /// Create HMAC-SHA1 signature config.
107    ///
108    /// # Arguments
109    /// * `header` - The HTTP header containing the signature
110    /// * `secret_env` - Environment variable containing the secret
111    pub const fn hmac_sha1(header: &'static str, secret_env: &'static str) -> SignatureConfig {
112        SignatureConfig {
113            algorithm: SignatureAlgorithm::HmacSha1,
114            header_name: header,
115            secret_env,
116        }
117    }
118
119    /// Create HMAC-SHA512 signature config.
120    ///
121    /// # Arguments
122    /// * `header` - The HTTP header containing the signature
123    /// * `secret_env` - Environment variable containing the secret
124    pub const fn hmac_sha512(header: &'static str, secret_env: &'static str) -> SignatureConfig {
125        SignatureConfig {
126            algorithm: SignatureAlgorithm::HmacSha512,
127            header_name: header,
128            secret_env,
129        }
130    }
131}
132
133#[cfg(test)]
134#[allow(clippy::unwrap_used, clippy::indexing_slicing)]
135mod tests {
136    use super::*;
137
138    #[test]
139    fn test_signature_config_creation() {
140        let config = WebhookSignature::hmac_sha256("X-Hub-Signature-256", "GITHUB_SECRET");
141        assert_eq!(config.algorithm, SignatureAlgorithm::HmacSha256);
142        assert_eq!(config.header_name, "X-Hub-Signature-256");
143        assert_eq!(config.secret_env, "GITHUB_SECRET");
144    }
145
146    #[test]
147    fn test_algorithm_prefix() {
148        assert_eq!(SignatureAlgorithm::HmacSha256.prefix(), "sha256=");
149        assert_eq!(SignatureAlgorithm::HmacSha1.prefix(), "sha1=");
150        assert_eq!(SignatureAlgorithm::HmacSha512.prefix(), "sha512=");
151    }
152
153    #[test]
154    fn test_idempotency_source_parsing() {
155        let header = IdempotencySource::parse("header:X-Request-Id");
156        assert!(matches!(
157            header,
158            Some(IdempotencySource::Header("X-Request-Id"))
159        ));
160
161        let body = IdempotencySource::parse("body:$.id");
162        assert!(matches!(body, Some(IdempotencySource::Body("$.id"))));
163
164        let invalid = IdempotencySource::parse("invalid");
165        assert!(invalid.is_none());
166    }
167
168    #[test]
169    fn test_idempotency_config_default_ttl() {
170        let config = IdempotencyConfig::new(IdempotencySource::Header("X-Id"));
171        assert_eq!(config.ttl, Duration::from_secs(24 * 60 * 60));
172    }
173}