Skip to main content

autumn_web/
webhook.rs

1//! Signed webhook intake for third-party callbacks.
2//!
3//! The [`SignedWebhook`] extractor verifies provider signatures against the
4//! exact HTTP request bytes before handler code runs. Configure endpoints with
5//! [`WebhookEndpointConfig`] under `security.webhooks.endpoints`.
6
7// autumn-determinism-gate: production code in this module must read time and
8// mint identifiers through the framework's injected seams (ClockSource /
9// Entropy), never `Instant::now()` / `Utc::now()` / `SystemTime::now()` /
10// `Uuid::new_v4()` directly. See CONTRIBUTING.md "Determinism seam gate"
11// (issue #1797). Justify exceptions with
12// #[allow(clippy::disallowed_methods, reason = "…")] at the narrowest scope.
13#![cfg_attr(not(test), deny(clippy::disallowed_methods))]
14
15use std::collections::HashMap;
16use std::future::Future;
17use std::pin::Pin;
18use std::sync::{Arc, Mutex};
19use std::time::{Duration, SystemTime, UNIX_EPOCH};
20
21use axum::extract::FromRequest;
22use bytes::Bytes;
23use http::{HeaderMap, StatusCode};
24use serde::Deserialize;
25use thiserror::Error;
26
27pub use crate::security::config::hmac_sha256_hex;
28
29const DEFAULT_TIMESTAMP_TOLERANCE_SECS: u64 = 300;
30const DEFAULT_REPLAY_WINDOW_SECS: u64 = 24 * 60 * 60;
31const DEFAULT_MAX_BODY_BYTES: usize = 1024 * 1024;
32const IN_MEMORY_REPLAY_CLEANUP_INTERVAL: usize = 128;
33const IN_MEMORY_REPLAY_CLEANUP_HIGH_WATER: usize = 16 * 1024;
34
35/// Provider preset for signed webhook verification.
36#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Deserialize)]
37#[serde(rename_all = "lowercase")]
38pub enum WebhookProvider {
39    /// Stripe-style `Stripe-Signature: t=...,v1=...`.
40    Stripe,
41    /// GitHub-style `X-Hub-Signature-256: sha256=...`.
42    Github,
43    /// Slack-style `X-Slack-Signature: v0=...`.
44    Slack,
45    /// Generic HMAC-SHA256 over the raw request body.
46    #[default]
47    Generic,
48}
49
50impl WebhookProvider {
51    /// Stable lower-case provider name.
52    #[must_use]
53    pub const fn as_str(self) -> &'static str {
54        match self {
55            Self::Stripe => "stripe",
56            Self::Github => "github",
57            Self::Slack => "slack",
58            Self::Generic => "generic",
59        }
60    }
61}
62
63impl std::fmt::Display for WebhookProvider {
64    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
65        f.write_str(self.as_str())
66    }
67}
68
69/// Signed webhook configuration section.
70#[derive(Debug, Clone, Default, Deserialize)]
71pub struct WebhookConfig {
72    /// Replay protection backend used for all replay-protected endpoints.
73    #[serde(default)]
74    pub replay: WebhookReplayConfig,
75    /// Configured signed webhook endpoints.
76    #[serde(default)]
77    pub endpoints: Vec<WebhookEndpointConfig>,
78}
79
80impl WebhookConfig {
81    /// Apply environment-sourced webhook configuration.
82    pub(crate) fn apply_env_overrides_with_env(&mut self, env: &dyn crate::config::Env) {
83        self.replay.apply_env_overrides_with_env(env);
84        self.resolve_secret_envs_with_env(env);
85    }
86
87    /// Resolve `secret_env` and `previous_secret_envs` entries from the
88    /// configured environment.
89    fn resolve_secret_envs_with_env(&mut self, env: &dyn crate::config::Env) {
90        for endpoint in &mut self.endpoints {
91            if endpoint.secret.is_none()
92                && let Some(env_name) = endpoint.secret_env.as_deref()
93                && let Ok(secret) = env.var(env_name)
94            {
95                endpoint.secret = Some(secret);
96            }
97
98            for env_name in &endpoint.previous_secret_envs {
99                if let Ok(secret) = env.var(env_name)
100                    && !secret.is_empty()
101                {
102                    endpoint.previous_secrets.push(secret);
103                }
104            }
105        }
106    }
107
108    /// Validate configured signed webhook endpoints.
109    ///
110    /// # Errors
111    ///
112    /// Returns [`WebhookConfigError`] when an endpoint has no usable secret,
113    /// an invalid path, or a weak production secret.
114    pub fn validate(&self, is_production: bool) -> Result<(), WebhookConfigError> {
115        for endpoint in &self.endpoints {
116            endpoint.validate(is_production)?;
117        }
118        validate_unique_endpoint_paths(&self.endpoints)?;
119        if self
120            .endpoints
121            .iter()
122            .any(|endpoint| endpoint.replay_protection)
123        {
124            self.replay.validate(is_production)?;
125        }
126        Ok(())
127    }
128}
129
130/// Replay protection backend for signed webhooks.
131#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
132#[serde(rename_all = "lowercase")]
133pub enum WebhookReplayBackend {
134    /// Process-local memory store. Suitable for tests, development, and
135    /// explicitly acknowledged single-replica deployments.
136    #[serde(alias = "local", alias = "in_memory")]
137    #[default]
138    Memory,
139    /// Redis `SET NX EX` store shared by every application replica.
140    Redis,
141}
142
143impl WebhookReplayBackend {
144    fn from_env_value(value: &str) -> Option<Self> {
145        match value.trim().to_ascii_lowercase().as_str() {
146            "memory" | "local" | "in_memory" | "in-memory" => Some(Self::Memory),
147            "redis" => Some(Self::Redis),
148            _ => None,
149        }
150    }
151}
152
153/// Replay protection storage configuration.
154#[derive(Debug, Clone, Deserialize)]
155pub struct WebhookReplayConfig {
156    /// Active replay backend.
157    #[serde(default)]
158    pub backend: WebhookReplayBackend,
159    /// Explicit production escape hatch for single-replica deployments.
160    #[serde(default)]
161    pub allow_memory_in_production: bool,
162    /// Redis backend options.
163    #[serde(default)]
164    pub redis: WebhookReplayRedisConfig,
165}
166
167impl Default for WebhookReplayConfig {
168    fn default() -> Self {
169        Self {
170            backend: WebhookReplayBackend::Memory,
171            allow_memory_in_production: false,
172            redis: WebhookReplayRedisConfig::default(),
173        }
174    }
175}
176
177impl WebhookReplayConfig {
178    fn apply_env_overrides_with_env(&mut self, env: &dyn crate::config::Env) {
179        if let Ok(value) = env.var("AUTUMN_SECURITY__WEBHOOKS__REPLAY__BACKEND") {
180            if let Some(backend) = WebhookReplayBackend::from_env_value(&value) {
181                self.backend = backend;
182            } else {
183                eprintln!(
184                    "Warning: AUTUMN_SECURITY__WEBHOOKS__REPLAY__BACKEND={value:?} is not valid \
185                     (expected memory or redis), ignoring"
186                );
187            }
188        }
189        if let Ok(value) = env.var("AUTUMN_SECURITY__WEBHOOKS__REPLAY__ALLOW_MEMORY_IN_PRODUCTION")
190        {
191            match value.trim().parse::<bool>() {
192                Ok(value) => self.allow_memory_in_production = value,
193                Err(error) => eprintln!(
194                    "Warning: AUTUMN_SECURITY__WEBHOOKS__REPLAY__ALLOW_MEMORY_IN_PRODUCTION \
195                     could not be parsed as bool: {error}"
196                ),
197            }
198        }
199        if let Ok(value) = env.var("AUTUMN_SECURITY__WEBHOOKS__REPLAY__REDIS__URL") {
200            let value = value.trim();
201            self.redis.url = if value.is_empty() {
202                None
203            } else {
204                Some(value.to_owned())
205            };
206        }
207        if let Ok(value) = env.var("AUTUMN_SECURITY__WEBHOOKS__REPLAY__REDIS__KEY_PREFIX")
208            && !value.trim().is_empty()
209        {
210            self.redis.key_prefix = value;
211        }
212    }
213
214    fn validate(&self, is_production: bool) -> Result<(), WebhookConfigError> {
215        match self.backend {
216            WebhookReplayBackend::Memory => {
217                if is_production && !self.allow_memory_in_production {
218                    return Err(WebhookConfigError::MemoryReplayInProduction);
219                }
220                Ok(())
221            }
222            WebhookReplayBackend::Redis => validate_redis_replay_config(&self.redis),
223        }
224    }
225}
226
227/// Redis replay protection backend configuration.
228#[derive(Debug, Clone, Deserialize)]
229pub struct WebhookReplayRedisConfig {
230    /// Redis connection URL.
231    #[serde(default)]
232    pub url: Option<String>,
233    /// Prefix for all replay keys stored in Redis.
234    #[serde(default = "default_replay_redis_key_prefix")]
235    pub key_prefix: String,
236}
237
238impl Default for WebhookReplayRedisConfig {
239    fn default() -> Self {
240        Self {
241            url: None,
242            key_prefix: default_replay_redis_key_prefix(),
243        }
244    }
245}
246
247/// Configuration for one signed webhook endpoint.
248#[derive(Debug, Clone, Deserialize)]
249pub struct WebhookEndpointConfig {
250    /// Unique endpoint name used in diagnostics and replay keys.
251    pub name: String,
252    /// Exact route path protected by this config.
253    pub path: String,
254    /// Provider verification preset.
255    #[serde(default)]
256    pub provider: WebhookProvider,
257    /// Current webhook signing secret.
258    #[serde(default)]
259    pub secret: Option<String>,
260    /// Environment variable that provides the current secret.
261    #[serde(default)]
262    pub secret_env: Option<String>,
263    /// Previous secrets accepted during a rotation grace window.
264    #[serde(default)]
265    pub previous_secrets: Vec<String>,
266    /// Environment variables that provide previous rotation secrets.
267    #[serde(default)]
268    pub previous_secret_envs: Vec<String>,
269    /// Maximum timestamp skew accepted for timestamped providers.
270    #[serde(default = "default_timestamp_tolerance_secs")]
271    pub timestamp_tolerance_secs: u64,
272    /// Replay rejection window for duplicate delivery IDs.
273    #[serde(default = "default_replay_window_secs")]
274    pub replay_window_secs: u64,
275    /// Whether duplicate delivery IDs are rejected.
276    #[serde(default = "default_true")]
277    pub replay_protection: bool,
278    /// Header carrying the signature. Provider defaults are applied by
279    /// constructors and by `Default`.
280    #[serde(default)]
281    pub signature_header: Option<String>,
282    /// Optional prefix stripped from header signatures before comparison.
283    #[serde(default)]
284    pub signature_prefix: Option<String>,
285    /// Optional header carrying a Unix timestamp.
286    #[serde(default)]
287    pub timestamp_header: Option<String>,
288    /// Optional header carrying provider delivery ID.
289    #[serde(default)]
290    pub delivery_id_header: Option<String>,
291    /// Optional header carrying provider event type.
292    #[serde(default)]
293    pub event_type_header: Option<String>,
294    /// Maximum raw body bytes read by the extractor.
295    #[serde(default = "default_max_body_bytes")]
296    pub max_body_bytes: usize,
297}
298
299impl Default for WebhookEndpointConfig {
300    fn default() -> Self {
301        Self::provider_defaults(WebhookProvider::Generic)
302    }
303}
304
305impl WebhookEndpointConfig {
306    /// Create a provider-shaped endpoint config with a current secret.
307    #[must_use]
308    pub fn new(
309        name: impl Into<String>,
310        path: impl Into<String>,
311        provider: WebhookProvider,
312        secret: impl Into<String>,
313    ) -> Self {
314        let mut config = Self::provider_defaults(provider);
315        config.name = name.into();
316        config.path = path.into();
317        config.secret = Some(secret.into());
318        config
319    }
320
321    /// Create a Stripe-style endpoint.
322    #[must_use]
323    pub fn stripe(
324        name: impl Into<String>,
325        path: impl Into<String>,
326        secret: impl Into<String>,
327    ) -> Self {
328        Self::new(name, path, WebhookProvider::Stripe, secret)
329    }
330
331    /// Create a GitHub-style endpoint.
332    #[must_use]
333    pub fn github(
334        name: impl Into<String>,
335        path: impl Into<String>,
336        secret: impl Into<String>,
337    ) -> Self {
338        Self::new(name, path, WebhookProvider::Github, secret)
339    }
340
341    /// Create a Slack-style endpoint.
342    #[must_use]
343    pub fn slack(
344        name: impl Into<String>,
345        path: impl Into<String>,
346        secret: impl Into<String>,
347    ) -> Self {
348        Self::new(name, path, WebhookProvider::Slack, secret)
349    }
350
351    /// Create a generic HMAC-SHA256 endpoint.
352    #[must_use]
353    pub fn generic(
354        name: impl Into<String>,
355        path: impl Into<String>,
356        secret: impl Into<String>,
357    ) -> Self {
358        Self::new(name, path, WebhookProvider::Generic, secret)
359    }
360
361    /// Accept one previous secret during rotation.
362    #[must_use]
363    pub fn with_previous_secret(mut self, secret: impl Into<String>) -> Self {
364        self.previous_secrets.push(secret.into());
365        self
366    }
367
368    /// Override timestamp tolerance.
369    #[must_use]
370    pub const fn with_timestamp_tolerance_secs(mut self, secs: u64) -> Self {
371        self.timestamp_tolerance_secs = secs;
372        self
373    }
374
375    /// Override replay rejection window.
376    #[must_use]
377    pub const fn with_replay_window_secs(mut self, secs: u64) -> Self {
378        self.replay_window_secs = secs;
379        self
380    }
381
382    /// Disable duplicate delivery rejection for this endpoint.
383    #[must_use]
384    pub const fn without_replay_protection(mut self) -> Self {
385        self.replay_protection = false;
386        self
387    }
388
389    fn provider_defaults(provider: WebhookProvider) -> Self {
390        let mut config = Self {
391            name: String::new(),
392            path: String::new(),
393            provider,
394            secret: None,
395            secret_env: None,
396            previous_secrets: Vec::new(),
397            previous_secret_envs: Vec::new(),
398            timestamp_tolerance_secs: DEFAULT_TIMESTAMP_TOLERANCE_SECS,
399            replay_window_secs: DEFAULT_REPLAY_WINDOW_SECS,
400            replay_protection: true,
401            signature_header: None,
402            signature_prefix: None,
403            timestamp_header: None,
404            delivery_id_header: None,
405            event_type_header: None,
406            max_body_bytes: DEFAULT_MAX_BODY_BYTES,
407        };
408
409        match provider {
410            WebhookProvider::Stripe => {
411                config.signature_header = Some("Stripe-Signature".to_owned());
412            }
413            WebhookProvider::Github => {
414                config.signature_header = Some("X-Hub-Signature-256".to_owned());
415                config.signature_prefix = Some("sha256=".to_owned());
416                config.delivery_id_header = Some("X-GitHub-Delivery".to_owned());
417                config.event_type_header = Some("X-GitHub-Event".to_owned());
418            }
419            WebhookProvider::Slack => {
420                config.signature_header = Some("X-Slack-Signature".to_owned());
421                config.signature_prefix = Some("v0=".to_owned());
422                config.timestamp_header = Some("X-Slack-Request-Timestamp".to_owned());
423            }
424            WebhookProvider::Generic => {
425                config.signature_header = Some("X-Webhook-Signature".to_owned());
426                config.signature_prefix = Some("sha256=".to_owned());
427                config.delivery_id_header = Some("X-Webhook-Delivery".to_owned());
428                config.event_type_header = Some("X-Webhook-Event".to_owned());
429            }
430        }
431
432        config
433    }
434
435    fn validate(&self, is_production: bool) -> Result<(), WebhookConfigError> {
436        if self.name.trim().is_empty() {
437            return Err(WebhookConfigError::InvalidEndpoint {
438                name: self.name.clone(),
439                message: "name must not be empty".to_owned(),
440            });
441        }
442        if !self.path.starts_with('/') || self.path.trim() == "/" || self.path.trim().is_empty() {
443            return Err(WebhookConfigError::InvalidEndpoint {
444                name: self.name.clone(),
445                message: format!("path {:?} must start with '/' and not be root", self.path),
446            });
447        }
448        let Some(secret) = self.secret.as_deref().filter(|value| !value.is_empty()) else {
449            return Err(WebhookConfigError::MissingSecret {
450                name: self.name.clone(),
451                path: self.path.clone(),
452            });
453        };
454
455        if is_production {
456            crate::security::config::validate_signing_secret(Some(secret), true).map_err(
457                |reason| WebhookConfigError::InvalidSecret {
458                    name: self.name.clone(),
459                    reason,
460                },
461            )?;
462            for (index, previous) in self.previous_secrets.iter().enumerate() {
463                crate::security::config::validate_signing_secret(Some(previous), true).map_err(
464                    |reason| WebhookConfigError::InvalidPreviousSecret {
465                        name: self.name.clone(),
466                        index,
467                        reason,
468                    },
469                )?;
470            }
471        }
472
473        Ok(())
474    }
475
476    fn apply_provider_defaults(&mut self) {
477        let defaults = Self::provider_defaults(self.provider);
478        if self.signature_header.is_none() {
479            self.signature_header = defaults.signature_header;
480        }
481        if self.signature_prefix.is_none() {
482            self.signature_prefix = defaults.signature_prefix;
483        }
484        if self.timestamp_header.is_none() {
485            self.timestamp_header = defaults.timestamp_header;
486        }
487        if self.delivery_id_header.is_none() {
488            self.delivery_id_header = defaults.delivery_id_header;
489        }
490        if self.event_type_header.is_none() {
491            self.event_type_header = defaults.event_type_header;
492        }
493    }
494}
495
496/// Webhook configuration validation failure.
497#[derive(Debug, Clone, PartialEq, Eq, Error)]
498pub enum WebhookConfigError {
499    /// A configured endpoint has no current secret.
500    #[error("webhook endpoint {name:?} at {path:?} is missing a secret")]
501    MissingSecret {
502        /// Endpoint name.
503        name: String,
504        /// Endpoint path.
505        path: String,
506    },
507    /// A configured endpoint is invalid.
508    #[error("webhook endpoint {name:?} is invalid: {message}")]
509    InvalidEndpoint {
510        /// Endpoint name.
511        name: String,
512        /// Validation message.
513        message: String,
514    },
515    /// Two endpoints declare the same route path.
516    #[error(
517        "duplicate webhook endpoint path {path:?}: endpoints {first_name:?} and \
518         {duplicate_name:?} would shadow each other"
519    )]
520    DuplicatePath {
521        /// Shared endpoint path.
522        path: String,
523        /// Name of the first endpoint using the path.
524        first_name: String,
525        /// Name of the later endpoint using the same path.
526        duplicate_name: String,
527    },
528    /// Current production secret is weak or malformed.
529    #[error("webhook endpoint {name:?} has invalid secret: {reason}")]
530    InvalidSecret {
531        /// Endpoint name.
532        name: String,
533        /// Signing secret validation error.
534        reason: crate::security::config::SigningSecretError,
535    },
536    /// Previous production secret is weak or malformed.
537    #[error("webhook endpoint {name:?} has invalid previous secret {index}: {reason}")]
538    InvalidPreviousSecret {
539        /// Endpoint name.
540        name: String,
541        /// Previous secret index.
542        index: usize,
543        /// Signing secret validation error.
544        reason: crate::security::config::SigningSecretError,
545    },
546    /// Process-local replay storage was selected for production webhooks.
547    #[error(
548        "webhook replay backend memory is not allowed in production; set \
549         security.webhooks.replay.backend = \"redis\" or explicitly set \
550         security.webhooks.replay.allow_memory_in_production = true"
551    )]
552    MemoryReplayInProduction,
553    /// Redis replay protection was selected without a URL.
554    #[error("webhook redis replay backend requires security.webhooks.replay.redis.url")]
555    RedisReplayMissingUrl,
556    /// Redis replay URL is malformed.
557    #[error("webhook redis replay backend URL is invalid: {0}")]
558    RedisReplayInvalidUrl(String),
559    /// Redis replay protection was selected without compiling the Redis feature.
560    #[error("webhook redis replay backend requires the autumn-web redis feature")]
561    RedisReplayFeatureDisabled,
562}
563
564#[derive(Debug)]
565struct ResolvedWebhookEndpoint {
566    config: WebhookEndpointConfig,
567    keys: crate::security::config::ResolvedSigningKeys,
568}
569
570/// Runtime registry for signed webhook endpoints.
571#[derive(Clone, Debug)]
572pub struct WebhookRegistry {
573    endpoints_by_path: Arc<HashMap<String, Arc<ResolvedWebhookEndpoint>>>,
574    replay_store: Arc<dyn WebhookReplayStore>,
575}
576
577impl WebhookRegistry {
578    /// Build a registry from config using the built-in in-memory replay store.
579    ///
580    /// # Errors
581    ///
582    /// Returns [`WebhookConfigError`] when any endpoint is missing a secret.
583    pub fn from_config(config: &WebhookConfig) -> Result<Self, WebhookConfigError> {
584        let replay_store = if config
585            .endpoints
586            .iter()
587            .any(|endpoint| endpoint.replay_protection)
588        {
589            replay_store_from_config(&config.replay)?
590        } else {
591            Arc::new(InMemoryWebhookReplayStore::default())
592        };
593        Self::from_config_with_shared_replay_store(config, replay_store)
594    }
595
596    /// Build a registry from config with a custom replay store.
597    ///
598    /// # Errors
599    ///
600    /// Returns [`WebhookConfigError`] when any endpoint is missing a secret.
601    pub fn from_config_with_replay_store(
602        config: &WebhookConfig,
603        replay_store: impl WebhookReplayStore + 'static,
604    ) -> Result<Self, WebhookConfigError> {
605        Self::from_config_with_shared_replay_store(config, Arc::new(replay_store))
606    }
607
608    /// Build a registry from config with a shared replay store.
609    ///
610    /// This is useful when multiple test app instances should share replay
611    /// state, or when an integration constructs its own durable backend.
612    ///
613    /// # Errors
614    ///
615    /// Returns [`WebhookConfigError`] when any endpoint is missing a secret.
616    pub fn from_config_with_shared_replay_store(
617        config: &WebhookConfig,
618        replay_store: Arc<dyn WebhookReplayStore>,
619    ) -> Result<Self, WebhookConfigError> {
620        validate_unique_endpoint_paths(&config.endpoints)?;
621        let mut endpoints_by_path = HashMap::new();
622        for endpoint in &config.endpoints {
623            let mut endpoint = endpoint.clone();
624            endpoint.apply_provider_defaults();
625            endpoint.validate(false)?;
626            let Some(secret) = endpoint.secret.as_ref() else {
627                return Err(WebhookConfigError::MissingSecret {
628                    name: endpoint.name.clone(),
629                    path: endpoint.path.clone(),
630                });
631            };
632            let current = secret.as_bytes().to_vec();
633            let previous = endpoint
634                .previous_secrets
635                .iter()
636                .map(|secret| secret.as_bytes().to_vec())
637                .collect();
638            endpoints_by_path.insert(
639                endpoint.path.clone(),
640                Arc::new(ResolvedWebhookEndpoint {
641                    config: endpoint,
642                    keys: crate::security::config::ResolvedSigningKeys::new(current, previous),
643                }),
644            );
645        }
646
647        Ok(Self {
648            endpoints_by_path: Arc::new(endpoints_by_path),
649            replay_store,
650        })
651    }
652
653    fn endpoint_for_path(&self, path: &str) -> Option<Arc<ResolvedWebhookEndpoint>> {
654        self.endpoints_by_path.get(path).cloned()
655    }
656}
657
658fn validate_unique_endpoint_paths(
659    endpoints: &[WebhookEndpointConfig],
660) -> Result<(), WebhookConfigError> {
661    let mut seen_paths = HashMap::new();
662    for endpoint in endpoints {
663        if let Some(first_name) = seen_paths.insert(endpoint.path.as_str(), endpoint.name.as_str())
664        {
665            return Err(WebhookConfigError::DuplicatePath {
666                path: endpoint.path.clone(),
667                first_name: first_name.to_owned(),
668                duplicate_name: endpoint.name.clone(),
669            });
670        }
671    }
672    Ok(())
673}
674
675/// Boxed replay store operation future.
676pub type WebhookReplayFuture<'a> =
677    Pin<Box<dyn Future<Output = Result<bool, WebhookReplayStoreError>> + Send + 'a>>;
678
679/// Replay store used to reject duplicate provider delivery IDs.
680pub trait WebhookReplayStore: Send + Sync + std::fmt::Debug {
681    /// Insert a delivery key and return `true`; return `false` if it already
682    /// exists inside the replay window.
683    fn check_and_insert<'a>(
684        &'a self,
685        key: &'a str,
686        received_at: SystemTime,
687        window: Duration,
688    ) -> WebhookReplayFuture<'a>;
689
690    /// Remove a delivery key from the store.
691    fn remove<'a>(&'a self, key: &'a str) -> WebhookReplayFuture<'a>;
692}
693
694impl<T> WebhookReplayStore for Arc<T>
695where
696    T: WebhookReplayStore + ?Sized,
697{
698    fn check_and_insert<'a>(
699        &'a self,
700        key: &'a str,
701        received_at: SystemTime,
702        window: Duration,
703    ) -> WebhookReplayFuture<'a> {
704        self.as_ref().check_and_insert(key, received_at, window)
705    }
706
707    fn remove<'a>(&'a self, key: &'a str) -> WebhookReplayFuture<'a> {
708        self.as_ref().remove(key)
709    }
710}
711
712/// Replay store operation failure.
713#[derive(Debug, Clone, Error)]
714#[error("{message}")]
715pub struct WebhookReplayStoreError {
716    message: String,
717}
718
719impl WebhookReplayStoreError {
720    /// Create a replay-store failure with a human-readable diagnostic.
721    ///
722    /// Custom [`WebhookReplayStore`] implementations should return this when
723    /// their durable backend is unavailable or cannot complete the atomic
724    /// delivery-ID claim. Autumn surfaces the failure as `503 Service
725    /// Unavailable` before the webhook handler runs.
726    #[must_use]
727    pub fn new(message: impl Into<String>) -> Self {
728        Self {
729            message: message.into(),
730        }
731    }
732}
733
734#[cfg(feature = "redis")]
735impl WebhookReplayStoreError {
736    fn backend(operation: &'static str, error: impl std::fmt::Display) -> Self {
737        Self::new(format!("webhook replay store {operation} failed: {error}"))
738    }
739}
740
741/// In-memory replay protection store.
742///
743/// This is suitable for tests, development, and single-process deployments. A
744/// multi-replica production fleet should configure the Redis replay backend.
745#[derive(Debug, Default)]
746pub struct InMemoryWebhookReplayStore {
747    state: Mutex<InMemoryWebhookReplayState>,
748}
749
750#[derive(Debug, Default)]
751struct InMemoryWebhookReplayState {
752    seen: HashMap<String, SystemTime>,
753    checks_since_cleanup: usize,
754}
755
756impl InMemoryWebhookReplayStore {
757    fn check_and_insert_sync(&self, key: &str, received_at: SystemTime, window: Duration) -> bool {
758        {
759            let mut state = self
760                .state
761                .lock()
762                .expect("webhook replay store lock poisoned");
763            state.checks_since_cleanup = state.checks_since_cleanup.saturating_add(1);
764
765            if let Some(expires_at) = state.seen.get(key).copied() {
766                if expires_at.duration_since(received_at).is_ok() {
767                    Self::cleanup_if_due(&mut state, received_at);
768                    drop(state);
769                    return false;
770                }
771                state.seen.remove(key);
772            }
773
774            let expires_at = received_at.checked_add(window).unwrap_or(received_at);
775            state.seen.insert(key.to_owned(), expires_at);
776            Self::cleanup_if_due(&mut state, received_at);
777            drop(state);
778        }
779        true
780    }
781
782    fn cleanup_if_due(state: &mut InMemoryWebhookReplayState, received_at: SystemTime) {
783        if state.checks_since_cleanup < IN_MEMORY_REPLAY_CLEANUP_INTERVAL
784            && state.seen.len() <= IN_MEMORY_REPLAY_CLEANUP_HIGH_WATER
785        {
786            return;
787        }
788
789        state.checks_since_cleanup = 0;
790        state
791            .seen
792            .retain(|_, expires_at| expires_at.duration_since(received_at).is_ok());
793    }
794}
795
796impl WebhookReplayStore for InMemoryWebhookReplayStore {
797    fn check_and_insert<'a>(
798        &'a self,
799        key: &'a str,
800        received_at: SystemTime,
801        window: Duration,
802    ) -> WebhookReplayFuture<'a> {
803        Box::pin(async move { Ok(self.check_and_insert_sync(key, received_at, window)) })
804    }
805
806    fn remove<'a>(&'a self, key: &'a str) -> WebhookReplayFuture<'a> {
807        self.state
808            .lock()
809            .expect("webhook replay store lock poisoned")
810            .seen
811            .remove(key);
812        Box::pin(async move { Ok(true) })
813    }
814}
815
816/// Redis replay protection store.
817#[cfg(feature = "redis")]
818#[derive(Clone, Debug)]
819pub struct RedisWebhookReplayStore {
820    connection: redis::aio::ConnectionManager,
821    key_prefix: String,
822}
823
824#[cfg(feature = "redis")]
825impl RedisWebhookReplayStore {
826    /// Build a Redis replay store from config.
827    ///
828    /// # Errors
829    ///
830    /// Returns [`WebhookConfigError`] when the URL is absent or malformed.
831    pub fn from_config(config: &WebhookReplayRedisConfig) -> Result<Self, WebhookConfigError> {
832        let url = config
833            .url
834            .as_deref()
835            .filter(|url| !url.trim().is_empty())
836            .ok_or(WebhookConfigError::RedisReplayMissingUrl)?;
837        let client = crate::redis_tls::open_client(url)
838            .map_err(|error| WebhookConfigError::RedisReplayInvalidUrl(error.to_string()))?;
839        let connection = redis::aio::ConnectionManager::new_lazy_with_config(
840            client,
841            redis::aio::ConnectionManagerConfig::new(),
842        )
843        .map_err(|error| WebhookConfigError::RedisReplayInvalidUrl(error.to_string()))?;
844        Ok(Self {
845            connection,
846            key_prefix: config.key_prefix.clone(),
847        })
848    }
849
850    fn key_for(&self, replay_key: &str) -> String {
851        format!("{}:{replay_key}", self.key_prefix)
852    }
853}
854
855#[cfg(feature = "redis")]
856impl WebhookReplayStore for RedisWebhookReplayStore {
857    fn check_and_insert<'a>(
858        &'a self,
859        key: &'a str,
860        received_at: SystemTime,
861        window: Duration,
862    ) -> WebhookReplayFuture<'a> {
863        Box::pin(async move {
864            let mut connection = self.connection.clone();
865            let key = self.key_for(key);
866            let ttl_secs = window.as_secs().max(1);
867            let received_unix = received_at
868                .duration_since(UNIX_EPOCH)
869                .map_err(|error| WebhookReplayStoreError::backend("timestamp", error))?
870                .as_secs()
871                .to_string();
872            let inserted: Option<String> = redis::cmd("SET")
873                .arg(&key)
874                .arg(received_unix)
875                .arg("NX")
876                .arg("EX")
877                .arg(ttl_secs)
878                .query_async(&mut connection)
879                .await
880                .map_err(|error| WebhookReplayStoreError::backend("insert", error))?;
881            Ok(inserted.is_some())
882        })
883    }
884
885    fn remove<'a>(&'a self, key: &'a str) -> WebhookReplayFuture<'a> {
886        Box::pin(async move {
887            let mut connection = self.connection.clone();
888            let key = self.key_for(key);
889            let _: () = redis::cmd("DEL")
890                .arg(&key)
891                .query_async(&mut connection)
892                .await
893                .map_err(|error| WebhookReplayStoreError::backend("delete", error))?;
894            Ok(true)
895        })
896    }
897}
898
899/// A request that has passed signed webhook verification.
900#[derive(Debug, Clone)]
901pub struct SignedWebhook {
902    provider: WebhookProvider,
903    endpoint: String,
904    delivery_id: Option<String>,
905    event_type: Option<String>,
906    received_at: SystemTime,
907    raw_body: Bytes,
908}
909
910impl SignedWebhook {
911    /// Provider preset that verified this request.
912    #[must_use]
913    pub const fn provider(&self) -> &'static str {
914        self.provider.as_str()
915    }
916
917    /// Configured endpoint name.
918    #[must_use]
919    pub fn endpoint(&self) -> &str {
920        &self.endpoint
921    }
922
923    /// Provider delivery ID, when present.
924    #[must_use]
925    pub fn delivery_id(&self) -> Option<&str> {
926        self.delivery_id.as_deref()
927    }
928
929    /// Provider event type, when present.
930    #[must_use]
931    pub fn event_type(&self) -> Option<&str> {
932        self.event_type.as_deref()
933    }
934
935    /// Request receive time used for timestamp and replay checks.
936    #[must_use]
937    pub const fn received_at(&self) -> SystemTime {
938        self.received_at
939    }
940
941    /// Exact verified request body bytes.
942    #[must_use]
943    pub fn raw_body(&self) -> &[u8] {
944        &self.raw_body
945    }
946
947    /// Decode the verified body as JSON.
948    ///
949    /// # Errors
950    ///
951    /// Returns `serde_json::Error` when the verified body is not valid JSON for
952    /// the requested type.
953    pub fn json<T: serde::de::DeserializeOwned>(&self) -> Result<T, serde_json::Error> {
954        serde_json::from_slice(&self.raw_body)
955    }
956}
957
958impl FromRequest<crate::AppState> for SignedWebhook {
959    type Rejection = crate::AutumnError;
960
961    async fn from_request(
962        req: axum::extract::Request,
963        state: &crate::AppState,
964    ) -> Result<Self, Self::Rejection> {
965        let (parts, body) = req.into_parts();
966        let path = parts.uri.path().to_owned();
967        let registry = state
968            .extension::<WebhookRegistry>()
969            .ok_or_else(|| WebhookVerifyError::RegistryMissing.into_autumn_error())?;
970        let endpoint = registry
971            .endpoint_for_path(&path)
972            .ok_or_else(|| WebhookVerifyError::EndpointMissing(path.clone()).into_autumn_error())?;
973        let body = axum::body::to_bytes(body, endpoint.config.max_body_bytes)
974            .await
975            .map_err(|err| {
976                crate::AutumnError::bad_request_msg(format!(
977                    "webhook body could not be read: {err}"
978                ))
979            })?;
980        // The ambient clock: real time outside a `Sim`, as the provider signs
981        // with real time, and the sim clock inside one (issue #2967). Not the
982        // app clock: a test that pins `TestApp::with_clock` still signs with
983        // real time.
984        let received_at = crate::time::ambient_system_time();
985        verify_request(&registry, &endpoint, &parts.headers, body, received_at)
986            .await
987            .map_err(WebhookVerifyError::into_autumn_error)
988    }
989}
990
991#[derive(Debug, Error)]
992enum WebhookVerifyError {
993    #[error("signed webhook registry is not installed")]
994    RegistryMissing,
995    #[error("no signed webhook endpoint is configured for path {0}")]
996    EndpointMissing(String),
997    #[error("missing required webhook header {0}")]
998    MissingHeader(String),
999    #[error("malformed webhook signature")]
1000    MalformedSignature,
1001    #[error("malformed webhook timestamp")]
1002    MalformedTimestamp,
1003    #[error("webhook timestamp is outside the accepted tolerance")]
1004    StaleTimestamp,
1005    #[error("webhook signature mismatch")]
1006    SignatureMismatch,
1007    #[error("missing webhook delivery ID")]
1008    MissingDeliveryId,
1009    #[error("duplicate webhook delivery")]
1010    DuplicateDelivery,
1011    #[error("webhook replay store unavailable: {0}")]
1012    ReplayStoreUnavailable(String),
1013}
1014
1015impl WebhookVerifyError {
1016    const fn status(&self) -> StatusCode {
1017        match self {
1018            Self::RegistryMissing | Self::EndpointMissing(_) => StatusCode::INTERNAL_SERVER_ERROR,
1019            Self::MissingHeader(_)
1020            | Self::MalformedSignature
1021            | Self::MalformedTimestamp
1022            | Self::MissingDeliveryId => StatusCode::BAD_REQUEST,
1023            Self::StaleTimestamp | Self::SignatureMismatch => StatusCode::UNAUTHORIZED,
1024            Self::DuplicateDelivery => StatusCode::CONFLICT,
1025            Self::ReplayStoreUnavailable(_) => StatusCode::SERVICE_UNAVAILABLE,
1026        }
1027    }
1028
1029    fn into_autumn_error(self) -> crate::AutumnError {
1030        crate::AutumnError::bad_request_msg(self.to_string()).with_status(self.status())
1031    }
1032}
1033
1034pub(crate) type ReplayStoreCell =
1035    std::sync::Arc<std::sync::Mutex<Option<(std::sync::Arc<dyn WebhookReplayStore>, Vec<String>)>>>;
1036
1037tokio::task_local! {
1038    pub static WEBHOOK_REPLAY_KEY: ReplayStoreCell;
1039}
1040
1041/// Guard that releases the in-flight webhook replay keys if the handler does not
1042/// complete normally.
1043///
1044/// Webhook delivery is at-least-once: providers retry on any failure, and
1045/// handlers are expected to be idempotent. So when a handler fails — whether it
1046/// panics, the request-timeout deadline cancels its future (this middleware is
1047/// applied *inner* to the timeout layer), or it returns a `5xx` (handled in
1048/// [`webhook_replay_cleanup_middleware`]) — the inserted replay keys are removed
1049/// so the provider's redelivery is accepted rather than rejected as a replay.
1050/// This is deliberate and consistent across all three failure modes; it does
1051/// mean a handler that committed its side effect *before* failing can be invoked
1052/// again on retry, which is the standard at-least-once contract handlers must
1053/// tolerate.
1054pub(crate) struct ReplayKeyGuard {
1055    cell: ReplayStoreCell,
1056    completed: bool,
1057}
1058
1059impl Drop for ReplayKeyGuard {
1060    fn drop(&mut self) {
1061        if !self.completed {
1062            let to_remove = match self.cell.lock() {
1063                Ok(mut guard) => guard.take(),
1064                Err(poisoned) => poisoned.into_inner().take(),
1065            };
1066            if let Some((store, keys)) = to_remove {
1067                tokio::spawn(async move {
1068                    for key in keys {
1069                        tracing::debug!(key = %key, "Releasing webhook replay key after handler panic or deadline cancellation");
1070                        let _ = store.remove(&key).await;
1071                    }
1072                });
1073            }
1074        }
1075    }
1076}
1077
1078/// Middleware to clean up webhook replay keys on handler failure.
1079pub async fn webhook_replay_cleanup_middleware(
1080    req: axum::extract::Request,
1081    next: axum::middleware::Next,
1082) -> axum::response::Response {
1083    let cell: ReplayStoreCell = std::sync::Arc::new(std::sync::Mutex::new(None));
1084    let cell_cloned = cell.clone();
1085
1086    let mut guard = ReplayKeyGuard {
1087        cell: cell_cloned.clone(),
1088        completed: false,
1089    };
1090
1091    let response = WEBHOOK_REPLAY_KEY
1092        .scope(cell, async move { next.run(req).await })
1093        .await;
1094
1095    guard.completed = true;
1096
1097    if let Some(cleanup) = release_keys_on_server_error(&cell_cloned, response.status()) {
1098        cleanup.await;
1099    }
1100
1101    response
1102}
1103
1104/// Take the request's inserted replay keys back out of `cell` and return the
1105/// work that removes them, when `status` says the request failed with a `5xx`.
1106///
1107/// Returns `None` on every other status, and on a `5xx` that inserted no keys —
1108/// so the common path allocates nothing and awaits nothing. Shared by
1109/// [`webhook_replay_cleanup_middleware`] and [`WebhookReplayCleanupFuture`].
1110fn release_keys_on_server_error(
1111    cell: &ReplayStoreCell,
1112    status: axum::http::StatusCode,
1113) -> Option<impl std::future::Future<Output = ()> + Send + 'static> {
1114    if !status.is_server_error() {
1115        return None;
1116    }
1117    let (store, keys) = match cell.lock() {
1118        Ok(mut guard) => guard.take(),
1119        Err(poisoned) => poisoned.into_inner().take(),
1120    }?;
1121    Some(async move {
1122        for key in keys {
1123            tracing::debug!(key = %key, "Releasing webhook replay key due to 5xx server error");
1124            let _ = store.remove(&key).await;
1125        }
1126    })
1127}
1128
1129/// Tower [`Layer`](tower::Layer) form of [`webhook_replay_cleanup_middleware`],
1130/// used by the framework's ingress stack.
1131///
1132/// The `axum::middleware::from_fn` it replaces `Box::pin`ned its wrapped async
1133/// block on every request, and cloned the erased service beneath it to get it in
1134/// there (issue #2214). Here the happy path — a request that is not a webhook
1135/// delivery, or one that succeeds — is a `TaskLocalFuture` holding the inner
1136/// service's own future: no boxed future, no clone of the stack below. (It
1137/// still mints the per-request [`ReplayStoreCell`] it always did; that
1138/// allocation is the middleware's own state, not `from_fn`'s wrapper.) Only the
1139/// rare `5xx` *that actually registered replay keys* takes a box, for the
1140/// removal work.
1141#[derive(Clone, Copy, Debug)]
1142pub(crate) struct WebhookReplayCleanupLayer;
1143
1144impl<S> tower::Layer<S> for WebhookReplayCleanupLayer {
1145    type Service = WebhookReplayCleanupService<S>;
1146
1147    fn layer(&self, inner: S) -> Self::Service {
1148        WebhookReplayCleanupService { inner }
1149    }
1150}
1151
1152/// Tower [`Service`](tower::Service) produced by [`WebhookReplayCleanupLayer`].
1153#[derive(Clone, Debug)]
1154pub(crate) struct WebhookReplayCleanupService<S> {
1155    inner: S,
1156}
1157
1158impl<S, ReqBody> tower::Service<axum::http::Request<ReqBody>> for WebhookReplayCleanupService<S>
1159where
1160    S: tower::Service<axum::http::Request<ReqBody>, Response = axum::response::Response>,
1161{
1162    type Response = S::Response;
1163    type Error = S::Error;
1164    type Future = WebhookReplayCleanupFuture<S::Future>;
1165
1166    fn poll_ready(
1167        &mut self,
1168        cx: &mut std::task::Context<'_>,
1169    ) -> std::task::Poll<Result<(), Self::Error>> {
1170        self.inner.poll_ready(cx)
1171    }
1172
1173    fn call(&mut self, req: axum::http::Request<ReqBody>) -> Self::Future {
1174        let cell: ReplayStoreCell = std::sync::Arc::new(std::sync::Mutex::new(None));
1175        // Built inside the scope, not merely polled inside it — see
1176        // `crate::capsule::capture` for why the argument form would run the
1177        // synchronous `Service::call` chain beneath this layer with the
1178        // task-local unset.
1179        let inner = WEBHOOK_REPLAY_KEY.sync_scope(cell.clone(), || self.inner.call(req));
1180        WebhookReplayCleanupFuture::Serving {
1181            inner: WEBHOOK_REPLAY_KEY.scope(cell.clone(), inner),
1182            guard: ReplayKeyGuard {
1183                cell: cell.clone(),
1184                completed: false,
1185            },
1186            cell,
1187        }
1188    }
1189}
1190
1191pin_project_lite::pin_project! {
1192    /// Future returned by [`WebhookReplayCleanupService`].
1193    ///
1194    /// `Serving` holds the [`ReplayKeyGuard`] alongside the scoped inner
1195    /// future, so dropping this future before it resolves — a handler panic, or
1196    /// the request-timeout layer above cancelling it — still runs the guard's
1197    /// `Drop` and releases the inserted keys, exactly as the `from_fn` form's
1198    /// dropped async block did.
1199    #[project = WebhookReplayCleanupFutureProj]
1200    pub(crate) enum WebhookReplayCleanupFuture<F> {
1201        Serving {
1202            #[pin]
1203            inner: tokio::task::futures::TaskLocalFuture<ReplayStoreCell, F>,
1204            cell: ReplayStoreCell,
1205            guard: ReplayKeyGuard,
1206        },
1207        /// A `5xx` that inserted replay keys: release them before handing the
1208        /// response back. This is the only branch that allocates.
1209        Releasing {
1210            #[pin]
1211            cleanup: std::pin::Pin<Box<dyn std::future::Future<Output = ()> + Send>>,
1212            response: Option<axum::response::Response>,
1213        },
1214    }
1215}
1216
1217impl<F, E> std::future::Future for WebhookReplayCleanupFuture<F>
1218where
1219    F: std::future::Future<Output = Result<axum::response::Response, E>>,
1220{
1221    type Output = Result<axum::response::Response, E>;
1222
1223    #[allow(
1224        clippy::expect_used,
1225        reason = "unreachable: future not polled after Ready"
1226    )]
1227    fn poll(
1228        mut self: std::pin::Pin<&mut Self>,
1229        cx: &mut std::task::Context<'_>,
1230    ) -> std::task::Poll<Self::Output> {
1231        loop {
1232            let next = match self.as_mut().project() {
1233                WebhookReplayCleanupFutureProj::Serving { inner, cell, guard } => {
1234                    let response = match std::task::ready!(inner.poll(cx)) {
1235                        Ok(response) => response,
1236                        Err(error) => {
1237                            // The inner service failed rather than responding.
1238                            // Leave `completed` false so the guard's `Drop`
1239                            // releases the keys, matching the panic/cancel path.
1240                            return std::task::Poll::Ready(Err(error));
1241                        }
1242                    };
1243                    // The handler ran to completion: the guard's drop-time
1244                    // release must not fire, whatever the status was.
1245                    guard.completed = true;
1246                    match release_keys_on_server_error(cell, response.status()) {
1247                        None => return std::task::Poll::Ready(Ok(response)),
1248                        Some(cleanup) => Self::Releasing {
1249                            cleanup: Box::pin(cleanup),
1250                            response: Some(response),
1251                        },
1252                    }
1253                }
1254                WebhookReplayCleanupFutureProj::Releasing { cleanup, response } => {
1255                    std::task::ready!(cleanup.poll(cx));
1256                    return std::task::Poll::Ready(Ok(response
1257                        .take()
1258                        .expect("WebhookReplayCleanupFuture polled after completion")));
1259                }
1260            };
1261            self.as_mut().set(next);
1262        }
1263    }
1264}
1265
1266async fn verify_request(
1267    registry: &WebhookRegistry,
1268    endpoint: &ResolvedWebhookEndpoint,
1269    headers: &HeaderMap,
1270    body: Bytes,
1271    received_at: SystemTime,
1272) -> Result<SignedWebhook, WebhookVerifyError> {
1273    match endpoint.config.provider {
1274        WebhookProvider::Stripe => verify_stripe(endpoint, headers, &body, received_at)?,
1275        WebhookProvider::Github | WebhookProvider::Generic => {
1276            verify_body_hmac(endpoint, headers, &body, None, received_at)?;
1277        }
1278        WebhookProvider::Slack => verify_slack(endpoint, headers, &body, received_at)?,
1279    }
1280
1281    let json_body = serde_json::from_slice::<serde_json::Value>(&body).ok();
1282    let delivery_id = resolve_delivery_id(&endpoint.config, headers, json_body.as_ref());
1283    if endpoint.config.replay_protection {
1284        let delivery_id = delivery_id
1285            .as_deref()
1286            .ok_or(WebhookVerifyError::MissingDeliveryId)?;
1287
1288        let mut keys_to_check = vec![format!(
1289            "{}:{}:id:{delivery_id}",
1290            endpoint.config.provider.as_str(),
1291            endpoint.config.name
1292        )];
1293
1294        if matches!(
1295            endpoint.config.provider,
1296            WebhookProvider::Github | WebhookProvider::Generic
1297        ) {
1298            let sig_hdr = signature_header(endpoint);
1299            if let Some(sig_val) = headers.get(sig_hdr).and_then(|v| v.to_str().ok()) {
1300                use sha2::{Digest, Sha256};
1301                let mut hasher = Sha256::new();
1302                hasher.update(sig_val.as_bytes());
1303                let sig_hash = hex::encode(hasher.finalize());
1304                keys_to_check.push(format!(
1305                    "{}:{}:sig:{sig_hash}",
1306                    endpoint.config.provider.as_str(),
1307                    endpoint.config.name
1308                ));
1309            }
1310        }
1311
1312        let window = Duration::from_secs(endpoint.config.replay_window_secs);
1313        let mut inserted_keys = Vec::new();
1314        for key in keys_to_check {
1315            match registry
1316                .replay_store
1317                .check_and_insert(&key, received_at, window)
1318                .await
1319            {
1320                Ok(true) => {
1321                    inserted_keys.push(key);
1322                }
1323                Ok(false) => {
1324                    // Rollback already inserted keys for this request
1325                    for inserted in inserted_keys {
1326                        let _ = registry.replay_store.remove(&inserted).await;
1327                    }
1328                    return Err(WebhookVerifyError::DuplicateDelivery);
1329                }
1330                Err(error) => {
1331                    // Rollback already inserted keys for this request
1332                    for inserted in inserted_keys {
1333                        let _ = registry.replay_store.remove(&inserted).await;
1334                    }
1335                    return Err(WebhookVerifyError::ReplayStoreUnavailable(
1336                        error.to_string(),
1337                    ));
1338                }
1339            }
1340        }
1341
1342        let _ = WEBHOOK_REPLAY_KEY.try_with(|cell| {
1343            let guard = match cell.lock() {
1344                Ok(g) => Some(g),
1345                Err(poisoned) => Some(poisoned.into_inner()),
1346            };
1347            if let Some(mut guard) = guard {
1348                *guard = Some((std::sync::Arc::clone(&registry.replay_store), inserted_keys));
1349            }
1350        });
1351    }
1352
1353    Ok(SignedWebhook {
1354        provider: endpoint.config.provider,
1355        endpoint: endpoint.config.name.clone(),
1356        delivery_id,
1357        event_type: resolve_event_type(&endpoint.config, headers, json_body.as_ref()),
1358        received_at,
1359        raw_body: body,
1360    })
1361}
1362
1363fn verify_stripe(
1364    endpoint: &ResolvedWebhookEndpoint,
1365    headers: &HeaderMap,
1366    body: &[u8],
1367    received_at: SystemTime,
1368) -> Result<(), WebhookVerifyError> {
1369    let header = required_header(headers, signature_header(endpoint))?;
1370    let (timestamp, signatures) = parse_stripe_signature(header)?;
1371    verify_timestamp(
1372        timestamp,
1373        received_at,
1374        endpoint.config.timestamp_tolerance_secs,
1375    )?;
1376
1377    let timestamp = timestamp.to_string();
1378    let mut signed_payload = Vec::with_capacity(timestamp.len() + 1 + body.len());
1379    signed_payload.extend_from_slice(timestamp.as_bytes());
1380    signed_payload.push(b'.');
1381    signed_payload.extend_from_slice(body);
1382
1383    if signatures
1384        .iter()
1385        .any(|signature| endpoint.keys.verify(&signed_payload, signature))
1386    {
1387        Ok(())
1388    } else {
1389        Err(WebhookVerifyError::SignatureMismatch)
1390    }
1391}
1392
1393fn verify_slack(
1394    endpoint: &ResolvedWebhookEndpoint,
1395    headers: &HeaderMap,
1396    body: &[u8],
1397    received_at: SystemTime,
1398) -> Result<(), WebhookVerifyError> {
1399    let timestamp_header = endpoint
1400        .config
1401        .timestamp_header
1402        .as_deref()
1403        .ok_or(WebhookVerifyError::MalformedTimestamp)?;
1404    let timestamp = required_header(headers, timestamp_header)?
1405        .parse::<i64>()
1406        .map_err(|_| WebhookVerifyError::MalformedTimestamp)?;
1407    verify_timestamp(
1408        timestamp,
1409        received_at,
1410        endpoint.config.timestamp_tolerance_secs,
1411    )?;
1412
1413    let timestamp = timestamp.to_string();
1414    let mut signed_payload = Vec::with_capacity(3 + timestamp.len() + 1 + body.len());
1415    signed_payload.extend_from_slice(b"v0:");
1416    signed_payload.extend_from_slice(timestamp.as_bytes());
1417    signed_payload.push(b':');
1418    signed_payload.extend_from_slice(body);
1419    verify_body_hmac(
1420        endpoint,
1421        headers,
1422        &signed_payload,
1423        endpoint.config.signature_prefix.as_deref(),
1424        received_at,
1425    )
1426}
1427
1428fn verify_body_hmac(
1429    endpoint: &ResolvedWebhookEndpoint,
1430    headers: &HeaderMap,
1431    body_or_base: &[u8],
1432    explicit_prefix: Option<&str>,
1433    received_at: SystemTime,
1434) -> Result<(), WebhookVerifyError> {
1435    if let Some(timestamp_header) = endpoint.config.timestamp_header.as_deref()
1436        && endpoint.config.provider != WebhookProvider::Slack
1437    {
1438        let timestamp = required_header(headers, timestamp_header)?
1439            .parse::<i64>()
1440            .map_err(|_| WebhookVerifyError::MalformedTimestamp)?;
1441        verify_timestamp(
1442            timestamp,
1443            received_at,
1444            endpoint.config.timestamp_tolerance_secs,
1445        )?;
1446    }
1447
1448    let mut signature = required_header(headers, signature_header(endpoint))?;
1449    let prefix = explicit_prefix.or(endpoint.config.signature_prefix.as_deref());
1450    if let Some(prefix) = prefix {
1451        signature = signature
1452            .strip_prefix(prefix)
1453            .ok_or(WebhookVerifyError::MalformedSignature)?;
1454    }
1455
1456    if endpoint.keys.verify(body_or_base, signature) {
1457        Ok(())
1458    } else {
1459        Err(WebhookVerifyError::SignatureMismatch)
1460    }
1461}
1462
1463fn signature_header(endpoint: &ResolvedWebhookEndpoint) -> &str {
1464    endpoint
1465        .config
1466        .signature_header
1467        .as_deref()
1468        .unwrap_or("X-Webhook-Signature")
1469}
1470
1471fn required_header<'a>(headers: &'a HeaderMap, name: &str) -> Result<&'a str, WebhookVerifyError> {
1472    headers
1473        .get(name)
1474        .ok_or_else(|| WebhookVerifyError::MissingHeader(name.to_owned()))?
1475        .to_str()
1476        .map_err(|_| WebhookVerifyError::MalformedSignature)
1477}
1478
1479fn parse_stripe_signature(header: &str) -> Result<(i64, Vec<&str>), WebhookVerifyError> {
1480    let mut timestamp = None;
1481    let mut signatures = Vec::new();
1482
1483    for part in header.split(',') {
1484        let Some((key, value)) = part.split_once('=') else {
1485            return Err(WebhookVerifyError::MalformedSignature);
1486        };
1487        match key.trim() {
1488            "t" => {
1489                timestamp = Some(
1490                    value
1491                        .trim()
1492                        .parse::<i64>()
1493                        .map_err(|_| WebhookVerifyError::MalformedTimestamp)?,
1494                );
1495            }
1496            "v1" => signatures.push(value.trim()),
1497            _ => {}
1498        }
1499    }
1500
1501    let timestamp = timestamp.ok_or(WebhookVerifyError::MalformedTimestamp)?;
1502    if signatures.is_empty() {
1503        return Err(WebhookVerifyError::MalformedSignature);
1504    }
1505    Ok((timestamp, signatures))
1506}
1507
1508fn verify_timestamp(
1509    timestamp: i64,
1510    received_at: SystemTime,
1511    tolerance_secs: u64,
1512) -> Result<(), WebhookVerifyError> {
1513    let now = i64::try_from(
1514        received_at
1515            .duration_since(UNIX_EPOCH)
1516            .map_err(|_| WebhookVerifyError::MalformedTimestamp)?
1517            .as_secs(),
1518    )
1519    .map_err(|_| WebhookVerifyError::MalformedTimestamp)?;
1520    let skew = now.abs_diff(timestamp);
1521    if skew > tolerance_secs {
1522        return Err(WebhookVerifyError::StaleTimestamp);
1523    }
1524    Ok(())
1525}
1526
1527fn resolve_delivery_id(
1528    config: &WebhookEndpointConfig,
1529    headers: &HeaderMap,
1530    json_body: Option<&serde_json::Value>,
1531) -> Option<String> {
1532    let header = config
1533        .delivery_id_header
1534        .as_deref()
1535        .and_then(|header| optional_header(headers, header));
1536
1537    match config.provider {
1538        WebhookProvider::Slack => header
1539            .or_else(|| slack_delivery_id(json_body))
1540            .or_else(|| json_string_field(json_body, "id")),
1541        _ => header.or_else(|| json_string_field(json_body, "id")),
1542    }
1543}
1544
1545fn resolve_event_type(
1546    config: &WebhookEndpointConfig,
1547    headers: &HeaderMap,
1548    json_body: Option<&serde_json::Value>,
1549) -> Option<String> {
1550    config
1551        .event_type_header
1552        .as_deref()
1553        .and_then(|header| optional_header(headers, header))
1554        .or_else(|| json_string_field(json_body, "type"))
1555        .or_else(|| nested_json_string_field(json_body, "event", "type"))
1556}
1557
1558fn optional_header(headers: &HeaderMap, name: &str) -> Option<String> {
1559    headers
1560        .get(name)
1561        .and_then(|value| value.to_str().ok())
1562        .filter(|value| !value.trim().is_empty())
1563        .map(str::to_owned)
1564}
1565
1566fn slack_delivery_id(json_body: Option<&serde_json::Value>) -> Option<String> {
1567    json_string_field(json_body, "event_id").or_else(|| {
1568        let value = json_body?;
1569        if value.get("type").and_then(serde_json::Value::as_str) == Some("url_verification") {
1570            value
1571                .get("challenge")
1572                .and_then(serde_json::Value::as_str)
1573                .map(str::to_owned)
1574        } else {
1575            None
1576        }
1577    })
1578}
1579
1580fn json_string_field(value: Option<&serde_json::Value>, field: &str) -> Option<String> {
1581    let value = value?;
1582    value
1583        .get(field)
1584        .and_then(serde_json::Value::as_str)
1585        .map(str::to_owned)
1586}
1587
1588fn nested_json_string_field(
1589    value: Option<&serde_json::Value>,
1590    parent: &str,
1591    field: &str,
1592) -> Option<String> {
1593    let value = value?;
1594    value
1595        .get(parent)
1596        .and_then(|parent_value| parent_value.get(field))
1597        .and_then(serde_json::Value::as_str)
1598        .map(str::to_owned)
1599}
1600
1601const fn default_timestamp_tolerance_secs() -> u64 {
1602    DEFAULT_TIMESTAMP_TOLERANCE_SECS
1603}
1604
1605const fn default_replay_window_secs() -> u64 {
1606    DEFAULT_REPLAY_WINDOW_SECS
1607}
1608
1609const fn default_max_body_bytes() -> usize {
1610    DEFAULT_MAX_BODY_BYTES
1611}
1612
1613const fn default_true() -> bool {
1614    true
1615}
1616
1617fn default_replay_redis_key_prefix() -> String {
1618    "autumn:webhooks:replay".to_owned()
1619}
1620
1621fn validate_redis_replay_config(
1622    config: &WebhookReplayRedisConfig,
1623) -> Result<(), WebhookConfigError> {
1624    let url = config
1625        .url
1626        .as_deref()
1627        .filter(|url| !url.trim().is_empty())
1628        .ok_or(WebhookConfigError::RedisReplayMissingUrl)?;
1629
1630    #[cfg(feature = "redis")]
1631    {
1632        crate::redis_tls::open_client(url)
1633            .map_err(|error| WebhookConfigError::RedisReplayInvalidUrl(error.to_string()))?;
1634        Ok(())
1635    }
1636
1637    #[cfg(not(feature = "redis"))]
1638    {
1639        let _ = url;
1640        Err(WebhookConfigError::RedisReplayFeatureDisabled)
1641    }
1642}
1643
1644fn replay_store_from_config(
1645    config: &WebhookReplayConfig,
1646) -> Result<Arc<dyn WebhookReplayStore>, WebhookConfigError> {
1647    match config.backend {
1648        WebhookReplayBackend::Memory => Ok(Arc::new(InMemoryWebhookReplayStore::default())),
1649        WebhookReplayBackend::Redis => {
1650            #[cfg(feature = "redis")]
1651            {
1652                Ok(Arc::new(RedisWebhookReplayStore::from_config(
1653                    &config.redis,
1654                )?))
1655            }
1656
1657            #[cfg(not(feature = "redis"))]
1658            {
1659                Err(WebhookConfigError::RedisReplayFeatureDisabled)
1660            }
1661        }
1662    }
1663}
1664
1665pub(crate) fn install_registry_from_config(
1666    state: &crate::AppState,
1667    config: &WebhookConfig,
1668) -> Result<(), WebhookConfigError> {
1669    if config.endpoints.is_empty() {
1670        return Ok(());
1671    }
1672    let registry = WebhookRegistry::from_config(config)?;
1673    state.insert_extension(registry);
1674    Ok(())
1675}
1676
1677#[cfg(test)]
1678mod tests {
1679    use super::*;
1680
1681    #[test]
1682    fn test_parse_stripe_signature_valid() {
1683        let header = "t=1614556800,v1=signature1,v1=signature2,v0=ignored";
1684        let (timestamp, signatures) = parse_stripe_signature(header).unwrap();
1685        assert_eq!(timestamp, 1_614_556_800);
1686        assert_eq!(signatures, vec!["signature1", "signature2"]);
1687    }
1688
1689    #[test]
1690    fn test_parse_stripe_signature_missing_timestamp() {
1691        let header = "v1=signature1";
1692        let err = parse_stripe_signature(header).unwrap_err();
1693        assert!(matches!(err, WebhookVerifyError::MalformedTimestamp));
1694    }
1695
1696    #[test]
1697    fn test_parse_stripe_signature_missing_signatures() {
1698        let header = "t=1614556800";
1699        let err = parse_stripe_signature(header).unwrap_err();
1700        assert!(matches!(err, WebhookVerifyError::MalformedSignature));
1701    }
1702
1703    #[test]
1704    fn test_parse_stripe_signature_malformed_parts() {
1705        let header = "t=1614556800,v1";
1706        let err = parse_stripe_signature(header).unwrap_err();
1707        assert!(matches!(err, WebhookVerifyError::MalformedSignature));
1708    }
1709
1710    #[test]
1711    fn test_parse_stripe_signature_malformed_timestamp() {
1712        let header = "t=not_a_number,v1=signature1";
1713        let err = parse_stripe_signature(header).unwrap_err();
1714        assert!(matches!(err, WebhookVerifyError::MalformedTimestamp));
1715    }
1716
1717    #[test]
1718    fn test_verify_timestamp_valid() {
1719        let received_at = UNIX_EPOCH + Duration::from_secs(1_614_556_800);
1720        assert!(verify_timestamp(1_614_556_800, received_at, 300).is_ok());
1721        assert!(verify_timestamp(1_614_556_800 + 300, received_at, 300).is_ok());
1722        assert!(verify_timestamp(1_614_556_800 - 300, received_at, 300).is_ok());
1723    }
1724
1725    #[test]
1726    fn test_verify_timestamp_stale() {
1727        let received_at = UNIX_EPOCH + Duration::from_secs(1_614_556_800);
1728        let err = verify_timestamp(1_614_556_800 + 301, received_at, 300).unwrap_err();
1729        assert!(matches!(err, WebhookVerifyError::StaleTimestamp));
1730
1731        let err2 = verify_timestamp(1_614_556_800 - 301, received_at, 300).unwrap_err();
1732        assert!(matches!(err2, WebhookVerifyError::StaleTimestamp));
1733    }
1734}
1735
1736/// Direct coverage for [`WebhookReplayCleanupService`] and its future's state
1737/// machine (issue #2214).
1738///
1739/// The framework's ingress installs the *layer*; the retained
1740/// [`webhook_replay_cleanup_middleware`] `async fn` is the form the pre-existing
1741/// suite exercises. The `Releasing` branch — the one piece of genuinely new
1742/// async state-machine code here, and the only branch that still allocates — is
1743/// reachable end-to-end only from a signed webhook delivery that registers a
1744/// replay key and then fails, so it is driven directly here instead.
1745#[cfg(test)]
1746mod replay_cleanup_service_tests {
1747    use super::{
1748        ReplayStoreCell, WEBHOOK_REPLAY_KEY, WebhookReplayCleanupLayer, WebhookReplayFuture,
1749        WebhookReplayStore,
1750    };
1751    use std::sync::atomic::{AtomicUsize, Ordering};
1752    use std::sync::{Arc, Mutex};
1753    use std::time::{Duration, SystemTime};
1754
1755    use axum::body::Body;
1756    use axum::http::{Request, Response, StatusCode};
1757    use tower::{Layer, Service, ServiceExt};
1758
1759    /// Replay store that records every key it is asked to remove.
1760    #[derive(Debug, Default)]
1761    struct RecordingStore {
1762        removed: Mutex<Vec<String>>,
1763        remove_calls: AtomicUsize,
1764    }
1765
1766    impl WebhookReplayStore for RecordingStore {
1767        fn check_and_insert<'a>(
1768            &'a self,
1769            _key: &'a str,
1770            _received_at: SystemTime,
1771            _window: Duration,
1772        ) -> WebhookReplayFuture<'a> {
1773            Box::pin(async { Ok(true) })
1774        }
1775
1776        fn remove<'a>(&'a self, key: &'a str) -> WebhookReplayFuture<'a> {
1777            self.remove_calls.fetch_add(1, Ordering::SeqCst);
1778            let key = key.to_owned();
1779            Box::pin(async move {
1780                self.removed.lock().expect("lock").push(key);
1781                Ok(true)
1782            })
1783        }
1784    }
1785
1786    /// Inner service that registers `keys` into the ambient replay cell — the
1787    /// way `SignedWebhook`'s extractor does — and then answers with `status`.
1788    fn delivery_handler(
1789        store: Arc<RecordingStore>,
1790        keys: Vec<String>,
1791        status: StatusCode,
1792    ) -> impl Service<Request<Body>, Response = Response<Body>, Error = std::convert::Infallible> + Clone
1793    {
1794        tower::service_fn(move |_req: Request<Body>| {
1795            let store = Arc::clone(&store);
1796            let keys = keys.clone();
1797            async move {
1798                WEBHOOK_REPLAY_KEY.with(|cell: &ReplayStoreCell| {
1799                    *cell.lock().expect("lock") =
1800                        Some((store as Arc<dyn WebhookReplayStore>, keys.clone()));
1801                });
1802                Ok::<_, std::convert::Infallible>(
1803                    Response::builder()
1804                        .status(status)
1805                        .body(Body::empty())
1806                        .expect("response builds"),
1807                )
1808            }
1809        })
1810    }
1811
1812    /// `#[allow(future_not_send)]`: `tower::service_fn`'s closure is not
1813    /// `Sync`, so this helper's future is `!Send`. It only ever runs on a
1814    /// `#[tokio::test]` current-thread runtime, which never moves it.
1815    #[allow(clippy::future_not_send)]
1816    async fn drive(store: &Arc<RecordingStore>, status: StatusCode) -> StatusCode {
1817        let service = WebhookReplayCleanupLayer.layer(delivery_handler(
1818            Arc::clone(store),
1819            vec!["evt_1".to_owned(), "evt_2".to_owned()],
1820            status,
1821        ));
1822        service
1823            .oneshot(Request::builder().body(Body::empty()).expect("request"))
1824            .await
1825            .expect("infallible")
1826            .status()
1827    }
1828
1829    /// The task-local really is installed for the inner service — without it
1830    /// `WEBHOOK_REPLAY_KEY.with(..)` in the handler panics.
1831    #[tokio::test]
1832    async fn the_replay_key_scope_is_installed_for_the_inner_service() {
1833        let store = Arc::new(RecordingStore::default());
1834        assert_eq!(drive(&store, StatusCode::OK).await, StatusCode::OK);
1835    }
1836
1837    /// A successful delivery keeps its replay keys — releasing them would let
1838    /// an attacker replay the signed payload inside the window.
1839    #[tokio::test]
1840    async fn a_2xx_never_releases_replay_keys() {
1841        let store = Arc::new(RecordingStore::default());
1842        drive(&store, StatusCode::OK).await;
1843        assert_eq!(store.remove_calls.load(Ordering::SeqCst), 0);
1844        assert!(store.removed.lock().expect("lock").is_empty());
1845    }
1846
1847    /// The `Releasing` branch: a `5xx` that registered keys releases every one
1848    /// of them, and the client still receives the original response.
1849    #[tokio::test]
1850    async fn a_5xx_releases_every_registered_replay_key_and_still_returns_the_response() {
1851        let store = Arc::new(RecordingStore::default());
1852        let status = drive(&store, StatusCode::INTERNAL_SERVER_ERROR).await;
1853        assert_eq!(
1854            status,
1855            StatusCode::INTERNAL_SERVER_ERROR,
1856            "the release branch must not swallow or rewrite the response"
1857        );
1858        let mut removed = store.removed.lock().expect("lock").clone();
1859        removed.sort();
1860        assert_eq!(removed, vec!["evt_1".to_owned(), "evt_2".to_owned()]);
1861    }
1862
1863    /// A `4xx` is the client's fault, not a delivery failure: keys stay
1864    /// consumed, exactly as before the conversion.
1865    #[tokio::test]
1866    async fn a_4xx_does_not_release_replay_keys() {
1867        let store = Arc::new(RecordingStore::default());
1868        drive(&store, StatusCode::BAD_REQUEST).await;
1869        assert_eq!(store.remove_calls.load(Ordering::SeqCst), 0);
1870    }
1871
1872    /// A `5xx` from a request that registered no keys must not take the
1873    /// allocating `Releasing` branch at all — it short-circuits to `None`.
1874    #[tokio::test]
1875    async fn a_5xx_with_no_registered_keys_releases_nothing() {
1876        let store = Arc::new(RecordingStore::default());
1877        let service =
1878            WebhookReplayCleanupLayer.layer(tower::service_fn(|_req: Request<Body>| async move {
1879                Ok::<_, std::convert::Infallible>(
1880                    Response::builder()
1881                        .status(StatusCode::BAD_GATEWAY)
1882                        .body(Body::empty())
1883                        .expect("response builds"),
1884                )
1885            }));
1886        let response = service
1887            .oneshot(Request::builder().body(Body::empty()).expect("request"))
1888            .await
1889            .expect("infallible");
1890        assert_eq!(response.status(), StatusCode::BAD_GATEWAY);
1891        assert_eq!(store.remove_calls.load(Ordering::SeqCst), 0);
1892    }
1893
1894    /// Cancellation: dropping the future before the inner service resolves must
1895    /// still release the keys, via `ReplayKeyGuard`'s `Drop`. This is the path
1896    /// the request-timeout layer above takes when a webhook handler blows its
1897    /// deadline, and the one the `from_fn` form got for free by dropping its
1898    /// boxed async block.
1899    #[tokio::test]
1900    async fn dropping_the_future_mid_flight_still_releases_the_keys() {
1901        let store = Arc::new(RecordingStore::default());
1902        let store_for_handler = Arc::clone(&store);
1903        let service =
1904            WebhookReplayCleanupLayer.layer(tower::service_fn(move |_req: Request<Body>| {
1905                let store = Arc::clone(&store_for_handler);
1906                async move {
1907                    WEBHOOK_REPLAY_KEY.with(|cell: &ReplayStoreCell| {
1908                        *cell.lock().expect("lock") = Some((
1909                            store as Arc<dyn WebhookReplayStore>,
1910                            vec!["evt_cancelled".to_owned()],
1911                        ));
1912                    });
1913                    // Never resolves: the only way out is cancellation.
1914                    std::future::pending::<Result<Response<Body>, std::convert::Infallible>>().await
1915                }
1916            }));
1917
1918        let handle = tokio::spawn(async move {
1919            let _ = service
1920                .oneshot(Request::builder().body(Body::empty()).expect("request"))
1921                .await;
1922        });
1923        // Let the handler run far enough to register its key, then cancel.
1924        tokio::task::yield_now().await;
1925        handle.abort();
1926        let _ = handle.await;
1927        // The guard's Drop spawns the removals; give them a turn.
1928        for _ in 0..8 {
1929            tokio::task::yield_now().await;
1930        }
1931
1932        assert_eq!(
1933            store.removed.lock().expect("lock").as_slice(),
1934            ["evt_cancelled".to_owned()],
1935            "a cancelled webhook delivery must release the replay keys it \
1936             registered, so the provider's redelivery is accepted"
1937        );
1938    }
1939}
1940
1941#[cfg(test)]
1942mod payload_extractor_tests {
1943    use super::*;
1944    use serde_json::json;
1945
1946    #[test]
1947    fn should_extract_slack_delivery_id_from_event_id() {
1948        let payload = json!({
1949            "event_id": "Ev12345",
1950            "type": "event_callback"
1951        });
1952        assert_eq!(
1953            slack_delivery_id(Some(&payload)),
1954            Some("Ev12345".to_owned())
1955        );
1956    }
1957
1958    #[test]
1959    fn should_extract_slack_delivery_id_from_url_verification_challenge() {
1960        let payload = json!({
1961            "type": "url_verification",
1962            "challenge": "Ch9876"
1963        });
1964        assert_eq!(slack_delivery_id(Some(&payload)), Some("Ch9876".to_owned()));
1965    }
1966
1967    #[test]
1968    fn should_return_none_when_slack_payload_missing_identifiers() {
1969        let payload = json!({
1970            "type": "event_callback",
1971            "text": "hello"
1972        });
1973        assert_eq!(slack_delivery_id(Some(&payload)), None);
1974    }
1975
1976    #[test]
1977    fn should_return_none_when_slack_payload_is_none() {
1978        assert_eq!(slack_delivery_id(None), None);
1979    }
1980
1981    #[test]
1982    fn should_extract_json_string_field() {
1983        let payload = json!({
1984            "id": "123",
1985            "count": 45
1986        });
1987        assert_eq!(
1988            json_string_field(Some(&payload), "id"),
1989            Some("123".to_owned())
1990        );
1991        assert_eq!(json_string_field(Some(&payload), "count"), None); // Not a string
1992        assert_eq!(json_string_field(Some(&payload), "missing"), None);
1993        assert_eq!(json_string_field(None, "id"), None);
1994    }
1995
1996    #[test]
1997    fn should_extract_nested_json_string_field() {
1998        let payload = json!({
1999            "event": {
2000                "type": "message",
2001                "count": 1
2002            },
2003            "other": "value"
2004        });
2005        assert_eq!(
2006            nested_json_string_field(Some(&payload), "event", "type"),
2007            Some("message".to_owned())
2008        );
2009        assert_eq!(
2010            nested_json_string_field(Some(&payload), "event", "count"),
2011            None
2012        ); // Not a string
2013        assert_eq!(
2014            nested_json_string_field(Some(&payload), "event", "missing"),
2015            None
2016        );
2017        assert_eq!(
2018            nested_json_string_field(Some(&payload), "missing", "type"),
2019            None
2020        );
2021        assert_eq!(nested_json_string_field(None, "event", "type"), None);
2022    }
2023}