Skip to main content

max / makenotwork

22.4 KB · 588 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 /// Pre-tax line-item total (cents) Stripe computed for the session. Used
131 /// only as a defense-in-depth reconciliation against our server-built line
132 /// items; absent on older/edge events, hence `Option`.
133 #[serde(default)]
134 pub amount_subtotal: Option<i64>,
135 }
136
137 #[derive(Debug, Default, serde::Deserialize)]
138 pub struct CheckoutCustomerDetailsView {
139 pub email: Option<String>,
140 }
141
142 /// Narrow view of a Subscription: id, status, cancellation flag, and the
143 /// item-level period fields rc.5 promoted from the top level.
144 #[derive(Debug, serde::Deserialize)]
145 pub struct SubscriptionView {
146 pub id: String,
147 pub status: String,
148 #[serde(default)]
149 pub cancel_at_period_end: bool,
150 #[serde(default)]
151 pub items: SubscriptionItemList,
152 }
153
154 impl SubscriptionView {
155 /// Period from `items.data[0]` (rc.5 moved these off the top-level Subscription).
156 pub fn current_period(&self) -> Option<(i64, i64)> {
157 self.items.data.first().map(|it| (it.current_period_start, it.current_period_end))
158 }
159 }
160
161 #[derive(Debug, Default, serde::Deserialize)]
162 pub struct SubscriptionItemList {
163 #[serde(default)]
164 pub data: Vec<SubscriptionItemView>,
165 }
166
167 #[derive(Debug, serde::Deserialize)]
168 pub struct SubscriptionItemView {
169 #[serde(default)]
170 pub current_period_start: i64,
171 #[serde(default)]
172 pub current_period_end: i64,
173 }
174
175 /// Narrow view of an Invoice: subscription id (via legacy `subscription` or
176 /// the rc.5 `parent.subscription_details.subscription` path), period bounds,
177 /// and billing reason.
178 #[derive(Debug, serde::Deserialize)]
179 pub struct InvoiceView {
180 #[serde(default)]
181 pub period_start: i64,
182 #[serde(default)]
183 pub period_end: i64,
184 #[serde(default)]
185 pub billing_reason: Option<String>,
186 #[serde(default, deserialize_with = "deserialize_expandable_id")]
187 pub subscription: Option<String>,
188 #[serde(default)]
189 pub parent: Option<InvoiceParentView>,
190 }
191
192 impl InvoiceView {
193 /// Pull the subscription id from either the legacy or new field path.
194 pub fn subscription_id(&self) -> Option<&str> {
195 if let Some(s) = &self.subscription {
196 return Some(s.as_str());
197 }
198 self.parent.as_ref()?
199 .subscription_details.as_ref()?
200 .subscription.as_deref()
201 }
202
203 pub fn is_renewal(&self) -> bool {
204 self.billing_reason.as_deref() == Some("subscription_cycle")
205 }
206 }
207
208 #[derive(Debug, serde::Deserialize)]
209 pub struct InvoiceParentView {
210 #[serde(default)]
211 pub subscription_details: Option<InvoiceSubscriptionDetailsView>,
212 }
213
214 #[derive(Debug, serde::Deserialize)]
215 pub struct InvoiceSubscriptionDetailsView {
216 #[serde(default, deserialize_with = "deserialize_expandable_id")]
217 pub subscription: Option<String>,
218 }
219
220 /// Stripe expandable fields are either a bare id string or a full object with
221 /// an `id` field. Pluck the id either way.
222 fn deserialize_expandable_id<'de, D>(deserializer: D) -> std::result::Result<Option<String>, D::Error>
223 where D: serde::Deserializer<'de> {
224 use serde::Deserialize;
225 let v = serde_json::Value::deserialize(deserializer)?;
226 Ok(match v {
227 serde_json::Value::Null => None,
228 serde_json::Value::String(s) => Some(s),
229 serde_json::Value::Object(mut map) => match map.remove("id") {
230 Some(serde_json::Value::String(s)) => Some(s),
231 _ => None,
232 },
233 _ => None,
234 })
235 }
236
237 /// Account update fields the dispatcher hands to the handler.
238 #[derive(Debug)]
239 pub struct AccountUpdate {
240 pub account_id: String,
241 pub charges_enabled: bool,
242 pub payouts_enabled: bool,
243 pub details_submitted: bool,
244 }
245
246 impl From<stripe_shared::Account> for AccountUpdate {
247 fn from(a: stripe_shared::Account) -> Self {
248 AccountUpdate {
249 account_id: a.id.to_string(),
250 charges_enabled: a.charges_enabled.unwrap_or(false),
251 payouts_enabled: a.payouts_enabled.unwrap_or(false),
252 details_submitted: a.details_submitted.unwrap_or(false),
253 }
254 }
255 }
256
257 /// Narrow view of an Account: only the fields we react to.
258 #[derive(Debug, serde::Deserialize)]
259 pub struct AccountView {
260 pub id: String,
261 #[serde(default)]
262 pub charges_enabled: bool,
263 #[serde(default)]
264 pub payouts_enabled: bool,
265 #[serde(default)]
266 pub details_submitted: bool,
267 }
268
269 impl From<AccountView> for AccountUpdate {
270 fn from(a: AccountView) -> Self {
271 AccountUpdate {
272 account_id: a.id,
273 charges_enabled: a.charges_enabled,
274 payouts_enabled: a.payouts_enabled,
275 details_submitted: a.details_submitted,
276 }
277 }
278 }
279
280 /// Narrow view of a Charge for refund processing.
281 #[derive(Debug, serde::Deserialize)]
282 pub struct ChargeView {
283 #[serde(default)]
284 pub amount: i64,
285 #[serde(default)]
286 pub amount_refunded: i64,
287 #[serde(default, deserialize_with = "deserialize_expandable_id")]
288 pub payment_intent: Option<String>,
289 }
290
291 /// Data extracted from a charge.refunded webhook event.
292 #[derive(Debug)]
293 pub struct ChargeRefundData {
294 pub payment_intent_id: String,
295 pub amount: Cents,
296 pub amount_refunded: Cents,
297 }
298
299 impl ChargeRefundData {
300 pub fn is_full_refund(&self) -> bool {
301 // Require `amount > 0` so $0 verification charges (which Stripe occasionally
302 // emits with `amount=0, amount_refunded=0`) are not treated as full refunds —
303 // that previously triggered `refund_transaction_by_payment_intent` with a
304 // default `unknown` intent ID.
305 self.amount > Cents::new(0) && self.amount_refunded >= self.amount
306 }
307
308 /// Build from a parsed charge view. Returns None when there is no
309 /// payment_intent; these events are out of scope here.
310 pub fn from_view(charge: ChargeView) -> Option<Self> {
311 Some(ChargeRefundData {
312 payment_intent_id: charge.payment_intent?,
313 amount: Cents::new(charge.amount),
314 amount_refunded: Cents::new(charge.amount_refunded),
315 })
316 }
317 }
318
319 // ---------------------------------------------------------------------------
320 // v2 thin event types
321 // ---------------------------------------------------------------------------
322
323 /// A Stripe v2 "thin" event: contains only the event type and a reference to
324 /// the related object, not the full object snapshot.
325 #[derive(Debug, serde::Deserialize)]
326 pub struct ThinEvent {
327 pub id: String,
328 #[serde(rename = "type")]
329 pub event_type: String,
330 pub related_object: Option<RelatedObject>,
331 }
332
333 /// Reference to the object that triggered a v2 event.
334 #[derive(Debug, serde::Deserialize)]
335 pub struct RelatedObject {
336 pub id: String,
337 #[serde(rename = "type")]
338 pub object_type: String,
339 }
340
341 /// Verify a Stripe webhook signature (v1 scheme, shared by v1 and v2 endpoints).
342 ///
343 /// Parses `t={ts},v1={hex}`, computes HMAC-SHA256 over `{ts}.{payload}`, and
344 /// compares in constant time. Rejects timestamps outside the configured
345 /// tolerance to prevent replay attacks.
346 pub fn verify_signature(payload: &str, header: &str, secret: &str) -> std::result::Result<(), String> {
347 let mut timestamp = None;
348 // Stripe emits a `v1=` value per active secret during rotation; collect
349 // them all and accept if any matches. The previous single-Option only
350 // kept the last value parsed, which silently broke rotation.
351 let mut signatures: Vec<&str> = Vec::new();
352 for part in header.split(',') {
353 if let Some(t) = part.strip_prefix("t=") {
354 timestamp = Some(t);
355 } else if let Some(s) = part.strip_prefix("v1=") {
356 signatures.push(s);
357 }
358 }
359
360 let timestamp = timestamp.ok_or("missing timestamp in signature header")?;
361 if signatures.is_empty() {
362 return Err("missing v1 signature in header".to_string());
363 }
364
365 let ts_secs: u64 = timestamp.parse().map_err(|_| "invalid timestamp")?;
366 let now_secs = std::time::SystemTime::now()
367 .duration_since(std::time::UNIX_EPOCH)
368 .map_err(|_| "system clock error")?
369 .as_secs();
370 let tolerance = crate::constants::WEBHOOK_TIMESTAMP_TOLERANCE_SECS;
371 if now_secs > ts_secs && now_secs - ts_secs > tolerance {
372 return Err("timestamp too old".to_string());
373 }
374 if ts_secs > now_secs && ts_secs - now_secs > tolerance {
375 return Err("timestamp too far in the future".to_string());
376 }
377
378 let signed_payload = format!("{}.{}", timestamp, payload);
379 let mut last_err = "signature mismatch".to_string();
380
381 for expected_sig in &signatures {
382 let expected_bytes = match hex::decode(expected_sig) {
383 Ok(b) => b,
384 Err(_) => {
385 last_err = "invalid hex in v1 signature".to_string();
386 continue;
387 }
388 };
389 let mut mac = HmacSha256::new_from_slice(secret.as_bytes())
390 .map_err(|_| "invalid HMAC key")?;
391 mac.update(signed_payload.as_bytes());
392 if mac.verify_slice(&expected_bytes).is_ok() {
393 return Ok(());
394 }
395 }
396
397 Err(last_err)
398 }
399
400 #[cfg(test)]
401 mod tests {
402 use super::*;
403 use serde_json::json;
404
405 #[test]
406 fn parse_envelope_extracts_id_type_and_object() {
407 let payload = r#"{"id":"evt_1","type":"checkout.session.completed","data":{"object":{"id":"cs_1"}}}"#;
408 let evt = UntypedEvent::from_payload(payload).unwrap();
409 assert_eq!(evt.id, "evt_1");
410 assert_eq!(evt.type_, "checkout.session.completed");
411 assert_eq!(evt.data_object["id"], "cs_1");
412 }
413
414 #[test]
415 fn parse_envelope_missing_data_object_errors() {
416 assert!(UntypedEvent::from_payload(r#"{"id":"x","type":"y"}"#).is_err());
417 }
418
419 #[test]
420 fn parse_envelope_error_messages_name_the_field() {
421 // Each failure mode should produce a body distinct enough that a future
422 // debugger reading Stripe Dashboard or our error logs knows exactly
423 // what was wrong, rather than a generic "Invalid webhook signature".
424 let missing_id = UntypedEvent::from_payload(r#"{"type":"t","data":{"object":{}}}"#).unwrap_err();
425 assert!(format!("{:?}", missing_id).contains("id"), "got: {:?}", missing_id);
426
427 let missing_type = UntypedEvent::from_payload(r#"{"id":"i","data":{"object":{}}}"#).unwrap_err();
428 assert!(format!("{:?}", missing_type).contains("type"), "got: {:?}", missing_type);
429
430 let missing_obj = UntypedEvent::from_payload(r#"{"id":"i","type":"t"}"#).unwrap_err();
431 assert!(format!("{:?}", missing_obj).contains("data.object"), "got: {:?}", missing_obj);
432
433 let bad_json = UntypedEvent::from_payload(r#"not json"#).unwrap_err();
434 assert!(format!("{:?}", bad_json).contains("parse failed"), "got: {:?}", bad_json);
435 }
436
437 // CheckoutSession parses from a real captured webhook fixture.
438 #[test]
439 fn checkout_session_parses_from_fixture() {
440 let raw = include_str!("../../tests/fixtures/webhooks/checkout.session.completed.connect.json");
441 let evt = UntypedEvent::from_payload(raw).unwrap();
442 let session: stripe_shared::CheckoutSession =
443 serde_json::from_value(evt.data_object).unwrap();
444 assert_eq!(session.mode, stripe_shared::CheckoutSessionMode::Payment);
445 }
446
447 // Subscription parses with current_period_* on items.data[0].
448 #[test]
449 fn subscription_parses_from_fixture_with_items_period() {
450 let raw = include_str!("../../tests/fixtures/webhooks/customer.subscription.updated.json");
451 let evt = UntypedEvent::from_payload(raw).unwrap();
452 let sub: stripe_shared::Subscription = serde_json::from_value(evt.data_object).unwrap();
453 let item = sub.items.data.first().expect("subscription has at least one item");
454 assert!(item.current_period_start > 0);
455 assert!(item.current_period_end > item.current_period_start);
456 }
457
458 // Invoice carries the new parent.subscription_details shape.
459 #[test]
460 fn invoice_parses_from_fixture() {
461 let raw = include_str!("../../tests/fixtures/webhooks/invoice.payment_succeeded.json");
462 let evt = UntypedEvent::from_payload(raw).unwrap();
463 let inv: stripe_shared::Invoice = serde_json::from_value(evt.data_object).unwrap();
464 assert!(inv.period_start > 0);
465 }
466
467 #[test]
468 fn account_update_conversion() {
469 let a: stripe_shared::Account = serde_json::from_value(json!({
470 "id": "acct_test123",
471 "object": "account",
472 "charges_enabled": true,
473 "payouts_enabled": true,
474 "details_submitted": true,
475 })).unwrap();
476 let u: AccountUpdate = a.into();
477 assert_eq!(u.account_id, "acct_test123");
478 assert!(u.charges_enabled);
479 assert!(u.payouts_enabled);
480 assert!(u.details_submitted);
481 }
482
483 #[test]
484 fn account_update_defaults_to_false_when_missing() {
485 let a: stripe_shared::Account = serde_json::from_value(json!({
486 "id": "acct_x",
487 "object": "account",
488 })).unwrap();
489 let u: AccountUpdate = a.into();
490 assert!(!u.charges_enabled);
491 assert!(!u.payouts_enabled);
492 assert!(!u.details_submitted);
493 }
494
495 // ChargeRefundData::from_charge JSON-roundtrip is covered by integration
496 // tests against real `charge.refunded` payloads — rc.5's `Charge` struct
497 // has ~30 non-Optional fields which makes hand-constructing a minimal one
498 // brittle. is_full_refund_* tests below pin the predicate semantics.
499
500 #[test]
501 fn is_full_refund_boundary() {
502 let exactly = ChargeRefundData {
503 payment_intent_id: "pi_a".to_string(),
504 amount: Cents::new(1000),
505 amount_refunded: Cents::new(1000),
506 };
507 assert!(exactly.is_full_refund());
508 let one_under = ChargeRefundData {
509 payment_intent_id: "pi_b".to_string(),
510 amount: Cents::new(1000),
511 amount_refunded: Cents::new(999),
512 };
513 assert!(!one_under.is_full_refund());
514 }
515
516 #[test]
517 fn is_full_refund_over_refunded_still_full() {
518 let over = ChargeRefundData {
519 payment_intent_id: "pi_c".to_string(),
520 amount: Cents::new(1000),
521 amount_refunded: Cents::new(1500),
522 };
523 assert!(over.is_full_refund());
524 }
525
526 #[test]
527 fn is_full_refund_zero_amount_is_not_full() {
528 // Stripe sometimes emits `charge.refunded` events with amount=0 for $0
529 // verification charges. Treating those as full refunds previously
530 // triggered `refund_transaction_by_payment_intent("unknown")`.
531 let zero = ChargeRefundData {
532 payment_intent_id: "pi_d".to_string(),
533 amount: Cents::new(0),
534 amount_refunded: Cents::new(0),
535 };
536 assert!(!zero.is_full_refund());
537 }
538
539 // --- verify_signature ---
540
541 fn sign_at(payload: &str, secret: &str, timestamp: u64) -> String {
542 use hmac::Mac;
543 let signed_payload = format!("{}.{}", timestamp, payload);
544 let mut mac = HmacSha256::new_from_slice(secret.as_bytes()).unwrap();
545 mac.update(signed_payload.as_bytes());
546 let hex_sig = hex::encode(mac.finalize().into_bytes());
547 format!("t={},v1={}", timestamp, hex_sig)
548 }
549
550 fn now_secs() -> u64 {
551 std::time::SystemTime::now()
552 .duration_since(std::time::UNIX_EPOCH).unwrap().as_secs()
553 }
554
555 #[test]
556 fn verify_signature_valid_current() {
557 let header = sign_at(r#"{"id":"evt_1"}"#, "whsec_test", now_secs());
558 assert!(verify_signature(r#"{"id":"evt_1"}"#, &header, "whsec_test").is_ok());
559 }
560
561 #[test]
562 fn verify_signature_rejected_stale_timestamp() {
563 let header = sign_at(r#"{"id":"evt_3"}"#, "whsec_test", now_secs() - 600);
564 let err = verify_signature(r#"{"id":"evt_3"}"#, &header, "whsec_test").unwrap_err();
565 assert!(err.contains("timestamp too old"), "got: {}", err);
566 }
567
568 #[test]
569 fn verify_signature_rejected_future_timestamp() {
570 let header = sign_at(r#"{"id":"evt_4"}"#, "whsec_test", now_secs() + 600);
571 let err = verify_signature(r#"{"id":"evt_4"}"#, &header, "whsec_test").unwrap_err();
572 assert!(err.contains("future"), "got: {}", err);
573 }
574
575 #[test]
576 fn verify_signature_accepted_within_tolerance() {
577 let header = sign_at(r#"{"id":"evt_5"}"#, "whsec_test", now_secs() - 240);
578 assert!(verify_signature(r#"{"id":"evt_5"}"#, &header, "whsec_test").is_ok());
579 }
580
581 #[test]
582 fn verify_signature_wrong_secret() {
583 let header = sign_at(r#"{"id":"evt_6"}"#, "whsec_test", now_secs());
584 let err = verify_signature(r#"{"id":"evt_6"}"#, &header, "wrong").unwrap_err();
585 assert!(err.contains("mismatch"), "got: {}", err);
586 }
587 }
588