use axum::response::{IntoResponse, Response}; use base64::{ prelude::{BASE64_STANDARD, BASE64_URL_SAFE}, Engine, }; use hmac::{Hmac, Mac}; use http::{header, HeaderMap, HeaderValue, StatusCode}; use itertools::Itertools; use serde::{Deserialize, Serialize}; use serde_json::json; use sha1::Sha1; use sha2::{Sha256, Sha512}; use std::{borrow::Cow, collections::HashMap}; const MAX_TIMESTAMP_AGE_SECS: i64 = 300; // 5 minutes fn validate_unix_timestamp(timestamp_str: &str) -> Result<(), AuthenticationError> { let ts: i64 = timestamp_str .parse() .map_err(|_| AuthenticationError::InvalidTimestamp)?; let now = chrono::Utc::now().timestamp(); let diff = now - ts; if diff > MAX_TIMESTAMP_AGE_SECS { return Err(AuthenticationError::TimestampTooOldError); } if diff < -MAX_TIMESTAMP_AGE_SECS { return Err(AuthenticationError::FutureTimestampError); } Ok(()) } pub type HmacSha256 = Hmac; pub type HmacSha512 = Hmac; pub type HmacSha1 = Hmac; mod github { use super::*; pub struct Github; impl WebhookHandler for Github { fn handle_challenge_request<'header>( &self, _: &'header HeaderMap, _: &SignatureConfigData, _: &str, ) -> Result, AuthenticationError> { Ok(None) } fn get_hmac_authentication_data<'payload, 'header, 'prefix>( &self, headers: &'header HeaderMap, raw_payload: &'payload str, ) -> Result, AuthenticationError> { let github_secret_header = headers.try_get_webhook_header("X-Hub-Signature-256")?; let authentication_data = SignatureAuthenticationData::new( Cow::Borrowed(raw_payload), github_secret_header, Some("sha256="), SignatureAuthenticationDetails::new(HmacAlgorithm::Sha256, Encoding::Hex), ); Ok(authentication_data) } } } mod slack { use super::*; pub struct Slack; impl WebhookHandler for Slack { fn handle_challenge_request<'header>( &self, _: &'header HeaderMap, _: &SignatureConfigData, _: &str, ) -> Result, AuthenticationError> { Ok(None) } fn get_hmac_authentication_data<'payload, 'header, 'prefix>( &self, headers: &'header HeaderMap, raw_payload: &'payload str, ) -> Result, AuthenticationError> { let slack_secret_signature = headers.try_get_webhook_header("X-Slack-Signature")?; let slack_timestamp_header = headers.try_get_webhook_header("X-Slack-Request-Timestamp")?; let signed_payload = format!("v0:{}:{}", slack_timestamp_header, raw_payload); Ok(SignatureAuthenticationData::new( Cow::Owned(signed_payload), slack_secret_signature, Some("v0="), SignatureAuthenticationDetails::new(HmacAlgorithm::Sha256, Encoding::Hex), )) } fn validate_timestamp(&self, headers: &HeaderMap) -> Result<(), AuthenticationError> { let ts = headers.try_get_webhook_header("X-Slack-Request-Timestamp")?; validate_unix_timestamp(ts) } } } mod stripe { use super::*; pub struct Stripe; impl WebhookHandler for Stripe { fn handle_challenge_request<'header>( &self, _: &'header HeaderMap, _: &SignatureConfigData, _: &str, ) -> Result, AuthenticationError> { Ok(None) } fn get_hmac_authentication_data<'payload, 'header, 'prefix>( &self, headers: &'header HeaderMap, raw_payload: &'payload str, ) -> Result, AuthenticationError> { let stripe_signature_header = headers.try_get_webhook_header("STRIPE-SIGNATURE")?; let stripe_signature = parse_signature(stripe_signature_header, (",", "=")); let timestamp = *stripe_signature .get("t") .ok_or(AuthenticationError::InvalidTimestamp)?; let v1 = *stripe_signature .get("v1") .ok_or(AuthenticationError::InvalidSignature)?; let signed_payload = format!("{}.{}", timestamp, raw_payload); Ok(SignatureAuthenticationData::new( Cow::Owned(signed_payload), v1, None, SignatureAuthenticationDetails::new(HmacAlgorithm::Sha256, Encoding::Hex), )) } fn validate_timestamp(&self, headers: &HeaderMap) -> Result<(), AuthenticationError> { let sig_header = headers.try_get_webhook_header("STRIPE-SIGNATURE")?; let sig = parse_signature(sig_header, (",", "=")); let ts = *sig.get("t").ok_or(AuthenticationError::InvalidTimestamp)?; validate_unix_timestamp(ts) } } } mod tiktok { use super::*; pub struct TikTok; impl WebhookHandler for TikTok { fn handle_challenge_request<'header>( &self, _: &'header HeaderMap, _: &SignatureConfigData, _: &str, ) -> Result, AuthenticationError> { Ok(None) } fn get_hmac_authentication_data<'payload, 'header, 'prefix>( &self, headers: &'header HeaderMap, raw_payload: &'payload str, ) -> Result, AuthenticationError> { let tiktok_secret_signature = headers.try_get_webhook_header("TikTok-Signature")?; let stripe_signature = parse_signature(tiktok_secret_signature, (",", "=")); let timestamp = *stripe_signature .get("t") .ok_or(AuthenticationError::InvalidTimestamp)?; let s = *stripe_signature .get("s") .ok_or(AuthenticationError::InvalidSignature)?; let signed_payload = format!("{}.{}", timestamp, raw_payload); Ok(SignatureAuthenticationData::new( Cow::Owned(signed_payload), s, None, SignatureAuthenticationDetails::new(HmacAlgorithm::Sha256, Encoding::Hex), )) } fn validate_timestamp(&self, headers: &HeaderMap) -> Result<(), AuthenticationError> { let sig_header = headers.try_get_webhook_header("TikTok-Signature")?; let sig = parse_signature(sig_header, (",", "=")); let ts = *sig.get("t").ok_or(AuthenticationError::InvalidTimestamp)?; validate_unix_timestamp(ts) } } } mod twitch { use super::*; use http::header; use serde_json::value::RawValue; #[derive(Debug, Deserialize)] struct TwitchCrcBody { challenge: String, #[allow(unused)] subscription: Box, } pub struct Twitch; impl WebhookHandler for Twitch { fn get_hmac_authentication_data<'payload, 'header, 'prefix>( &self, headers: &'header HeaderMap, raw_payload: &'payload str, ) -> Result, AuthenticationError> { let twitch_secret_signature = headers.try_get_webhook_header("Twitch-Eventsub-Message-Signature")?; let twitch_message_id_header = headers.try_get_webhook_header("Twitch-Eventsub-Message-Id")?; let twitch_timestamp_header = headers.try_get_webhook_header("Twitch-Eventsub-Message-Timestamp")?; let message = format!( "{}{}{}", twitch_message_id_header, twitch_timestamp_header, raw_payload ); Ok(SignatureAuthenticationData::new( Cow::Owned(message), twitch_secret_signature, Some("sha256="), SignatureAuthenticationDetails::new(HmacAlgorithm::Sha256, Encoding::Hex), )) } fn handle_challenge_request<'header>( &self, headers: &'header HeaderMap, signature_config_data: &SignatureConfigData, raw_payload: &str, ) -> Result, AuthenticationError> { let authentication_data = self.get_hmac_authentication_data(headers, raw_payload)?; verify_hmac_signature(authentication_data, &signature_config_data.secret_key)?; let twitch_eventsub_message_type = headers.try_get_webhook_header("Twitch-Eventsub-Message-Type")?; if twitch_eventsub_message_type != "webhook_callback_verification" { return Ok(None); } let twitch_crc_body = serde_json::from_str::(raw_payload).map_err(|e| { AuthenticationError::InvalidChallengeResponse(format!( "Twitch :{}", e.to_string() )) })?; let response = ( StatusCode::OK, [(header::CONTENT_TYPE, "text/plain")], twitch_crc_body.challenge.to_string(), ); Ok(Some(response.into_response())) } fn validate_timestamp(&self, headers: &HeaderMap) -> Result<(), AuthenticationError> { let ts_str = headers.try_get_webhook_header("Twitch-Eventsub-Message-Timestamp")?; let ts: chrono::DateTime = chrono::DateTime::parse_from_rfc3339(ts_str) .map_err(|_| AuthenticationError::InvalidTimestamp)? .into(); let now = chrono::Utc::now(); let diff = (now - ts).num_seconds(); // Twitch recommends 10 minutes tolerance if diff > 600 { return Err(AuthenticationError::TimestampTooOldError); } if diff < -600 { return Err(AuthenticationError::FutureTimestampError); } Ok(()) } } } mod zoom { use axum::Json; use super::*; #[derive(Debug, Deserialize)] struct ZoomPayload { #[serde(rename = "plainToken")] plain_token: String, } #[derive(Debug, Deserialize)] #[allow(unused)] struct ZoomChallengeResponse { payload: ZoomPayload, event_ts: u64, event: String, } pub struct Zoom; impl WebhookHandler for Zoom { fn handle_challenge_request<'header>( &self, _: &'header HeaderMap, signature_config_data: &SignatureConfigData, raw_payload: &str, ) -> Result, AuthenticationError> { let Ok(zoom_request_body) = serde_json::from_str::(raw_payload) else { return Ok(None); }; if zoom_request_body.event != "endpoint.url_validation" { return Ok(None); } // Prevent this challenge endpoint from being used as a signing oracle. // Legitimate Zoom validation tokens are short random hex strings that // never contain colons. The exploit requires crafting a plainToken in the // `v0:{timestamp}:{body}` webhook-signing format (always containing colons) // to obtain a valid signature for an arbitrary body. Reject any token that // does not look like a legitimate Zoom validation token. if zoom_request_body.payload.plain_token.contains(':') || zoom_request_body.payload.plain_token.len() > 128 { return Err(AuthenticationError::InvalidChallengeResponse( "Zoom: invalid plainToken format".to_string(), )); } let hmac_signature = calculate_hmac_signature( HmacAlgorithm::Sha256, &signature_config_data.secret_key, &zoom_request_body.payload.plain_token, ); let encoded_hmac_signature = encode_hmac_signature(Encoding::Hex, &hmac_signature); let response = ( StatusCode::OK, Json(json!({ "plainToken": zoom_request_body.payload.plain_token, "encryptedToken": encoded_hmac_signature })), ); Ok(Some(response.into_response())) } fn get_hmac_authentication_data<'payload, 'header, 'prefix>( &self, headers: &'header HeaderMap, raw_payload: &'payload str, ) -> Result, AuthenticationError> { let zoom_signature_header = headers.try_get_webhook_header("x-zm-signature")?; let zoom_timestamp_header = headers.try_get_webhook_header("x-zm-request-timestamp")?; let message = format!("v0:{}:{}", zoom_timestamp_header, raw_payload); Ok(SignatureAuthenticationData::new( Cow::Owned(message), zoom_signature_header, Some("v0="), SignatureAuthenticationDetails::new(HmacAlgorithm::Sha256, Encoding::Hex), )) } fn validate_timestamp(&self, headers: &HeaderMap) -> Result<(), AuthenticationError> { let ts = headers.try_get_webhook_header("x-zm-request-timestamp")?; validate_unix_timestamp(ts) } } } use constant_time_eq::constant_time_eq; use github::Github; use slack::Slack; use stripe::Stripe; use tiktok::TikTok; use twitch::Twitch; use zoom::Zoom; #[derive(Debug)] pub struct SignatureAuthenticationDetails { pub algorithm_to_use: HmacAlgorithm, pub header_key_encoding: Encoding, } impl SignatureAuthenticationDetails { #[inline] fn new(algorithm_to_use: HmacAlgorithm, header_key_encoding: Encoding) -> Self { Self { algorithm_to_use, header_key_encoding } } } fn parse_signature<'header>( signature: &'header str, splitters: (&str, &str), ) -> HashMap<&'header str, &'header str> { let headers: HashMap<&str, &str> = signature .split(splitters.0) .map(|header| { let mut key_and_value = header.split(splitters.1); let key = key_and_value.next(); let value = key_and_value.next(); (key, value) }) .filter_map(|(key, value)| match (key, value) { (Some(key), Some(value)) => Some((key, value)), _ => None, }) .collect(); headers } #[derive(Debug)] pub struct SignatureAuthenticationData<'payload, 'header, 'prefix> { pub signed_payload: Cow<'payload, str>, pub header_key_value: &'header str, pub signature_prefix: Option<&'prefix str>, pub config: SignatureAuthenticationDetails, } impl<'payload, 'header, 'prefix> SignatureAuthenticationData<'payload, 'header, 'prefix> { pub fn new( signed_payload: Cow<'payload, str>, header_key_value: &'header str, signature_prefix: Option<&'prefix str>, config: SignatureAuthenticationDetails, ) -> Self { Self { signed_payload, header_key_value, signature_prefix, config } } } pub trait WebhookHandler { fn handle_challenge_request<'header>( &self, headers: &'header HeaderMap, signature_config_data: &SignatureConfigData, raw_payload: &str, ) -> Result, AuthenticationError>; fn get_hmac_authentication_data<'payload, 'header, 'prefix>( &self, headers: &'header HeaderMap, raw_payload: &'payload str, ) -> Result, AuthenticationError>; fn validate_timestamp(&self, _headers: &HeaderMap) -> Result<(), AuthenticationError> { Ok(()) } } #[derive(Clone, Copy, Debug, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum HmacAlgorithm { Sha1, Sha256, Sha512, } #[derive(Clone, Copy, Debug, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum Encoding { Base64, Base64Uri, Hex, } #[derive(Debug, Serialize, Deserialize, Clone)] pub struct SignatureAuthenticationMethod { algorithm: HmacAlgorithm, encoding: Encoding, signature_header_name: String, signature_prefix: Option, } pub struct SignatureConfigData<'config> { secret_key: &'config str, } #[derive(Debug, Serialize, Deserialize, Clone)] pub struct SignatureAuthentication { signature_provider: WebhookType, secret_key: String, authentication_config: Option, } #[derive(Debug, Serialize, Deserialize, Clone)] pub struct BasicAuthAuthentication { pub username: String, pub password: String, } #[derive(Debug, Serialize, Deserialize, Clone)] pub struct ApiKeyAuthentication { pub api_key_header: String, pub api_key_secret: String, } #[derive(Debug, Eq, PartialEq, Hash, Clone, Copy, Serialize, Deserialize)] #[non_exhaustive] pub enum WebhookType { Github, Slack, Stripe, TikTok, Twitch, Zoom, Custom, } impl WebhookType { pub fn get_webhook_handler(&self) -> Option<&'static dyn WebhookHandler> { let handler: &'static dyn WebhookHandler = match *self { WebhookType::Github => &Github, WebhookType::Slack => &Slack, WebhookType::Stripe => &Stripe, WebhookType::TikTok => &TikTok, WebhookType::Twitch => &Twitch, WebhookType::Zoom => &Zoom, WebhookType::Custom => return None, }; Some(handler) } } trait TryGetWebhookHeader { fn try_get_webhook_header<'header>( &'header self, header_name: &str, ) -> Result<&'header str, AuthenticationError>; } impl TryGetWebhookHeader for HeaderMap { fn try_get_webhook_header<'header>( &'header self, header_name: &str, ) -> Result<&'header str, AuthenticationError> { let Some(signature_header) = self.get(header_name) else { return Err(AuthenticationError::MissingHeader(header_name.to_string())); }; let Some(signature_header) = signature_header.to_str().ok() else { return Err(AuthenticationError::InvalidHeader(header_name.to_string())); }; Ok(signature_header) } } pub fn calculate_hmac_signature(algorithm: HmacAlgorithm, secret: &str, payload: &str) -> Vec { match algorithm { HmacAlgorithm::Sha1 => { let mut mac = HmacSha1::new_from_slice(secret.as_bytes()).expect("HMAC can take key of any size"); mac.update(payload.as_bytes()); mac.finalize().into_bytes().to_vec() } HmacAlgorithm::Sha256 => { let mut mac = HmacSha256::new_from_slice(secret.as_bytes()) .expect("HMAC can take key of any size"); mac.update(payload.as_bytes()); mac.finalize().into_bytes().to_vec() } HmacAlgorithm::Sha512 => { let mut mac = HmacSha512::new_from_slice(secret.as_bytes()) .expect("HMAC can take key of any size"); mac.update(payload.as_bytes()); mac.finalize().into_bytes().to_vec() } } } pub fn encode_hmac_signature(encoding: Encoding, hmac_signature: &[u8]) -> String { match encoding { Encoding::Hex => hex::encode(hmac_signature), Encoding::Base64 => BASE64_STANDARD.encode(hmac_signature), Encoding::Base64Uri => BASE64_URL_SAFE.encode(hmac_signature), } } pub fn verify_hmac_signature( authentication_data: SignatureAuthenticationData, webhook_signing_secret: &str, ) -> Result<(), AuthenticationError> { let hmac_signature = calculate_hmac_signature( authentication_data.config.algorithm_to_use, &webhook_signing_secret, &authentication_data.signed_payload, ); let encoded_signature = encode_hmac_signature( authentication_data.config.header_key_encoding, &hmac_signature, ); let final_expected_signature = if let Some(signature_prefix) = authentication_data.signature_prefix { format!("{}{}", signature_prefix, encoded_signature) } else { encoded_signature }; if !constant_time_eq( final_expected_signature.as_bytes(), authentication_data.header_key_value.as_bytes(), ) { return Err(AuthenticationError::InvalidSignature); } Ok(()) } #[derive(Debug, Serialize, Deserialize, Clone)] #[serde(untagged)] pub enum AuthenticationMethod { Signature(SignatureAuthentication), BasicAuth(BasicAuthAuthentication), ApiKey(ApiKeyAuthentication), } impl AuthenticationMethod { pub fn authenticate_http_request( &self, headers: &HeaderMap, raw_payload: Option<&String>, ) -> Result, AuthenticationError> { match self { AuthenticationMethod::Signature(SignatureAuthentication { secret_key, authentication_config, signature_provider, }) => { let raw_payload = raw_payload.ok_or(AuthenticationError::InvalidPayload)?; let config_data = SignatureConfigData { secret_key: &secret_key }; let handler = signature_provider.get_webhook_handler(); let challenge_response = handler .map(|handler| { handler.handle_challenge_request(headers, &config_data, raw_payload) }) .transpose()? .flatten(); if let Some(challenge_response) = challenge_response { return Ok(Some(challenge_response)); } if let Some(handler) = handler { handler.validate_timestamp(headers)?; } let authentication_data = match handler { Some(handler) => handler.get_hmac_authentication_data(headers, raw_payload)?, None => { let authentication_config = authentication_config .as_ref() .ok_or(AuthenticationError::InvalidCustomConfig)?; let signature_header_value = headers .try_get_webhook_header(&authentication_config.signature_header_name)?; SignatureAuthenticationData::new( Cow::Borrowed(raw_payload), signature_header_value, authentication_config.signature_prefix.as_deref(), SignatureAuthenticationDetails::new( authentication_config.algorithm, authentication_config.encoding, ), ) } }; verify_hmac_signature(authentication_data, &secret_key)?; } AuthenticationMethod::ApiKey(ApiKeyAuthentication { api_key_header, api_key_secret, }) => { let api_key_to_cmp = headers .try_get_webhook_header(&api_key_header) .map_err(|_| AuthenticationError::InvalidApiKey)?; if !constant_time_eq(api_key_to_cmp.as_bytes(), api_key_secret.as_bytes()) { return Err(AuthenticationError::InvalidApiKey); } } AuthenticationMethod::BasicAuth(BasicAuthAuthentication { username, password }) => { let mut credentials_store = headers .try_get_webhook_header("Authorization") .map_err(|_| AuthenticationError::UnauthorizedBasicHttpAuth)? .split(' '); let _ = credentials_store .next() .filter(|r#type| *r#type == "Basic") .ok_or(AuthenticationError::UnauthorizedBasicHttpAuth)?; let credentials_as_base64 = credentials_store .next() .ok_or(AuthenticationError::UnauthorizedBasicHttpAuth)?; let credentials_from_base64_as_bytes = BASE64_STANDARD .decode(credentials_as_base64.as_bytes()) .map_err(|_| AuthenticationError::UnauthorizedBasicHttpAuth)?; let credentials_separated_with_colon = String::from_utf8(credentials_from_base64_as_bytes) .map_err(|_| AuthenticationError::UnauthorizedBasicHttpAuth)?; let credentials = credentials_separated_with_colon.split(':').collect_vec(); if credentials.len() != 2 { return Err(AuthenticationError::UnauthorizedBasicHttpAuth); } if !constant_time_eq(credentials.get(0).unwrap().as_bytes(), username.as_bytes()) || !constant_time_eq( credentials.get(1).unwrap().as_bytes(), password.as_bytes(), ) { return Err(AuthenticationError::UnauthorizedBasicHttpAuth); } } } Ok(None) } } #[derive(thiserror::Error, Debug)] pub enum AuthenticationError { #[error("failed to parse timestamp")] InvalidTimestamp, #[error("invalid secret")] InvalidSecret(#[from] base64::DecodeError), #[error("invalid header `{0}`")] InvalidHeader(String), #[error("signature timestamp too old")] TimestampTooOldError, #[error("signature timestamp too far in future")] FutureTimestampError, #[error("missing header {0}")] MissingHeader(String), #[error("signature invalid")] InvalidSignature, #[error("payload invalid")] InvalidPayload, #[error("invalid custom config")] InvalidCustomConfig, #[error("invalid auth header: {0}")] InvalidAuthHeader(String), #[error("invalid api key")] InvalidApiKey, #[error("invalid challenge response: {0}")] InvalidChallengeResponse(String), #[error("")] UnauthorizedBasicHttpAuth, } impl IntoResponse for AuthenticationError { fn into_response(self) -> Response { let (status, error_message) = match &self { AuthenticationError::InvalidTimestamp | AuthenticationError::InvalidPayload | AuthenticationError::InvalidHeader(_) | AuthenticationError::MissingHeader(_) | AuthenticationError::TimestampTooOldError | AuthenticationError::FutureTimestampError | AuthenticationError::InvalidCustomConfig | AuthenticationError::InvalidChallengeResponse(_) => { (StatusCode::BAD_REQUEST, self.to_string()) } AuthenticationError::InvalidSecret(_) | AuthenticationError::InvalidSignature | AuthenticationError::InvalidAuthHeader(_) => { (StatusCode::UNAUTHORIZED, self.to_string()) } AuthenticationError::UnauthorizedBasicHttpAuth => { return ( StatusCode::UNAUTHORIZED, [(header::WWW_AUTHENTICATE, r#"Basic realm="Restricted Area""#)], "Unauthorized", ) .into_response() } AuthenticationError::InvalidApiKey => { return (StatusCode::UNAUTHORIZED, "Unauthorized").into_response() } }; let body = json!({ "error": error_message }); let mut headers = HeaderMap::new(); headers.insert("Content-Type", HeaderValue::from_static("application/json")); (status, headers, body.to_string()).into_response() } } #[cfg(test)] mod tests { use super::*; // --- calculate_hmac_signature --- #[test] fn test_hmac_sha256_deterministic() { let sig1 = calculate_hmac_signature(HmacAlgorithm::Sha256, "secret", "payload"); let sig2 = calculate_hmac_signature(HmacAlgorithm::Sha256, "secret", "payload"); assert_eq!(sig1, sig2); } #[test] fn test_hmac_sha256_different_keys() { let sig1 = calculate_hmac_signature(HmacAlgorithm::Sha256, "key1", "payload"); let sig2 = calculate_hmac_signature(HmacAlgorithm::Sha256, "key2", "payload"); assert_ne!(sig1, sig2); } #[test] fn test_hmac_sha256_different_payloads() { let sig1 = calculate_hmac_signature(HmacAlgorithm::Sha256, "secret", "payload1"); let sig2 = calculate_hmac_signature(HmacAlgorithm::Sha256, "secret", "payload2"); assert_ne!(sig1, sig2); } #[test] fn test_hmac_sha1_length() { let sig = calculate_hmac_signature(HmacAlgorithm::Sha1, "secret", "payload"); assert_eq!(sig.len(), 20); // SHA1 = 160 bits = 20 bytes } #[test] fn test_hmac_sha256_length() { let sig = calculate_hmac_signature(HmacAlgorithm::Sha256, "secret", "payload"); assert_eq!(sig.len(), 32); // SHA256 = 256 bits = 32 bytes } #[test] fn test_hmac_sha512_length() { let sig = calculate_hmac_signature(HmacAlgorithm::Sha512, "secret", "payload"); assert_eq!(sig.len(), 64); // SHA512 = 512 bits = 64 bytes } #[test] fn test_hmac_sha256_empty_payload() { let sig = calculate_hmac_signature(HmacAlgorithm::Sha256, "secret", ""); assert_eq!(sig.len(), 32); } #[test] fn test_hmac_sha256_empty_key() { let sig = calculate_hmac_signature(HmacAlgorithm::Sha256, "", "payload"); assert_eq!(sig.len(), 32); } // --- encode_hmac_signature --- #[test] fn test_encode_hex() { let bytes = vec![0xde, 0xad, 0xbe, 0xef]; assert_eq!(encode_hmac_signature(Encoding::Hex, &bytes), "deadbeef"); } #[test] fn test_encode_base64() { let bytes = vec![0xde, 0xad, 0xbe, 0xef]; let encoded = encode_hmac_signature(Encoding::Base64, &bytes); assert_eq!(BASE64_STANDARD.decode(&encoded).unwrap(), bytes); } #[test] fn test_encode_base64uri() { let bytes = vec![0xde, 0xad, 0xbe, 0xef]; let encoded = encode_hmac_signature(Encoding::Base64Uri, &bytes); assert_eq!(BASE64_URL_SAFE.decode(&encoded).unwrap(), bytes); } #[test] fn test_encode_hex_empty() { assert_eq!(encode_hmac_signature(Encoding::Hex, &[]), ""); } // --- verify_hmac_signature round-trip --- fn make_auth_data<'a>( payload: &'a str, header_value: &'a str, prefix: Option<&'a str>, algorithm: HmacAlgorithm, encoding: Encoding, ) -> SignatureAuthenticationData<'a, 'a, 'a> { SignatureAuthenticationData::new( Cow::Borrowed(payload), header_value, prefix, SignatureAuthenticationDetails::new(algorithm, encoding), ) } #[test] fn test_verify_hmac_sha256_hex_roundtrip() { let secret = "my_webhook_secret"; let payload = r#"{"event":"push"}"#; let sig = calculate_hmac_signature(HmacAlgorithm::Sha256, secret, payload); let encoded = encode_hmac_signature(Encoding::Hex, &sig); let data = make_auth_data( payload, &encoded, None, HmacAlgorithm::Sha256, Encoding::Hex, ); assert!(verify_hmac_signature(data, secret).is_ok()); } #[test] fn test_verify_hmac_sha256_base64_roundtrip() { let secret = "my_secret"; let payload = "test body"; let sig = calculate_hmac_signature(HmacAlgorithm::Sha256, secret, payload); let encoded = encode_hmac_signature(Encoding::Base64, &sig); let data = make_auth_data( payload, &encoded, None, HmacAlgorithm::Sha256, Encoding::Base64, ); assert!(verify_hmac_signature(data, secret).is_ok()); } #[test] fn test_verify_hmac_sha512_hex_roundtrip() { let secret = "long_secret_key"; let payload = "some data"; let sig = calculate_hmac_signature(HmacAlgorithm::Sha512, secret, payload); let encoded = encode_hmac_signature(Encoding::Hex, &sig); let data = make_auth_data( payload, &encoded, None, HmacAlgorithm::Sha512, Encoding::Hex, ); assert!(verify_hmac_signature(data, secret).is_ok()); } #[test] fn test_verify_hmac_sha1_hex_roundtrip() { let secret = "sha1_key"; let payload = "data"; let sig = calculate_hmac_signature(HmacAlgorithm::Sha1, secret, payload); let encoded = encode_hmac_signature(Encoding::Hex, &sig); let data = make_auth_data(payload, &encoded, None, HmacAlgorithm::Sha1, Encoding::Hex); assert!(verify_hmac_signature(data, secret).is_ok()); } #[test] fn test_verify_with_prefix() { let secret = "key"; let payload = "body"; let sig = calculate_hmac_signature(HmacAlgorithm::Sha256, secret, payload); let encoded = encode_hmac_signature(Encoding::Hex, &sig); let header_value = format!("sha256={}", encoded); let data = make_auth_data( payload, &header_value, Some("sha256="), HmacAlgorithm::Sha256, Encoding::Hex, ); assert!(verify_hmac_signature(data, secret).is_ok()); } #[test] fn test_verify_wrong_signature() { let data = make_auth_data( "payload", "wrong_signature_value", None, HmacAlgorithm::Sha256, Encoding::Hex, ); let result = verify_hmac_signature(data, "secret"); assert!(matches!(result, Err(AuthenticationError::InvalidSignature))); } #[test] fn test_verify_wrong_key() { let secret = "correct_key"; let payload = "body"; let sig = calculate_hmac_signature(HmacAlgorithm::Sha256, secret, payload); let encoded = encode_hmac_signature(Encoding::Hex, &sig); let data = make_auth_data( payload, &encoded, None, HmacAlgorithm::Sha256, Encoding::Hex, ); let result = verify_hmac_signature(data, "wrong_key"); assert!(matches!(result, Err(AuthenticationError::InvalidSignature))); } #[test] fn test_verify_wrong_prefix() { let secret = "key"; let payload = "body"; let sig = calculate_hmac_signature(HmacAlgorithm::Sha256, secret, payload); let encoded = encode_hmac_signature(Encoding::Hex, &sig); let header_value = format!("v0={}", encoded); let data = make_auth_data( payload, &header_value, Some("sha256="), HmacAlgorithm::Sha256, Encoding::Hex, ); assert!(matches!( verify_hmac_signature(data, secret), Err(AuthenticationError::InvalidSignature) )); } // --- parse_signature --- #[test] fn test_parse_signature_stripe_format() { let header = "t=1234567890,v1=abc123def456"; let parsed = parse_signature(header, (",", "=")); assert_eq!(parsed.get("t"), Some(&"1234567890")); assert_eq!(parsed.get("v1"), Some(&"abc123def456")); } #[test] fn test_parse_signature_tiktok_format() { let header = "t=1234567890,s=signaturevalue"; let parsed = parse_signature(header, (",", "=")); assert_eq!(parsed.get("t"), Some(&"1234567890")); assert_eq!(parsed.get("s"), Some(&"signaturevalue")); } #[test] fn test_parse_signature_single_entry() { let header = "key=value"; let parsed = parse_signature(header, (",", "=")); assert_eq!(parsed.get("key"), Some(&"value")); assert_eq!(parsed.len(), 1); } #[test] fn test_parse_signature_empty_string() { let parsed = parse_signature("", (",", "=")); assert!(parsed.is_empty() || parsed.len() == 1); } #[test] fn test_parse_signature_multiple_entries() { let header = "a=1,b=2,c=3"; let parsed = parse_signature(header, (",", "=")); assert_eq!(parsed.len(), 3); assert_eq!(parsed.get("a"), Some(&"1")); assert_eq!(parsed.get("b"), Some(&"2")); assert_eq!(parsed.get("c"), Some(&"3")); } // --- WebhookType serde --- #[test] fn test_webhook_type_serde_roundtrip() { for wt in [ WebhookType::Github, WebhookType::Slack, WebhookType::Stripe, WebhookType::TikTok, WebhookType::Twitch, WebhookType::Zoom, WebhookType::Custom, ] { let json = serde_json::to_value(wt).unwrap(); let deserialized: WebhookType = serde_json::from_value(json).unwrap(); assert_eq!(wt, deserialized); } } #[test] fn test_webhook_type_handler_known_providers() { assert!(WebhookType::Github.get_webhook_handler().is_some()); assert!(WebhookType::Slack.get_webhook_handler().is_some()); assert!(WebhookType::Stripe.get_webhook_handler().is_some()); assert!(WebhookType::TikTok.get_webhook_handler().is_some()); assert!(WebhookType::Twitch.get_webhook_handler().is_some()); assert!(WebhookType::Zoom.get_webhook_handler().is_some()); } #[test] fn test_webhook_type_custom_has_no_handler() { assert!(WebhookType::Custom.get_webhook_handler().is_none()); } // --- HmacAlgorithm / Encoding serde --- #[test] fn test_hmac_algorithm_serde() { assert_eq!(serde_json::to_value(HmacAlgorithm::Sha1).unwrap(), "sha1"); assert_eq!( serde_json::to_value(HmacAlgorithm::Sha256).unwrap(), "sha256" ); assert_eq!( serde_json::to_value(HmacAlgorithm::Sha512).unwrap(), "sha512" ); } #[test] fn test_encoding_serde() { assert_eq!(serde_json::to_value(Encoding::Hex).unwrap(), "hex"); assert_eq!(serde_json::to_value(Encoding::Base64).unwrap(), "base64"); assert_eq!( serde_json::to_value(Encoding::Base64Uri).unwrap(), "base64uri" ); } // --- TryGetWebhookHeader --- #[test] fn test_try_get_header_present() { let mut headers = HeaderMap::new(); headers.insert("X-Custom-Header", HeaderValue::from_static("value123")); assert_eq!( headers.try_get_webhook_header("X-Custom-Header").unwrap(), "value123" ); } #[test] fn test_try_get_header_missing() { let headers = HeaderMap::new(); let result = headers.try_get_webhook_header("X-Missing"); assert!(matches!(result, Err(AuthenticationError::MissingHeader(_)))); } #[test] fn test_try_get_header_case_insensitive() { let mut headers = HeaderMap::new(); headers.insert("x-hub-signature-256", HeaderValue::from_static("sig")); assert_eq!( headers .try_get_webhook_header("X-Hub-Signature-256") .unwrap(), "sig" ); } // --- GitHub webhook end-to-end --- fn github_headers(secret: &str, payload: &str) -> HeaderMap { let sig = calculate_hmac_signature(HmacAlgorithm::Sha256, secret, payload); let encoded = encode_hmac_signature(Encoding::Hex, &sig); let mut headers = HeaderMap::new(); headers.insert( "X-Hub-Signature-256", HeaderValue::from_str(&format!("sha256={}", encoded)).unwrap(), ); headers } #[test] fn test_github_authenticate_valid() { let secret = "github_webhook_secret"; let payload = r#"{"action":"opened","number":1}"#.to_string(); let headers = github_headers(secret, &payload); let method = AuthenticationMethod::Signature(SignatureAuthentication { signature_provider: WebhookType::Github, secret_key: secret.to_string(), authentication_config: None, }); assert!(method .authenticate_http_request(&headers, Some(&payload)) .is_ok()); } #[test] fn test_github_authenticate_wrong_secret() { let payload = r#"{"action":"opened"}"#.to_string(); let headers = github_headers("correct_secret", &payload); let method = AuthenticationMethod::Signature(SignatureAuthentication { signature_provider: WebhookType::Github, secret_key: "wrong_secret".to_string(), authentication_config: None, }); assert!(method .authenticate_http_request(&headers, Some(&payload)) .is_err()); } #[test] fn test_github_authenticate_missing_header() { let payload = r#"{"action":"opened"}"#.to_string(); let headers = HeaderMap::new(); let method = AuthenticationMethod::Signature(SignatureAuthentication { signature_provider: WebhookType::Github, secret_key: "secret".to_string(), authentication_config: None, }); assert!(matches!( method.authenticate_http_request(&headers, Some(&payload)), Err(AuthenticationError::MissingHeader(_)) )); } #[test] fn test_github_authenticate_no_payload() { let headers = HeaderMap::new(); let method = AuthenticationMethod::Signature(SignatureAuthentication { signature_provider: WebhookType::Github, secret_key: "secret".to_string(), authentication_config: None, }); assert!(matches!( method.authenticate_http_request(&headers, None), Err(AuthenticationError::InvalidPayload) )); } // --- Slack webhook end-to-end --- fn slack_headers(secret: &str, payload: &str, timestamp: &str) -> HeaderMap { let signed_payload = format!("v0:{}:{}", timestamp, payload); let sig = calculate_hmac_signature(HmacAlgorithm::Sha256, secret, &signed_payload); let encoded = encode_hmac_signature(Encoding::Hex, &sig); let mut headers = HeaderMap::new(); headers.insert( "X-Slack-Signature", HeaderValue::from_str(&format!("v0={}", encoded)).unwrap(), ); headers.insert( "X-Slack-Request-Timestamp", HeaderValue::from_str(timestamp).unwrap(), ); headers } fn current_timestamp() -> String { chrono::Utc::now().timestamp().to_string() } #[test] fn test_slack_authenticate_valid() { let secret = "slack_signing_secret"; let payload = "token=xxx&command=%2Ftest".to_string(); let timestamp = ¤t_timestamp(); let headers = slack_headers(secret, &payload, timestamp); let method = AuthenticationMethod::Signature(SignatureAuthentication { signature_provider: WebhookType::Slack, secret_key: secret.to_string(), authentication_config: None, }); assert!(method .authenticate_http_request(&headers, Some(&payload)) .is_ok()); } #[test] fn test_slack_authenticate_stale_timestamp_rejected() { let secret = "slack_secret"; let payload = "data".to_string(); let headers = slack_headers(secret, &payload, "1000000000"); let method = AuthenticationMethod::Signature(SignatureAuthentication { signature_provider: WebhookType::Slack, secret_key: secret.to_string(), authentication_config: None, }); assert!(matches!( method.authenticate_http_request(&headers, Some(&payload)), Err(AuthenticationError::TimestampTooOldError) )); } // --- Stripe webhook end-to-end --- fn stripe_headers(secret: &str, payload: &str, timestamp: &str) -> HeaderMap { let signed_payload = format!("{}.{}", timestamp, payload); let sig = calculate_hmac_signature(HmacAlgorithm::Sha256, secret, &signed_payload); let encoded = encode_hmac_signature(Encoding::Hex, &sig); let mut headers = HeaderMap::new(); headers.insert( "STRIPE-SIGNATURE", HeaderValue::from_str(&format!("t={},v1={}", timestamp, encoded)).unwrap(), ); headers } #[test] fn test_stripe_authenticate_valid() { let secret = "whsec_stripe_secret"; let payload = r#"{"id":"evt_123"}"#.to_string(); let timestamp = ¤t_timestamp(); let headers = stripe_headers(secret, &payload, timestamp); let method = AuthenticationMethod::Signature(SignatureAuthentication { signature_provider: WebhookType::Stripe, secret_key: secret.to_string(), authentication_config: None, }); assert!(method .authenticate_http_request(&headers, Some(&payload)) .is_ok()); } #[test] fn test_stripe_authenticate_wrong_secret() { let payload = r#"{"id":"evt_123"}"#.to_string(); let headers = stripe_headers("correct", &payload, "12345"); let method = AuthenticationMethod::Signature(SignatureAuthentication { signature_provider: WebhookType::Stripe, secret_key: "wrong".to_string(), authentication_config: None, }); assert!(method .authenticate_http_request(&headers, Some(&payload)) .is_err()); } // --- TikTok webhook end-to-end --- fn tiktok_headers(secret: &str, payload: &str, timestamp: &str) -> HeaderMap { let signed_payload = format!("{}.{}", timestamp, payload); let sig = calculate_hmac_signature(HmacAlgorithm::Sha256, secret, &signed_payload); let encoded = encode_hmac_signature(Encoding::Hex, &sig); let mut headers = HeaderMap::new(); headers.insert( "TikTok-Signature", HeaderValue::from_str(&format!("t={},s={}", timestamp, encoded)).unwrap(), ); headers } #[test] fn test_tiktok_authenticate_valid() { let secret = "tiktok_secret"; let payload = r#"{"event":"video.upload"}"#.to_string(); let timestamp = ¤t_timestamp(); let headers = tiktok_headers(secret, &payload, timestamp); let method = AuthenticationMethod::Signature(SignatureAuthentication { signature_provider: WebhookType::TikTok, secret_key: secret.to_string(), authentication_config: None, }); assert!(method .authenticate_http_request(&headers, Some(&payload)) .is_ok()); } // --- Twitch webhook end-to-end --- fn twitch_headers( secret: &str, payload: &str, message_id: &str, timestamp: &str, message_type: &str, ) -> HeaderMap { let message = format!("{}{}{}", message_id, timestamp, payload); let sig = calculate_hmac_signature(HmacAlgorithm::Sha256, secret, &message); let encoded = encode_hmac_signature(Encoding::Hex, &sig); let mut headers = HeaderMap::new(); headers.insert( "Twitch-Eventsub-Message-Signature", HeaderValue::from_str(&format!("sha256={}", encoded)).unwrap(), ); headers.insert( "Twitch-Eventsub-Message-Id", HeaderValue::from_str(message_id).unwrap(), ); headers.insert( "Twitch-Eventsub-Message-Timestamp", HeaderValue::from_str(timestamp).unwrap(), ); headers.insert( "Twitch-Eventsub-Message-Type", HeaderValue::from_str(message_type).unwrap(), ); headers } fn current_rfc3339_timestamp() -> String { chrono::Utc::now().to_rfc3339() } #[test] fn test_twitch_authenticate_valid_notification() { let secret = "twitch_secret"; let payload = r#"{"subscription":{},"event":{"user_id":"123"}}"#.to_string(); let ts = current_rfc3339_timestamp(); let headers = twitch_headers(secret, &payload, "msg-123", &ts, "notification"); let method = AuthenticationMethod::Signature(SignatureAuthentication { signature_provider: WebhookType::Twitch, secret_key: secret.to_string(), authentication_config: None, }); assert!(method .authenticate_http_request(&headers, Some(&payload)) .is_ok()); } #[test] fn test_twitch_challenge_response() { let secret = "twitch_secret"; let payload = r#"{"challenge":"test_challenge_string","subscription":{"id":"sub-123"}}"#; let ts = current_rfc3339_timestamp(); let headers = twitch_headers( secret, payload, "msg-456", &ts, "webhook_callback_verification", ); let handler = WebhookType::Twitch.get_webhook_handler().unwrap(); let config_data = SignatureConfigData { secret_key: secret }; let response = handler .handle_challenge_request(&headers, &config_data, payload) .unwrap(); assert!(response.is_some()); } #[test] fn test_twitch_non_challenge_returns_none() { let secret = "twitch_secret"; let payload = r#"{"subscription":{},"event":{}}"#; let ts = current_rfc3339_timestamp(); let headers = twitch_headers(secret, payload, "msg-789", &ts, "notification"); let handler = WebhookType::Twitch.get_webhook_handler().unwrap(); let config_data = SignatureConfigData { secret_key: secret }; let response = handler .handle_challenge_request(&headers, &config_data, payload) .unwrap(); assert!(response.is_none()); } // --- Zoom webhook end-to-end --- fn zoom_headers(secret: &str, payload: &str, timestamp: &str) -> HeaderMap { let message = format!("v0:{}:{}", timestamp, payload); let sig = calculate_hmac_signature(HmacAlgorithm::Sha256, secret, &message); let encoded = encode_hmac_signature(Encoding::Hex, &sig); let mut headers = HeaderMap::new(); headers.insert( "x-zm-signature", HeaderValue::from_str(&format!("v0={}", encoded)).unwrap(), ); headers.insert( "x-zm-request-timestamp", HeaderValue::from_str(timestamp).unwrap(), ); headers } #[test] fn test_zoom_authenticate_valid() { let secret = "zoom_secret"; let payload = r#"{"event":"meeting.started"}"#.to_string(); let timestamp = ¤t_timestamp(); let headers = zoom_headers(secret, &payload, timestamp); let method = AuthenticationMethod::Signature(SignatureAuthentication { signature_provider: WebhookType::Zoom, secret_key: secret.to_string(), authentication_config: None, }); assert!(method .authenticate_http_request(&headers, Some(&payload)) .is_ok()); } #[test] fn test_zoom_challenge_response() { let secret = "zoom_secret"; let payload = r#"{"event":"endpoint.url_validation","event_ts":1234567890,"payload":{"plainToken":"abc123"}}"#; let handler = WebhookType::Zoom.get_webhook_handler().unwrap(); let config_data = SignatureConfigData { secret_key: secret }; let response = handler .handle_challenge_request(&HeaderMap::new(), &config_data, payload) .unwrap(); assert!(response.is_some()); } #[test] fn test_zoom_non_challenge_returns_none() { let payload = r#"{"event":"meeting.started","event_ts":1234567890,"payload":{"plainToken":"abc"}}"#; let handler = WebhookType::Zoom.get_webhook_handler().unwrap(); let config_data = SignatureConfigData { secret_key: "secret" }; let response = handler .handle_challenge_request(&HeaderMap::new(), &config_data, payload) .unwrap(); assert!(response.is_none()); } #[test] fn test_zoom_challenge_normal_token_succeeds() { // A legitimate Zoom validation token is a short random alphanumeric string. let payload = r#"{"event":"endpoint.url_validation","event_ts":1234567890,"payload":{"plainToken":"qgg8vlvZRS6UYooatFL8Aw"}}"#; let handler = WebhookType::Zoom.get_webhook_handler().unwrap(); let config_data = SignatureConfigData { secret_key: "zoom_secret" }; let response = handler .handle_challenge_request(&HeaderMap::new(), &config_data, payload) .unwrap(); assert!(response.is_some()); } #[test] fn test_zoom_challenge_token_with_colons_rejected() { // Exploit attempt: a plainToken crafted in the `v0:{ts}:{body}` signing format // would let an attacker obtain a valid webhook signature for an arbitrary body. let payload = r#"{"event":"endpoint.url_validation","event_ts":1234567890,"payload":{"plainToken":"v0:1234567890:{\"forged\":\"body\"}"}}"#; let handler = WebhookType::Zoom.get_webhook_handler().unwrap(); let config_data = SignatureConfigData { secret_key: "zoom_secret" }; let result = handler.handle_challenge_request(&HeaderMap::new(), &config_data, payload); assert!(matches!( result, Err(AuthenticationError::InvalidChallengeResponse(_)) )); } #[test] fn test_zoom_challenge_token_too_long_rejected() { // A plainToken exceeding 128 chars cannot be a legitimate Zoom validation token. let long_token = "a".repeat(129); let payload = format!( r#"{{"event":"endpoint.url_validation","event_ts":1234567890,"payload":{{"plainToken":"{}"}}}}"#, long_token ); let handler = WebhookType::Zoom.get_webhook_handler().unwrap(); let config_data = SignatureConfigData { secret_key: "zoom_secret" }; let result = handler.handle_challenge_request(&HeaderMap::new(), &config_data, &payload); assert!(matches!( result, Err(AuthenticationError::InvalidChallengeResponse(_)) )); } // --- Custom webhook end-to-end --- #[test] fn test_custom_signature_authenticate_valid() { let secret = "custom_key"; let payload = "custom body".to_string(); let sig = calculate_hmac_signature(HmacAlgorithm::Sha256, secret, &payload); let encoded = encode_hmac_signature(Encoding::Hex, &sig); let mut headers = HeaderMap::new(); headers.insert("X-My-Signature", HeaderValue::from_str(&encoded).unwrap()); let method = AuthenticationMethod::Signature(SignatureAuthentication { signature_provider: WebhookType::Custom, secret_key: secret.to_string(), authentication_config: Some(SignatureAuthenticationMethod { algorithm: HmacAlgorithm::Sha256, encoding: Encoding::Hex, signature_header_name: "X-My-Signature".to_string(), signature_prefix: None, }), }); assert!(method .authenticate_http_request(&headers, Some(&payload)) .is_ok()); } #[test] fn test_custom_signature_with_prefix() { let secret = "key"; let payload = "body".to_string(); let sig = calculate_hmac_signature(HmacAlgorithm::Sha512, secret, &payload); let encoded = encode_hmac_signature(Encoding::Base64, &sig); let mut headers = HeaderMap::new(); headers.insert( "X-Sig", HeaderValue::from_str(&format!("hmac={}", encoded)).unwrap(), ); let method = AuthenticationMethod::Signature(SignatureAuthentication { signature_provider: WebhookType::Custom, secret_key: secret.to_string(), authentication_config: Some(SignatureAuthenticationMethod { algorithm: HmacAlgorithm::Sha512, encoding: Encoding::Base64, signature_header_name: "X-Sig".to_string(), signature_prefix: Some("hmac=".to_string()), }), }); assert!(method .authenticate_http_request(&headers, Some(&payload)) .is_ok()); } #[test] fn test_custom_signature_missing_config() { let payload = "body".to_string(); let headers = HeaderMap::new(); let method = AuthenticationMethod::Signature(SignatureAuthentication { signature_provider: WebhookType::Custom, secret_key: "secret".to_string(), authentication_config: None, }); assert!(matches!( method.authenticate_http_request(&headers, Some(&payload)), Err(AuthenticationError::InvalidCustomConfig) )); } // --- API key authentication --- #[test] fn test_api_key_authenticate_valid() { let mut headers = HeaderMap::new(); headers.insert("X-API-Key", HeaderValue::from_static("my_secret_key_123")); let method = AuthenticationMethod::ApiKey(ApiKeyAuthentication { api_key_header: "X-API-Key".to_string(), api_key_secret: "my_secret_key_123".to_string(), }); assert!(method.authenticate_http_request(&headers, None).is_ok()); } #[test] fn test_api_key_authenticate_wrong_key() { let mut headers = HeaderMap::new(); headers.insert("X-API-Key", HeaderValue::from_static("wrong_key")); let method = AuthenticationMethod::ApiKey(ApiKeyAuthentication { api_key_header: "X-API-Key".to_string(), api_key_secret: "correct_key".to_string(), }); assert!(matches!( method.authenticate_http_request(&headers, None), Err(AuthenticationError::InvalidApiKey) )); } #[test] fn test_api_key_authenticate_missing_header() { let headers = HeaderMap::new(); let method = AuthenticationMethod::ApiKey(ApiKeyAuthentication { api_key_header: "X-API-Key".to_string(), api_key_secret: "secret".to_string(), }); assert!(matches!( method.authenticate_http_request(&headers, None), Err(AuthenticationError::InvalidApiKey) )); } // --- Basic auth --- fn basic_auth_header(username: &str, password: &str) -> HeaderMap { let credentials = BASE64_STANDARD.encode(format!("{}:{}", username, password)); let mut headers = HeaderMap::new(); headers.insert( "Authorization", HeaderValue::from_str(&format!("Basic {}", credentials)).unwrap(), ); headers } #[test] fn test_basic_auth_valid() { let headers = basic_auth_header("admin", "password123"); let method = AuthenticationMethod::BasicAuth(BasicAuthAuthentication { username: "admin".to_string(), password: "password123".to_string(), }); assert!(method.authenticate_http_request(&headers, None).is_ok()); } #[test] fn test_basic_auth_wrong_password() { let headers = basic_auth_header("admin", "wrong"); let method = AuthenticationMethod::BasicAuth(BasicAuthAuthentication { username: "admin".to_string(), password: "correct".to_string(), }); assert!(matches!( method.authenticate_http_request(&headers, None), Err(AuthenticationError::UnauthorizedBasicHttpAuth) )); } #[test] fn test_basic_auth_wrong_username() { let headers = basic_auth_header("wrong_user", "password"); let method = AuthenticationMethod::BasicAuth(BasicAuthAuthentication { username: "admin".to_string(), password: "password".to_string(), }); assert!(matches!( method.authenticate_http_request(&headers, None), Err(AuthenticationError::UnauthorizedBasicHttpAuth) )); } #[test] fn test_basic_auth_missing_header() { let headers = HeaderMap::new(); let method = AuthenticationMethod::BasicAuth(BasicAuthAuthentication { username: "admin".to_string(), password: "password".to_string(), }); assert!(matches!( method.authenticate_http_request(&headers, None), Err(AuthenticationError::UnauthorizedBasicHttpAuth) )); } #[test] fn test_basic_auth_bearer_instead_of_basic() { let mut headers = HeaderMap::new(); headers.insert( "Authorization", HeaderValue::from_static("Bearer sometoken"), ); let method = AuthenticationMethod::BasicAuth(BasicAuthAuthentication { username: "admin".to_string(), password: "password".to_string(), }); assert!(matches!( method.authenticate_http_request(&headers, None), Err(AuthenticationError::UnauthorizedBasicHttpAuth) )); } #[test] fn test_basic_auth_invalid_base64() { let mut headers = HeaderMap::new(); headers.insert( "Authorization", HeaderValue::from_static("Basic !!!invalid!!!"), ); let method = AuthenticationMethod::BasicAuth(BasicAuthAuthentication { username: "admin".to_string(), password: "password".to_string(), }); assert!(matches!( method.authenticate_http_request(&headers, None), Err(AuthenticationError::UnauthorizedBasicHttpAuth) )); } // --- AuthenticationMethod serde (untagged enum) --- #[test] fn test_authentication_method_signature_serde() { let method = AuthenticationMethod::Signature(SignatureAuthentication { signature_provider: WebhookType::Github, secret_key: "secret".to_string(), authentication_config: None, }); let json = serde_json::to_value(&method).unwrap(); assert_eq!(json["signature_provider"], "Github"); assert_eq!(json["secret_key"], "secret"); let deserialized: AuthenticationMethod = serde_json::from_value(json).unwrap(); match deserialized { AuthenticationMethod::Signature(sig) => { assert_eq!(sig.signature_provider, WebhookType::Github); assert_eq!(sig.secret_key, "secret"); } _ => panic!("expected Signature variant"), } } #[test] fn test_authentication_method_api_key_serde() { let method = AuthenticationMethod::ApiKey(ApiKeyAuthentication { api_key_header: "X-Key".to_string(), api_key_secret: "val".to_string(), }); let json = serde_json::to_value(&method).unwrap(); let deserialized: AuthenticationMethod = serde_json::from_value(json).unwrap(); match deserialized { AuthenticationMethod::ApiKey(ak) => { assert_eq!(ak.api_key_header, "X-Key"); assert_eq!(ak.api_key_secret, "val"); } _ => panic!("expected ApiKey variant"), } } #[test] fn test_authentication_method_basic_auth_serde() { let method = AuthenticationMethod::BasicAuth(BasicAuthAuthentication { username: "user".to_string(), password: "pass".to_string(), }); let json = serde_json::to_value(&method).unwrap(); let deserialized: AuthenticationMethod = serde_json::from_value(json).unwrap(); match deserialized { AuthenticationMethod::BasicAuth(ba) => { assert_eq!(ba.username, "user"); assert_eq!(ba.password, "pass"); } _ => panic!("expected BasicAuth variant"), } } // --- AuthenticationError into_response --- #[test] fn test_error_invalid_signature_is_401() { let response = AuthenticationError::InvalidSignature.into_response(); assert_eq!(response.status(), StatusCode::UNAUTHORIZED); } #[test] fn test_error_missing_header_is_400() { let response = AuthenticationError::MissingHeader("X-Sig".to_string()).into_response(); assert_eq!(response.status(), StatusCode::BAD_REQUEST); } #[test] fn test_error_invalid_payload_is_400() { let response = AuthenticationError::InvalidPayload.into_response(); assert_eq!(response.status(), StatusCode::BAD_REQUEST); } #[test] fn test_error_invalid_api_key_is_401() { let response = AuthenticationError::InvalidApiKey.into_response(); assert_eq!(response.status(), StatusCode::UNAUTHORIZED); } #[test] fn test_error_basic_auth_has_www_authenticate() { let response = AuthenticationError::UnauthorizedBasicHttpAuth.into_response(); assert_eq!(response.status(), StatusCode::UNAUTHORIZED); assert!(response.headers().contains_key("www-authenticate")); } #[test] fn test_error_invalid_custom_config_is_400() { let response = AuthenticationError::InvalidCustomConfig.into_response(); assert_eq!(response.status(), StatusCode::BAD_REQUEST); } #[test] fn test_error_invalid_timestamp_is_400() { let response = AuthenticationError::InvalidTimestamp.into_response(); assert_eq!(response.status(), StatusCode::BAD_REQUEST); } // --- validate_unix_timestamp --- #[test] fn test_validate_unix_timestamp_current() { let now = chrono::Utc::now().timestamp().to_string(); assert!(validate_unix_timestamp(&now).is_ok()); } #[test] fn test_validate_unix_timestamp_recent() { let ts = (chrono::Utc::now().timestamp() - 60).to_string(); assert!(validate_unix_timestamp(&ts).is_ok()); } #[test] fn test_validate_unix_timestamp_too_old() { let ts = (chrono::Utc::now().timestamp() - 600).to_string(); assert!(matches!( validate_unix_timestamp(&ts), Err(AuthenticationError::TimestampTooOldError) )); } #[test] fn test_validate_unix_timestamp_future() { let ts = (chrono::Utc::now().timestamp() + 600).to_string(); assert!(matches!( validate_unix_timestamp(&ts), Err(AuthenticationError::FutureTimestampError) )); } #[test] fn test_validate_unix_timestamp_invalid() { assert!(matches!( validate_unix_timestamp("not-a-number"), Err(AuthenticationError::InvalidTimestamp) )); } // --- Slack timestamp validation --- #[test] fn test_slack_validate_timestamp_current() { let handler = slack::Slack; let mut headers = HeaderMap::new(); let now = chrono::Utc::now().timestamp().to_string(); headers.insert("X-Slack-Request-Timestamp", now.parse().unwrap()); assert!(handler.validate_timestamp(&headers).is_ok()); } #[test] fn test_slack_validate_timestamp_stale() { let handler = slack::Slack; let mut headers = HeaderMap::new(); let old = (chrono::Utc::now().timestamp() - 600).to_string(); headers.insert("X-Slack-Request-Timestamp", old.parse().unwrap()); assert!(handler.validate_timestamp(&headers).is_err()); } // --- Twitch timestamp validation (ISO 8601) --- #[test] fn test_twitch_validate_timestamp_current() { let handler = twitch::Twitch; let mut headers = HeaderMap::new(); let now = chrono::Utc::now().to_rfc3339(); headers.insert("Twitch-Eventsub-Message-Timestamp", now.parse().unwrap()); assert!(handler.validate_timestamp(&headers).is_ok()); } #[test] fn test_twitch_validate_timestamp_stale() { let handler = twitch::Twitch; let mut headers = HeaderMap::new(); let old = (chrono::Utc::now() - chrono::TimeDelta::seconds(1200)).to_rfc3339(); headers.insert("Twitch-Eventsub-Message-Timestamp", old.parse().unwrap()); assert!(handler.validate_timestamp(&headers).is_err()); } }