rastrillo / abono Public

abono: the Stripe client and webhook signature check, from Patrix

Summary

Adds internal/stripeapi, Farshad's hand-written Stripe client moved out of Patrix and generalised: Client (Key, Base, HTTP), GetSubscription, ListSubscriptions, GetPrice, CreateCheckout, GetCheckout, PortalURL, PromotionCode, Error and NotFound, plus VerifySignature with an exported ErrBadSignature. API version is pinned as APIVersion 2025-03-31.basil. call takes an idempotency key, sent as Idempotency-Key only when non-empty.
Agent notes
Stdlib only. Client.HTTP nil means a 30-second-timeout client. Plan parsing and the Patrix model are not ported; they belong to later tasks. Tests port Farshad's signature and subscription-shape tests and add the pinned-version, idempotency, error, paging, tolerance and rotation cases.
claude agent, operated by paul@keymail.dev 8e639491d99259ad0e7c1ebd0d6942516bbc02d5 parent b568876
4 files changed, +621 −0
  • internal/stripeapi/client.go +280 −0
  • internal/stripeapi/client_test.go +197 −0
  • internal/stripeapi/signature.go +57 −0
  • internal/stripeapi/signature_test.go +87 −0

CI passed — run details

CI log
=== vet ===
go vet ./...
go: downloading amadan.net/rastrillo/rastrillo v0.28.1-0.20261022095550-8fbde25ba6f1
=== fmt ===
=== test ===
go test ./... -count=1
ok  	amadan.net/rastrillo/abono	0.012s
ok  	amadan.net/rastrillo/abono/internal/stripeapi	0.006s
=== race ===
CGO_ENABLED=1 go test ./... -race -count=1
ok  	amadan.net/rastrillo/abono	1.091s
ok  	amadan.net/rastrillo/abono/internal/stripeapi	1.017s
=== browser ===
browser: no browser test file yet, nothing to run
diff --git a/internal/stripeapi/client.go b/internal/stripeapi/client.go
new file mode 100644
index 0000000..2d2dd79
--- /dev/null
+++ b/internal/stripeapi/client.go
@@ -0,0 +1,280 @@
+// Package stripeapi is the few calls abono makes to Stripe's API, and the
+// webhook signature check, by hand rather than through stripe-go.
+package stripeapi
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "strings"
+ "time"
+)
+
+// Client is the few calls abono makes to Stripe's API: a handful of
+// endpoints are less to vet than the SDK, and the fields read are the
+// ones written down here.
+type Client struct {
+ // Key is the account's secret or restricted key. A restricted key
+ // needs: Checkout Sessions and Customer portal write; Subscriptions,
+ // Customers, Prices and Promotion codes read.
+ Key string
+ // Base is the API's address; "" is Stripe's. Tests point it at a fake.
+ Base string
+ // HTTP is the transport; nil is a client with a 30-second timeout.
+ HTTP *http.Client
+}
+
+// APIVersion is pinned so a change to the account's default version in
+// Stripe's dashboard changes nothing here. Webhooks arrive in the
+// endpoint's own version, which is why the handler reads only ids from
+// them and fetches the subscription again through this.
+const APIVersion = "2025-03-31.basil"
+
+// Subscription is what abono reads of one.
+type Subscription struct {
+ ID string `json:"id"`
+ Status string `json:"status"`
+ CancelAtPeriodEnd bool `json:"cancel_at_period_end"`
+ // CurrentPeriodEnd moved onto the items in 2025-03-31.basil; the
+ // top-level one is read for an older account version's webhooks.
+ CurrentPeriodEnd int64 `json:"current_period_end"`
+ Customer struct {
+ ID string `json:"id"`
+ Email string `json:"email"`
+ Name string `json:"name"`
+ } `json:"customer"`
+ Items struct {
+ Data []struct {
+ CurrentPeriodEnd int64 `json:"current_period_end"`
+ Price struct {
+ ID string `json:"id"`
+ Recurring struct {
+ Interval string `json:"interval"`
+ } `json:"recurring"`
+ } `json:"price"`
+ } `json:"data"`
+ } `json:"items"`
+ Metadata map[string]string `json:"metadata"`
+}
+
+// PeriodEnd is when the period paid for ends, or the zero time.
+func (s Subscription) PeriodEnd() time.Time {
+ end := s.CurrentPeriodEnd
+ for _, it := range s.Items.Data {
+ if it.CurrentPeriodEnd > end {
+ end = it.CurrentPeriodEnd
+ }
+ }
+ if end == 0 {
+ return time.Time{}
+ }
+ return time.Unix(end, 0).UTC()
+}
+
+// PriceID is the first item's price.
+func (s Subscription) PriceID() (id, interval string) {
+ if len(s.Items.Data) == 0 {
+ return "", ""
+ }
+ p := s.Items.Data[0].Price
+ return p.ID, p.Recurring.Interval
+}
+
+// subscriptionWire is a subscription as the API sends it, whose
+// customer is an id unless expanded.
+type subscriptionWire struct {
+ Subscription
+ Customer json.RawMessage `json:"customer"`
+}
+
+func (w subscriptionWire) decode() (Subscription, error) {
+ s := w.Subscription
+ if len(w.Customer) > 0 && w.Customer[0] == '"' {
+ return s, json.Unmarshal(w.Customer, &s.Customer.ID)
+ }
+ if len(w.Customer) > 0 && string(w.Customer) != "null" {
+ return s, json.Unmarshal(w.Customer, &s.Customer)
+ }
+ return s, nil
+}
+
+// GetSubscription fetches one subscription with its customer.
+func (c *Client) GetSubscription(ctx context.Context, id string) (Subscription, error) {
+ var w subscriptionWire
+ if err := c.call(ctx, http.MethodGet, "/v1/subscriptions/"+url.PathEscape(id), url.Values{"expand[]": {"customer"}}, "", &w); err != nil {
+ return Subscription{}, err
+ }
+ return w.decode()
+}
+
+// ListSubscriptions calls each with every subscription on the account, any
+// status, a page at a time.
+func (c *Client) ListSubscriptions(ctx context.Context, each func(Subscription) error) error {
+ after := ""
+ for {
+ q := url.Values{"status": {"all"}, "limit": {"100"}, "expand[]": {"data.customer"}}
+ if after != "" {
+ q.Set("starting_after", after)
+ }
+ var page struct {
+ Data []subscriptionWire `json:"data"`
+ HasMore bool `json:"has_more"`
+ }
+ if err := c.call(ctx, http.MethodGet, "/v1/subscriptions", q, "", &page); err != nil {
+ return err
+ }
+ for _, w := range page.Data {
+ s, err := w.decode()
+ if err != nil {
+ return err
+ }
+ if err := each(s); err != nil {
+ return err
+ }
+ after = s.ID
+ }
+ if !page.HasMore || len(page.Data) == 0 {
+ return nil
+ }
+ }
+}
+
+// Price is a recurring price: what a plan costs and how often.
+type Price struct {
+ ID string `json:"id"`
+ UnitAmount int64 `json:"unit_amount"`
+ Currency string `json:"currency"`
+ Recurring struct {
+ Interval string `json:"interval"`
+ } `json:"recurring"`
+}
+
+// GetPrice fetches one price.
+func (c *Client) GetPrice(ctx context.Context, id string) (Price, error) {
+ var p Price
+ return p, c.call(ctx, http.MethodGet, "/v1/prices/"+url.PathEscape(id), nil, "", &p)
+}
+
+// CheckoutSession is a hosted Checkout page, or one that has finished.
+type CheckoutSession struct {
+ ID string `json:"id"`
+ URL string `json:"url"`
+ Status string `json:"status"`
+ ClientReferenceID string `json:"client_reference_id"`
+ Subscription string `json:"subscription"`
+ CustomerDetails struct {
+ Email string `json:"email"`
+ } `json:"customer_details"`
+}
+
+// CreateCheckout starts a hosted Checkout with form's parameters.
+func (c *Client) CreateCheckout(ctx context.Context, form url.Values) (CheckoutSession, error) {
+ var s CheckoutSession
+ return s, c.call(ctx, http.MethodPost, "/v1/checkout/sessions", form, "", &s)
+}
+
+// GetCheckout fetches one Checkout session.
+func (c *Client) GetCheckout(ctx context.Context, id string) (CheckoutSession, error) {
+ var s CheckoutSession
+ return s, c.call(ctx, http.MethodGet, "/v1/checkout/sessions/"+url.PathEscape(id), nil, "", &s)
+}
+
+// PortalURL is a Customer Portal session for customer, coming back to
+// returnURL: where a subscriber changes their card, plan, or cancels.
+func (c *Client) PortalURL(ctx context.Context, customer, returnURL string) (string, error) {
+ var s struct {
+ URL string `json:"url"`
+ }
+ err := c.call(ctx, http.MethodPost, "/v1/billing_portal/sessions",
+ url.Values{"customer": {customer}, "return_url": {returnURL}}, "", &s)
+ return s.URL, err
+}
+
+// PromotionCode is the active promotion code with this customer-facing
+// code ("INNOVATE"), or "" when there is none.
+func (c *Client) PromotionCode(ctx context.Context, code string) (string, error) {
+ var page struct {
+ Data []struct {
+ ID string `json:"id"`
+ } `json:"data"`
+ }
+ if err := c.call(ctx, http.MethodGet, "/v1/promotion_codes", url.Values{"code": {code}, "active": {"true"}, "limit": {"1"}}, "", &page); err != nil {
+ return "", err
+ }
+ if len(page.Data) == 0 {
+ return "", nil
+ }
+ return page.Data[0].ID, nil
+}
+
+// Error is Stripe's own refusal.
+type Error struct {
+ Status int
+ Type string `json:"type"`
+ Code string `json:"code"`
+ Message string `json:"message"`
+}
+
+func (e *Error) Error() string {
+ return fmt.Sprintf("stripe: %d %s %s: %s", e.Status, e.Type, e.Code, e.Message)
+}
+
+// NotFound reports whether err is Stripe saying the object doesn't exist.
+func NotFound(err error) bool {
+ var e *Error
+ return errors.As(err, &e) && e.Status == http.StatusNotFound
+}
+
+func (c *Client) call(ctx context.Context, method, path string, form url.Values, idempotencyKey string, out any) error {
+ base := c.Base
+ if base == "" {
+ base = "https://api.stripe.com"
+ }
+ var body io.Reader
+ u := base + path
+ if method == http.MethodGet {
+ if len(form) > 0 {
+ u += "?" + form.Encode()
+ }
+ } else {
+ body = strings.NewReader(form.Encode())
+ }
+ req, err := http.NewRequestWithContext(ctx, method, u, body)
+ if err != nil {
+ return err
+ }
+ req.Header.Set("Authorization", "Bearer "+c.Key)
+ req.Header.Set("Stripe-Version", APIVersion)
+ if body != nil {
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ }
+ if idempotencyKey != "" {
+ req.Header.Set("Idempotency-Key", idempotencyKey)
+ }
+ client := c.HTTP
+ if client == nil {
+ client = &http.Client{Timeout: 30 * time.Second}
+ }
+ resp, err := client.Do(req)
+ if err != nil {
+ return err
+ }
+ defer resp.Body.Close()
+ data, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
+ if err != nil {
+ return err
+ }
+ if resp.StatusCode/100 != 2 {
+ var e struct {
+ Error Error `json:"error"`
+ }
+ _ = json.Unmarshal(data, &e)
+ e.Error.Status = resp.StatusCode
+ return &e.Error
+ }
+ return json.Unmarshal(data, out)
+}
diff --git a/internal/stripeapi/client_test.go b/internal/stripeapi/client_test.go
new file mode 100644
index 0000000..3dae21c
--- /dev/null
+++ b/internal/stripeapi/client_test.go
@@ -0,0 +1,197 @@
+package stripeapi
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "testing"
+ "time"
+)
+
+// A subscription reads the same in the API version abono pins (period
+// on the items) and an older one's webhook (on the subscription), with
+// its customer expanded or not.
+func TestSubscriptionWire(t *testing.T) {
+ t.Parallel()
+ for name, raw := range map[string]string{
+ "basil, expanded": `{"id":"sub_1","status":"active","customer":{"id":"cus_1","email":"a@example.com"},"items":{"data":[{"current_period_end":1800000000,"price":{"id":"price_A","recurring":{"interval":"year"}}}]}}`,
+ "older, id only": `{"id":"sub_1","status":"active","customer":"cus_1","current_period_end":1800000000,"items":{"data":[{"price":{"id":"price_A","recurring":{"interval":"year"}}}]}}`,
+ } {
+ var w subscriptionWire
+ if err := json.Unmarshal([]byte(raw), &w); err != nil {
+ t.Fatal(err)
+ }
+ s, err := w.decode()
+ if err != nil {
+ t.Fatal(err)
+ }
+ price, interval := s.PriceID()
+ if s.Customer.ID != "cus_1" || price != "price_A" || interval != "year" || !s.PeriodEnd().Equal(time.Unix(1_800_000_000, 0)) {
+ t.Errorf("%s: %+v", name, s)
+ }
+ }
+}
+
+// fake serves h and returns a Client pointed at it with key "k".
+func fake(t *testing.T, h http.HandlerFunc) *Client {
+ t.Helper()
+ srv := httptest.NewServer(h)
+ t.Cleanup(srv.Close)
+ return &Client{Key: "k", Base: srv.URL}
+}
+
+func TestCallSendsPinnedVersionAndKey(t *testing.T) {
+ t.Parallel()
+ var version, auth string
+ c := fake(t, func(w http.ResponseWriter, r *http.Request) {
+ version, auth = r.Header.Get("Stripe-Version"), r.Header.Get("Authorization")
+ w.Write([]byte(`{}`))
+ })
+ if _, err := c.GetCheckout(context.Background(), "cs_1"); err != nil {
+ t.Fatal(err)
+ }
+ if version != "2025-03-31.basil" || auth != "Bearer k" {
+ t.Errorf("Stripe-Version %q, Authorization %q", version, auth)
+ }
+}
+
+func TestCallSendsIdempotencyKeyOnlyWhenGiven(t *testing.T) {
+ t.Parallel()
+ var got []string
+ var present []bool
+ c := fake(t, func(w http.ResponseWriter, r *http.Request) {
+ _, ok := r.Header["Idempotency-Key"]
+ present = append(present, ok)
+ got = append(got, r.Header.Get("Idempotency-Key"))
+ w.Write([]byte(`{}`))
+ })
+ var out struct{}
+ if err := c.call(context.Background(), http.MethodPost, "/v1/x", url.Values{"a": {"b"}}, "", &out); err != nil {
+ t.Fatal(err)
+ }
+ if err := c.call(context.Background(), http.MethodPost, "/v1/x", url.Values{"a": {"b"}}, "idem-1", &out); err != nil {
+ t.Fatal(err)
+ }
+ if len(got) != 2 || present[0] || !present[1] || got[1] != "idem-1" {
+ t.Errorf("keys %q, present %v", got, present)
+ }
+}
+
+func TestStripeErrorDecodes(t *testing.T) {
+ t.Parallel()
+ c := fake(t, func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusPaymentRequired)
+ w.Write([]byte(`{"error":{"type":"card_error","code":"card_declined","message":"Your card was declined."}}`))
+ })
+ _, err := c.GetCheckout(context.Background(), "cs_1")
+ var e *Error
+ if !errors.As(err, &e) {
+ t.Fatalf("err %T %v, want *Error", err, err)
+ }
+ if *e != (Error{Status: 402, Type: "card_error", Code: "card_declined", Message: "Your card was declined."}) {
+ t.Errorf("%+v", *e)
+ }
+ if NotFound(err) {
+ t.Error("402 reported as not found")
+ }
+}
+
+func TestNotFound(t *testing.T) {
+ t.Parallel()
+ c := fake(t, func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusNotFound)
+ w.Write([]byte(`{"error":{"type":"invalid_request_error","code":"resource_missing","message":"No such subscription"}}`))
+ })
+ _, err := c.GetSubscription(context.Background(), "sub_x")
+ if !NotFound(err) {
+ t.Errorf("not found not recognised: %v", err)
+ }
+}
+
+func TestListSubscriptionsPages(t *testing.T) {
+ t.Parallel()
+ var afters []string
+ c := fake(t, func(w http.ResponseWriter, r *http.Request) {
+ after := r.URL.Query().Get("starting_after")
+ afters = append(afters, after)
+ if after == "" {
+ w.Write([]byte(`{"has_more":true,"data":[{"id":"sub_1","customer":"cus_1"},{"id":"sub_2","customer":"cus_2"}]}`))
+ return
+ }
+ w.Write([]byte(`{"has_more":false,"data":[{"id":"sub_3","customer":{"id":"cus_3","email":"c@example.com"}}]}`))
+ })
+ var ids, custs []string
+ err := c.ListSubscriptions(context.Background(), func(s Subscription) error {
+ ids, custs = append(ids, s.ID), append(custs, s.Customer.ID)
+ return nil
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(ids) != 3 || ids[0] != "sub_1" || ids[2] != "sub_3" || custs[2] != "cus_3" {
+ t.Errorf("ids %v customers %v", ids, custs)
+ }
+ if len(afters) != 2 || afters[0] != "" || afters[1] != "sub_2" {
+ t.Errorf("starting_after %q, want [\"\" \"sub_2\"]", afters)
+ }
+}
+
+func TestGetPrice(t *testing.T) {
+ t.Parallel()
+ var path string
+ c := fake(t, func(w http.ResponseWriter, r *http.Request) {
+ path = r.URL.Path
+ w.Write([]byte(`{"id":"price_A","unit_amount":4900,"currency":"eur","recurring":{"interval":"year"}}`))
+ })
+ p, err := c.GetPrice(context.Background(), "price_A")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if path != "/v1/prices/price_A" || p.ID != "price_A" || p.UnitAmount != 4900 || p.Currency != "eur" || p.Recurring.Interval != "year" {
+ t.Errorf("%s %+v", path, p)
+ }
+}
+
+func TestCreateCheckoutAndPortalAndPromotion(t *testing.T) {
+ t.Parallel()
+ c := fake(t, func(w http.ResponseWriter, r *http.Request) {
+ r.ParseForm()
+ switch r.URL.Path {
+ case "/v1/checkout/sessions":
+ if r.Method != http.MethodPost || r.PostForm.Get("mode") != "subscription" {
+ t.Errorf("checkout: %s %v", r.Method, r.PostForm)
+ }
+ w.Write([]byte(`{"id":"cs_1","url":"https://checkout.example/cs_1"}`))
+ case "/v1/billing_portal/sessions":
+ if r.PostForm.Get("customer") != "cus_1" || r.PostForm.Get("return_url") != "https://x/back" {
+ t.Errorf("portal: %v", r.PostForm)
+ }
+ w.Write([]byte(`{"url":"https://portal.example/p"}`))
+ case "/v1/promotion_codes":
+ if r.URL.Query().Get("code") == "NONE" {
+ w.Write([]byte(`{"data":[]}`))
+ return
+ }
+ w.Write([]byte(`{"data":[{"id":"promo_1"}]}`))
+ default:
+ t.Errorf("unexpected %s", r.URL.Path)
+ }
+ })
+ ctx := context.Background()
+ cs, err := c.CreateCheckout(ctx, url.Values{"mode": {"subscription"}})
+ if err != nil || cs.ID != "cs_1" || cs.URL != "https://checkout.example/cs_1" {
+ t.Errorf("checkout %+v %v", cs, err)
+ }
+ if u, err := c.PortalURL(ctx, "cus_1", "https://x/back"); err != nil || u != "https://portal.example/p" {
+ t.Errorf("portal %q %v", u, err)
+ }
+ if id, err := c.PromotionCode(ctx, "INNOVATE"); err != nil || id != "promo_1" {
+ t.Errorf("promo %q %v", id, err)
+ }
+ if id, err := c.PromotionCode(ctx, "NONE"); err != nil || id != "" {
+ t.Errorf("no promo %q %v", id, err)
+ }
+}
diff --git a/internal/stripeapi/signature.go b/internal/stripeapi/signature.go
new file mode 100644
index 0000000..c7d07bd
--- /dev/null
+++ b/internal/stripeapi/signature.go
@@ -0,0 +1,57 @@
+package stripeapi
+
+import (
+ "crypto/hmac"
+ "crypto/sha256"
+ "encoding/hex"
+ "errors"
+ "strconv"
+ "strings"
+ "time"
+)
+
+// webhookTolerance is how old a signed delivery may be: Stripe's own
+// libraries' five minutes, which is what stops a captured one replaying.
+const webhookTolerance = 5 * time.Minute
+
+// ErrBadSignature is every way a delivery can fail the check: the header
+// is malformed, stale, or signed by none of the secrets.
+var ErrBadSignature = errors.New("stripeapi: webhook signature does not check")
+
+// VerifySignature checks a Stripe-Signature header ("t=…,v1=…[,v1=…]")
+// over body against any of secrets (more than one while rotating).
+func VerifySignature(header string, body []byte, secrets []string, now time.Time) error {
+ var ts string
+ var sigs [][]byte
+ for _, part := range strings.Split(header, ",") {
+ k, v, _ := strings.Cut(strings.TrimSpace(part), "=")
+ switch k {
+ case "t":
+ ts = v
+ case "v1":
+ if b, err := hex.DecodeString(v); err == nil {
+ sigs = append(sigs, b)
+ }
+ }
+ }
+ t, err := strconv.ParseInt(ts, 10, 64)
+ if err != nil || len(sigs) == 0 {
+ return ErrBadSignature
+ }
+ if d := now.Sub(time.Unix(t, 0)); d > webhookTolerance || d < -webhookTolerance {
+ return ErrBadSignature
+ }
+ for _, secret := range secrets {
+ mac := hmac.New(sha256.New, []byte(secret))
+ mac.Write([]byte(ts))
+ mac.Write([]byte("."))
+ mac.Write(body)
+ want := mac.Sum(nil)
+ for _, s := range sigs {
+ if hmac.Equal(s, want) {
+ return nil
+ }
+ }
+ }
+ return ErrBadSignature
+}
diff --git a/internal/stripeapi/signature_test.go b/internal/stripeapi/signature_test.go
new file mode 100644
index 0000000..1bea4c3
--- /dev/null
+++ b/internal/stripeapi/signature_test.go
@@ -0,0 +1,87 @@
+package stripeapi
+
+import (
+ "crypto/hmac"
+ "crypto/sha256"
+ "encoding/hex"
+ "errors"
+ "strconv"
+ "testing"
+ "time"
+)
+
+func signed(body string, at time.Time, secret string) string {
+ ts := strconv.FormatInt(at.Unix(), 10)
+ mac := hmac.New(sha256.New, []byte(secret))
+ mac.Write([]byte(ts + "." + body))
+ return "t=" + ts + ",v1=" + hex.EncodeToString(mac.Sum(nil))
+}
+
+func TestVerifySignature(t *testing.T) {
+ t.Parallel()
+ now := time.Unix(1_800_000_000, 0)
+ body := `{"id":"evt_1"}`
+ ok := signed(body, now, "whsec_new")
+ for name, c := range map[string]struct {
+ header, body string
+ secrets []string
+ want bool
+ }{
+ "good": {ok, body, []string{"whsec_new"}, true},
+ "rotating, either": {ok, body, []string{"whsec_old", "whsec_new"}, true},
+ "two v1s, one good": {ok + ",v1=00ff", body, []string{"whsec_new"}, true},
+ "wrong secret": {ok, body, []string{"whsec_old"}, false},
+ "body changed": {ok, `{"id":"evt_2"}`, []string{"whsec_new"}, false},
+ "too old": {signed(body, now.Add(-6*time.Minute), "whsec_new"), body, []string{"whsec_new"}, false},
+ "from the future": {signed(body, now.Add(6*time.Minute), "whsec_new"), body, []string{"whsec_new"}, false},
+ "no timestamp": {"v1=00", body, []string{"whsec_new"}, false},
+ "empty": {"", body, []string{"whsec_new"}, false},
+ "v0 only (Stripe's test scheme)": {"t=" + strconv.FormatInt(now.Unix(), 10) + ",v0=00", body, []string{"whsec_new"}, false},
+ } {
+ err := VerifySignature(c.header, []byte(c.body), c.secrets, now)
+ if got := err == nil; got != c.want {
+ t.Errorf("%s: verified %v, want %v", name, got, c.want)
+ }
+ if err != nil && !errors.Is(err, ErrBadSignature) {
+ t.Errorf("%s: error %v is not ErrBadSignature", name, err)
+ }
+ }
+}
+
+// The window is five minutes either side of now: just inside passes,
+// just outside fails.
+func TestVerifySignatureTolerance(t *testing.T) {
+ t.Parallel()
+ now := time.Unix(1_800_000_000, 0)
+ body := `{"id":"evt_1"}`
+ secrets := []string{"whsec"}
+ for name, c := range map[string]struct {
+ at time.Time
+ want bool
+ }{
+ "4m59s old": {now.Add(-4*time.Minute - 59*time.Second), true},
+ "4m59s ahead": {now.Add(4*time.Minute + 59*time.Second), true},
+ "5m01s old": {now.Add(-5*time.Minute - time.Second), false},
+ "5m01s ahead": {now.Add(5*time.Minute + time.Second), false},
+ } {
+ err := VerifySignature(signed(body, c.at, "whsec"), []byte(body), secrets, now)
+ if got := err == nil; got != c.want {
+ t.Errorf("%s: verified %v (%v), want %v", name, got, err, c.want)
+ }
+ }
+}
+
+// While a secret is being rotated Stripe signs with both; the second
+// of two configured secrets is enough.
+func TestVerifySignatureRotatingSecrets(t *testing.T) {
+ t.Parallel()
+ now := time.Unix(1_800_000_000, 0)
+ body := `{"id":"evt_1"}`
+ header := signed(body, now, "whsec_second")
+ if err := VerifySignature(header, []byte(body), []string{"whsec_first", "whsec_second"}, now); err != nil {
+ t.Fatalf("second secret did not match: %v", err)
+ }
+ if err := VerifySignature(header, []byte(body), []string{"whsec_first", "whsec_third"}, now); !errors.Is(err, ErrBadSignature) {
+ t.Fatalf("neither secret matches, got %v", err)
+ }
+}