use axum::http::HeaderMap;
use subtle::ConstantTimeEq;
#[derive(Debug, Clone, Default)]
pub enum WebhookAuth {
#[default]
None,
SharedSecret {
secret: String,
},
}
impl WebhookAuth {
pub fn from_env() -> Self {
match std::env::var("RKAT_WEBHOOK_SECRET") {
Ok(s) if !s.is_empty() => Self::SharedSecret { secret: s },
_ => Self::None,
}
}
}
pub fn verify_webhook(headers: &HeaderMap, auth: &WebhookAuth) -> Result<(), &'static str> {
match auth {
WebhookAuth::None => Ok(()),
WebhookAuth::SharedSecret { secret } => {
let provided = headers
.get("x-webhook-secret")
.and_then(|v| v.to_str().ok())
.unwrap_or("");
if secret.len() == provided.len()
&& bool::from(secret.as_bytes().ct_eq(provided.as_bytes()))
{
Ok(())
} else {
Err("invalid webhook secret")
}
}
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
fn auth_secret(s: &str) -> WebhookAuth {
WebhookAuth::SharedSecret {
secret: s.to_string(),
}
}
#[test]
fn test_webhook_auth_none_always_passes() {
let headers = HeaderMap::new();
assert!(verify_webhook(&headers, &WebhookAuth::None).is_ok());
}
#[test]
fn test_webhook_auth_shared_secret_correct() {
let mut headers = HeaderMap::new();
headers.insert("x-webhook-secret", "test-secret-123".parse().unwrap());
assert!(verify_webhook(&headers, &auth_secret("test-secret-123")).is_ok());
}
#[test]
fn test_webhook_auth_shared_secret_wrong() {
let mut headers = HeaderMap::new();
headers.insert("x-webhook-secret", "wrong-secret".parse().unwrap());
assert!(verify_webhook(&headers, &auth_secret("test-secret-123")).is_err());
}
#[test]
fn test_webhook_auth_shared_secret_missing_header() {
let headers = HeaderMap::new();
assert!(verify_webhook(&headers, &auth_secret("test-secret-123")).is_err());
}
#[test]
fn test_webhook_auth_shared_secret_different_length() {
let mut headers = HeaderMap::new();
headers.insert("x-webhook-secret", "short".parse().unwrap());
assert!(verify_webhook(&headers, &auth_secret("much-longer-secret")).is_err());
}
}