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
100 changes: 99 additions & 1 deletion packages/atxp-client/src/atxpFetcher.payment.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import fetchMock from 'fetch-mock';
import { mockResourceServer, mockAuthorizationServer } from './clientTestHelpers.js';
import * as CTH from '@atxp/common/src/commonTestHelpers.js';
import { ATXPFetcher } from './atxpFetcher.js';
import { OAuthDb, FetchLike, AuthorizationServerUrl, DEFAULT_AUTHORIZATION_SERVER } from '@atxp/common';
import { OAuthDb, FetchLike, AuthorizationServerUrl, DEFAULT_AUTHORIZATION_SERVER, AccessToken } from '@atxp/common';
import { PaymentMaker, ProspectivePayment } from './types.js';
import { McpError } from '@modelcontextprotocol/sdk/types.js';
import BigNumber from 'bignumber.js';
Expand Down Expand Up @@ -401,3 +401,101 @@ describe('atxpFetcher.fetch payment', () => {
);
});
});

describe('atxpFetcher protocol handler retry uses OAuth-authenticated fetch', () => {
it('should include OAuth Bearer token on ATXPAccountHandler retry requests', async () => {
// This test verifies the fix: getProtocolConfig().fetchFn wraps oauthClient.fetch
// (not raw fetch), so retries from protocol handlers include the Authorization: Bearer header.
//
// Flow:
// 1. ATXPFetcher.fetch() → oauthClient.fetch() → resource server returns 402
// 2. tryProtocolHandlers() → ATXPAccountHandler.handlePaymentChallenge()
// 3. account.authorize() returns credential
// 4. ATXPAccountHandler retries via config.fetchFn(url, retryInit) → oauthClient.fetch()
// 5. oauthClient._doFetch adds Authorization: Bearer from stored token
// 6. Retry request has BOTH X-ATXP-PAYMENT and Authorization: Bearer headers

const f = fetchMock.createInstance();
const resourceUrl = 'https://example.com/mcp';

// Mock the resource server PRM and auth server (needed for OAuthClient initialization)
mockResourceServer(f, 'https://example.com', '/mcp', DEFAULT_AUTHORIZATION_SERVER);
mockAuthorizationServer(f, DEFAULT_AUTHORIZATION_SERVER);

// First POST: resource server returns 402 with challenge data
f.postOnce(resourceUrl, {
status: 402,
body: {
chargeAmount: '0.01',
paymentRequestUrl: `${DEFAULT_AUTHORIZATION_SERVER}/payment-request/pr_test`,
paymentRequestId: 'pr_test',
},
});
// Second POST: retry after payment authorization succeeds
f.postOnce(resourceUrl, {
status: 200,
body: { content: [{ type: 'text', text: 'paid content' }] },
});

// Create account with usesAccountsAuthorize: true → uses ATXPAccountHandler
const authorize = vi.fn().mockResolvedValue({
protocol: 'atxp',
credential: 'test-payment-credential',
});
const account: Account = {
getAccountId: async () => 'test-user' as any,
paymentMakers: [],
usesAccountsAuthorize: true,
getSources: async () => [],
createSpendPermission: async () => null,
authorize,
};

// Pre-seed the OAuth DB with an access token so oauthClient._doFetch adds
// the Authorization: Bearer header on requests to the resource URL.
const db = new MemoryOAuthDb();
const storedToken: AccessToken = {
accessToken: 'oauth-bearer-token-123',
resourceUrl: 'https://example.com/mcp',
expiresAt: Date.now() + 60_000,
};
await db.saveAccessToken('test-user', 'https://example.com/mcp', storedToken);

const fetcher = new ATXPFetcher({
account,
db,
destinationMakers: new Map(),
fetchFn: f.fetchHandler,
});

const res = await fetcher.fetch(resourceUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
});

expect(res.status).toBe(200);

// Verify authorize was called
expect(authorize).toHaveBeenCalledTimes(1);

// Get all calls to the resource URL
const mcpCalls = f.callHistory.callLogs.filter(
call => call.url === resourceUrl,
);
expect(mcpCalls.length).toBe(2);

const retryCall = mcpCalls[1];
// fetch-mock stores headers in args[1].headers (the init object passed to fetch)
const retryInit = retryCall.args[1] as RequestInit | undefined;
const retryHeaders = new Headers(retryInit?.headers);

// The retry must include the payment credential header
expect(retryHeaders.get('X-ATXP-PAYMENT')).toBe('test-payment-credential');

// CRITICAL: The retry must ALSO include the OAuth Bearer token.
// This is the bug that was fixed — previously getProtocolConfig().fetchFn used raw fetch
// instead of oauthClient.fetch, so the Bearer token was missing on retries.
expect(retryHeaders.get('Authorization')).toBe('Bearer oauth-bearer-token-123');
});
});
8 changes: 7 additions & 1 deletion packages/atxp-client/src/atxpFetcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -638,12 +638,18 @@ export class ATXPFetcher {

/**
* Build protocol config for passing to protocol handlers.
* Uses the OAuth-authenticated fetch so retries include the Bearer token.
* This is essential for MCP where the server needs both the payment credential
* header (X-PAYMENT, X-ATXP-PAYMENT, etc.) AND the OAuth Bearer token.
*/
protected getProtocolConfig(): ProtocolConfig {
return {
account: this.account,
logger: this.logger,
fetchFn: this.safeFetchFn,
fetchFn: async (url: string | URL, init?: RequestInit) => {
const oauthClient = await this.getOAuthClient();
return oauthClient.fetch(url, init);
},
approvePayment: this.approvePayment,
onPayment: this.onPayment,
onPaymentFailure: this.onPaymentFailure
Expand Down
Loading