meritocrab_api/
extractors.rs1use crate::{error::ApiError, state::AppState};
2use axum::{
3 extract::{FromRequest, Request},
4 http::header::HeaderMap,
5};
6use hmac::{Hmac, Mac};
7use sha2::Sha256;
8use subtle::ConstantTimeEq;
9
10type HmacSha256 = Hmac<Sha256>;
11
12#[derive(Debug)]
17pub struct VerifiedWebhookPayload(pub Vec<u8>);
18
19impl FromRequest<AppState> for VerifiedWebhookPayload {
20 type Rejection = ApiError;
21
22 async fn from_request(req: Request, state: &AppState) -> Result<Self, Self::Rejection> {
23 let (parts, body) = req.into_parts();
24
25 let signature = extract_signature(&parts.headers)?;
27
28 let body_bytes = axum::body::to_bytes(body, usize::MAX)
30 .await
31 .map_err(|e| ApiError::Internal(format!("Failed to read request body: {}", e)))?
32 .to_vec();
33
34 verify_signature(&body_bytes, &signature, state.webhook_secret.expose())?;
36
37 Ok(VerifiedWebhookPayload(body_bytes))
38 }
39}
40
41fn extract_signature(headers: &HeaderMap) -> Result<Vec<u8>, ApiError> {
43 let signature_header = headers
44 .get("X-Hub-Signature-256")
45 .ok_or_else(|| {
46 ApiError::InvalidSignature("X-Hub-Signature-256 header not found".to_string())
47 })?
48 .to_str()
49 .map_err(|e| ApiError::InvalidSignature(format!("Invalid header encoding: {}", e)))?;
50
51 let signature_hex = signature_header.strip_prefix("sha256=").ok_or_else(|| {
53 ApiError::InvalidSignature("Signature must start with 'sha256='".to_string())
54 })?;
55
56 hex::decode(signature_hex)
58 .map_err(|e| ApiError::InvalidSignature(format!("Invalid hex encoding: {}", e)))
59}
60
61fn verify_signature(body: &[u8], signature: &[u8], secret: &str) -> Result<(), ApiError> {
63 let mut mac = HmacSha256::new_from_slice(secret.as_bytes())
64 .map_err(|e| ApiError::Internal(format!("HMAC initialization failed: {}", e)))?;
65
66 mac.update(body);
67 let expected = mac.finalize().into_bytes();
68
69 if expected.ct_eq(signature).into() {
71 Ok(())
72 } else {
73 Err(ApiError::InvalidSignature("Signature mismatch".to_string()))
74 }
75}
76
77#[cfg(test)]
78mod tests {
79 use super::*;
80
81 #[test]
82 fn test_extract_signature_valid() {
83 let mut headers = HeaderMap::new();
84 headers.insert(
85 "X-Hub-Signature-256",
86 "sha256=0123456789abcdef".parse().unwrap(),
87 );
88
89 let result = extract_signature(&headers);
90 assert!(result.is_ok());
91 }
92
93 #[test]
94 fn test_extract_signature_missing() {
95 let headers = HeaderMap::new();
96 let result = extract_signature(&headers);
97 assert!(result.is_err());
98 }
99
100 #[test]
101 fn test_extract_signature_invalid_format() {
102 let mut headers = HeaderMap::new();
103 headers.insert("X-Hub-Signature-256", "invalid-format".parse().unwrap());
104
105 let result = extract_signature(&headers);
106 assert!(result.is_err());
107 }
108
109 #[test]
110 fn test_verify_signature_valid() {
111 let body = b"test body";
112 let secret = "test-secret";
113 let mut mac = HmacSha256::new_from_slice(secret.as_bytes()).unwrap();
114 mac.update(body);
115 let signature = mac.finalize().into_bytes();
116
117 let result = verify_signature(body, &signature, secret);
118 assert!(result.is_ok());
119 }
120
121 #[test]
122 fn test_verify_signature_invalid() {
123 let body = b"test body";
124 let secret = "test-secret";
125 let wrong_signature = [0u8; 32];
126
127 let result = verify_signature(body, &wrong_signature, secret);
128 assert!(result.is_err());
129 }
130}