Skip to main content

webhooksmith_actix/
lib.rs

1//! Actix-web integration for webhooksmith.
2//!
3//! Provides [`VerifiedWebhook`] and [`TypedWebhook<T>`] extractors that verify
4//! incoming webhook signatures (HMAC-SHA256) and reject forged, stale, or
5//! oversized requests.
6//!
7//! # Setup
8//!
9//! Register the signing secret via [`WebhookSecret`] app data, then use
10//! [`VerifiedWebhook`] or [`TypedWebhook<T>`] as handler parameters:
11//!
12//! ```rust,no_run
13//! use actix_web::{web, App, HttpServer, HttpResponse, Responder};
14//! use webhooksmith_actix::{WebhookSecret, VerifiedWebhook, TypedWebhook};
15//! use serde::Deserialize;
16//!
17//! #[derive(Deserialize)]
18//! struct OrderCreated { order_id: u64 }
19//!
20//! async fn handle_raw(webhook: VerifiedWebhook) -> impl Responder {
21//!     tracing::info!(event_type = %webhook.event_type, event_id = ?webhook.event_id, "received");
22//!     HttpResponse::Ok().finish()
23//! }
24//!
25//! async fn handle_typed(webhook: TypedWebhook<OrderCreated>) -> impl Responder {
26//!     tracing::info!(order_id = %webhook.payload.order_id, "order created");
27//!     HttpResponse::Ok().finish()
28//! }
29//!
30//! # async fn run() -> std::io::Result<()> {
31//! HttpServer::new(|| {
32//!     App::new()
33//!         .app_data(WebhookSecret::new("your-signing-secret"))
34//!         .route("/webhooks", web::post().to(handle_raw))
35//!         .route("/orders", web::post().to(handle_typed))
36//! })
37//! .bind("0.0.0.0:8080")?
38//! .run()
39//! .await
40//! # }
41//! ```
42//!
43//! # Rejection behaviour
44//!
45//! | Condition | Status | Body |
46//! |-----------|--------|------|
47//! | Missing `x-hooksmith-signature` | 401 | `{"error":"missing signature"}` |
48//! | Missing `x-hooksmith-timestamp` | 400 | `{"error":"missing timestamp"}` |
49//! | Invalid timestamp (not an integer) | 400 | `{"error":"invalid timestamp"}` |
50//! | Stale timestamp (> 300 s skew) | 401 | `{"error":"stale timestamp"}` |
51//! | Signature mismatch | 401 | `{"error":"invalid signature"}` |
52//! | Body > 1 MB | 413 | `{"error":"payload too large"}` |
53//! | Body is not valid JSON | 422 | `{"error":"body must be JSON"}` |
54
55use 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
66// ── Constants ─────────────────────────────────────────────────────────────────
67
68const MAX_BODY_BYTES: usize = 1_048_576; // 1 MB
69const TIMESTAMP_TOLERANCE_SECS: i64 = 300;
70
71// ── WebhookSecret ─────────────────────────────────────────────────────────────
72
73/// App data holding the webhook signing secret.
74///
75/// Register once via `.app_data(WebhookSecret::new("your-secret"))`.
76#[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// ── WebhookPayload ────────────────────────────────────────────────────────────
86
87/// Verified webhook metadata and body, produced by the extractors.
88#[derive(Debug, Clone)]
89pub struct WebhookPayload {
90    /// Value of the `x-hooksmith-event-type` header (may be empty if absent).
91    pub event_type: String,
92    /// Value of the `x-hooksmith-event-id` header. `None` if the header was not sent.
93    pub event_id: Option<String>,
94    /// Unix timestamp from the `x-hooksmith-timestamp` header.
95    pub timestamp: i64,
96    /// Raw JSON body bytes (signature already verified).
97    pub body: Bytes,
98}
99
100// ── Extraction error ──────────────────────────────────────────────────────────
101
102#[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
154// ── Core verification logic ───────────────────────────────────────────────────
155
156async fn extract_and_verify(
157    req: &HttpRequest,
158    payload: &mut Payload,
159) -> Result<WebhookPayload, WebhookError> {
160    // Retrieve secret from app data
161    let secret = req
162        .app_data::<WebhookSecret>()
163        .ok_or(WebhookError::SecretNotConfigured)?
164        .0
165        .clone();
166
167    // Read required headers
168    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    // Stale timestamp guard
185    let now = chrono::Utc::now().timestamp();
186    if (now - timestamp).abs() > TIMESTAMP_TOLERANCE_SECS {
187        return Err(WebhookError::StaleTimestamp);
188    }
189
190    // Read body with size cap using actix's body extractor
191    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    // Must be valid JSON
204    if serde_json::from_slice::<serde_json::Value>(&body).is_err() {
205        return Err(WebhookError::BodyNotJson);
206    }
207
208    // Verify HMAC-SHA256 signature
209    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
229// ── VerifiedWebhook extractor ─────────────────────────────────────────────────
230
231/// Actix-web extractor that verifies the incoming webhook signature.
232///
233/// On success, gives you the raw JSON body and metadata.
234/// On failure, responds with a structured JSON error.
235///
236/// # Example
237/// ```rust,no_run
238/// use actix_web::{web, HttpResponse, Responder};
239/// use webhooksmith_actix::VerifiedWebhook;
240///
241/// async fn handler(webhook: VerifiedWebhook) -> impl Responder {
242///     println!("event_type: {}", webhook.event_type);
243///     HttpResponse::Ok().finish()
244/// }
245/// ```
246pub 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
267// ── TypedWebhook<T> extractor ─────────────────────────────────────────────────
268
269/// Actix-web extractor that verifies the signature AND deserializes the JSON body
270/// into `T`.
271///
272/// # Example
273/// ```rust,no_run
274/// use actix_web::{web, HttpResponse, Responder};
275/// use serde::Deserialize;
276/// use webhooksmith_actix::TypedWebhook;
277///
278/// #[derive(Deserialize)]
279/// struct OrderCreated { order_id: u64 }
280///
281/// async fn handler(webhook: TypedWebhook<OrderCreated>) -> impl Responder {
282///     println!("order: {}", webhook.payload.order_id);
283///     HttpResponse::Ok().finish()
284/// }
285/// ```
286pub 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}