Skip to main content

webhooksmith_axum/
lib.rs

1//! Axum integration for webhooksmith.
2//!
3//! # Verifying incoming webhooks
4//!
5//! Add the [`WebhookSecretLayer`] to your router, then use the
6//! [`VerifiedWebhook`] extractor in any handler. It automatically verifies the
7//! HMAC-SHA256 signature, rejects stale timestamps, and gives you the raw JSON
8//! body. Use [`TypedWebhook<T>`] if you want automatic deserialization.
9//!
10//! ```rust,no_run
11//! use axum::{Router, routing::post, http::StatusCode};
12//! use webhooksmith_axum::{WebhookSecretLayer, VerifiedWebhook, TypedWebhook};
13//! use serde::Deserialize;
14//!
15//! #[derive(Deserialize)]
16//! struct OrderCreated { order_id: u64 }
17//!
18//! async fn handle_raw(VerifiedWebhook(body): VerifiedWebhook) -> StatusCode {
19//!     tracing::info!(event_type = %body.event_type, "received webhook");
20//!     StatusCode::OK
21//! }
22//!
23//! async fn handle_typed(TypedWebhook(order): TypedWebhook<OrderCreated>) -> StatusCode {
24//!     tracing::info!(order_id = order.order_id, "order created");
25//!     StatusCode::OK
26//! }
27//!
28//! let app: Router = Router::new()
29//!     .route("/webhooks", post(handle_raw))
30//!     .route("/orders", post(handle_typed))
31//!     .layer(WebhookSecretLayer::new("your-signing-secret"));
32//! ```
33
34mod admin;
35pub use admin::admin;
36
37use axum::{
38    async_trait,
39    extract::{FromRequest, Request},
40    http::StatusCode,
41    response::{IntoResponse, Response},
42};
43use webhooksmith::signing;
44use serde::de::DeserializeOwned;
45use tower_layer::Layer;
46use std::sync::Arc;
47
48const MAX_BODY_BYTES: usize = 1_048_576; // 1 MB
49
50// ── Secret storage ────────────────────────────────────────────────────────────
51
52/// The signing secret injected by [`WebhookSecretLayer`].
53#[derive(Clone)]
54struct WebhookSecret(Arc<String>);
55
56// ── Tower layer ───────────────────────────────────────────────────────────────
57
58/// Tower middleware layer that injects the webhook signing secret into
59/// request extensions so extractors can verify signatures.
60///
61/// Apply this to your router once at startup:
62/// ```rust,no_run
63/// # use axum::Router;
64/// # use webhooksmith_axum::WebhookSecretLayer;
65/// let app: Router = Router::new()
66///     /* ... routes ... */
67///     .layer(WebhookSecretLayer::new("your-secret"));
68/// ```
69#[derive(Clone)]
70pub struct WebhookSecretLayer {
71    secret: Arc<String>,
72}
73
74impl WebhookSecretLayer {
75    pub fn new(secret: impl Into<String>) -> Self {
76        Self { secret: Arc::new(secret.into()) }
77    }
78}
79
80impl<S> Layer<S> for WebhookSecretLayer {
81    type Service = WebhookSecretService<S>;
82
83    fn layer(&self, inner: S) -> Self::Service {
84        WebhookSecretService {
85            inner,
86            secret: self.secret.clone(),
87        }
88    }
89}
90
91/// The middleware service produced by [`WebhookSecretLayer`].
92#[derive(Clone)]
93pub struct WebhookSecretService<S> {
94    inner: S,
95    secret: Arc<String>,
96}
97
98impl<S, B> tower::Service<Request<B>> for WebhookSecretService<S>
99where
100    S: tower::Service<Request<B>>,
101{
102    type Response = S::Response;
103    type Error = S::Error;
104    type Future = S::Future;
105
106    fn poll_ready(
107        &mut self,
108        cx: &mut std::task::Context<'_>,
109    ) -> std::task::Poll<Result<(), Self::Error>> {
110        self.inner.poll_ready(cx)
111    }
112
113    fn call(&mut self, mut req: Request<B>) -> Self::Future {
114        req.extensions_mut()
115            .insert(WebhookSecret(self.secret.clone()));
116        self.inner.call(req)
117    }
118}
119
120// ── Extractor rejection ───────────────────────────────────────────────────────
121
122/// Rejection type returned when signature verification fails.
123#[derive(Debug)]
124pub enum WebhookRejection {
125    MissingSecret,
126    MissingTimestamp,
127    MissingSignature,
128    BodyTooLarge,
129    InvalidSignature,
130    InvalidBody(serde_json::Error),
131}
132
133impl IntoResponse for WebhookRejection {
134    fn into_response(self) -> Response {
135        let (status, msg) = match &self {
136            Self::MissingSecret => (StatusCode::INTERNAL_SERVER_ERROR, "webhook secret not configured"),
137            Self::MissingTimestamp => (StatusCode::BAD_REQUEST, "missing x-hooksmith-timestamp header"),
138            Self::MissingSignature => (StatusCode::UNAUTHORIZED, "missing x-hooksmith-signature header"),
139            Self::BodyTooLarge => (StatusCode::PAYLOAD_TOO_LARGE, "request body too large"),
140            Self::InvalidSignature => (StatusCode::UNAUTHORIZED, "invalid webhook signature"),
141            Self::InvalidBody(_) => (StatusCode::UNPROCESSABLE_ENTITY, "invalid JSON body"),
142        };
143        (status, msg).into_response()
144    }
145}
146
147// ── Verified webhook payload ──────────────────────────────────────────────────
148
149/// The verified and parsed content of an incoming webhook request.
150pub struct WebhookPayload {
151    pub event_type: String,
152    pub event_id: Option<String>,
153    pub timestamp: i64,
154    pub body: serde_json::Value,
155}
156
157async fn extract_and_verify(req: Request) -> Result<WebhookPayload, WebhookRejection> {
158    // Retrieve the secret injected by WebhookSecretLayer
159    let secret = req
160        .extensions()
161        .get::<WebhookSecret>()
162        .ok_or(WebhookRejection::MissingSecret)?
163        .0
164        .clone();
165
166    // Read required headers before consuming the body
167    let timestamp: i64 = req
168        .headers()
169        .get("x-hooksmith-timestamp")
170        .and_then(|v| v.to_str().ok())
171        .and_then(|v| v.parse().ok())
172        .ok_or(WebhookRejection::MissingTimestamp)?;
173
174    let signature = req
175        .headers()
176        .get("x-hooksmith-signature")
177        .and_then(|v| v.to_str().ok())
178        .ok_or(WebhookRejection::MissingSignature)?
179        .to_owned();
180
181    let event_type = req
182        .headers()
183        .get("x-hooksmith-event-type")
184        .and_then(|v| v.to_str().ok())
185        .unwrap_or("unknown")
186        .to_owned();
187
188    let event_id = req
189        .headers()
190        .get("x-hooksmith-event-id")
191        .and_then(|v| v.to_str().ok())
192        .map(|s| s.to_owned());
193
194    // Buffer the body up to the size limit
195    let bytes = axum::body::to_bytes(req.into_body(), MAX_BODY_BYTES)
196        .await
197        .map_err(|_| WebhookRejection::BodyTooLarge)?;
198
199    // Verify HMAC signature — rejects stale timestamps automatically
200    if !signing::verify(&secret, timestamp, &bytes, &signature) {
201        tracing::warn!(
202            event_type = %event_type,
203            "webhook signature verification failed"
204        );
205        return Err(WebhookRejection::InvalidSignature);
206    }
207
208    let body: serde_json::Value =
209        serde_json::from_slice(&bytes).map_err(WebhookRejection::InvalidBody)?;
210
211    Ok(WebhookPayload { event_type, event_id, timestamp, body })
212}
213
214// ── VerifiedWebhook extractor ─────────────────────────────────────────────────
215
216/// Axum extractor that verifies the webhooksmith HMAC-SHA256 signature and
217/// returns the raw JSON payload.
218///
219/// Rejects with 401 if the signature is missing or invalid.
220/// Rejects with 400 if the timestamp header is missing.
221/// Requires [`WebhookSecretLayer`] on the router.
222pub struct VerifiedWebhook(pub WebhookPayload);
223
224#[async_trait]
225impl<S> FromRequest<S> for VerifiedWebhook
226where
227    S: Send + Sync,
228{
229    type Rejection = WebhookRejection;
230
231    async fn from_request(req: Request, _state: &S) -> Result<Self, Self::Rejection> {
232        Ok(Self(extract_and_verify(req).await?))
233    }
234}
235
236// ── TypedWebhook<T> extractor ─────────────────────────────────────────────────
237
238/// Axum extractor that verifies the webhooksmith signature and deserializes the
239/// JSON body into `T`.
240///
241/// Returns 422 if the body doesn't match `T`.
242pub struct TypedWebhook<T>(pub T);
243
244#[async_trait]
245impl<S, T> FromRequest<S> for TypedWebhook<T>
246where
247    S: Send + Sync,
248    T: DeserializeOwned,
249{
250    type Rejection = WebhookRejection;
251
252    async fn from_request(req: Request, _state: &S) -> Result<Self, Self::Rejection> {
253        let payload = extract_and_verify(req).await?;
254        let typed: T =
255            serde_json::from_value(payload.body).map_err(WebhookRejection::InvalidBody)?;
256        Ok(Self(typed))
257    }
258}