Skip to main content

finlight_client/
webhook.rs

1use chrono::{DateTime, Utc};
2use hmac::{Hmac, KeyInit, Mac};
3use sha2::Sha256;
4
5use crate::error::WebhookVerificationError;
6use crate::models::Article;
7use crate::models::flex::parse_datetime;
8
9const SIGNATURE_PREFIX: &str = "sha256=";
10const REPLAY_TOLERANCE_SECS: i64 = 5 * 60;
11
12/// Verifies a finlight webhook and returns the contained article.
13///
14/// `raw_body` must be the unmodified request body. `signature` is the value
15/// of the `X-Webhook-Signature` header (with or without the `sha256=`
16/// prefix). `endpoint_secret` is your webhook secret from the finlight
17/// dashboard. `timestamp` is the `X-Webhook-Timestamp` header; pass `None` if
18/// the webhook has none, otherwise it is included in the signed message and
19/// checked against a 5-minute replay tolerance.
20pub fn construct_webhook_event(
21    raw_body: &[u8],
22    signature: &str,
23    endpoint_secret: &str,
24    timestamp: Option<&str>,
25) -> Result<Article, WebhookVerificationError> {
26    let signature = signature
27        .strip_prefix(SIGNATURE_PREFIX)
28        .unwrap_or(signature);
29    let signature = hex::decode(signature)
30        .map_err(|_| WebhookVerificationError("Invalid webhook signature".into()))?;
31
32    let mut mac = Hmac::<Sha256>::new_from_slice(endpoint_secret.as_bytes())
33        .expect("HMAC accepts keys of any length");
34    if let Some(ts) = timestamp {
35        mac.update(ts.as_bytes());
36        mac.update(b".");
37    }
38    mac.update(raw_body);
39    mac.verify_slice(&signature)
40        .map_err(|_| WebhookVerificationError("Invalid webhook signature".into()))?;
41
42    if let Some(ts) = timestamp {
43        verify_timestamp(ts)?;
44    }
45
46    serde_json::from_slice(raw_body)
47        .map_err(|_| WebhookVerificationError("Invalid JSON payload".into()))
48}
49
50fn verify_timestamp(timestamp: &str) -> Result<(), WebhookVerificationError> {
51    let parsed: DateTime<Utc> = parse_datetime(timestamp)
52        .ok_or_else(|| WebhookVerificationError("Invalid timestamp format".into()))?;
53    let age = Utc::now().signed_duration_since(parsed).num_seconds();
54    if age.abs() > REPLAY_TOLERANCE_SECS {
55        return Err(WebhookVerificationError(
56            "Webhook timestamp outside allowed tolerance".into(),
57        ));
58    }
59    Ok(())
60}