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| {
50 ApiError::InvalidSignature(format!("Invalid header encoding: {}", e))
51 })?;
52
53 let signature_hex = signature_header
55 .strip_prefix("sha256=")
56 .ok_or_else(|| {
57 ApiError::InvalidSignature("Signature must start with 'sha256='".to_string())
58 })?;
59
60 hex::decode(signature_hex).map_err(|e| {
62 ApiError::InvalidSignature(format!("Invalid hex encoding: {}", e))
63 })
64}
65
66fn verify_signature(body: &[u8], signature: &[u8], secret: &str) -> Result<(), ApiError> {
68 let mut mac = HmacSha256::new_from_slice(secret.as_bytes()).map_err(|e| {
69 ApiError::Internal(format!("HMAC initialization failed: {}", e))
70 })?;
71
72 mac.update(body);
73 let expected = mac.finalize().into_bytes();
74
75 if expected.ct_eq(signature).into() {
77 Ok(())
78 } else {
79 Err(ApiError::InvalidSignature(
80 "Signature mismatch".to_string(),
81 ))
82 }
83}
84
85#[cfg(test)]
86mod tests {
87 use super::*;
88
89 fn compute_signature(body: &[u8], secret: &str) -> String {
90 let mut mac = HmacSha256::new_from_slice(secret.as_bytes()).unwrap();
91 mac.update(body);
92 let result = mac.finalize();
93 format!("sha256={}", hex::encode(result.into_bytes()))
94 }
95
96 #[test]
97 fn test_extract_signature_valid() {
98 let mut headers = HeaderMap::new();
99 headers.insert(
100 "X-Hub-Signature-256",
101 "sha256=0123456789abcdef".parse().unwrap(),
102 );
103
104 let result = extract_signature(&headers);
105 assert!(result.is_ok());
106 }
107
108 #[test]
109 fn test_extract_signature_missing() {
110 let headers = HeaderMap::new();
111 let result = extract_signature(&headers);
112 assert!(result.is_err());
113 }
114
115 #[test]
116 fn test_extract_signature_invalid_format() {
117 let mut headers = HeaderMap::new();
118 headers.insert(
119 "X-Hub-Signature-256",
120 "invalid-format".parse().unwrap(),
121 );
122
123 let result = extract_signature(&headers);
124 assert!(result.is_err());
125 }
126
127 #[test]
128 fn test_verify_signature_valid() {
129 let body = b"test body";
130 let secret = "test-secret";
131 let mut mac = HmacSha256::new_from_slice(secret.as_bytes()).unwrap();
132 mac.update(body);
133 let signature = mac.finalize().into_bytes();
134
135 let result = verify_signature(body, &signature, secret);
136 assert!(result.is_ok());
137 }
138
139 #[test]
140 fn test_verify_signature_invalid() {
141 let body = b"test body";
142 let secret = "test-secret";
143 let wrong_signature = [0u8; 32];
144
145 let result = verify_signature(body, &wrong_signature, secret);
146 assert!(result.is_err());
147 }
148}