Skip to content

Commit 242d296

Browse files
authored
Merge pull request #18 from Z-M-Huang/feat/jwtHeader
feat: Implement static and dynamic authorization for FacilitatorClient.
2 parents 5d4c0e4 + c8b0ade commit 242d296

File tree

6 files changed

+397
-22
lines changed

6 files changed

+397
-22
lines changed

http/facilitator.go

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,46 @@ import (
1616
"github.com/mark3labs/x402-go/retry"
1717
)
1818

19+
// AuthorizationProvider is a function that returns an Authorization header value.
20+
// This is useful for dynamic tokens (e.g., JWT refresh) where the value may change.
21+
//
22+
// Thread-safety: The provider function is called on each HTTP request, including
23+
// during retry attempts. If your provider accesses shared state or performs I/O
24+
// (e.g., token refresh), ensure it is safe for concurrent use. The FacilitatorClient
25+
// does not serialize calls to the provider.
26+
type AuthorizationProvider func() string
27+
1928
// FacilitatorClient is a client for communicating with x402 facilitator services.
2029
type FacilitatorClient struct {
2130
BaseURL string
2231
Client *http.Client
2332
Timeouts x402.TimeoutConfig // Timeout configuration for payment operations
2433
MaxRetries int // Maximum number of retry attempts for failed requests (default: 0)
2534
RetryDelay time.Duration // Delay between retry attempts (default: 100ms)
35+
36+
// Authorization is a static Authorization header value (e.g., "Bearer token" or "Basic base64").
37+
// If AuthorizationProvider is also set, the provider takes precedence.
38+
Authorization string
39+
40+
// AuthorizationProvider is a function that returns an Authorization header value.
41+
// This is useful for dynamic tokens that may need to be refreshed.
42+
// If set, this takes precedence over the static Authorization field.
43+
AuthorizationProvider AuthorizationProvider
44+
}
45+
46+
// setAuthorizationHeader sets the Authorization header on the request if configured.
47+
// If AuthorizationProvider is set, it is called to get the current token value;
48+
// otherwise, the static Authorization string is used. This is called per-request.
49+
func (c *FacilitatorClient) setAuthorizationHeader(req *http.Request) {
50+
var authValue string
51+
if c.AuthorizationProvider != nil {
52+
authValue = c.AuthorizationProvider()
53+
} else if c.Authorization != "" {
54+
authValue = c.Authorization
55+
}
56+
if authValue != "" {
57+
req.Header.Set("Authorization", authValue)
58+
}
2659
}
2760

2861
// FacilitatorRequest is the request payload sent to the facilitator.
@@ -79,6 +112,7 @@ func (c *FacilitatorClient) Verify(ctx context.Context, payment x402.PaymentPayl
79112
return nil, fmt.Errorf("failed to create request: %w", err)
80113
}
81114
httpReq.Header.Set("Content-Type", "application/json")
115+
c.setAuthorizationHeader(httpReq)
82116

83117
// Send request
84118
resp, err := c.Client.Do(httpReq)
@@ -135,6 +169,7 @@ func (c *FacilitatorClient) Supported(ctx context.Context) (*facilitator.Support
135169
if err != nil {
136170
return nil, fmt.Errorf("failed to create request: %w", err)
137171
}
172+
c.setAuthorizationHeader(httpReq)
138173

139174
// Send request
140175
resp, err := c.Client.Do(httpReq)
@@ -203,6 +238,7 @@ func (c *FacilitatorClient) Settle(ctx context.Context, payment x402.PaymentPayl
203238
return nil, fmt.Errorf("failed to create request: %w", err)
204239
}
205240
httpReq.Header.Set("Content-Type", "application/json")
241+
c.setAuthorizationHeader(httpReq)
206242

207243
// Send request
208244
resp, err := c.Client.Do(httpReq)

http/facilitator_test.go

Lines changed: 272 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,278 @@ func TestFacilitatorClient_Verify(t *testing.T) {
6767
}
6868
}
6969

70+
func TestFacilitatorClient_Verify_WithStaticAuthorization(t *testing.T) {
71+
expectedAuth := "Bearer test-api-key"
72+
73+
// Create a mock facilitator server that validates the Authorization header
74+
mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
75+
// Check that Authorization header is present
76+
authHeader := r.Header.Get("Authorization")
77+
if authHeader != expectedAuth {
78+
t.Errorf("Expected Authorization header %q, got %q", expectedAuth, authHeader)
79+
w.WriteHeader(http.StatusUnauthorized)
80+
return
81+
}
82+
83+
response := facilitator.VerifyResponse{
84+
IsValid: true,
85+
Payer: "0x857b06519E91e3A54538791bDbb0E22373e36b66",
86+
}
87+
88+
w.Header().Set("Content-Type", "application/json")
89+
if err := json.NewEncoder(w).Encode(response); err != nil {
90+
t.Errorf("Failed to encode response: %v", err)
91+
}
92+
}))
93+
defer mockServer.Close()
94+
95+
client := &FacilitatorClient{
96+
BaseURL: mockServer.URL,
97+
Client: &http.Client{},
98+
Timeouts: x402.DefaultTimeouts,
99+
Authorization: expectedAuth,
100+
}
101+
102+
payload := x402.PaymentPayload{
103+
X402Version: 1,
104+
Scheme: "exact",
105+
Network: "base-sepolia",
106+
}
107+
108+
requirement := x402.PaymentRequirement{
109+
Scheme: "exact",
110+
Network: "base-sepolia",
111+
MaxAmountRequired: "10000",
112+
}
113+
114+
resp, err := client.Verify(context.Background(), payload, requirement)
115+
if err != nil {
116+
t.Fatalf("Verify failed: %v", err)
117+
}
118+
119+
if !resp.IsValid {
120+
t.Error("Expected IsValid to be true")
121+
}
122+
}
123+
124+
func TestFacilitatorClient_Verify_WithAuthorizationProvider(t *testing.T) {
125+
callCount := 0
126+
provider := func() string {
127+
callCount++
128+
return "Bearer dynamic-token-" + string(rune('0'+callCount))
129+
}
130+
131+
// Expected token for the first call
132+
expectedAuth := "Bearer dynamic-token-1"
133+
134+
// Create a mock facilitator server that validates the Authorization header
135+
mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
136+
authHeader := r.Header.Get("Authorization")
137+
if authHeader == "" {
138+
t.Error("Expected Authorization header to be present")
139+
w.WriteHeader(http.StatusUnauthorized)
140+
return
141+
}
142+
143+
// Verify the dynamic token value is used and static is ignored
144+
if authHeader != expectedAuth {
145+
t.Errorf("Expected Authorization header %q, got %q", expectedAuth, authHeader)
146+
w.WriteHeader(http.StatusUnauthorized)
147+
return
148+
}
149+
150+
response := facilitator.VerifyResponse{
151+
IsValid: true,
152+
Payer: "0x857b06519E91e3A54538791bDbb0E22373e36b66",
153+
}
154+
155+
w.Header().Set("Content-Type", "application/json")
156+
if err := json.NewEncoder(w).Encode(response); err != nil {
157+
t.Errorf("Failed to encode response: %v", err)
158+
}
159+
}))
160+
defer mockServer.Close()
161+
162+
client := &FacilitatorClient{
163+
BaseURL: mockServer.URL,
164+
Client: &http.Client{},
165+
Timeouts: x402.DefaultTimeouts,
166+
Authorization: "Bearer static-should-be-ignored",
167+
AuthorizationProvider: provider,
168+
}
169+
170+
payload := x402.PaymentPayload{
171+
X402Version: 1,
172+
Scheme: "exact",
173+
Network: "base-sepolia",
174+
}
175+
176+
requirement := x402.PaymentRequirement{
177+
Scheme: "exact",
178+
Network: "base-sepolia",
179+
MaxAmountRequired: "10000",
180+
}
181+
182+
_, err := client.Verify(context.Background(), payload, requirement)
183+
if err != nil {
184+
t.Fatalf("Verify failed: %v", err)
185+
}
186+
187+
if callCount != 1 {
188+
t.Errorf("Expected AuthorizationProvider to be called exactly once, got %d calls", callCount)
189+
}
190+
}
191+
192+
func TestFacilitatorClient_Verify_WithoutAuthorization(t *testing.T) {
193+
// Create a mock facilitator server that checks no Authorization header is sent
194+
mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
195+
authHeader := r.Header.Get("Authorization")
196+
if authHeader != "" {
197+
t.Errorf("Expected no Authorization header, got %q", authHeader)
198+
}
199+
200+
response := facilitator.VerifyResponse{
201+
IsValid: true,
202+
Payer: "0x857b06519E91e3A54538791bDbb0E22373e36b66",
203+
}
204+
205+
w.Header().Set("Content-Type", "application/json")
206+
if err := json.NewEncoder(w).Encode(response); err != nil {
207+
t.Errorf("Failed to encode response: %v", err)
208+
}
209+
}))
210+
defer mockServer.Close()
211+
212+
client := &FacilitatorClient{
213+
BaseURL: mockServer.URL,
214+
Client: &http.Client{},
215+
Timeouts: x402.DefaultTimeouts,
216+
// No Authorization or AuthorizationProvider set
217+
}
218+
219+
payload := x402.PaymentPayload{
220+
X402Version: 1,
221+
Scheme: "exact",
222+
Network: "base-sepolia",
223+
}
224+
225+
requirement := x402.PaymentRequirement{
226+
Scheme: "exact",
227+
Network: "base-sepolia",
228+
MaxAmountRequired: "10000",
229+
}
230+
231+
_, err := client.Verify(context.Background(), payload, requirement)
232+
if err != nil {
233+
t.Fatalf("Verify failed: %v", err)
234+
}
235+
}
236+
237+
func TestFacilitatorClient_Settle_WithStaticAuthorization(t *testing.T) {
238+
expectedAuth := "Bearer settle-api-key"
239+
240+
// Create a mock facilitator server that validates the Authorization header
241+
mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
242+
authHeader := r.Header.Get("Authorization")
243+
if authHeader != expectedAuth {
244+
t.Errorf("Expected Authorization header %q, got %q", expectedAuth, authHeader)
245+
w.WriteHeader(http.StatusUnauthorized)
246+
return
247+
}
248+
249+
response := x402.SettlementResponse{
250+
Success: true,
251+
Transaction: "0x1234567890abcdef",
252+
Network: "base-sepolia",
253+
Payer: "0x857b06519E91e3A54538791bDbb0E22373e36b66",
254+
}
255+
256+
w.Header().Set("Content-Type", "application/json")
257+
if err := json.NewEncoder(w).Encode(response); err != nil {
258+
t.Errorf("Failed to encode response: %v", err)
259+
}
260+
}))
261+
defer mockServer.Close()
262+
263+
client := &FacilitatorClient{
264+
BaseURL: mockServer.URL,
265+
Client: &http.Client{},
266+
Timeouts: x402.DefaultTimeouts,
267+
Authorization: expectedAuth,
268+
}
269+
270+
payload := x402.PaymentPayload{
271+
X402Version: 1,
272+
Scheme: "exact",
273+
Network: "base-sepolia",
274+
}
275+
276+
requirement := x402.PaymentRequirement{
277+
Scheme: "exact",
278+
Network: "base-sepolia",
279+
MaxAmountRequired: "10000",
280+
}
281+
282+
resp, err := client.Settle(context.Background(), payload, requirement)
283+
if err != nil {
284+
t.Fatalf("Settle failed: %v", err)
285+
}
286+
287+
if !resp.Success {
288+
t.Error("Expected Success to be true")
289+
}
290+
}
291+
292+
func TestFacilitatorClient_Supported_WithStaticAuthorization(t *testing.T) {
293+
expectedAuth := "Bearer supported-api-key"
294+
295+
// Create a mock facilitator server that validates the Authorization header
296+
mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
297+
if r.URL.Path != "/supported" {
298+
t.Errorf("Expected path /supported, got %s", r.URL.Path)
299+
}
300+
301+
authHeader := r.Header.Get("Authorization")
302+
if authHeader != expectedAuth {
303+
t.Errorf("Expected Authorization header %q, got %q", expectedAuth, authHeader)
304+
w.WriteHeader(http.StatusUnauthorized)
305+
return
306+
}
307+
308+
response := facilitator.SupportedResponse{
309+
Kinds: []facilitator.SupportedKind{
310+
{
311+
X402Version: 1,
312+
Scheme: "exact",
313+
Network: "base-sepolia",
314+
},
315+
},
316+
}
317+
318+
w.Header().Set("Content-Type", "application/json")
319+
if err := json.NewEncoder(w).Encode(response); err != nil {
320+
t.Errorf("Failed to encode response: %v", err)
321+
}
322+
}))
323+
defer mockServer.Close()
324+
325+
client := &FacilitatorClient{
326+
BaseURL: mockServer.URL,
327+
Client: &http.Client{},
328+
Timeouts: x402.DefaultTimeouts,
329+
Authorization: expectedAuth,
330+
}
331+
332+
resp, err := client.Supported(context.Background())
333+
if err != nil {
334+
t.Fatalf("Supported failed: %v", err)
335+
}
336+
337+
if len(resp.Kinds) != 1 {
338+
t.Errorf("Expected 1 kind, got %d", len(resp.Kinds))
339+
}
340+
}
341+
70342
func TestFacilitatorClient_Settle(t *testing.T) {
71343
// Create a mock facilitator server
72344
mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {

http/gin/middleware.go

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -50,18 +50,22 @@ import (
5050
func NewGinX402Middleware(config *httpx402.Config) gin.HandlerFunc {
5151
// Create facilitator client
5252
facilitator := &httpx402.FacilitatorClient{
53-
BaseURL: config.FacilitatorURL,
54-
Client: &http.Client{},
55-
Timeouts: x402.DefaultTimeouts,
53+
BaseURL: config.FacilitatorURL,
54+
Client: &http.Client{},
55+
Timeouts: x402.DefaultTimeouts,
56+
Authorization: config.FacilitatorAuthorization,
57+
AuthorizationProvider: config.FacilitatorAuthorizationProvider,
5658
}
5759

5860
// Create fallback facilitator client if configured
5961
var fallbackFacilitator *httpx402.FacilitatorClient
6062
if config.FallbackFacilitatorURL != "" {
6163
fallbackFacilitator = &httpx402.FacilitatorClient{
62-
BaseURL: config.FallbackFacilitatorURL,
63-
Client: &http.Client{},
64-
Timeouts: x402.DefaultTimeouts,
64+
BaseURL: config.FallbackFacilitatorURL,
65+
Client: &http.Client{},
66+
Timeouts: x402.DefaultTimeouts,
67+
Authorization: config.FallbackFacilitatorAuthorization,
68+
AuthorizationProvider: config.FallbackFacilitatorAuthorizationProvider,
6569
}
6670
}
6771

0 commit comments

Comments
 (0)