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.
4 files changed,
+621
−0
internal/stripeapi/client.go+280 −0internal/stripeapi/client_test.go+197 −0internal/stripeapi/signature.go+57 −0internal/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
Intent
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) |
| + } |
| +} |