Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 12 additions & 28 deletions internal/x402/forwardauth.go
Original file line number Diff line number Diff line change
Expand Up @@ -99,10 +99,10 @@ var (
facilitatorSettleTimeout = 60 * time.Second
)

// NewForwardAuthMiddleware creates an x402 payment-gating middleware that accepts
// both x402 wire versions. It reads the payment from the X-PAYMENT (v1) or
// PAYMENT-SIGNATURE (v2) header, verifies the payment with the facilitator, and
// optionally settles after a successful downstream response.
// NewForwardAuthMiddleware creates an x402 payment-gating middleware compatible
// with the v1 wire format. It checks the X-PAYMENT header, verifies the payment
// with the facilitator, and optionally settles after a successful downstream
// response.
//
// When VerifyOnly is true (Traefik ForwardAuth path), settlement is skipped.
// When VerifyOnly is false (standalone gateway path), settlement runs only
Expand All @@ -128,15 +128,7 @@ func NewForwardAuthMiddleware(cfg ForwardAuthConfig, requirements []x402types.Pa

return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// x402 v1 clients send the payment under X-PAYMENT; x402 v2 clients
// (agentcash, poncho, coinbase SDK >= v2) send it under PAYMENT-SIGNATURE.
// Our 402 challenge advertises x402Version 2, so spec-compliant v2 buyers
// use PAYMENT-SIGNATURE. Accept both — otherwise a v2 payment is silently
// ignored and the caller is re-challenged with no way to pay.
paymentHeader := r.Header.Get("X-PAYMENT")
if paymentHeader == "" {
paymentHeader = r.Header.Get("PAYMENT-SIGNATURE")
}
if paymentHeader == "" {
send(w, r, requirements, cfg.Extensions)
return
Expand All @@ -145,20 +137,18 @@ func NewForwardAuthMiddleware(cfg ForwardAuthConfig, requirements []x402types.Pa
// Decode the base64-encoded payment payload.
payloadBytes, err := base64.StdEncoding.DecodeString(paymentHeader)
if err != nil {
log.Printf("x402: invalid payment header base64: %v", err)
log.Printf("x402: invalid X-PAYMENT base64: %v", err)
http.Error(w, "Invalid payment header", http.StatusBadRequest)
return
}

// Unmarshal via the canonical x402 types helper rather than a local
// json.Unmarshal, so the payload envelope stays in lockstep with the SDK.
payloadPtr, err := x402types.ToPaymentPayload(payloadBytes)
if err != nil {
log.Printf("x402: invalid payment payload: %v", err)
// Find matching requirement by scheme+network.
var payload x402types.PaymentPayload
if err := json.Unmarshal(payloadBytes, &payload); err != nil {
log.Printf("x402: invalid payment JSON: %v", err)
http.Error(w, "Invalid payment header", http.StatusBadRequest)
return
}
payload := *payloadPtr

matchedReq, found := findMatchingRequirementV1(payload, requirements)
if !found {
Expand Down Expand Up @@ -205,9 +195,7 @@ func NewForwardAuthMiddleware(cfg ForwardAuthConfig, requirements []x402types.Pa
// before http.Error commits the status code.
if settleResp != nil && settleResp.Transaction != "" {
settleJSON, _ := json.Marshal(settleResp)
encodedSettle := base64.StdEncoding.EncodeToString(settleJSON)
w.Header().Set("X-PAYMENT-RESPONSE", encodedSettle)
w.Header().Set("PAYMENT-RESPONSE", encodedSettle)
w.Header().Set("X-PAYMENT-RESPONSE", base64.StdEncoding.EncodeToString(settleJSON))
log.Printf("x402: facilitator returned tx %s with the error — verify on-chain (network=%s payer=%s)",
settleResp.Transaction, settleResp.Network, settleResp.Payer)
}
Expand All @@ -221,13 +209,9 @@ func NewForwardAuthMiddleware(cfg ForwardAuthConfig, requirements []x402types.Pa
return false
}

// Encode the settlement receipt. v1 clients read it from
// X-PAYMENT-RESPONSE; x402 v2 clients read PAYMENT-RESPONSE.
// Emit both so either wire version can confirm the settle.
// Encode settlement response as X-PAYMENT-RESPONSE header.
settleJSON, _ := json.Marshal(settleResp)
encodedSettle := base64.StdEncoding.EncodeToString(settleJSON)
w.Header().Set("X-PAYMENT-RESPONSE", encodedSettle)
w.Header().Set("PAYMENT-RESPONSE", encodedSettle)
w.Header().Set("X-PAYMENT-RESPONSE", base64.StdEncoding.EncodeToString(settleJSON))
return true
},
onFailure: func(statusCode int) {
Expand Down
77 changes: 0 additions & 77 deletions internal/x402/forwardauth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -159,83 +159,6 @@ func TestForwardAuth_ValidPayment_VerifyOnly(t *testing.T) {
}
}

// TestForwardAuth_ValidPayment_PaymentSignatureHeader_V2 pins the x402 v2 wire
// fix: our 402 challenge advertises x402Version 2, so spec-compliant v2 buyers
// (agentcash, poncho, coinbase SDK >= v2) attach the payment under the
// PAYMENT-SIGNATURE header, not the legacy X-PAYMENT. Before the fix the verifier
// only read X-PAYMENT, so a v2 payment was silently ignored and re-challenged —
// no verify, no settle, no log. This asserts the v2 header is now honored.
func TestForwardAuth_ValidPayment_PaymentSignatureHeader_V2(t *testing.T) {
var verifyCalled, settleCalled atomic.Int32
fac := mockFacilitatorV1(true, true, &verifyCalled, &settleCalled)
defer fac.Close()

mw := NewForwardAuthMiddleware(ForwardAuthConfig{
FacilitatorURL: fac.URL,
VerifyOnly: true,
}, testRequirements())

var innerCalled bool
inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
innerCalled = true
w.WriteHeader(http.StatusOK)
})

req := httptest.NewRequest("POST", "/v1/chat/completions", nil)
req.Header.Set("PAYMENT-SIGNATURE", validPaymentHeader()) // v2 header, no X-PAYMENT
rec := httptest.NewRecorder()
mw(inner).ServeHTTP(rec, req)

if rec.Code != http.StatusOK {
t.Errorf("status = %d, want %d (v2 PAYMENT-SIGNATURE must be accepted)", rec.Code, http.StatusOK)
}
if !innerCalled {
t.Error("inner handler was not called for a valid v2 PAYMENT-SIGNATURE payment")
}
if verifyCalled.Load() != 1 {
t.Errorf("verify called %d times, want 1 (v2 header should reach the facilitator)", verifyCalled.Load())
}
}

// TestForwardAuth_SettleOnSuccess_PaymentSignatureHeader_V2 asserts a v2 payment
// settles end-to-end and that the settlement receipt is mirrored onto BOTH the
// legacy X-PAYMENT-RESPONSE and the v2 PAYMENT-RESPONSE header so either wire
// version can read it.
func TestForwardAuth_SettleOnSuccess_PaymentSignatureHeader_V2(t *testing.T) {
var verifyCalled, settleCalled atomic.Int32
fac := mockFacilitatorV1(true, true, &verifyCalled, &settleCalled)
defer fac.Close()

mw := NewForwardAuthMiddleware(ForwardAuthConfig{
FacilitatorURL: fac.URL,
VerifyOnly: false,
}, testRequirements())

inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"result":"ok"}`))
})

req := httptest.NewRequest("POST", "/v1/chat/completions", nil)
req.Header.Set("PAYMENT-SIGNATURE", validPaymentHeader())
rec := httptest.NewRecorder()
mw(inner).ServeHTTP(rec, req)

if rec.Code != http.StatusOK {
t.Errorf("status = %d, want %d", rec.Code, http.StatusOK)
}
if settleCalled.Load() != 1 {
t.Errorf("settle called %d times, want 1", settleCalled.Load())
}
// Both receipt headers must be present for cross-version clients.
if rec.Header().Get("X-PAYMENT-RESPONSE") == "" {
t.Error("X-PAYMENT-RESPONSE header not set after settlement")
}
if rec.Header().Get("PAYMENT-RESPONSE") == "" {
t.Error("PAYMENT-RESPONSE (v2) header not set after settlement")
}
}

func TestForwardAuth_InvalidPayment_Returns402(t *testing.T) {
var verifyCalled, settleCalled atomic.Int32
fac := mockFacilitatorV1(false, true, &verifyCalled, &settleCalled)
Expand Down
Loading