Skip to main content

max / makenotwork

22.1 KB · 583 lines History Blame Raw
1 //! Webhook signature verification and event extraction.
2 //!
3 //! rc.5 ships no webhook helper, so we keep the local HMAC `verify_signature`
4 //! and a thin `UntypedEvent` envelope. The webhook dispatcher matches on
5 //! `type_` and consumes `data_object` (no per-extractor clones).
6
7 use hmac::{Hmac, Mac};
8 use sha2::Sha256;
9
10 use crate::db::Cents;
11 use crate::error::{AppError, Result};
12 use super::StripeClient;
13
14 type HmacSha256 = Hmac<Sha256>;
15
16 /// A Stripe webhook envelope after signature verification and JSON parsing.
17 ///
18 /// `data_object` is the raw `data.object` JSON value, ready to be consumed
19 /// by `serde_json::from_value` into a typed rc.5 struct.
20 #[derive(Debug, Clone)]
21 pub struct UntypedEvent {
22 pub id: String,
23 pub type_: String,
24 pub data_object: serde_json::Value,
25 }
26
27 impl UntypedEvent {
28 /// Parse a JSON webhook payload. Caller must verify the signature first.
29 pub fn from_payload(payload: &str) -> Result<Self> {
30 let mut v: serde_json::Value = serde_json::from_str(payload).map_err(|e| {
31 tracing::warn!(error.kind = "envelope_json", error = %e, "webhook envelope JSON parse failed");
32 AppError::BadRequest(format!("Webhook envelope JSON parse failed: {e}"))
33 })?;
34
35 let id = take_string(&mut v, "id").ok_or_else(|| {
36 tracing::warn!(error.kind = "envelope_missing_field", missing = "id", "webhook envelope missing required field");
37 AppError::BadRequest("Webhook envelope missing required field: id".to_string())
38 })?;
39 let type_ = take_string(&mut v, "type").ok_or_else(|| {
40 tracing::warn!(error.kind = "envelope_missing_field", missing = "type", "webhook envelope missing required field");
41 AppError::BadRequest("Webhook envelope missing required field: type".to_string())
42 })?;
43 let data_object = v.get_mut("data")
44 .and_then(|d| d.get_mut("object"))
45 .map(std::mem::take)
46 .ok_or_else(|| {
47 tracing::warn!(error.kind = "envelope_missing_field", missing = "data.object", "webhook envelope missing required field");
48 AppError::BadRequest("Webhook envelope missing required field: data.object".to_string())
49 })?;
50
51 Ok(UntypedEvent { id, type_, data_object })
52 }
53 }
54
55 fn take_string(v: &mut serde_json::Value, key: &str) -> Option<String> {
56 v.get_mut(key).and_then(|s| match std::mem::take(s) {
57 serde_json::Value::String(s) => Some(s),
58 _ => None,
59 })
60 }
61
62 impl StripeClient {
63 /// Verify the webhook signature and return the parsed envelope.
64 ///
65 /// Tries each configured signing secret in turn and accepts on the first
66 /// match. We run multiple endpoints (`mnw-connect`, `mnw-you`), each with
67 /// its own secret; signatures don't carry an endpoint id, so checking
68 /// every secret is the only option.
69 ///
70 /// On failure the returned `AppError::BadRequest` body is specific enough
71 /// to distinguish signature failures ("Invalid webhook signature: ...") from
72 /// payload-shape failures ("Webhook envelope JSON parse failed: ...",
73 /// "Webhook envelope missing required field: ..."). The Stripe Dashboard
74 /// surfaces these bodies for failed webhook deliveries, so wording matters.
75 /// Past incidents (Stripe API version mismatch producing serde
76 /// `missing field` errors) were initially misread as signature failures.
77 #[tracing::instrument(skip_all, name = "payments::verify_webhook")]
78 pub fn verify_webhook(&self, payload: &str, signature: &str) -> Result<UntypedEvent> {
79 let mut last_err: Option<String> = None;
80 for secret in &self.config.webhook_secret {
81 match verify_signature(payload, signature, secret) {
82 Ok(()) => return UntypedEvent::from_payload(payload),
83 Err(e) => last_err = Some(e),
84 }
85 }
86 let reason = last_err.unwrap_or_else(|| "no signing secrets configured".to_string());
87 tracing::warn!(error.kind = "signature", reason = %reason, "webhook signature verification failed against all configured secrets");
88 Err(AppError::BadRequest(format!("Invalid webhook signature: {reason}")))
89 }
90
91 /// Verify a v2 thin event webhook and return the parsed JSON body.
92 ///
93 /// See `verify_webhook` for the failure-mode taxonomy.
94 #[tracing::instrument(skip_all, name = "payments::verify_webhook_v2")]
95 pub fn verify_webhook_v2(&self, payload: &str, signature: &str) -> Result<serde_json::Value> {
96 let secret = self.config.webhook_secret_v2.as_deref().ok_or_else(|| {
97 AppError::ServiceUnavailable("Stripe v2 webhook secret not configured".to_string())
98 })?;
99
100 verify_signature(payload, signature, secret).map_err(|e| {
101 tracing::warn!(error.kind = "signature", reason = %e, "v2 webhook signature verification failed");
102 AppError::BadRequest(format!("Invalid webhook signature: {e}"))
103 })?;
104
105 serde_json::from_str(payload).map_err(|e| {
106 tracing::warn!(error.kind = "envelope_json", error = %e, "v2 webhook payload parse failed");
107 AppError::BadRequest(format!("Webhook payload JSON parse failed: {e}"))
108 })
109 }
110 }
111
112 /// Narrow view of a CheckoutSession: only the fields any handler reads.
113 ///
114 /// Built ad-hoc rather than via `stripe_shared::CheckoutSession` to stay
115 /// resilient against new required fields Stripe adds. The original migration
116 /// bug was caused by an over-strict typed struct.
117 #[derive(Debug, Default, serde::Deserialize)]
118 pub struct CheckoutSessionView {
119 pub id: String,
120 #[serde(default)]
121 pub metadata: Option<std::collections::HashMap<String, String>>,
122 #[serde(default, deserialize_with = "deserialize_expandable_id")]
123 pub payment_intent: Option<String>,
124 #[serde(default, deserialize_with = "deserialize_expandable_id")]
125 pub subscription: Option<String>,
126 #[serde(default, deserialize_with = "deserialize_expandable_id")]
127 pub customer: Option<String>,
128 #[serde(default)]
129 pub customer_details: Option<CheckoutCustomerDetailsView>,
130 }
131
132 #[derive(Debug, Default, serde::Deserialize)]
133 pub struct CheckoutCustomerDetailsView {
134 pub email: Option<String>,
135 }
136
137 /// Narrow view of a Subscription: id, status, cancellation flag, and the
138 /// item-level period fields rc.5 promoted from the top level.
139 #[derive(Debug, serde::Deserialize)]
140 pub struct SubscriptionView {
141 pub id: String,
142 pub status: String,
143 #[serde(default)]
144 pub cancel_at_period_end: bool,
145 #[serde(default)]
146 pub items: SubscriptionItemList,
147 }
148
149 impl SubscriptionView {
150 /// Period from `items.data[0]` (rc.5 moved these off the top-level Subscription).
151 pub fn current_period(&self) -> Option<(i64, i64)> {
152 self.items.data.first().map(|it| (it.current_period_start, it.current_period_end))
153 }
154 }
155
156 #[derive(Debug, Default, serde::Deserialize)]
157 pub struct SubscriptionItemList {
158 #[serde(default)]
159 pub data: Vec<SubscriptionItemView>,
160 }
161
162 #[derive(Debug, serde::Deserialize)]
163 pub struct SubscriptionItemView {
164 #[serde(default)]
165 pub current_period_start: i64,
166 #[serde(default)]
167 pub current_period_end: i64,
168 }
169
170 /// Narrow view of an Invoice: subscription id (via legacy `subscription` or
171 /// the rc.5 `parent.subscription_details.subscription` path), period bounds,
172 /// and billing reason.
173 #[derive(Debug, serde::Deserialize)]
174 pub struct InvoiceView {
175 #[serde(default)]
176 pub period_start: i64,
177 #[serde(default)]
178 pub period_end: i64,
179 #[serde(default)]
180 pub billing_reason: Option<String>,
181 #[serde(default, deserialize_with = "deserialize_expandable_id")]
182 pub subscription: Option<String>,
183 #[serde(default)]
184 pub parent: Option<InvoiceParentView>,
185 }
186
187 impl InvoiceView {
188 /// Pull the subscription id from either the legacy or new field path.
189 pub fn subscription_id(&self) -> Option<&str> {
190 if let Some(s) = &self.subscription {
191 return Some(s.as_str());
192 }
193 self.parent.as_ref()?
194 .subscription_details.as_ref()?
195 .subscription.as_deref()
196 }
197
198 pub fn is_renewal(&self) -> bool {
199 self.billing_reason.as_deref() == Some("subscription_cycle")
200 }
201 }
202
203 #[derive(Debug, serde::Deserialize)]
204 pub struct InvoiceParentView {
205 #[serde(default)]
206 pub subscription_details: Option<InvoiceSubscriptionDetailsView>,
207 }
208
209 #[derive(Debug, serde::Deserialize)]
210 pub struct InvoiceSubscriptionDetailsView {
211 #[serde(default, deserialize_with = "deserialize_expandable_id")]
212 pub subscription: Option<String>,
213 }
214
215 /// Stripe expandable fields are either a bare id string or a full object with
216 /// an `id` field. Pluck the id either way.
217 fn deserialize_expandable_id<'de, D>(deserializer: D) -> std::result::Result<Option<String>, D::Error>
218 where D: serde::Deserializer<'de> {
219 use serde::Deserialize;
220 let v = serde_json::Value::deserialize(deserializer)?;
221 Ok(match v {
222 serde_json::Value::Null => None,
223 serde_json::Value::String(s) => Some(s),
224 serde_json::Value::Object(mut map) => match map.remove("id") {
225 Some(serde_json::Value::String(s)) => Some(s),
226 _ => None,
227 },
228 _ => None,
229 })
230 }
231
232 /// Account update fields the dispatcher hands to the handler.
233 #[derive(Debug)]
234 pub struct AccountUpdate {
235 pub account_id: String,
236 pub charges_enabled: bool,
237 pub payouts_enabled: bool,
238 pub details_submitted: bool,
239 }
240
241 impl From<stripe_shared::Account> for AccountUpdate {
242 fn from(a: stripe_shared::Account) -> Self {
243 AccountUpdate {
244 account_id: a.id.to_string(),
245 charges_enabled: a.charges_enabled.unwrap_or(false),
246 payouts_enabled: a.payouts_enabled.unwrap_or(false),
247 details_submitted: a.details_submitted.unwrap_or(false),
248 }
249 }
250 }
251
252 /// Narrow view of an Account: only the fields we react to.
253 #[derive(Debug, serde::Deserialize)]
254 pub struct AccountView {
255 pub id: String,
256 #[serde(default)]
257 pub charges_enabled: bool,
258 #[serde(default)]
259 pub payouts_enabled: bool,
260 #[serde(default)]
261 pub details_submitted: bool,
262 }
263
264 impl From<AccountView> for AccountUpdate {
265 fn from(a: AccountView) -> Self {
266 AccountUpdate {
267 account_id: a.id,
268 charges_enabled: a.charges_enabled,
269 payouts_enabled: a.payouts_enabled,
270 details_submitted: a.details_submitted,
271 }
272 }
273 }
274
275 /// Narrow view of a Charge for refund processing.
276 #[derive(Debug, serde::Deserialize)]
277 pub struct ChargeView {
278 #[serde(default)]
279 pub amount: i64,
280 #[serde(default)]
281 pub amount_refunded: i64,
282 #[serde(default, deserialize_with = "deserialize_expandable_id")]
283 pub payment_intent: Option<String>,
284 }
285
286 /// Data extracted from a charge.refunded webhook event.
287 #[derive(Debug)]
288 pub struct ChargeRefundData {
289 pub payment_intent_id: String,
290 pub amount: Cents,
291 pub amount_refunded: Cents,
292 }
293
294 impl ChargeRefundData {
295 pub fn is_full_refund(&self) -> bool {
296 // Require `amount > 0` so $0 verification charges (which Stripe occasionally
297 // emits with `amount=0, amount_refunded=0`) are not treated as full refunds —
298 // that previously triggered `refund_transaction_by_payment_intent` with a
299 // default `unknown` intent ID.
300 self.amount > Cents::new(0) && self.amount_refunded >= self.amount
301 }
302
303 /// Build from a parsed charge view. Returns None when there is no
304 /// payment_intent; these events are out of scope here.
305 pub fn from_view(charge: ChargeView) -> Option<Self> {
306 Some(ChargeRefundData {
307 payment_intent_id: charge.payment_intent?,
308 amount: Cents::new(charge.amount),
309 amount_refunded: Cents::new(charge.amount_refunded),
310 })
311 }
312 }
313
314 // ---------------------------------------------------------------------------
315 // v2 thin event types
316 // ---------------------------------------------------------------------------
317
318 /// A Stripe v2 "thin" event: contains only the event type and a reference to
319 /// the related object, not the full object snapshot.
320 #[derive(Debug, serde::Deserialize)]
321 pub struct ThinEvent {
322 pub id: String,
323 #[serde(rename = "type")]
324 pub event_type: String,
325 pub related_object: Option<RelatedObject>,
326 }
327
328 /// Reference to the object that triggered a v2 event.
329 #[derive(Debug, serde::Deserialize)]
330 pub struct RelatedObject {
331 pub id: String,
332 #[serde(rename = "type")]
333 pub object_type: String,
334 }
335
336 /// Verify a Stripe webhook signature (v1 scheme, shared by v1 and v2 endpoints).
337 ///
338 /// Parses `t={ts},v1={hex}`, computes HMAC-SHA256 over `{ts}.{payload}`, and
339 /// compares in constant time. Rejects timestamps outside the configured
340 /// tolerance to prevent replay attacks.
341 pub fn verify_signature(payload: &str, header: &str, secret: &str) -> std::result::Result<(), String> {
342 let mut timestamp = None;
343 // Stripe emits a `v1=` value per active secret during rotation; collect
344 // them all and accept if any matches. The previous single-Option only
345 // kept the last value parsed, which silently broke rotation.
346 let mut signatures: Vec<&str> = Vec::new();
347 for part in header.split(',') {
348 if let Some(t) = part.strip_prefix("t=") {
349 timestamp = Some(t);
350 } else if let Some(s) = part.strip_prefix("v1=") {
351 signatures.push(s);
352 }
353 }
354
355 let timestamp = timestamp.ok_or("missing timestamp in signature header")?;
356 if signatures.is_empty() {
357 return Err("missing v1 signature in header".to_string());
358 }
359
360 let ts_secs: u64 = timestamp.parse().map_err(|_| "invalid timestamp")?;
361 let now_secs = std::time::SystemTime::now()
362 .duration_since(std::time::UNIX_EPOCH)
363 .map_err(|_| "system clock error")?
364 .as_secs();
365 let tolerance = crate::constants::WEBHOOK_TIMESTAMP_TOLERANCE_SECS;
366 if now_secs > ts_secs && now_secs - ts_secs > tolerance {
367 return Err("timestamp too old".to_string());
368 }
369 if ts_secs > now_secs && ts_secs - now_secs > tolerance {
370 return Err("timestamp too far in the future".to_string());
371 }
372
373 let signed_payload = format!("{}.{}", timestamp, payload);
374 let mut last_err = "signature mismatch".to_string();
375
376 for expected_sig in &signatures {
377 let expected_bytes = match hex::decode(expected_sig) {
378 Ok(b) => b,
379 Err(_) => {
380 last_err = "invalid hex in v1 signature".to_string();
381 continue;
382 }
383 };
384 let mut mac = HmacSha256::new_from_slice(secret.as_bytes())
385 .map_err(|_| "invalid HMAC key")?;
386 mac.update(signed_payload.as_bytes());
387 if mac.verify_slice(&expected_bytes).is_ok() {
388 return Ok(());
389 }
390 }
391
392 Err(last_err)
393 }
394
395 #[cfg(test)]
396 mod tests {
397 use super::*;
398 use serde_json::json;
399
400 #[test]
401 fn parse_envelope_extracts_id_type_and_object() {
402 let payload = r#"{"id":"evt_1","type":"checkout.session.completed","data":{"object":{"id":"cs_1"}}}"#;
403 let evt = UntypedEvent::from_payload(payload).unwrap();
404 assert_eq!(evt.id, "evt_1");
405 assert_eq!(evt.type_, "checkout.session.completed");
406 assert_eq!(evt.data_object["id"], "cs_1");
407 }
408
409 #[test]
410 fn parse_envelope_missing_data_object_errors() {
411 assert!(UntypedEvent::from_payload(r#"{"id":"x","type":"y"}"#).is_err());
412 }
413
414 #[test]
415 fn parse_envelope_error_messages_name_the_field() {
416 // Each failure mode should produce a body distinct enough that a future
417 // debugger reading Stripe Dashboard or our error logs knows exactly
418 // what was wrong, rather than a generic "Invalid webhook signature".
419 let missing_id = UntypedEvent::from_payload(r#"{"type":"t","data":{"object":{}}}"#).unwrap_err();
420 assert!(format!("{:?}", missing_id).contains("id"), "got: {:?}", missing_id);
421
422 let missing_type = UntypedEvent::from_payload(r#"{"id":"i","data":{"object":{}}}"#).unwrap_err();
423 assert!(format!("{:?}", missing_type).contains("type"), "got: {:?}", missing_type);
424
425 let missing_obj = UntypedEvent::from_payload(r#"{"id":"i","type":"t"}"#).unwrap_err();
426 assert!(format!("{:?}", missing_obj).contains("data.object"), "got: {:?}", missing_obj);
427
428 let bad_json = UntypedEvent::from_payload(r#"not json"#).unwrap_err();
429 assert!(format!("{:?}", bad_json).contains("parse failed"), "got: {:?}", bad_json);
430 }
431
432 // CheckoutSession parses from a real captured webhook fixture.
433 #[test]
434 fn checkout_session_parses_from_fixture() {
435 let raw = include_str!("../../tests/fixtures/webhooks/checkout.session.completed.connect.json");
436 let evt = UntypedEvent::from_payload(raw).unwrap();
437 let session: stripe_shared::CheckoutSession =
438 serde_json::from_value(evt.data_object).unwrap();
439 assert_eq!(session.mode, stripe_shared::CheckoutSessionMode::Payment);
440 }
441
442 // Subscription parses with current_period_* on items.data[0].
443 #[test]
444 fn subscription_parses_from_fixture_with_items_period() {
445 let raw = include_str!("../../tests/fixtures/webhooks/customer.subscription.updated.json");
446 let evt = UntypedEvent::from_payload(raw).unwrap();
447 let sub: stripe_shared::Subscription = serde_json::from_value(evt.data_object).unwrap();
448 let item = sub.items.data.first().expect("subscription has at least one item");
449 assert!(item.current_period_start > 0);
450 assert!(item.current_period_end > item.current_period_start);
451 }
452
453 // Invoice carries the new parent.subscription_details shape.
454 #[test]
455 fn invoice_parses_from_fixture() {
456 let raw = include_str!("../../tests/fixtures/webhooks/invoice.payment_succeeded.json");
457 let evt = UntypedEvent::from_payload(raw).unwrap();
458 let inv: stripe_shared::Invoice = serde_json::from_value(evt.data_object).unwrap();
459 assert!(inv.period_start > 0);
460 }
461
462 #[test]
463 fn account_update_conversion() {
464 let a: stripe_shared::Account = serde_json::from_value(json!({
465 "id": "acct_test123",
466 "object": "account",
467 "charges_enabled": true,
468 "payouts_enabled": true,
469 "details_submitted": true,
470 })).unwrap();
471 let u: AccountUpdate = a.into();
472 assert_eq!(u.account_id, "acct_test123");
473 assert!(u.charges_enabled);
474 assert!(u.payouts_enabled);
475 assert!(u.details_submitted);
476 }
477
478 #[test]
479 fn account_update_defaults_to_false_when_missing() {
480 let a: stripe_shared::Account = serde_json::from_value(json!({
481 "id": "acct_x",
482 "object": "account",
483 })).unwrap();
484 let u: AccountUpdate = a.into();
485 assert!(!u.charges_enabled);
486 assert!(!u.payouts_enabled);
487 assert!(!u.details_submitted);
488 }
489
490 // ChargeRefundData::from_charge JSON-roundtrip is covered by integration
491 // tests against real `charge.refunded` payloads — rc.5's `Charge` struct
492 // has ~30 non-Optional fields which makes hand-constructing a minimal one
493 // brittle. is_full_refund_* tests below pin the predicate semantics.
494
495 #[test]
496 fn is_full_refund_boundary() {
497 let exactly = ChargeRefundData {
498 payment_intent_id: "pi_a".to_string(),
499 amount: Cents::new(1000),
500 amount_refunded: Cents::new(1000),
501 };
502 assert!(exactly.is_full_refund());
503 let one_under = ChargeRefundData {
504 payment_intent_id: "pi_b".to_string(),
505 amount: Cents::new(1000),
506 amount_refunded: Cents::new(999),
507 };
508 assert!(!one_under.is_full_refund());
509 }
510
511 #[test]
512 fn is_full_refund_over_refunded_still_full() {
513 let over = ChargeRefundData {
514 payment_intent_id: "pi_c".to_string(),
515 amount: Cents::new(1000),
516 amount_refunded: Cents::new(1500),
517 };
518 assert!(over.is_full_refund());
519 }
520
521 #[test]
522 fn is_full_refund_zero_amount_is_NOT_full() {
523 // Stripe sometimes emits `charge.refunded` events with amount=0 for $0
524 // verification charges. Treating those as full refunds previously
525 // triggered `refund_transaction_by_payment_intent("unknown")`.
526 let zero = ChargeRefundData {
527 payment_intent_id: "pi_d".to_string(),
528 amount: Cents::new(0),
529 amount_refunded: Cents::new(0),
530 };
531 assert!(!zero.is_full_refund());
532 }
533
534 // --- verify_signature ---
535
536 fn sign_at(payload: &str, secret: &str, timestamp: u64) -> String {
537 use hmac::Mac;
538 let signed_payload = format!("{}.{}", timestamp, payload);
539 let mut mac = HmacSha256::new_from_slice(secret.as_bytes()).unwrap();
540 mac.update(signed_payload.as_bytes());
541 let hex_sig = hex::encode(mac.finalize().into_bytes());
542 format!("t={},v1={}", timestamp, hex_sig)
543 }
544
545 fn now_secs() -> u64 {
546 std::time::SystemTime::now()
547 .duration_since(std::time::UNIX_EPOCH).unwrap().as_secs()
548 }
549
550 #[test]
551 fn verify_signature_valid_current() {
552 let header = sign_at(r#"{"id":"evt_1"}"#, "whsec_test", now_secs());
553 assert!(verify_signature(r#"{"id":"evt_1"}"#, &header, "whsec_test").is_ok());
554 }
555
556 #[test]
557 fn verify_signature_rejected_stale_timestamp() {
558 let header = sign_at(r#"{"id":"evt_3"}"#, "whsec_test", now_secs() - 600);
559 let err = verify_signature(r#"{"id":"evt_3"}"#, &header, "whsec_test").unwrap_err();
560 assert!(err.contains("timestamp too old"), "got: {}", err);
561 }
562
563 #[test]
564 fn verify_signature_rejected_future_timestamp() {
565 let header = sign_at(r#"{"id":"evt_4"}"#, "whsec_test", now_secs() + 600);
566 let err = verify_signature(r#"{"id":"evt_4"}"#, &header, "whsec_test").unwrap_err();
567 assert!(err.contains("future"), "got: {}", err);
568 }
569
570 #[test]
571 fn verify_signature_accepted_within_tolerance() {
572 let header = sign_at(r#"{"id":"evt_5"}"#, "whsec_test", now_secs() - 240);
573 assert!(verify_signature(r#"{"id":"evt_5"}"#, &header, "whsec_test").is_ok());
574 }
575
576 #[test]
577 fn verify_signature_wrong_secret() {
578 let header = sign_at(r#"{"id":"evt_6"}"#, "whsec_test", now_secs());
579 let err = verify_signature(r#"{"id":"evt_6"}"#, &header, "wrong").unwrap_err();
580 assert!(err.contains("mismatch"), "got: {}", err);
581 }
582 }
583