Skip to content

Commit e342018

Browse files
authored
Merge pull request #994 from meridianhub/stripe-identity-kyc--5--webhook-adapter
feat: implement StripeWebhookAdapter for Stripe Identity webhooks
2 parents 1d67199 + a212598 commit e342018

2 files changed

Lines changed: 834 additions & 0 deletions

File tree

Lines changed: 315 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,315 @@
1+
// Package http provides the HTTP adapter for receiving KYC/AML verification webhooks.
2+
package http
3+
4+
import (
5+
"bytes"
6+
"crypto/hmac"
7+
"crypto/sha256"
8+
"encoding/hex"
9+
"encoding/json"
10+
"errors"
11+
"io"
12+
"log/slog"
13+
"net/http"
14+
"net/http/httptest"
15+
"strconv"
16+
"strings"
17+
"time"
18+
19+
"github.com/meridianhub/meridian/services/party/verification"
20+
)
21+
22+
// Stripe webhook adapter errors.
23+
var (
24+
ErrStripeSignatureMissing = errors.New("missing Stripe-Signature header")
25+
ErrStripeSignatureInvalid = errors.New("invalid Stripe webhook signature")
26+
ErrStripeTimestampExpired = errors.New("stripe webhook timestamp expired")
27+
ErrStripeEventParseFailed = errors.New("failed to parse Stripe event")
28+
ErrStripeUnknownEventType = errors.New("unknown or irrelevant Stripe event type")
29+
ErrNilInnerHandler = errors.New("inner webhook handler cannot be nil")
30+
ErrEmptyStripeSecret = errors.New("stripe webhook secret cannot be empty")
31+
ErrEmptyInnerSecret = errors.New("inner HMAC secret cannot be empty")
32+
)
33+
34+
// stripeSignatureHeader is the HTTP header Stripe uses for webhook signatures.
35+
const stripeSignatureHeader = "Stripe-Signature"
36+
37+
// stripeTimestampTolerance is the maximum age of a Stripe webhook timestamp.
38+
const stripeTimestampTolerance = 5 * time.Minute
39+
40+
// stripeEvent represents the top-level Stripe webhook event payload.
41+
type stripeEvent struct {
42+
ID string `json:"id"`
43+
Type string `json:"type"`
44+
Data stripeEventData `json:"data"`
45+
}
46+
47+
type stripeEventData struct {
48+
Object stripeVerificationSession `json:"object"`
49+
}
50+
51+
type stripeVerificationSession struct {
52+
ID string `json:"id"`
53+
Status string `json:"status"`
54+
LastVerificationReport string `json:"last_verification_report"`
55+
Metadata map[string]string `json:"metadata"`
56+
}
57+
58+
// StripeWebhookAdapter translates Stripe Identity webhooks into the generic
59+
// VerificationWebhookHandler format.
60+
type StripeWebhookAdapter struct {
61+
inner *VerificationWebhookHandler
62+
stripeSecret []byte
63+
innerHMACSecret []byte
64+
logger *slog.Logger
65+
}
66+
67+
// StripeWebhookAdapterConfig contains configuration for creating a StripeWebhookAdapter.
68+
type StripeWebhookAdapterConfig struct {
69+
// InnerHandler is the generic webhook handler to delegate to after translation.
70+
InnerHandler *VerificationWebhookHandler
71+
// WebhookSecret is the Stripe webhook endpoint secret used to validate inbound signatures.
72+
WebhookSecret []byte
73+
// InnerHMACSecret is the HMAC secret used to sign the synthetic request sent to InnerHandler.
74+
// This must match the secret configured in InnerHandler for the "stripe" or "default" provider.
75+
InnerHMACSecret []byte
76+
Logger *slog.Logger
77+
}
78+
79+
// NewStripeWebhookAdapter creates a new StripeWebhookAdapter.
80+
func NewStripeWebhookAdapter(cfg StripeWebhookAdapterConfig) (*StripeWebhookAdapter, error) {
81+
if cfg.InnerHandler == nil {
82+
return nil, ErrNilInnerHandler
83+
}
84+
if len(cfg.WebhookSecret) == 0 {
85+
return nil, ErrEmptyStripeSecret
86+
}
87+
if len(cfg.InnerHMACSecret) == 0 {
88+
return nil, ErrEmptyInnerSecret
89+
}
90+
91+
logger := cfg.Logger
92+
if logger == nil {
93+
logger = slog.Default()
94+
}
95+
96+
return &StripeWebhookAdapter{
97+
inner: cfg.InnerHandler,
98+
stripeSecret: cfg.WebhookSecret,
99+
innerHMACSecret: cfg.InnerHMACSecret,
100+
logger: logger,
101+
}, nil
102+
}
103+
104+
// ServeHTTP implements http.Handler. It validates the Stripe signature, translates
105+
// the event to the generic VerificationWebhookRequest, and delegates to the inner handler.
106+
func (a *StripeWebhookAdapter) ServeHTTP(w http.ResponseWriter, r *http.Request) {
107+
// Only accept POST requests
108+
if r.Method != http.MethodPost {
109+
writeStripeErrorResponse(w, http.StatusMethodNotAllowed, "method not allowed")
110+
return
111+
}
112+
113+
// Read raw body — needed for signature validation
114+
defer func() { _ = r.Body.Close() }()
115+
body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) // 1 MB limit
116+
if err != nil {
117+
a.logger.Error("failed to read Stripe webhook body", "error", err)
118+
writeStripeErrorResponse(w, http.StatusBadRequest, ErrInvalidRequestBody.Error())
119+
return
120+
}
121+
122+
// Validate Stripe signature
123+
sigHeader := r.Header.Get(stripeSignatureHeader)
124+
if sigHeader == "" {
125+
a.logger.Warn("missing Stripe-Signature header")
126+
writeStripeErrorResponse(w, http.StatusUnauthorized, ErrStripeSignatureMissing.Error())
127+
return
128+
}
129+
130+
if err := validateStripeSignature(body, sigHeader, a.stripeSecret, stripeTimestampTolerance); err != nil {
131+
if errors.Is(err, ErrStripeTimestampExpired) {
132+
a.logger.Warn("Stripe webhook timestamp expired")
133+
writeStripeErrorResponse(w, http.StatusBadRequest, err.Error())
134+
return
135+
}
136+
a.logger.Warn("invalid Stripe webhook signature", "error", err)
137+
writeStripeErrorResponse(w, http.StatusUnauthorized, ErrStripeSignatureInvalid.Error())
138+
return
139+
}
140+
141+
// Parse Stripe event
142+
var event stripeEvent
143+
if err := json.Unmarshal(body, &event); err != nil {
144+
a.logger.Error("failed to parse Stripe event", "error", err)
145+
writeStripeErrorResponse(w, http.StatusBadRequest, ErrStripeEventParseFailed.Error())
146+
return
147+
}
148+
149+
// Map Stripe event type to verification status
150+
status, ok := mapStripeEventToStatus(event.Type)
151+
if !ok {
152+
a.logger.Info("ignoring irrelevant Stripe event type", "event_type", event.Type)
153+
// Acknowledge gracefully — Stripe expects 2xx for events we don't care about
154+
writeStripeSuccessResponse(w, "event type not relevant")
155+
return
156+
}
157+
158+
// Build the generic VerificationWebhookRequest
159+
now := time.Now().UTC()
160+
webhookReq := VerificationWebhookRequest{
161+
VerificationID: event.Data.Object.ID,
162+
Status: string(status),
163+
Timestamp: now,
164+
Metadata: event.Data.Object.Metadata,
165+
}
166+
167+
// Marshal to JSON for the synthetic request
168+
syntheticBody, err := json.Marshal(webhookReq)
169+
if err != nil {
170+
a.logger.Error("failed to marshal synthetic webhook request", "error", err)
171+
writeStripeErrorResponse(w, http.StatusInternalServerError, "internal error")
172+
return
173+
}
174+
175+
// Sign the synthetic body with the inner handler's HMAC secret
176+
sig := GenerateWebhookSignature(syntheticBody, a.innerHMACSecret)
177+
178+
// Build a synthetic http.Request targeting the inner handler
179+
syntheticReq, err := http.NewRequestWithContext(
180+
r.Context(),
181+
http.MethodPost,
182+
"/webhooks/verification/stripe",
183+
bytes.NewReader(syntheticBody),
184+
)
185+
if err != nil {
186+
a.logger.Error("failed to create synthetic request", "error", err)
187+
writeStripeErrorResponse(w, http.StatusInternalServerError, "internal error")
188+
return
189+
}
190+
syntheticReq.Header.Set("Content-Type", "application/json")
191+
syntheticReq.Header.Set(WebhookSignatureHeader, sig)
192+
193+
// Delegate to the inner handler, capturing its response
194+
rr := httptest.NewRecorder()
195+
a.inner.HandleWebhook(rr, syntheticReq)
196+
197+
// Relay the inner handler's response back to the Stripe caller
198+
for k, vs := range rr.Header() {
199+
for _, v := range vs {
200+
w.Header().Add(k, v)
201+
}
202+
}
203+
w.WriteHeader(rr.Code)
204+
_, _ = w.Write(rr.Body.Bytes())
205+
}
206+
207+
// parseStripeSignature parses the Stripe-Signature header.
208+
// Format: "t=<timestamp>,v1=<sig1>,v1=<sig2>,..."
209+
func parseStripeSignature(header string) (timestamp string, signatures []string, err error) {
210+
parts := strings.Split(header, ",")
211+
for _, part := range parts {
212+
part = strings.TrimSpace(part)
213+
idx := strings.IndexByte(part, '=')
214+
if idx < 0 {
215+
continue
216+
}
217+
key := part[:idx]
218+
val := part[idx+1:]
219+
switch key {
220+
case "t":
221+
timestamp = val
222+
case "v1":
223+
signatures = append(signatures, val)
224+
}
225+
}
226+
if timestamp == "" {
227+
return "", nil, ErrStripeSignatureInvalid
228+
}
229+
if len(signatures) == 0 {
230+
return "", nil, ErrStripeSignatureInvalid
231+
}
232+
return timestamp, signatures, nil
233+
}
234+
235+
// validateStripeSignature validates the Stripe webhook signature.
236+
func validateStripeSignature(payload []byte, sigHeader string, secret []byte, tolerance time.Duration) error {
237+
timestamp, signatures, err := parseStripeSignature(sigHeader)
238+
if err != nil {
239+
return ErrStripeSignatureInvalid
240+
}
241+
242+
// Validate timestamp
243+
ts, err := strconv.ParseInt(timestamp, 10, 64)
244+
if err != nil {
245+
return ErrStripeSignatureInvalid
246+
}
247+
webhookTime := time.Unix(ts, 0)
248+
age := time.Since(webhookTime)
249+
if age > tolerance {
250+
return ErrStripeTimestampExpired
251+
}
252+
253+
// Compute expected signature: HMAC-SHA256(secret, timestamp + "." + payload)
254+
signedPayload := timestamp + "." + string(payload)
255+
mac := hmac.New(sha256.New, secret)
256+
mac.Write([]byte(signedPayload))
257+
expected := hex.EncodeToString(mac.Sum(nil))
258+
259+
// Accept if any v1 signature matches (constant-time comparison)
260+
for _, sig := range signatures {
261+
expectedBytes, err := hex.DecodeString(expected)
262+
if err != nil {
263+
continue
264+
}
265+
sigBytes, err := hex.DecodeString(sig)
266+
if err != nil {
267+
continue
268+
}
269+
if hmac.Equal(expectedBytes, sigBytes) {
270+
return nil
271+
}
272+
}
273+
return ErrStripeSignatureInvalid
274+
}
275+
276+
// mapStripeEventToStatus maps a Stripe Identity event type to a verification.Status.
277+
// Returns (status, true) for known event types, ("", false) for unknown/irrelevant ones.
278+
func mapStripeEventToStatus(eventType string) (verification.Status, bool) {
279+
switch eventType {
280+
case "identity.verification_session.verified":
281+
return verification.StatusApproved, true
282+
case "identity.verification_session.canceled":
283+
return verification.StatusRejected, true
284+
case "identity.verification_session.requires_input":
285+
return verification.StatusPending, true
286+
case "identity.verification_session.processing":
287+
return verification.StatusPending, true
288+
case "identity.verification_session.redacted":
289+
return verification.StatusRejected, true
290+
default:
291+
return "", false
292+
}
293+
}
294+
295+
// writeStripeErrorResponse writes an error response in the standard webhook format.
296+
func writeStripeErrorResponse(w http.ResponseWriter, statusCode int, message string) {
297+
w.Header().Set("Content-Type", "application/json")
298+
w.WriteHeader(statusCode)
299+
resp := VerificationWebhookResponse{
300+
Acknowledged: false,
301+
Error: message,
302+
}
303+
_ = json.NewEncoder(w).Encode(resp)
304+
}
305+
306+
// writeStripeSuccessResponse writes a success response in the standard webhook format.
307+
func writeStripeSuccessResponse(w http.ResponseWriter, message string) {
308+
w.Header().Set("Content-Type", "application/json")
309+
w.WriteHeader(http.StatusOK)
310+
resp := VerificationWebhookResponse{
311+
Acknowledged: true,
312+
Message: message,
313+
}
314+
_ = json.NewEncoder(w).Encode(resp)
315+
}

0 commit comments

Comments
 (0)