Skip to main content

ironflow_runtime/
webhook.rs

1//! Webhook authentication strategies for incoming HTTP requests.
2//!
3//! This module provides [`WebhookAuth`], an enum that supports multiple
4//! authentication schemes commonly used by webhook providers such as GitHub
5//! and GitLab. It can also be used with custom header-based or HMAC-based
6//! authentication flows.
7
8use hmac::{Hmac, Mac};
9use sha2::Sha256;
10use subtle::ConstantTimeEq;
11use tracing::warn;
12
13type HmacSha256 = Hmac<Sha256>;
14
15/// Authentication strategy for a webhook endpoint.
16///
17/// Each variant describes how to verify that an incoming request is legitimate.
18/// Use the constructor methods ([`WebhookAuth::none`], [`WebhookAuth::header`],
19/// [`WebhookAuth::github`], [`WebhookAuth::gitlab`]) rather than building
20/// variants manually, as they normalise header names and apply the correct
21/// defaults.
22///
23/// # Examples
24///
25/// ```no_run
26/// use ironflow_runtime::webhook::WebhookAuth;
27///
28/// // No authentication
29/// let auth = WebhookAuth::none();
30///
31/// // GitHub HMAC-SHA256 authentication
32/// let auth = WebhookAuth::github("my-webhook-secret");
33///
34/// // GitLab static-token authentication
35/// let auth = WebhookAuth::gitlab("my-gitlab-token");
36///
37/// // Custom header authentication
38/// let auth = WebhookAuth::header("x-api-key", "secret-value");
39/// ```
40#[derive(Clone)]
41pub enum WebhookAuth {
42    /// No authentication - every request is accepted.
43    None,
44    /// Static header comparison.
45    ///
46    /// The request must contain a header whose value exactly matches
47    /// `expected`. The header `name` is stored in lower-case.
48    Header {
49        /// Lower-cased header name.
50        name: String,
51        /// Expected header value.
52        expected: String,
53    },
54    /// HMAC-SHA256 signature verification.
55    ///
56    /// The request must contain a header whose value is a hex-encoded
57    /// HMAC-SHA256 digest prefixed with `sha256=`. The digest is computed
58    /// over the raw request body using `secret` as the HMAC key.
59    HmacSha256 {
60        /// Header name that carries the signature (e.g. `x-hub-signature-256`).
61        header: String,
62        /// Shared secret used to compute the HMAC.
63        secret: String,
64    },
65}
66
67impl std::fmt::Debug for WebhookAuth {
68    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
69        match self {
70            Self::None => write!(f, "WebhookAuth::None"),
71            Self::Header { name, .. } => f
72                .debug_struct("WebhookAuth::Header")
73                .field("name", name)
74                .field("expected", &"[REDACTED]")
75                .finish(),
76            Self::HmacSha256 { header, .. } => f
77                .debug_struct("WebhookAuth::HmacSha256")
78                .field("header", header)
79                .field("secret", &"[REDACTED]")
80                .finish(),
81        }
82    }
83}
84
85impl WebhookAuth {
86    /// Creates an authentication strategy that accepts every request.
87    ///
88    /// # Examples
89    ///
90    /// ```no_run
91    /// use ironflow_runtime::webhook::WebhookAuth;
92    ///
93    /// let auth = WebhookAuth::none();
94    /// ```
95    pub fn none() -> Self {
96        Self::None
97    }
98
99    /// Creates a static-header authentication strategy.
100    ///
101    /// The `name` is automatically lower-cased so that look-ups are
102    /// case-insensitive (HTTP headers are case-insensitive by spec).
103    ///
104    /// # Examples
105    ///
106    /// ```no_run
107    /// use ironflow_runtime::webhook::WebhookAuth;
108    ///
109    /// let auth = WebhookAuth::header("x-api-key", "super-secret");
110    /// ```
111    pub fn header(name: &str, expected: &str) -> Self {
112        if expected.is_empty() {
113            warn!(
114                "WebhookAuth::header created with an empty expected value - any request with an empty header will be accepted"
115            );
116        }
117        Self::Header {
118            name: name.to_lowercase(),
119            expected: expected.to_string(),
120        }
121    }
122
123    /// Preset for **GitLab** webhooks.
124    ///
125    /// GitLab sends the secret token in the `X-Gitlab-Token` header as a
126    /// plain-text value. This is a convenience wrapper around
127    /// [`WebhookAuth::header`].
128    ///
129    /// # Examples
130    ///
131    /// ```no_run
132    /// use ironflow_runtime::webhook::WebhookAuth;
133    ///
134    /// let auth = WebhookAuth::gitlab("my-gitlab-secret");
135    /// ```
136    pub fn gitlab(secret: &str) -> Self {
137        if secret.is_empty() {
138            warn!(
139                "WebhookAuth::gitlab created with an empty token - any request with an empty X-Gitlab-Token header will be accepted"
140            );
141        }
142        Self::Header {
143            name: "x-gitlab-token".to_string(),
144            expected: secret.to_string(),
145        }
146    }
147
148    /// Preset for **GitHub** webhooks.
149    ///
150    /// GitHub signs the payload body with HMAC-SHA256 and sends the result
151    /// in the `X-Hub-Signature-256` header, prefixed with `sha256=`.
152    /// This constructor configures the correct header name and stores the
153    /// shared secret.
154    ///
155    /// # Examples
156    ///
157    /// ```no_run
158    /// use ironflow_runtime::webhook::WebhookAuth;
159    ///
160    /// let auth = WebhookAuth::github("my-github-webhook-secret");
161    /// ```
162    pub fn github(secret: &str) -> Self {
163        if secret.is_empty() {
164            warn!(
165                "WebhookAuth::github created with an empty secret - HMAC verification will be trivially bypassable"
166            );
167        }
168        Self::HmacSha256 {
169            header: "x-hub-signature-256".to_string(),
170            secret: secret.to_string(),
171        }
172    }
173
174    /// Verifies an incoming request against this authentication strategy.
175    ///
176    /// Returns `true` if the request is authentic, `false` otherwise.
177    ///
178    /// # Behaviour per variant
179    ///
180    /// | Variant | Verification |
181    /// |---|---|
182    /// | [`None`](WebhookAuth::None) | Always returns `true`. |
183    /// | [`Header`](WebhookAuth::Header) | Checks that the named header equals the expected value. |
184    /// | [`HmacSha256`](WebhookAuth::HmacSha256) | Strips the `sha256=` prefix, hex-decodes the signature, computes HMAC-SHA256 over `body`, and compares in constant time. |
185    ///
186    /// # Examples
187    ///
188    /// ```no_run
189    /// use axum::http::HeaderMap;
190    /// use ironflow_runtime::webhook::WebhookAuth;
191    ///
192    /// let auth = WebhookAuth::none();
193    /// let headers = HeaderMap::new();
194    /// assert!(auth.verify(&headers, b"any body"));
195    /// ```
196    pub fn verify(&self, headers: &axum::http::HeaderMap, body: &[u8]) -> bool {
197        match self {
198            Self::None => true,
199            Self::Header { name, expected } => headers
200                .get(name)
201                .and_then(|v| v.to_str().ok())
202                .is_some_and(|v| v.as_bytes().ct_eq(expected.as_bytes()).into()),
203            Self::HmacSha256 { header, secret } => {
204                let Some(signature) = headers.get(header).and_then(|v| v.to_str().ok()) else {
205                    return false;
206                };
207                let Some(signature) = signature.strip_prefix("sha256=") else {
208                    return false;
209                };
210                let Ok(sig_bytes) = hex::decode(signature) else {
211                    return false;
212                };
213                let Ok(mut mac) = HmacSha256::new_from_slice(secret.as_bytes()) else {
214                    return false;
215                };
216                mac.update(body);
217                mac.verify_slice(&sig_bytes).is_ok()
218            }
219        }
220    }
221}
222
223/// Compute an HMAC-SHA256 signature string (prefixed with `sha256=`).
224///
225/// Shared helper for tests and integration test suites.
226#[cfg(test)]
227pub(crate) fn compute_test_hmac(secret: &[u8], body: &[u8]) -> String {
228    let mut mac = HmacSha256::new_from_slice(secret).expect("HMAC key rejected");
229    mac.update(body);
230    format!("sha256={}", hex::encode(mac.finalize().into_bytes()))
231}
232
233#[cfg(test)]
234mod tests {
235    use super::*;
236    use axum::http::HeaderMap;
237
238    // ── WebhookAuth::None ──────────────────────────────────────────
239
240    #[test]
241    fn none_always_returns_true() {
242        let auth = WebhookAuth::none();
243        let headers = HeaderMap::new();
244        assert!(auth.verify(&headers, b"anything"));
245    }
246
247    #[test]
248    fn none_empty_body_returns_true() {
249        let auth = WebhookAuth::none();
250        let headers = HeaderMap::new();
251        assert!(auth.verify(&headers, b""));
252    }
253
254    #[test]
255    fn none_empty_headers_returns_true() {
256        let auth = WebhookAuth::none();
257        let headers = HeaderMap::new();
258        assert!(auth.verify(&headers, b"payload"));
259    }
260
261    // ── WebhookAuth::Header ────────────────────────────────────────
262
263    #[test]
264    fn header_correct_value_returns_true() {
265        let auth = WebhookAuth::header("x-api-key", "secret123");
266        let mut headers = HeaderMap::new();
267        headers.insert("x-api-key", "secret123".parse().unwrap());
268        assert!(auth.verify(&headers, b""));
269    }
270
271    #[test]
272    fn header_wrong_value_returns_false() {
273        let auth = WebhookAuth::header("x-api-key", "secret123");
274        let mut headers = HeaderMap::new();
275        headers.insert("x-api-key", "wrong".parse().unwrap());
276        assert!(!auth.verify(&headers, b""));
277    }
278
279    #[test]
280    fn header_missing_returns_false() {
281        let auth = WebhookAuth::header("x-api-key", "secret123");
282        let headers = HeaderMap::new();
283        assert!(!auth.verify(&headers, b""));
284    }
285
286    #[test]
287    fn header_name_lookup_is_case_insensitive() {
288        let auth = WebhookAuth::header("X-Api-Key", "secret123");
289        let mut headers = HeaderMap::new();
290        headers.insert("x-api-key", "secret123".parse().unwrap());
291        assert!(auth.verify(&headers, b""));
292    }
293
294    #[test]
295    fn header_value_comparison_is_case_sensitive() {
296        let auth = WebhookAuth::header("x-api-key", "Secret123");
297        let mut headers = HeaderMap::new();
298        headers.insert("x-api-key", "secret123".parse().unwrap());
299        assert!(!auth.verify(&headers, b""));
300    }
301
302    #[test]
303    fn header_empty_expected_with_empty_value_returns_true() {
304        let auth = WebhookAuth::header("x-api-key", "");
305        let mut headers = HeaderMap::new();
306        headers.insert("x-api-key", "".parse().unwrap());
307        assert!(auth.verify(&headers, b""));
308    }
309
310    // ── WebhookAuth::gitlab ────────────────────────────────────────
311
312    #[test]
313    fn gitlab_correct_token_returns_true() {
314        let auth = WebhookAuth::gitlab("gl-token-abc");
315        let mut headers = HeaderMap::new();
316        headers.insert("x-gitlab-token", "gl-token-abc".parse().unwrap());
317        assert!(auth.verify(&headers, b""));
318    }
319
320    #[test]
321    fn gitlab_wrong_token_returns_false() {
322        let auth = WebhookAuth::gitlab("gl-token-abc");
323        let mut headers = HeaderMap::new();
324        headers.insert("x-gitlab-token", "wrong".parse().unwrap());
325        assert!(!auth.verify(&headers, b""));
326    }
327
328    #[test]
329    fn gitlab_missing_header_returns_false() {
330        let auth = WebhookAuth::gitlab("gl-token-abc");
331        let headers = HeaderMap::new();
332        assert!(!auth.verify(&headers, b""));
333    }
334
335    // ── WebhookAuth::github ────────────────────────────────────────
336
337    #[test]
338    fn github_valid_hmac_returns_true() {
339        let secret = "gh-secret";
340        let body = b"payload body";
341        let auth = WebhookAuth::github(secret);
342        let sig = compute_test_hmac(secret.as_bytes(), body);
343        let mut headers = HeaderMap::new();
344        headers.insert("x-hub-signature-256", sig.parse().unwrap());
345        assert!(auth.verify(&headers, body));
346    }
347
348    #[test]
349    fn github_invalid_signature_returns_false() {
350        let auth = WebhookAuth::github("gh-secret");
351        let mut headers = HeaderMap::new();
352        headers.insert(
353            "x-hub-signature-256",
354            "sha256=0000000000000000000000000000000000000000000000000000000000000000"
355                .parse()
356                .unwrap(),
357        );
358        assert!(!auth.verify(&headers, b"payload"));
359    }
360
361    #[test]
362    fn github_missing_header_returns_false() {
363        let auth = WebhookAuth::github("gh-secret");
364        let headers = HeaderMap::new();
365        assert!(!auth.verify(&headers, b"payload"));
366    }
367
368    // ── WebhookAuth::HmacSha256 (direct) ──────────────────────────
369
370    #[test]
371    fn hmac_valid_signature_verifies() {
372        let secret = "my-secret";
373        let body = b"request body";
374        let auth = WebhookAuth::HmacSha256 {
375            header: "x-signature".to_string(),
376            secret: secret.to_string(),
377        };
378        let sig = compute_test_hmac(secret.as_bytes(), body);
379        let mut headers = HeaderMap::new();
380        headers.insert("x-signature", sig.parse().unwrap());
381        assert!(auth.verify(&headers, body));
382    }
383
384    #[test]
385    fn hmac_tampered_signature_returns_false() {
386        let secret = "my-secret";
387        let body = b"request body";
388        let auth = WebhookAuth::HmacSha256 {
389            header: "x-signature".to_string(),
390            secret: secret.to_string(),
391        };
392        let mut sig = compute_test_hmac(secret.as_bytes(), body);
393        // Tamper with the last character
394        sig.pop();
395        sig.push('0');
396        let mut headers = HeaderMap::new();
397        headers.insert("x-signature", sig.parse().unwrap());
398        assert!(!auth.verify(&headers, body));
399    }
400
401    #[test]
402    fn hmac_missing_header_returns_false() {
403        let auth = WebhookAuth::HmacSha256 {
404            header: "x-signature".to_string(),
405            secret: "my-secret".to_string(),
406        };
407        let headers = HeaderMap::new();
408        assert!(!auth.verify(&headers, b"body"));
409    }
410
411    #[test]
412    fn hmac_no_sha256_prefix_returns_false() {
413        let auth = WebhookAuth::HmacSha256 {
414            header: "x-signature".to_string(),
415            secret: "my-secret".to_string(),
416        };
417        let mut headers = HeaderMap::new();
418        headers.insert(
419            "x-signature",
420            "abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890"
421                .parse()
422                .unwrap(),
423        );
424        assert!(!auth.verify(&headers, b"body"));
425    }
426
427    #[test]
428    fn hmac_invalid_hex_returns_false() {
429        let auth = WebhookAuth::HmacSha256 {
430            header: "x-signature".to_string(),
431            secret: "my-secret".to_string(),
432        };
433        let mut headers = HeaderMap::new();
434        headers.insert("x-signature", "sha256=not-valid-hex!".parse().unwrap());
435        assert!(!auth.verify(&headers, b"body"));
436    }
437
438    #[test]
439    fn hmac_wrong_secret_returns_false() {
440        let body = b"request body";
441        let sig = compute_test_hmac(b"correct-secret", body);
442        let auth = WebhookAuth::HmacSha256 {
443            header: "x-signature".to_string(),
444            secret: "wrong-secret".to_string(),
445        };
446        let mut headers = HeaderMap::new();
447        headers.insert("x-signature", sig.parse().unwrap());
448        assert!(!auth.verify(&headers, body));
449    }
450
451    #[test]
452    fn hmac_empty_body_verifies() {
453        let secret = "my-secret";
454        let body = b"";
455        let auth = WebhookAuth::HmacSha256 {
456            header: "x-signature".to_string(),
457            secret: secret.to_string(),
458        };
459        let sig = compute_test_hmac(secret.as_bytes(), body);
460        let mut headers = HeaderMap::new();
461        headers.insert("x-signature", sig.parse().unwrap());
462        assert!(auth.verify(&headers, body));
463    }
464
465    #[test]
466    fn hmac_body_tampered_returns_false() {
467        let secret = "my-secret";
468        let auth = WebhookAuth::HmacSha256 {
469            header: "x-signature".to_string(),
470            secret: secret.to_string(),
471        };
472        let sig = compute_test_hmac(secret.as_bytes(), b"original body");
473        let mut headers = HeaderMap::new();
474        headers.insert("x-signature", sig.parse().unwrap());
475        assert!(!auth.verify(&headers, b"tampered body"));
476    }
477
478    #[test]
479    fn hmac_empty_secret_still_works() {
480        let secret = "";
481        let body = b"some body";
482        let auth = WebhookAuth::HmacSha256 {
483            header: "x-signature".to_string(),
484            secret: secret.to_string(),
485        };
486        let sig = compute_test_hmac(secret.as_bytes(), body);
487        let mut headers = HeaderMap::new();
488        headers.insert("x-signature", sig.parse().unwrap());
489        assert!(auth.verify(&headers, body));
490    }
491
492    #[test]
493    fn debug_redacts_header_secret() {
494        let auth = WebhookAuth::header("x-api-key", "super-secret");
495        let debug = format!("{:?}", auth);
496        assert!(debug.contains("[REDACTED]"));
497        assert!(!debug.contains("super-secret"));
498    }
499
500    #[test]
501    fn debug_redacts_hmac_secret() {
502        let auth = WebhookAuth::github("my-secret-key");
503        let debug = format!("{:?}", auth);
504        assert!(debug.contains("[REDACTED]"));
505        assert!(!debug.contains("my-secret-key"));
506    }
507
508    #[test]
509    fn debug_none_format() {
510        let auth = WebhookAuth::none();
511        let debug = format!("{:?}", auth);
512        assert_eq!(debug, "WebhookAuth::None");
513    }
514
515    #[test]
516    fn hmac_rfc4231_test_vector() {
517        // RFC 4231 Test Case 2 (adapted: Key=0x0b*20, Data="Hi There")
518        let key_bytes = hex::decode("0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b").unwrap();
519        let body = b"Hi There";
520        let expected_mac = "b0344c61d8db38535ca8afceaf0bf12b881dc200c9833da726e9376c2e32cff7";
521
522        // Compute with raw key bytes to verify the test vector
523        let mut mac = HmacSha256::new_from_slice(&key_bytes).unwrap();
524        mac.update(body);
525        let computed = hex::encode(mac.finalize().into_bytes());
526        assert_eq!(computed, expected_mac);
527
528        // Now verify through WebhookAuth - but note that WebhookAuth uses
529        // secret.as_bytes() (UTF-8), not raw hex bytes. So we construct an
530        // HmacSha256 variant with the raw key by converting key_bytes to a
531        // String via unsafe (the bytes are not valid UTF-8, so we use
532        // Latin-1 mapping instead).
533        // Since the verify method uses `secret.as_bytes()`, and we need the
534        // key to be exactly `key_bytes`, we can only test this if those bytes
535        // round-trip through String::as_bytes(). They do if we build a String
536        // from those bytes using Latin-1. However Rust strings are UTF-8 and
537        // 0x0b is valid UTF-8 (it's a control char), so this works.
538        let secret_str = String::from_utf8(key_bytes).unwrap();
539        let auth = WebhookAuth::HmacSha256 {
540            header: "x-signature".to_string(),
541            secret: secret_str,
542        };
543        let sig = format!("sha256={}", expected_mac);
544        let mut headers = HeaderMap::new();
545        headers.insert("x-signature", sig.parse().unwrap());
546        assert!(auth.verify(&headers, body));
547    }
548}