use std::str::FromStr;
use chrono::Utc;
use hmac::{Hmac, Mac};
use sha2::Sha256;
use stripe_shared::ApiVersion;
use stripe_shared::event::EventType;
use crate::{EventObject, WebhookError};
#[derive(Clone, Debug)]
#[cfg_attr(feature = "serialize", derive(serde::Serialize))]
pub struct Event {
#[cfg_attr(feature = "serialize", serde(skip_serializing_if = "Option::is_none"))]
pub account: Option<String>,
#[cfg_attr(feature = "serialize", serde(skip_serializing_if = "Option::is_none"))]
pub api_version: Option<ApiVersion>,
pub created: stripe_types::Timestamp,
pub data: EventData,
pub id: stripe_shared::event::EventId,
pub livemode: bool,
#[cfg_attr(feature = "serialize", serde(rename = "object"))]
pub object: EventObjectType,
pub pending_webhooks: i64,
#[cfg_attr(feature = "serialize", serde(skip_serializing_if = "Option::is_none"))]
pub request: Option<stripe_shared::NotificationEventRequest>,
#[cfg_attr(feature = "serialize", serde(rename = "type"))]
pub type_: EventType,
}
#[derive(Clone, Debug, PartialEq, Eq)]
#[cfg_attr(feature = "serialize", derive(serde::Serialize))]
#[cfg_attr(feature = "deserialize", derive(serde::Deserialize))]
pub enum EventObjectType {
#[cfg_attr(any(feature = "serialize", feature = "deserialize"), serde(rename = "event"))]
Event,
}
#[derive(Clone, Debug)]
#[cfg_attr(feature = "serialize", derive(serde::Serialize))]
#[cfg_attr(feature = "deserialize", derive(serde::Deserialize))]
pub struct EventData {
pub object: EventObject,
#[cfg_attr(
any(feature = "deserialize", feature = "serialize"),
serde(with = "stripe_types::with_serde_json_opt")
)]
#[cfg_attr(feature = "serialize", serde(skip_serializing_if = "Option::is_none"))]
pub previous_attributes: Option<miniserde::json::Value>,
}
#[cfg(feature = "deserialize")]
impl<'de> serde::Deserialize<'de> for Event {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
use serde::de::Error;
#[derive(serde::Deserialize)]
struct EventProxy {
pub account: Option<String>,
pub api_version: Option<ApiVersion>,
pub created: stripe_types::Timestamp,
pub id: stripe_shared::event::EventId,
pub livemode: bool,
#[serde(rename = "object")]
#[allow(dead_code)]
pub object_type: String, pub pending_webhooks: i64,
pub request: Option<stripe_shared::NotificationEventRequest>,
#[serde(rename = "type")]
pub type_: EventType,
pub data: serde_json::Value,
}
let proxy = EventProxy::deserialize(deserializer)?;
let object_value =
proxy.data.get("object").ok_or_else(|| Error::missing_field("data.object"))?.clone();
let object =
EventObject::from_json_value(proxy.type_.as_str(), object_value).map_err(|e| {
if e.contains(':') {
Error::custom(format!("data.object.{e}"))
} else {
Error::custom(format!("data.object: {e}"))
}
})?;
let previous_attributes =
if let Some(prev_attrs) = proxy.data.get("previous_attributes") {
let prev_attrs_str = serde_json::to_string(prev_attrs).map_err(|e| {
Error::custom(format!("Failed to serialize previous_attributes: {e}"))
})?;
Some(miniserde::json::from_str(&prev_attrs_str).map_err(|e| {
Error::custom(format!("Failed to parse previous_attributes: {e}"))
})?)
} else {
None
};
Ok(Event {
account: proxy.account,
api_version: proxy.api_version,
created: proxy.created,
data: EventData { object, previous_attributes },
id: proxy.id,
livemode: proxy.livemode,
object: EventObjectType::Event,
pending_webhooks: proxy.pending_webhooks,
request: proxy.request,
type_: proxy.type_,
})
}
}
#[derive(Debug)]
pub struct Webhook {
current_timestamp: i64,
}
impl Webhook {
pub fn generate_test_header(payload: &str, secret: &str, timestamp: Option<i64>) -> String {
let timestamp = timestamp.unwrap_or_else(|| Utc::now().timestamp());
let signed_payload = format!("{timestamp}.{payload}");
let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes())
.expect("HMAC can take key of any size");
mac.update(signed_payload.as_bytes());
let result = mac.finalize().into_bytes();
let v1 = hex::encode(&result[..]);
format!("t={timestamp},v1={v1}")
}
pub fn insecure(payload: &str) -> Result<Event, WebhookError> {
if !cfg!(debug_assertions) {
tracing::warn!(
"Webhook::insecure() bypasses signature verification and should only be used for local testing. \
Use Webhook::construct_event() for production code."
);
}
Self { current_timestamp: 0 }.parse_payload(payload)
}
pub fn construct_event(payload: &str, sig: &str, secret: &str) -> Result<Event, WebhookError> {
Self { current_timestamp: Utc::now().timestamp() }.do_construct_event(payload, sig, secret)
}
pub fn construct_event_with_timestamp(
payload: &str,
sig: &str,
secret: &str,
timestamp: i64,
) -> Result<Event, WebhookError> {
Self { current_timestamp: timestamp }.do_construct_event(payload, sig, secret)
}
fn do_construct_event(
self,
payload: &str,
sig: &str,
secret: &str,
) -> Result<Event, WebhookError> {
let signature = Signature::parse(sig)?;
let signed_payload = format!("{}.{}", signature.t, payload);
let mut mac =
Hmac::<Sha256>::new_from_slice(secret.as_bytes()).map_err(|_| WebhookError::BadKey)?;
mac.update(signed_payload.as_bytes());
let sig = hex::decode(signature.v1).map_err(|_| WebhookError::BadSignature)?;
mac.verify_slice(sig.as_slice()).map_err(|_| WebhookError::BadSignature)?;
if (self.current_timestamp - signature.t).abs() > 300 {
return Err(WebhookError::BadTimestamp(signature.t));
}
self.parse_payload(payload)
}
#[tracing::instrument]
fn parse_payload(self, payload: &str) -> Result<Event, WebhookError> {
let base_evt: stripe_shared::Event = miniserde::json::from_str(payload)
.map_err(|_| WebhookError::BadParse("could not deserialize webhook event".into()))?;
let event_obj =
EventObject::from_raw_data(base_evt.type_.as_str(), base_evt.data.object)
.ok_or_else(|| WebhookError::BadParse("could not parse event object".into()))?;
let api_version = base_evt.api_version.as_ref().and_then(|s| ApiVersion::from_str(s).ok());
if let Some(event_version) = &api_version {
if event_version != &stripe_shared::version::VERSION {
tracing::warn!(
event_version=?event_version,
sdk_version=?stripe_shared::version::VERSION,
"API version mismatch: SDK compiled with {:?}, but event received with {:?}",
stripe_shared::version::VERSION,
event_version
);
}
}
Ok(Event {
account: base_evt.account,
api_version: base_evt
.api_version
.map(|s| ApiVersion::from_str(&s).expect("infallible")),
created: base_evt.created,
data: EventData {
object: event_obj,
previous_attributes: base_evt.data.previous_attributes,
},
id: base_evt.id,
livemode: base_evt.livemode,
object: EventObjectType::Event,
pending_webhooks: base_evt.pending_webhooks,
request: base_evt.request,
type_: base_evt.type_,
})
}
}
#[derive(Debug)]
struct Signature<'r> {
t: i64,
v1: &'r str,
}
impl<'r> Signature<'r> {
fn parse(raw: &'r str) -> Result<Signature<'r>, WebhookError> {
let mut t: Option<i64> = None;
let mut v1: Option<&'r str> = None;
for pair in raw.split(',') {
let (key, val) = pair.split_once('=').ok_or(WebhookError::BadSignature)?;
match key {
"t" => {
t = Some(val.parse().map_err(WebhookError::BadHeader)?);
}
"v1" => {
v1 = Some(val);
}
_ => {}
}
}
Ok(Signature {
t: t.ok_or(WebhookError::BadSignature)?,
v1: v1.ok_or(WebhookError::BadSignature)?,
})
}
}
#[cfg(test)]
mod tests {
use serde_json::{Value, json};
use super::*;
use crate::{AccountExternalAccountCreated, EventType};
const WEBHOOK_SECRET: &str = "secret";
#[test]
fn test_signature_parse() {
let raw_signature =
"t=1492774577,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd";
let signature = Signature::parse(raw_signature).unwrap();
assert_eq!(signature.t, 1492774577);
assert_eq!(
signature.v1,
"5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd"
);
let raw_signature_with_test_mode = "t=1492774577,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd,v0=6ffbb59b2300aae63f272406069a9788598b792a944a07aba816edb039989a39";
let signature = Signature::parse(raw_signature_with_test_mode).unwrap();
assert_eq!(signature.t, 1492774577);
assert_eq!(
signature.v1,
"5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd"
);
}
#[test]
fn test_generate_test_header() {
let payload = json!({
"id": "evt_test",
"object": "event",
"api_version": "2017-05-25",
"created": 1492774577,
"livemode": false,
"pending_webhooks": 1,
"data": {
"object": {
"object": "bank_account",
"country": "us",
"currency": "usd",
"id": "ba_test",
"last4": "6789",
"status": "verified",
}
},
"type": "account.external_account.created"
})
.to_string();
let secret = "whsec_test_secret";
let timestamp = 1492774577;
let signature = Webhook::generate_test_header(&payload, secret, Some(timestamp));
assert!(signature.starts_with("t=1492774577,v1="));
let event =
Webhook::construct_event_with_timestamp(&payload, &signature, secret, timestamp);
match event {
Ok(e) => {
assert_eq!(e.id.as_str(), "evt_test");
assert_eq!(e.type_, EventType::AccountExternalAccountCreated);
}
Err(e) => panic!("panic! {}", e),
}
}
#[test]
fn test_generate_test_header_integration() {
let payload = json!({
"id": "evt_test_webhook",
"object": "event",
"api_version": "2017-05-25",
"created": 1533204620,
"livemode": false,
"pending_webhooks": 1,
"data": {
"object": {
"object": "bank_account",
"country": "us",
"currency": "usd",
"id": "ba_test",
"last4": "6789",
"status": "verified",
}
},
"type": "account.external_account.created"
})
.to_string();
let secret = "whsec_test_secret";
let timestamp = Utc::now().timestamp();
let signature = Webhook::generate_test_header(&payload, secret, Some(timestamp));
let result =
Webhook::construct_event_with_timestamp(&payload, &signature, secret, timestamp);
assert!(result.is_ok());
let event = result.unwrap();
assert_eq!(event.id.as_str(), "evt_test_webhook");
assert_eq!(event.type_, EventType::AccountExternalAccountCreated);
}
fn get_mock_stripe_sig(msg: &str, timestamp: i64) -> String {
Webhook::generate_test_header(msg, WEBHOOK_SECRET, Some(timestamp))
}
fn mock_webhook_event(event_type: &EventType, data: Value) -> Value {
json!({
"id": "evt_123",
"object": "event",
"account": "acct_123",
"api_version": "2017-05-25",
"created": 1533204620,
"livemode": false,
"pending_webhooks": 1,
"request": {
"id": "req_123",
"idempotency_key": "idempotency-key-123"
},
"data": {
"object": data,
},
"type": event_type.to_string()
})
}
#[track_caller]
fn parse_mock_webhook_event(event_type: EventType, data: Value) -> EventObject {
let now = Utc::now().timestamp();
let payload = mock_webhook_event(&event_type, data).to_string();
let sig = get_mock_stripe_sig(&payload, now);
let webhook = Webhook { current_timestamp: now };
let parsed = webhook.do_construct_event(&payload, &sig, WEBHOOK_SECRET).unwrap();
assert_eq!(parsed.type_, event_type);
parsed.data.object
}
#[test]
#[cfg(feature = "async-stripe-billing")]
fn test_webhook_construct_event() {
let object = json!({
"id": "ii_123",
"object": "invoiceitem",
"amount": 1000,
"currency": "usd",
"customer": "cus_123",
"date": 1533204620,
"description": "Test Invoice Item",
"discountable": false,
"invoice": "in_123",
"livemode": false,
"metadata": {},
"period": {
"start": 1533204620,
"end": 1533204620
},
"proration": false,
"quantity": 3,
"quantity_decimal": "0"
});
let payload = mock_webhook_event(&EventType::InvoiceitemCreated, object);
let event_timestamp = 1533204620;
let signature = format!(
"t={event_timestamp},v1=d7373bc68f4bd320b253cd7461f87af6e1cdf1b4d7db1614d8d1d746972d2d0a,v0=63f3a72374a733066c4be69ed7f8e5ac85c22c9f0a6a612ab9a025a9e4ee7eef"
);
let webhook = Webhook { current_timestamp: event_timestamp };
let event = webhook
.do_construct_event(&payload.to_string(), &signature, WEBHOOK_SECRET)
.expect("Failed to construct event");
assert_eq!(event.type_, EventType::InvoiceitemCreated);
assert_eq!(event.id.as_str(), "evt_123",);
assert_eq!(event.account, "acct_123".parse().ok());
assert_eq!(event.created, 1533204620);
let EventObject::InvoiceitemCreated(invoice) = event.data.object else {
panic!("expected invoice item created");
};
assert_eq!(invoice.id.as_str(), "ii_123");
assert_eq!(invoice.quantity, 3);
}
#[cfg(feature = "async-stripe-billing")]
#[test]
fn test_billing_portal_session() {
let object = json!({
"configuration": "bpc_123",
"created": 1533204620,
"customer": "cus_123",
"id": "bps_123",
"livemode": false,
"url": "http://localhost:3000"
});
let result = parse_mock_webhook_event(EventType::BillingPortalSessionCreated, object);
let EventObject::BillingPortalSessionCreated(session) = result else {
panic!("expected billing portal session");
};
assert_eq!(session.url, "http://localhost:3000");
assert_eq!(session.id.as_str(), "bps_123");
assert_eq!(session.configuration.id().as_str(), "bpc_123");
}
#[test]
fn deserialize_polymorphic() {
let object = json!({
"object": "bank_account",
"country": "us",
"currency": "gbp",
"id": "ba_123",
"last4": "1234",
"status": "status",
});
let result = parse_mock_webhook_event(EventType::AccountExternalAccountCreated, object);
let EventObject::AccountExternalAccountCreated(bank_account) = result else {
panic!("unexpected type parsed");
};
let AccountExternalAccountCreated::BankAccount(bank_account) = *bank_account else {
panic!("unexpected type parsed");
};
assert_eq!(bank_account.id.as_str(), "ba_123");
assert_eq!(bank_account.last4, "1234");
}
}