Skip to main content

reasonkit_web/stripe/
config.rs

1//! Stripe Webhook Configuration
2//!
3//! CONS-003 COMPLIANT: All secrets loaded from environment variables.
4
5use std::env;
6use std::time::Duration;
7
8use crate::stripe::error::{StripeWebhookError, StripeWebhookResult};
9
10/// Configuration for Stripe webhook handling
11#[derive(Debug, Clone)]
12pub struct StripeWebhookConfig {
13    /// Webhook signing secret (whsec_...)
14    /// NEVER log this value
15    webhook_secret: String,
16
17    /// Maximum age for webhook timestamps (replay attack protection)
18    /// Stripe recommends 5 minutes (300 seconds)
19    pub max_timestamp_age: Duration,
20
21    /// Maximum allowed clock drift (future timestamps)
22    pub max_clock_drift: Duration,
23
24    /// Idempotency TTL - how long to remember processed events
25    pub idempotency_ttl: Duration,
26
27    /// Maximum number of events to track for idempotency
28    pub idempotency_max_entries: usize,
29
30    /// Async processing timeout
31    pub processing_timeout: Duration,
32
33    /// Number of retry attempts for failed processing
34    pub max_retries: u32,
35
36    /// Base delay for exponential backoff (doubles each retry)
37    pub retry_base_delay: Duration,
38
39    /// Whether to log event payloads (DISABLE in production for PII)
40    pub log_payloads: bool,
41}
42
43impl StripeWebhookConfig {
44    /// Create configuration from environment variables
45    ///
46    /// # Environment Variables
47    ///
48    /// - `STRIPE_WEBHOOK_SECRET` (required): Webhook signing secret (whsec_...)
49    /// - `STRIPE_WEBHOOK_MAX_AGE` (optional): Max timestamp age in seconds (default: 300)
50    /// - `STRIPE_WEBHOOK_IDEMPOTENCY_TTL` (optional): Idempotency TTL in seconds (default: 86400)
51    /// - `STRIPE_WEBHOOK_PROCESSING_TIMEOUT` (optional): Processing timeout in seconds (default: 30)
52    /// - `STRIPE_WEBHOOK_MAX_RETRIES` (optional): Max retry attempts (default: 3)
53    /// - `STRIPE_WEBHOOK_LOG_PAYLOADS` (optional): Log payloads - DISABLE IN PROD (default: false)
54    ///
55    /// # Errors
56    ///
57    /// Returns `StripeWebhookError::MissingSecret` if `STRIPE_WEBHOOK_SECRET` is not set.
58    pub fn from_env() -> StripeWebhookResult<Self> {
59        // CONS-003: Secret from environment variable only
60        let webhook_secret =
61            env::var("STRIPE_WEBHOOK_SECRET").map_err(|_| StripeWebhookError::MissingSecret)?;
62
63        Self::validate_secret(&webhook_secret)?;
64
65        let max_timestamp_age = env::var("STRIPE_WEBHOOK_MAX_AGE")
66            .ok()
67            .and_then(|v| v.parse::<u64>().ok())
68            .map(Duration::from_secs)
69            .unwrap_or(Duration::from_secs(300)); // 5 minutes
70
71        let idempotency_ttl = env::var("STRIPE_WEBHOOK_IDEMPOTENCY_TTL")
72            .ok()
73            .and_then(|v| v.parse::<u64>().ok())
74            .map(Duration::from_secs)
75            .unwrap_or(Duration::from_secs(86400)); // 24 hours
76
77        let processing_timeout = env::var("STRIPE_WEBHOOK_PROCESSING_TIMEOUT")
78            .ok()
79            .and_then(|v| v.parse::<u64>().ok())
80            .map(Duration::from_secs)
81            .unwrap_or(Duration::from_secs(30));
82
83        let max_retries = env::var("STRIPE_WEBHOOK_MAX_RETRIES")
84            .ok()
85            .and_then(|v| v.parse::<u32>().ok())
86            .unwrap_or(3);
87
88        let log_payloads = env::var("STRIPE_WEBHOOK_LOG_PAYLOADS")
89            .map(|v| v.to_lowercase() == "true")
90            .unwrap_or(false);
91
92        Ok(Self {
93            webhook_secret,
94            max_timestamp_age,
95            max_clock_drift: Duration::from_secs(60), // 1 minute future tolerance
96            idempotency_ttl,
97            idempotency_max_entries: 100_000,
98            processing_timeout,
99            max_retries,
100            retry_base_delay: Duration::from_secs(1),
101            log_payloads,
102        })
103    }
104
105    /// Create a test configuration (for testing only)
106    #[cfg(test)]
107    pub fn test_config() -> Self {
108        Self {
109            webhook_secret: "whsec_test_secret_for_unit_tests_only_12345".to_string(),
110            max_timestamp_age: Duration::from_secs(300),
111            max_clock_drift: Duration::from_secs(60),
112            idempotency_ttl: Duration::from_secs(3600),
113            idempotency_max_entries: 1000,
114            processing_timeout: Duration::from_secs(5),
115            max_retries: 3,
116            retry_base_delay: Duration::from_millis(100),
117            log_payloads: true, // OK for tests
118        }
119    }
120
121    /// Validate the webhook secret format
122    fn validate_secret(secret: &str) -> StripeWebhookResult<()> {
123        if secret.is_empty() {
124            return Err(StripeWebhookError::InvalidSecretFormat(
125                "Secret cannot be empty".to_string(),
126            ));
127        }
128
129        // Stripe webhook secrets start with "whsec_"
130        if !secret.starts_with("whsec_") {
131            tracing::warn!("STRIPE_WEBHOOK_SECRET does not start with 'whsec_' - may be invalid");
132        }
133
134        // Minimum reasonable length
135        if secret.len() < 20 {
136            return Err(StripeWebhookError::InvalidSecretFormat(
137                "Secret too short (minimum 20 characters)".to_string(),
138            ));
139        }
140
141        Ok(())
142    }
143
144    /// Get the webhook signing secret
145    ///
146    /// # Security Note
147    ///
148    /// This method returns a reference to the secret. NEVER log this value.
149    pub(crate) fn webhook_secret(&self) -> &str {
150        &self.webhook_secret
151    }
152
153    /// Calculate retry delay with exponential backoff and jitter
154    pub fn retry_delay(&self, attempt: u32) -> Duration {
155        let base = self.retry_base_delay.as_millis() as u64;
156        let delay = base.saturating_mul(2u64.saturating_pow(attempt));
157
158        // Add jitter (10-20% of delay)
159        let jitter = delay / 10 + (rand::random::<u64>() % (delay / 10 + 1));
160        Duration::from_millis(delay.saturating_add(jitter).min(30_000)) // Cap at 30s
161    }
162}
163
164#[cfg(test)]
165mod tests {
166    use super::*;
167
168    #[test]
169    fn test_config_validation() {
170        // Valid secret
171        assert!(
172            StripeWebhookConfig::validate_secret("whsec_test_secret_12345678901234567890").is_ok()
173        );
174
175        // Empty secret
176        assert!(matches!(
177            StripeWebhookConfig::validate_secret(""),
178            Err(StripeWebhookError::InvalidSecretFormat(_))
179        ));
180
181        // Too short
182        assert!(matches!(
183            StripeWebhookConfig::validate_secret("short"),
184            Err(StripeWebhookError::InvalidSecretFormat(_))
185        ));
186    }
187
188    #[test]
189    fn test_retry_delay() {
190        let config = StripeWebhookConfig::test_config();
191
192        let delay0 = config.retry_delay(0);
193        let delay1 = config.retry_delay(1);
194        let delay2 = config.retry_delay(2);
195
196        // Each delay should roughly double
197        assert!(delay1 > delay0);
198        assert!(delay2 > delay1);
199
200        // Should be capped at 30 seconds
201        let delay_max = config.retry_delay(20);
202        assert!(delay_max <= Duration::from_secs(30));
203    }
204}