kasapay_core/webhook.rs
1//! What a provider posts when a payment finishes without us asking.
2
3use std::fmt;
4
5use crate::error::Error;
6use crate::id::{EventId, PaymentId};
7use crate::money::Money;
8use crate::provider::{ProviderId, async_trait};
9use crate::raw::Raw;
10
11/// A header a signature depends on arrived more than once.
12///
13/// [`Delivery::signed_header`] answers this rather than picking one of them.
14#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
15#[error("`{name}` arrived {count} times; a signed delivery carries it once")]
16pub struct RepeatedHeader {
17 /// The header that was asked for.
18 pub name: Box<str>,
19 /// How many times it arrived.
20 pub count: usize,
21}
22
23/// One delivery from a provider, exactly as it arrived.
24///
25/// Headers and bytes, and nothing parsed: **a signature is over the bytes the
26/// provider sent**, and a body that has been through a JSON parser and back is
27/// a different sequence of bytes that will not verify. A web framework hands
28/// the body over as `&[u8]` before anything else touches it, and that is what
29/// belongs here.
30///
31/// Header names are matched without regard to case, because HTTP/2 lowercases
32/// them and HTTP/1.1 does not.
33#[derive(Debug, Clone, Copy)]
34pub struct Delivery<'a> {
35 headers: &'a [(&'a str, &'a str)],
36 body: &'a [u8],
37}
38
39impl<'a> Delivery<'a> {
40 /// Holds a delivery's headers and its body.
41 #[must_use]
42 pub const fn new(headers: &'a [(&'a str, &'a str)], body: &'a [u8]) -> Self {
43 Self { headers, body }
44 }
45
46 /// The first header with this name, ignoring case.
47 ///
48 /// For anything a signature depends on, use
49 /// [`Delivery::signed_header`] instead: this one answers the first of two
50 /// and says nothing about the second.
51 #[must_use]
52 pub fn header(&self, name: &str) -> Option<&'a str> {
53 self.headers
54 .iter()
55 .find(|(key, _)| key.eq_ignore_ascii_case(name))
56 .map(|(_, value)| *value)
57 }
58
59 /// The header with this name, refusing a delivery that carries two.
60 ///
61 /// `Ok(None)` is a delivery that carries none, which is the caller's own
62 /// error to phrase — a header that is merely absent and one that is
63 /// contradicted are different failures and deserve different words.
64 ///
65 /// # Why this exists
66 ///
67 /// A signature is a claim about one delivery, and two headers making that
68 /// claim are two claims. Whichever a verifier picks, something in front of
69 /// it — a proxy, a load balancer, whatever wrote the second — picked
70 /// differently, and the pair of them no longer agree about what was
71 /// signed. That disagreement is the whole of a header-smuggling attack,
72 /// and the only safe reading of it is that this delivery cannot be
73 /// trusted.
74 ///
75 /// It costs nothing to refuse: no provider here sends a signature header
76 /// twice, so a delivery that carries two did not come from them intact.
77 pub fn signed_header(&self, name: &str) -> Result<Option<&'a str>, RepeatedHeader> {
78 let mut found = self
79 .headers
80 .iter()
81 .filter(|(key, _)| key.eq_ignore_ascii_case(name));
82 let Some((_, value)) = found.next() else {
83 return Ok(None);
84 };
85 let count = 1 + found.count();
86 if count > 1 {
87 return Err(RepeatedHeader {
88 name: name.into(),
89 count,
90 });
91 }
92 Ok(Some(value))
93 }
94
95 /// Every header, in the order they arrived.
96 #[must_use]
97 pub const fn headers(&self) -> &'a [(&'a str, &'a str)] {
98 self.headers
99 }
100
101 /// The body as it arrived.
102 #[must_use]
103 pub const fn body(&self) -> &'a [u8] {
104 self.body
105 }
106
107 /// The body as text, for a provider that posts a form or JSON.
108 ///
109 /// `None` for a body that is not UTF-8. Nothing lossy: a body that is not
110 /// what the provider documents is one to refuse rather than repair.
111 #[must_use]
112 pub fn body_str(&self) -> Option<&'a str> {
113 std::str::from_utf8(self.body).ok()
114 }
115}
116
117/// What a delivery says happened.
118///
119/// `Other` is not an error and must never become one. A provider adding an
120/// event type is normal, and a handler that answers an error for one it does
121/// not know drives the provider into a redelivery loop that can run for days
122/// — for something nobody wanted in the first place.
123#[derive(Debug, Clone, PartialEq, Eq, Hash)]
124#[non_exhaustive]
125pub enum EventKind {
126 /// Funds are held and not taken.
127 Authorized,
128 /// Funds are taken.
129 Captured,
130 /// Money has gone back.
131 Refunded,
132 /// The payment was refused, and will not proceed.
133 Failed,
134 /// The payment was withdrawn before it completed.
135 Canceled,
136 /// Something else, named as the provider named it.
137 Other(Box<str>),
138}
139
140/// What a provider told us happened, once it has been shown to be theirs.
141///
142/// Every field is public and the struct is open, for the same reason
143/// [`Charge`](crate::Charge) is: an adapter in someone else's repository has
144/// to be able to build one.
145///
146/// # Why this is not an enum of [`Charge`](crate::Charge) and [`Refund`](crate::Refund)
147///
148/// It was the other candidate, and it asks each adapter to build a whole
149/// charge out of a delivery. Stripe's webhook carries a PaymentIntent and can;
150/// PayTR's notice signs three fields and cannot say what currency they are in;
151/// Mollie's carries an identifier and nothing else at all. The shape that
152/// survives all three is this one — what happened, to which payment, and the
153/// body for everything else — and what the caller does next is read the
154/// payment back, which is a call they already have.
155#[derive(Debug, Clone)]
156pub struct Event {
157 /// What the caller writes into a unique index before acting on this.
158 ///
159 /// The second delivery of an event collides with the first instead of
160 /// shipping the order twice, which is the whole reason this field is not
161 /// optional — a delivery nothing identifies is one nothing can deduplicate.
162 ///
163 /// **Read [`Id::source`](crate::Id::source) before trusting it.** Stripe
164 /// and PayPal issue an identifier for the delivery itself; PayTR and
165 /// Mollie issue none, so kasapay composes one out of the fields the
166 /// provider did send and says which they were. A composed key is unique
167 /// exactly as far as those fields are, and no further — two deliveries
168 /// that differ only in a field the provider did not sign are one key.
169 pub id: EventId,
170 /// What happened.
171 pub kind: EventKind,
172 /// The payment it happened to, where the delivery names one.
173 ///
174 /// `None` for a delivery about something that is not a payment — a dispute
175 /// opening, a payout landing — which arrives as
176 /// [`EventKind::Other`] and is a normal thing to receive.
177 pub payment: Option<PaymentId>,
178 /// The amount the delivery carried, where it carried one that can be
179 /// trusted.
180 ///
181 /// `None` is not "zero" and not "the provider sent nothing". PayTR's
182 /// notice carries an amount whose currency is **outside the hash**, so the
183 /// figure is a number without a unit and is left here rather than guessed
184 /// at; it is on [`Event::raw`] with everything else.
185 pub amount: Option<Money>,
186 /// Which provider sent it.
187 pub provider: ProviderId,
188 /// The delivery's own body, kept whole.
189 pub raw: Raw,
190}
191
192/// Checks that a delivery is the provider's, and says what it means.
193///
194/// Separate from [`Provider`](crate::Provider) because it is a separate thing
195/// to hold: verification needs a webhook secret the API credentials do not
196/// carry, and a process that takes payments does not always handle the
197/// callbacks for them.
198///
199/// # Verification is not one mechanism
200///
201/// `verify` is `async` because for two of the four providers that implement it
202/// here, checking a delivery is a network call rather than a hash:
203///
204/// | | how a delivery is shown to be theirs |
205/// |---|---|
206/// | Stripe | HMAC-SHA256 over `timestamp.body`, with a tolerance window |
207/// | PayTR | HMAC-SHA256 over three of the notice's fields |
208/// | Mollie | **nothing is signed** — the delivery carries an identifier, and the payment is read back over the merchant's own authenticated connection |
209/// | PayPal | PayPal verifies it, at `/v1/notifications/verify-webhook-signature` |
210///
211/// A trait that took only `(headers, body) -> Result<Event, Error>`
212/// synchronously would fit the first two and force the other two to lie.
213///
214/// # Answering the provider is not this trait's business
215///
216/// An `Err` here says **do not act on this**. It does not say what to answer:
217/// PayTR retries any reply that is not exactly `OK` for days, so a handler
218/// that turns [`ErrorKind::Untrusted`](crate::ErrorKind::Untrusted) into a 500
219/// has arranged for a forged notice to be delivered again every hour. Answer
220/// the provider what the provider documents, and act only on `Ok`.
221#[async_trait]
222pub trait Webhook: fmt::Debug + Send + Sync {
223 /// Which provider this verifies deliveries from.
224 fn provider(&self) -> ProviderId;
225
226 /// Shows that a delivery is the provider's, and reads what it says.
227 ///
228 /// # Errors
229 ///
230 /// [`ErrorKind::Untrusted`](crate::ErrorKind::Untrusted) for a delivery
231 /// that cannot be shown to be theirs — a signature that does not match,
232 /// one that is missing where the provider always sends one, or a timestamp
233 /// outside the tolerance a replay would fall outside. Nothing has been
234 /// read out of the body at that point, and nothing should be.
235 ///
236 /// [`ErrorKind::Malformed`](crate::ErrorKind::Malformed) for one that
237 /// verifies and is then not the shape the provider documents. An event
238 /// *type* this crate does not know is not that: it is
239 /// [`EventKind::Other`], and it is `Ok`.
240 async fn verify(&self, delivery: &Delivery<'_>) -> Result<Event, Error>;
241}
242
243#[cfg(test)]
244mod tests {
245 use super::Delivery;
246
247 #[test]
248 fn a_header_is_found_whatever_case_it_arrived_in() {
249 let headers = [("Stripe-Signature", "t=1,v1=abc"), ("Accept", "*/*")];
250 let delivery = Delivery::new(&headers, b"{}");
251 assert_eq!(delivery.header("stripe-signature"), Some("t=1,v1=abc"));
252 assert_eq!(delivery.header("STRIPE-SIGNATURE"), Some("t=1,v1=abc"));
253 assert_eq!(delivery.header("x-nothing"), None);
254 }
255
256 #[test]
257 fn a_signed_header_that_arrived_twice_is_refused_rather_than_chosen_between() {
258 let headers = [
259 ("Stripe-Signature", "t=1,v1=abc"),
260 ("Accept", "*/*"),
261 ("stripe-signature", "t=1,v1=forged"),
262 ];
263 let delivery = Delivery::new(&headers, b"{}");
264 // The lenient reader picks one and cannot say the other was there.
265 assert_eq!(delivery.header("Stripe-Signature"), Some("t=1,v1=abc"));
266
267 let repeated = delivery
268 .signed_header("Stripe-Signature")
269 .expect_err("two claims about one delivery are not one claim");
270 assert_eq!(repeated.count, 2);
271 assert!(repeated.to_string().contains("arrived 2 times"));
272 }
273
274 #[test]
275 fn a_header_that_never_arrived_is_not_the_same_failure_as_one_that_arrived_twice() {
276 let headers = [("Accept", "*/*")];
277 let delivery = Delivery::new(&headers, b"{}");
278 assert_eq!(
279 delivery
280 .signed_header("Stripe-Signature")
281 .expect("absent is not ambiguous"),
282 None
283 );
284 }
285
286 #[test]
287 fn a_body_that_is_not_utf8_is_kept_as_bytes_and_read_as_nothing() {
288 let delivery = Delivery::new(&[], &[0xff, 0xfe]);
289 assert_eq!(delivery.body(), &[0xff, 0xfe]);
290 assert!(delivery.body_str().is_none());
291 }
292}