use crate::config::WebhookEndpoint;
use crate::error::WebhookError;
use axum::http::HeaderMap;
use hmac::{Hmac, Mac};
use sha2::Sha256;
use subtle::ConstantTimeEq;
type HmacSha256 = Hmac<Sha256>;
pub fn verify(
endpoint: &WebhookEndpoint,
headers: &HeaderMap,
body: &[u8],
) -> Result<(), WebhookError> {
match endpoint.auth.as_str() {
"none" => Ok(()),
"bearer" => verify_bearer(endpoint, headers),
"hmac-sha256" => verify_hmac(endpoint, headers, body),
"api-key" => verify_api_key(endpoint, headers),
other => Err(WebhookError::AuthFailed(format!(
"unknown auth method: {other}"
))),
}
}
fn verify_bearer(endpoint: &WebhookEndpoint, headers: &HeaderMap) -> Result<(), WebhookError> {
let expected = endpoint
.resolve_secret()
.ok_or_else(|| WebhookError::AuthFailed("bearer secret not configured".into()))?;
let header = headers
.get("authorization")
.and_then(|v| v.to_str().ok())
.ok_or_else(|| WebhookError::AuthFailed("missing Authorization header".into()))?;
let token = header
.strip_prefix("Bearer ")
.ok_or_else(|| WebhookError::AuthFailed("Authorization must be 'Bearer <token>'".into()))?;
if token.as_bytes().ct_eq(expected.as_bytes()).into() {
Ok(())
} else {
Err(WebhookError::AuthFailed("invalid bearer token".into()))
}
}
fn verify_hmac(
endpoint: &WebhookEndpoint,
headers: &HeaderMap,
body: &[u8],
) -> Result<(), WebhookError> {
let secret = endpoint
.resolve_secret()
.ok_or_else(|| WebhookError::AuthFailed("HMAC secret not configured".into()))?;
let sig_header = ["x-signature-256", "x-hub-signature-256", "x-webhook-signature"]
.iter()
.find_map(|name| headers.get(*name).and_then(|v| v.to_str().ok()))
.ok_or_else(|| {
WebhookError::AuthFailed(
"missing signature header (X-Signature-256, X-Hub-Signature-256, or X-Webhook-Signature)".into(),
)
})?;
let sig_hex = sig_header.strip_prefix("sha256=").unwrap_or(sig_header);
let sig_bytes = hex::decode(sig_hex)
.map_err(|_| WebhookError::AuthFailed("signature is not valid hex".into()))?;
let mut mac = HmacSha256::new_from_slice(secret.as_bytes())
.map_err(|e| WebhookError::AuthFailed(format!("HMAC init failed: {e}")))?;
mac.update(body);
let expected = mac.finalize().into_bytes();
if expected.as_slice().ct_eq(&sig_bytes).into() {
Ok(())
} else {
Err(WebhookError::AuthFailed("HMAC signature mismatch".into()))
}
}
fn verify_api_key(endpoint: &WebhookEndpoint, headers: &HeaderMap) -> Result<(), WebhookError> {
let expected = endpoint
.resolve_secret()
.ok_or_else(|| WebhookError::AuthFailed("API key not configured".into()))?;
let header_name = endpoint.api_key_header.as_deref().unwrap_or("x-api-key");
let provided = headers
.get(header_name)
.and_then(|v| v.to_str().ok())
.ok_or_else(|| WebhookError::AuthFailed(format!("missing {header_name} header")))?;
if provided.as_bytes().ct_eq(expected.as_bytes()).into() {
Ok(())
} else {
Err(WebhookError::AuthFailed("invalid API key".into()))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::WebhookEndpoint;
use axum::http::HeaderMap;
fn make_endpoint(auth: &str, secret: &str) -> WebhookEndpoint {
WebhookEndpoint {
path: "/test".into(),
job_id: 1,
auth: auth.into(),
secret: Some(secret.into()),
api_key_header: None,
name: None,
}
}
#[test]
fn test_no_auth() {
let ep = WebhookEndpoint {
path: "/test".into(),
job_id: 1,
auth: "none".into(),
secret: None,
api_key_header: None,
name: None,
};
assert!(verify(&ep, &HeaderMap::new(), b"").is_ok());
}
#[test]
fn test_bearer_valid() {
let ep = make_endpoint("bearer", "my-token");
let mut headers = HeaderMap::new();
headers.insert("authorization", "Bearer my-token".parse().unwrap());
assert!(verify(&ep, &headers, b"").is_ok());
}
#[test]
fn test_bearer_invalid() {
let ep = make_endpoint("bearer", "my-token");
let mut headers = HeaderMap::new();
headers.insert("authorization", "Bearer wrong-token".parse().unwrap());
assert!(verify(&ep, &headers, b"").is_err());
}
#[test]
fn test_bearer_missing_header() {
let ep = make_endpoint("bearer", "my-token");
assert!(verify(&ep, &HeaderMap::new(), b"").is_err());
}
#[test]
fn test_hmac_valid() {
let ep = make_endpoint("hmac-sha256", "secret-key");
let body = b"hello world";
let mut mac = HmacSha256::new_from_slice(b"secret-key").unwrap();
mac.update(body);
let sig = hex::encode(mac.finalize().into_bytes());
let mut headers = HeaderMap::new();
headers.insert("x-signature-256", format!("sha256={sig}").parse().unwrap());
assert!(verify(&ep, &headers, body).is_ok());
}
#[test]
fn test_hmac_valid_no_prefix() {
let ep = make_endpoint("hmac-sha256", "secret-key");
let body = b"test";
let mut mac = HmacSha256::new_from_slice(b"secret-key").unwrap();
mac.update(body);
let sig = hex::encode(mac.finalize().into_bytes());
let mut headers = HeaderMap::new();
headers.insert("x-webhook-signature", sig.parse().unwrap());
assert!(verify(&ep, &headers, body).is_ok());
}
#[test]
fn test_hmac_invalid_signature() {
let ep = make_endpoint("hmac-sha256", "secret-key");
let mut headers = HeaderMap::new();
headers.insert(
"x-signature-256",
"sha256=0000000000000000000000000000000000000000000000000000000000000000"
.parse()
.unwrap(),
);
assert!(verify(&ep, &headers, b"hello").is_err());
}
#[test]
fn test_api_key_valid() {
let ep = make_endpoint("api-key", "my-api-key");
let mut headers = HeaderMap::new();
headers.insert("x-api-key", "my-api-key".parse().unwrap());
assert!(verify(&ep, &headers, b"").is_ok());
}
#[test]
fn test_api_key_custom_header() {
let mut ep = make_endpoint("api-key", "my-key");
ep.api_key_header = Some("X-Custom-Auth".into());
let mut headers = HeaderMap::new();
headers.insert("x-custom-auth", "my-key".parse().unwrap());
assert!(verify(&ep, &headers, b"").is_ok());
}
#[test]
fn test_api_key_invalid() {
let ep = make_endpoint("api-key", "correct-key");
let mut headers = HeaderMap::new();
headers.insert("x-api-key", "wrong-key".parse().unwrap());
assert!(verify(&ep, &headers, b"").is_err());
}
}