Skip to main content

drasi_source_http/
auth.rs

1// Copyright 2025 The Drasi Authors.
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! Authentication module for webhook requests.
16//!
17//! Supports:
18//! - HMAC signature verification (SHA1, SHA256) for GitHub, Shopify, etc.
19//! - Bearer token verification
20
21use crate::config::{
22    AuthConfig, BearerConfig, SignatureAlgorithm, SignatureConfig, SignatureEncoding,
23};
24use anyhow::{anyhow, Result};
25use hmac::{Hmac, Mac};
26use sha1::Sha1;
27use sha2::Sha256;
28use std::env;
29use subtle::ConstantTimeEq;
30
31/// Result of authentication verification
32#[derive(Debug, Clone, PartialEq)]
33pub enum AuthResult {
34    /// Authentication succeeded
35    Success,
36    /// No authentication configured (pass-through)
37    NotConfigured,
38    /// Authentication failed with reason
39    Failed(String),
40}
41
42impl AuthResult {
43    /// Returns true if authentication passed or was not required
44    pub fn is_ok(&self) -> bool {
45        matches!(self, AuthResult::Success | AuthResult::NotConfigured)
46    }
47}
48
49/// Verify authentication for a webhook request
50///
51/// If both signature and bearer token are configured, both must pass.
52pub fn verify_auth(
53    auth_config: Option<&AuthConfig>,
54    headers: &axum::http::HeaderMap,
55    body: &[u8],
56) -> AuthResult {
57    let Some(config) = auth_config else {
58        return AuthResult::NotConfigured;
59    };
60
61    // Check signature if configured
62    if let Some(ref sig_config) = config.signature {
63        match verify_signature(sig_config, headers, body) {
64            Ok(()) => {}
65            Err(e) => return AuthResult::Failed(format!("Signature verification failed: {e}")),
66        }
67    }
68
69    // Check bearer token if configured
70    if let Some(ref bearer_config) = config.bearer {
71        match verify_bearer(bearer_config, headers) {
72            Ok(()) => {}
73            Err(e) => return AuthResult::Failed(format!("Bearer token verification failed: {e}")),
74        }
75    }
76
77    // If we get here, all configured auth methods passed
78    if config.signature.is_some() || config.bearer.is_some() {
79        AuthResult::Success
80    } else {
81        AuthResult::NotConfigured
82    }
83}
84
85/// Verify HMAC signature
86fn verify_signature(
87    config: &SignatureConfig,
88    headers: &axum::http::HeaderMap,
89    body: &[u8],
90) -> Result<()> {
91    // Get the secret from environment variable
92    let secret = env::var(&config.secret_env).map_err(|_| {
93        anyhow!(
94            "Environment variable '{}' not set for signature secret",
95            config.secret_env
96        )
97    })?;
98
99    // Get the signature from header
100    let signature_header = headers
101        .get(&config.header)
102        .ok_or_else(|| anyhow!("Signature header '{}' not found", config.header))?
103        .to_str()
104        .map_err(|_| anyhow!("Invalid signature header value"))?;
105
106    // Strip prefix if configured
107    let signature_value = if let Some(ref prefix) = config.prefix {
108        signature_header
109            .strip_prefix(prefix)
110            .ok_or_else(|| anyhow!("Signature header missing expected prefix '{prefix}'"))?
111    } else {
112        signature_header
113    };
114
115    // Decode the signature based on encoding
116    let received_signature = match config.encoding {
117        SignatureEncoding::Hex => {
118            hex::decode(signature_value).map_err(|e| anyhow!("Invalid hex signature: {e}"))?
119        }
120        SignatureEncoding::Base64 => {
121            base64::Engine::decode(&base64::engine::general_purpose::STANDARD, signature_value)
122                .map_err(|e| anyhow!("Invalid base64 signature: {e}"))?
123        }
124    };
125
126    // Compute expected signature
127    let expected_signature = match config.algorithm {
128        SignatureAlgorithm::HmacSha1 => compute_hmac_sha1(secret.as_bytes(), body)?,
129        SignatureAlgorithm::HmacSha256 => compute_hmac_sha256(secret.as_bytes(), body)?,
130    };
131
132    // Constant-time comparison
133    if constant_time_compare(&received_signature, &expected_signature) {
134        Ok(())
135    } else {
136        Err(anyhow!("Signature mismatch"))
137    }
138}
139
140/// Compute HMAC-SHA1
141fn compute_hmac_sha1(key: &[u8], data: &[u8]) -> Result<Vec<u8>> {
142    let mut mac =
143        Hmac::<Sha1>::new_from_slice(key).map_err(|e| anyhow!("HMAC-SHA1 key error: {e}"))?;
144    mac.update(data);
145    Ok(mac.finalize().into_bytes().to_vec())
146}
147
148/// Compute HMAC-SHA256
149fn compute_hmac_sha256(key: &[u8], data: &[u8]) -> Result<Vec<u8>> {
150    let mut mac =
151        Hmac::<Sha256>::new_from_slice(key).map_err(|e| anyhow!("HMAC-SHA256 key error: {e}"))?;
152    mac.update(data);
153    Ok(mac.finalize().into_bytes().to_vec())
154}
155
156/// Constant-time comparison to prevent timing attacks
157fn constant_time_compare(a: &[u8], b: &[u8]) -> bool {
158    if a.len() != b.len() {
159        return false;
160    }
161    a.ct_eq(b).into()
162}
163
164/// Verify bearer token
165fn verify_bearer(config: &BearerConfig, headers: &axum::http::HeaderMap) -> Result<()> {
166    // Get the expected token from environment variable
167    let expected_token = env::var(&config.token_env).map_err(|_| {
168        anyhow!(
169            "Environment variable '{}' not set for bearer token",
170            config.token_env
171        )
172    })?;
173
174    // Get the Authorization header
175    let auth_header = headers
176        .get(axum::http::header::AUTHORIZATION)
177        .ok_or_else(|| anyhow!("Authorization header not found"))?
178        .to_str()
179        .map_err(|_| anyhow!("Invalid Authorization header value"))?;
180
181    // Extract bearer token
182    let received_token = auth_header
183        .strip_prefix("Bearer ")
184        .or_else(|| auth_header.strip_prefix("bearer "))
185        .ok_or_else(|| anyhow!("Authorization header is not a Bearer token"))?;
186
187    // Constant-time comparison
188    if constant_time_compare(received_token.as_bytes(), expected_token.as_bytes()) {
189        Ok(())
190    } else {
191        Err(anyhow!("Bearer token mismatch"))
192    }
193}
194
195#[cfg(test)]
196mod tests {
197    use super::*;
198    use axum::http::HeaderMap;
199
200    fn create_headers(headers: &[(&str, &str)]) -> HeaderMap {
201        let mut map = HeaderMap::new();
202        for (name, value) in headers {
203            map.insert(
204                axum::http::HeaderName::from_bytes(name.as_bytes()).unwrap(),
205                axum::http::HeaderValue::from_str(value).unwrap(),
206            );
207        }
208        map
209    }
210
211    #[test]
212    fn test_auth_not_configured() {
213        let headers = HeaderMap::new();
214        let result = verify_auth(None, &headers, b"body");
215        assert_eq!(result, AuthResult::NotConfigured);
216    }
217
218    #[test]
219    fn test_auth_empty_config() {
220        let config = AuthConfig {
221            signature: None,
222            bearer: None,
223        };
224        let headers = HeaderMap::new();
225        let result = verify_auth(Some(&config), &headers, b"body");
226        assert_eq!(result, AuthResult::NotConfigured);
227    }
228
229    #[test]
230    fn test_hmac_sha256_github_style() {
231        // Set up environment variable
232        env::set_var("TEST_GITHUB_SECRET", "test-secret");
233
234        let body = b"test payload";
235
236        // Compute expected signature
237        let expected_sig = compute_hmac_sha256(b"test-secret", body).unwrap();
238        let sig_hex = hex::encode(&expected_sig);
239        let sig_header = format!("sha256={sig_hex}");
240
241        let config = AuthConfig {
242            signature: Some(SignatureConfig {
243                algorithm: SignatureAlgorithm::HmacSha256,
244                secret_env: "TEST_GITHUB_SECRET".to_string(),
245                header: "X-Hub-Signature-256".to_string(),
246                prefix: Some("sha256=".to_string()),
247                encoding: SignatureEncoding::Hex,
248            }),
249            bearer: None,
250        };
251
252        let headers = create_headers(&[("X-Hub-Signature-256", &sig_header)]);
253        let result = verify_auth(Some(&config), &headers, body);
254        assert_eq!(result, AuthResult::Success);
255
256        env::remove_var("TEST_GITHUB_SECRET");
257    }
258
259    #[test]
260    fn test_hmac_sha256_base64_shopify_style() {
261        // Set up environment variable
262        env::set_var("TEST_SHOPIFY_SECRET", "shopify-secret");
263
264        let body = b"order data";
265
266        // Compute expected signature
267        let expected_sig = compute_hmac_sha256(b"shopify-secret", body).unwrap();
268        let sig_base64 =
269            base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &expected_sig);
270
271        let config = AuthConfig {
272            signature: Some(SignatureConfig {
273                algorithm: SignatureAlgorithm::HmacSha256,
274                secret_env: "TEST_SHOPIFY_SECRET".to_string(),
275                header: "X-Shopify-Hmac-Sha256".to_string(),
276                prefix: None,
277                encoding: SignatureEncoding::Base64,
278            }),
279            bearer: None,
280        };
281
282        let headers = create_headers(&[("X-Shopify-Hmac-Sha256", &sig_base64)]);
283        let result = verify_auth(Some(&config), &headers, body);
284        assert_eq!(result, AuthResult::Success);
285
286        env::remove_var("TEST_SHOPIFY_SECRET");
287    }
288
289    #[test]
290    fn test_hmac_sha1() {
291        env::set_var("TEST_SHA1_SECRET", "sha1-secret");
292
293        let body = b"test data";
294        let expected_sig = compute_hmac_sha1(b"sha1-secret", body).unwrap();
295        let sig_hex = hex::encode(&expected_sig);
296        let sig_header = format!("sha1={sig_hex}");
297
298        let config = AuthConfig {
299            signature: Some(SignatureConfig {
300                algorithm: SignatureAlgorithm::HmacSha1,
301                secret_env: "TEST_SHA1_SECRET".to_string(),
302                header: "X-Signature".to_string(),
303                prefix: Some("sha1=".to_string()),
304                encoding: SignatureEncoding::Hex,
305            }),
306            bearer: None,
307        };
308
309        let headers = create_headers(&[("X-Signature", &sig_header)]);
310        let result = verify_auth(Some(&config), &headers, body);
311        assert_eq!(result, AuthResult::Success);
312
313        env::remove_var("TEST_SHA1_SECRET");
314    }
315
316    #[test]
317    fn test_signature_mismatch() {
318        env::set_var("TEST_SECRET_MISMATCH", "correct-secret");
319
320        let config = AuthConfig {
321            signature: Some(SignatureConfig {
322                algorithm: SignatureAlgorithm::HmacSha256,
323                secret_env: "TEST_SECRET_MISMATCH".to_string(),
324                header: "X-Signature".to_string(),
325                prefix: None,
326                encoding: SignatureEncoding::Hex,
327            }),
328            bearer: None,
329        };
330
331        // Use wrong signature
332        let headers = create_headers(&[(
333            "X-Signature",
334            "0000000000000000000000000000000000000000000000000000000000000000",
335        )]);
336        let result = verify_auth(Some(&config), &headers, b"body");
337
338        match result {
339            AuthResult::Failed(msg) => assert!(msg.contains("mismatch")),
340            _ => panic!("Expected AuthResult::Failed"),
341        }
342
343        env::remove_var("TEST_SECRET_MISMATCH");
344    }
345
346    #[test]
347    fn test_missing_signature_header() {
348        env::set_var("TEST_SECRET_MISSING", "secret");
349
350        let config = AuthConfig {
351            signature: Some(SignatureConfig {
352                algorithm: SignatureAlgorithm::HmacSha256,
353                secret_env: "TEST_SECRET_MISSING".to_string(),
354                header: "X-Signature".to_string(),
355                prefix: None,
356                encoding: SignatureEncoding::Hex,
357            }),
358            bearer: None,
359        };
360
361        let headers = HeaderMap::new();
362        let result = verify_auth(Some(&config), &headers, b"body");
363
364        match result {
365            AuthResult::Failed(msg) => assert!(msg.contains("not found")),
366            _ => panic!("Expected AuthResult::Failed"),
367        }
368
369        env::remove_var("TEST_SECRET_MISSING");
370    }
371
372    #[test]
373    fn test_bearer_token_success() {
374        env::set_var("TEST_BEARER_TOKEN", "my-secret-token");
375
376        let config = AuthConfig {
377            signature: None,
378            bearer: Some(BearerConfig {
379                token_env: "TEST_BEARER_TOKEN".to_string(),
380            }),
381        };
382
383        let headers = create_headers(&[("authorization", "Bearer my-secret-token")]);
384        let result = verify_auth(Some(&config), &headers, b"body");
385        assert_eq!(result, AuthResult::Success);
386
387        env::remove_var("TEST_BEARER_TOKEN");
388    }
389
390    #[test]
391    fn test_bearer_token_mismatch() {
392        env::set_var("TEST_BEARER_MISMATCH", "correct-token");
393
394        let config = AuthConfig {
395            signature: None,
396            bearer: Some(BearerConfig {
397                token_env: "TEST_BEARER_MISMATCH".to_string(),
398            }),
399        };
400
401        let headers = create_headers(&[("authorization", "Bearer wrong-token")]);
402        let result = verify_auth(Some(&config), &headers, b"body");
403
404        match result {
405            AuthResult::Failed(msg) => assert!(msg.contains("mismatch")),
406            _ => panic!("Expected AuthResult::Failed"),
407        }
408
409        env::remove_var("TEST_BEARER_MISMATCH");
410    }
411
412    #[test]
413    fn test_missing_bearer_header() {
414        env::set_var("TEST_BEARER_MISSING", "token");
415
416        let config = AuthConfig {
417            signature: None,
418            bearer: Some(BearerConfig {
419                token_env: "TEST_BEARER_MISSING".to_string(),
420            }),
421        };
422
423        let headers = HeaderMap::new();
424        let result = verify_auth(Some(&config), &headers, b"body");
425
426        match result {
427            AuthResult::Failed(msg) => assert!(msg.contains("not found")),
428            _ => panic!("Expected AuthResult::Failed"),
429        }
430
431        env::remove_var("TEST_BEARER_MISSING");
432    }
433
434    #[test]
435    fn test_both_signature_and_bearer() {
436        env::set_var("TEST_BOTH_SECRET", "sig-secret");
437        env::set_var("TEST_BOTH_TOKEN", "bearer-token");
438
439        let body = b"body";
440        let expected_sig = compute_hmac_sha256(b"sig-secret", body).unwrap();
441        let sig_hex = hex::encode(&expected_sig);
442
443        let config = AuthConfig {
444            signature: Some(SignatureConfig {
445                algorithm: SignatureAlgorithm::HmacSha256,
446                secret_env: "TEST_BOTH_SECRET".to_string(),
447                header: "X-Signature".to_string(),
448                prefix: None,
449                encoding: SignatureEncoding::Hex,
450            }),
451            bearer: Some(BearerConfig {
452                token_env: "TEST_BOTH_TOKEN".to_string(),
453            }),
454        };
455
456        let headers = create_headers(&[
457            ("X-Signature", &sig_hex),
458            ("authorization", "Bearer bearer-token"),
459        ]);
460        let result = verify_auth(Some(&config), &headers, body);
461        assert_eq!(result, AuthResult::Success);
462
463        env::remove_var("TEST_BOTH_SECRET");
464        env::remove_var("TEST_BOTH_TOKEN");
465    }
466
467    #[test]
468    fn test_both_auth_signature_fails() {
469        env::set_var("TEST_BOTH_SIG_FAIL_SECRET", "sig-secret");
470        env::set_var("TEST_BOTH_SIG_FAIL_TOKEN", "bearer-token");
471
472        let config = AuthConfig {
473            signature: Some(SignatureConfig {
474                algorithm: SignatureAlgorithm::HmacSha256,
475                secret_env: "TEST_BOTH_SIG_FAIL_SECRET".to_string(),
476                header: "X-Signature".to_string(),
477                prefix: None,
478                encoding: SignatureEncoding::Hex,
479            }),
480            bearer: Some(BearerConfig {
481                token_env: "TEST_BOTH_SIG_FAIL_TOKEN".to_string(),
482            }),
483        };
484
485        // Correct bearer but wrong signature
486        let headers = create_headers(&[
487            (
488                "X-Signature",
489                "0000000000000000000000000000000000000000000000000000000000000000",
490            ),
491            ("authorization", "Bearer bearer-token"),
492        ]);
493        let result = verify_auth(Some(&config), &headers, b"body");
494
495        match result {
496            AuthResult::Failed(msg) => assert!(msg.contains("Signature")),
497            _ => panic!("Expected AuthResult::Failed"),
498        }
499
500        env::remove_var("TEST_BOTH_SIG_FAIL_SECRET");
501        env::remove_var("TEST_BOTH_SIG_FAIL_TOKEN");
502    }
503
504    #[test]
505    fn test_constant_time_compare() {
506        assert!(constant_time_compare(b"hello", b"hello"));
507        assert!(!constant_time_compare(b"hello", b"world"));
508        assert!(!constant_time_compare(b"hello", b"hell"));
509        assert!(!constant_time_compare(b"", b"a"));
510        assert!(constant_time_compare(b"", b""));
511    }
512}