use base64::{engine::general_purpose::STANDARD as BASE64, Engine};
use hmac::{Hmac, Mac};
use sha2::Sha256;
use super::types::{OAuthRedirectParams, VerificationError};
type HmacSha256 = Hmac<Sha256>;
pub fn verify_hmac(
hmac_header: &str,
raw_body: &[u8],
client_secret: &str,
) -> Result<(), VerificationError> {
let received_hmac = BASE64
.decode(hmac_header)
.map_err(|_| VerificationError::InvalidBase64)?;
let mut mac = HmacSha256::new_from_slice(client_secret.as_bytes())
.expect("HMAC can take key of any size");
mac.update(raw_body);
mac.verify_slice(&received_hmac)
.map_err(|_| VerificationError::InvalidHmac)
}
pub fn verify_hmac_from_headers(
headers: &std::collections::HashMap<String, String>,
raw_body: &[u8],
client_secret: &str,
) -> Result<(), VerificationError> {
let hmac_header = headers
.get("x-shopify-hmac-sha256")
.or_else(|| headers.get("X-Shopify-Hmac-SHA256"))
.ok_or(VerificationError::MissingHmacHeader)?;
verify_hmac(hmac_header, raw_body, client_secret)
}
pub fn verify_oauth_redirect_hmac(
params: &OAuthRedirectParams,
client_secret: &str,
) -> Result<(), VerificationError> {
let mut pairs: Vec<(&str, &str)> = vec![
("embedded", ¶ms.embedded),
("host", ¶ms.host),
("locale", ¶ms.locale),
("session", ¶ms.session),
("shop", ¶ms.shop),
("timestamp", ¶ms.timestamp),
];
if let Some(v) = ¶ms.id_token {
pairs.push(("id_token", v));
}
if let Some(v) = ¶ms.app_load_id {
pairs.push(("app_load_id", v));
}
pairs.sort_by_key(|(k, _)| *k);
let message = pairs
.iter()
.map(|(k, v)| format!("{k}={v}"))
.collect::<Vec<_>>()
.join("&");
let digest = hex::decode(¶ms.hmac).map_err(|_| VerificationError::InvalidHmac)?;
verify_hmac(&BASE64.encode(digest), message.as_bytes(), client_secret)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_verify_hmac_valid() {
let client_secret = "test_secret";
let body = b"{\"test\":\"data\"}";
let mut mac = HmacSha256::new_from_slice(client_secret.as_bytes()).unwrap();
mac.update(body);
let result = mac.finalize();
let expected_hmac = BASE64.encode(result.into_bytes());
let result = verify_hmac(&expected_hmac, body, client_secret);
assert!(result.is_ok());
}
#[test]
fn test_verify_hmac_invalid() {
let client_secret = "test_secret";
let body = b"{\"test\":\"data\"}";
let wrong_hmac = BASE64.encode(b"wrong_hmac_value_here_123456");
let result = verify_hmac(&wrong_hmac, body, client_secret);
assert!(matches!(result, Err(VerificationError::InvalidHmac)));
}
#[test]
fn test_verify_hmac_invalid_base64() {
let client_secret = "test_secret";
let body = b"{\"test\":\"data\"}";
let invalid_base64 = "not_valid_base64!@#$%";
let result = verify_hmac(invalid_base64, body, client_secret);
assert!(matches!(result, Err(VerificationError::InvalidBase64)));
}
#[test]
fn test_verify_hmac_from_headers_valid() {
let client_secret = "test_secret";
let body = b"{\"test\":\"data\"}";
let mut mac = HmacSha256::new_from_slice(client_secret.as_bytes()).unwrap();
mac.update(body);
let result = mac.finalize();
let expected_hmac = BASE64.encode(result.into_bytes());
let mut headers = std::collections::HashMap::new();
headers.insert("x-shopify-hmac-sha256".to_string(), expected_hmac);
let result = verify_hmac_from_headers(&headers, body, client_secret);
assert!(result.is_ok());
}
#[test]
fn test_verify_hmac_from_headers_case_insensitive() {
let client_secret = "test_secret";
let body = b"{\"test\":\"data\"}";
let mut mac = HmacSha256::new_from_slice(client_secret.as_bytes()).unwrap();
mac.update(body);
let result = mac.finalize();
let expected_hmac = BASE64.encode(result.into_bytes());
let mut headers = std::collections::HashMap::new();
headers.insert("X-Shopify-Hmac-SHA256".to_string(), expected_hmac);
let result = verify_hmac_from_headers(&headers, body, client_secret);
assert!(result.is_ok());
}
#[test]
fn test_verify_hmac_from_headers_missing() {
let client_secret = "test_secret";
let body = b"{\"test\":\"data\"}";
let headers = std::collections::HashMap::new();
let result = verify_hmac_from_headers(&headers, body, client_secret);
assert!(matches!(result, Err(VerificationError::MissingHmacHeader)));
}
#[test]
fn test_shopify_example() {
let client_secret = "hush";
let body = b"{\"example\":\"webhook_data\"}";
let mut mac = HmacSha256::new_from_slice(client_secret.as_bytes()).unwrap();
mac.update(body);
let result = mac.finalize();
let hmac = BASE64.encode(result.into_bytes());
let verification = verify_hmac(&hmac, body, client_secret);
assert!(verification.is_ok());
let wrong_verification = verify_hmac(&hmac, b"different_body", client_secret);
assert!(matches!(
wrong_verification,
Err(VerificationError::InvalidHmac)
));
}
fn sample_params(hmac: String) -> OAuthRedirectParams {
OAuthRedirectParams {
hmac,
embedded: "1".to_string(),
host: "ZXhhbXBsZS5teXNob3BpZnkuY29t".to_string(),
locale: "en".to_string(),
session: "abc123".to_string(),
shop: "example.myshopify.com".to_string(),
timestamp: "1700000000".to_string(),
id_token: None,
app_load_id: None,
}
}
#[test]
fn test_verify_oauth_redirect_hmac_valid() {
let client_secret = "test_secret";
let message = "embedded=1&host=ZXhhbXBsZS5teXNob3BpZnkuY29t&locale=en&session=abc123&shop=example.myshopify.com×tamp=1700000000";
let mut mac = HmacSha256::new_from_slice(client_secret.as_bytes()).unwrap();
mac.update(message.as_bytes());
let hmac_hex = hex::encode(mac.finalize().into_bytes());
let params = sample_params(hmac_hex);
assert!(verify_oauth_redirect_hmac(¶ms, client_secret).is_ok());
}
#[test]
fn test_verify_oauth_redirect_hmac_invalid() {
let client_secret = "test_secret";
let params = sample_params(hex::encode([0u8; 32]));
assert!(matches!(
verify_oauth_redirect_hmac(¶ms, client_secret),
Err(VerificationError::InvalidHmac)
));
}
#[test]
fn test_verify_oauth_redirect_hmac_non_hex() {
let client_secret = "test_secret";
let params = sample_params("not-hex!!".to_string());
assert!(matches!(
verify_oauth_redirect_hmac(¶ms, client_secret),
Err(VerificationError::InvalidHmac)
));
}
}