webhooksmith_actix/
lib.rs1use actix_web::{
56 FromRequest, HttpRequest, HttpResponse,
57 dev::Payload,
58 error::ResponseError,
59 http::StatusCode,
60 web::Bytes,
61};
62use serde::de::DeserializeOwned;
63use std::{fmt, future::Future, pin::Pin, sync::Arc};
64use webhooksmith::signing;
65
66const MAX_BODY_BYTES: usize = 1_048_576; const TIMESTAMP_TOLERANCE_SECS: i64 = 300;
70
71#[derive(Clone)]
77pub struct WebhookSecret(pub(crate) Arc<String>);
78
79impl WebhookSecret {
80 pub fn new(secret: impl Into<String>) -> Self {
81 Self(Arc::new(secret.into()))
82 }
83}
84
85#[derive(Debug, Clone)]
89pub struct WebhookPayload {
90 pub event_type: String,
92 pub event_id: Option<String>,
94 pub timestamp: i64,
96 pub body: Bytes,
98}
99
100#[derive(Debug)]
103pub enum WebhookError {
104 MissingSignature,
105 MissingTimestamp,
106 InvalidTimestamp,
107 StaleTimestamp,
108 InvalidSignature,
109 PayloadTooLarge,
110 BodyNotJson,
111 SecretNotConfigured,
112 BodyReadError(String),
113}
114
115impl fmt::Display for WebhookError {
116 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
117 match self {
118 Self::MissingSignature => write!(f, "missing signature"),
119 Self::MissingTimestamp => write!(f, "missing timestamp"),
120 Self::InvalidTimestamp => write!(f, "invalid timestamp"),
121 Self::StaleTimestamp => write!(f, "stale timestamp"),
122 Self::InvalidSignature => write!(f, "invalid signature"),
123 Self::PayloadTooLarge => write!(f, "payload too large"),
124 Self::BodyNotJson => write!(f, "body must be JSON"),
125 Self::SecretNotConfigured => write!(f, "webhook secret not configured"),
126 Self::BodyReadError(e) => write!(f, "body read error: {e}"),
127 }
128 }
129}
130
131impl ResponseError for WebhookError {
132 fn status_code(&self) -> StatusCode {
133 match self {
134 Self::MissingSignature => StatusCode::UNAUTHORIZED,
135 Self::MissingTimestamp => StatusCode::BAD_REQUEST,
136 Self::InvalidTimestamp => StatusCode::BAD_REQUEST,
137 Self::StaleTimestamp => StatusCode::UNAUTHORIZED,
138 Self::InvalidSignature => StatusCode::UNAUTHORIZED,
139 Self::PayloadTooLarge => StatusCode::PAYLOAD_TOO_LARGE,
140 Self::BodyNotJson => StatusCode::UNPROCESSABLE_ENTITY,
141 Self::SecretNotConfigured => StatusCode::INTERNAL_SERVER_ERROR,
142 Self::BodyReadError(_) => StatusCode::BAD_REQUEST,
143 }
144 }
145
146 fn error_response(&self) -> HttpResponse {
147 let body = serde_json::json!({"error": self.to_string()});
148 HttpResponse::build(self.status_code())
149 .content_type("application/json")
150 .json(body)
151 }
152}
153
154async fn extract_and_verify(
157 req: &HttpRequest,
158 payload: &mut Payload,
159) -> Result<WebhookPayload, WebhookError> {
160 let secret = req
162 .app_data::<WebhookSecret>()
163 .ok_or(WebhookError::SecretNotConfigured)?
164 .0
165 .clone();
166
167 let sig = req
169 .headers()
170 .get("x-hooksmith-signature")
171 .and_then(|v| v.to_str().ok())
172 .map(str::to_owned)
173 .ok_or(WebhookError::MissingSignature)?;
174
175 let ts_str = req
176 .headers()
177 .get("x-hooksmith-timestamp")
178 .and_then(|v| v.to_str().ok())
179 .map(str::to_owned)
180 .ok_or(WebhookError::MissingTimestamp)?;
181
182 let timestamp: i64 = ts_str.parse().map_err(|_| WebhookError::InvalidTimestamp)?;
183
184 let now = chrono::Utc::now().timestamp();
186 if (now - timestamp).abs() > TIMESTAMP_TOLERANCE_SECS {
187 return Err(WebhookError::StaleTimestamp);
188 }
189
190 use futures::StreamExt;
192 let mut chunks: Vec<u8> = Vec::new();
193 while let Some(chunk) = payload.next().await {
194 let chunk = chunk.map_err(|e| WebhookError::BodyReadError(e.to_string()))?;
195 if chunks.len() + chunk.len() > MAX_BODY_BYTES {
196 return Err(WebhookError::PayloadTooLarge);
197 }
198 chunks.extend_from_slice(&chunk);
199 }
200
201 let body = Bytes::from(chunks);
202
203 if serde_json::from_slice::<serde_json::Value>(&body).is_err() {
205 return Err(WebhookError::BodyNotJson);
206 }
207
208 if !signing::verify(&secret, timestamp, &body, &sig) {
210 return Err(WebhookError::InvalidSignature);
211 }
212
213 let event_type = req
214 .headers()
215 .get("x-hooksmith-event-type")
216 .and_then(|v| v.to_str().ok())
217 .unwrap_or("")
218 .to_owned();
219
220 let event_id = req
221 .headers()
222 .get("x-hooksmith-event-id")
223 .and_then(|v| v.to_str().ok())
224 .map(str::to_owned);
225
226 Ok(WebhookPayload { event_type, event_id, timestamp, body })
227}
228
229pub struct VerifiedWebhook(pub WebhookPayload);
247
248impl std::ops::Deref for VerifiedWebhook {
249 type Target = WebhookPayload;
250 fn deref(&self) -> &Self::Target { &self.0 }
251}
252
253impl FromRequest for VerifiedWebhook {
254 type Error = WebhookError;
255 type Future = Pin<Box<dyn Future<Output = Result<Self, Self::Error>>>>;
256
257 fn from_request(req: &HttpRequest, payload: &mut Payload) -> Self::Future {
258 let req = req.clone();
259 let mut payload = payload.take();
260 Box::pin(async move {
261 let verified = extract_and_verify(&req, &mut payload).await?;
262 Ok(VerifiedWebhook(verified))
263 })
264 }
265}
266
267pub struct TypedWebhook<T> {
287 pub payload: T,
288 pub meta: WebhookPayload,
289}
290
291impl<T: DeserializeOwned + 'static> FromRequest for TypedWebhook<T> {
292 type Error = WebhookError;
293 type Future = Pin<Box<dyn Future<Output = Result<Self, Self::Error>>>>;
294
295 fn from_request(req: &HttpRequest, payload: &mut Payload) -> Self::Future {
296 let req = req.clone();
297 let mut payload = payload.take();
298 Box::pin(async move {
299 let meta = extract_and_verify(&req, &mut payload).await?;
300 let typed: T = serde_json::from_slice(&meta.body)
301 .map_err(|_| WebhookError::BodyNotJson)?;
302 Ok(TypedWebhook { payload: typed, meta })
303 })
304 }
305}