diff --git a/packages/atxp-client/src/atxpClient.ts b/packages/atxp-client/src/atxpClient.ts index 9e6554f..1bc242e 100644 --- a/packages/atxp-client/src/atxpClient.ts +++ b/packages/atxp-client/src/atxpClient.ts @@ -8,7 +8,8 @@ import { DEFAULT_ATXP_ACCOUNTS_SERVER, ATXPAccount } from "@atxp/common"; type RequiredClientConfigFields = 'mcpServer' | 'account'; type OptionalClientConfig = Omit; -type BuildableClientConfigFields = 'oAuthDb' | 'logger' | 'destinationMakers'; +// BuildableClientConfigFields are excluded from DEFAULT_CLIENT_CONFIG - they're either truly optional or built at runtime +type BuildableClientConfigFields = 'oAuthDb' | 'logger' | 'destinationMakers' | 'scopedSpendConfig'; // Detect if we're in a browser environment and bind fetch appropriately const getFetch = (): typeof fetch => { diff --git a/packages/atxp-client/src/atxpFetcher.scopedSpend.test.ts b/packages/atxp-client/src/atxpFetcher.scopedSpend.test.ts new file mode 100644 index 0000000..c830dce --- /dev/null +++ b/packages/atxp-client/src/atxpFetcher.scopedSpend.test.ts @@ -0,0 +1,269 @@ +import { MemoryOAuthDb, Account, DEFAULT_AUTHORIZATION_SERVER } from '@atxp/common'; +import { describe, it, expect, vi } from 'vitest'; +import fetchMock from 'fetch-mock'; +import { mockResourceServer, mockAuthorizationServer } from './clientTestHelpers.js'; +import { ATXPFetcher } from './atxpFetcher.js'; +import { OAuthDb, FetchLike } from '@atxp/common'; +import { PaymentMaker, ScopedSpendConfig } from './types.js'; + +function mockPaymentMakers(solanaPaymentMaker?: PaymentMaker) { + solanaPaymentMaker = solanaPaymentMaker ?? { + makePayment: vi.fn().mockResolvedValue({ transactionId: 'testPaymentId', chain: 'solana' }), + generateJWT: vi.fn().mockResolvedValue('testJWT'), + getSourceAddress: vi.fn().mockReturnValue('SolAddress123') + }; + return [solanaPaymentMaker]; +} + +function atxpFetcher( + fetchFn: FetchLike, + paymentMakers?: PaymentMaker[], + db?: OAuthDb, + options?: { + atxpAccountsServer?: string; + scopedSpendConfig?: ScopedSpendConfig; + } +) { + const account: Account = { + getAccountId: async () => "bdj" as any, + paymentMakers: paymentMakers ?? mockPaymentMakers(), + getSources: async () => [{ + address: 'SolAddress123', + chain: 'solana' as any, + walletType: 'eoa' as any + }] + }; + + return new ATXPFetcher({ + account, + db: db ?? new MemoryOAuthDb(), + destinationMakers: new Map(), + fetchFn, + atxpAccountsServer: options?.atxpAccountsServer, + scopedSpendConfig: options?.scopedSpendConfig + }); +} + +describe('atxpFetcher scoped spend token', () => { + it('should use standard auth flow when scopedSpendConfig is not set', async () => { + const f = fetchMock.createInstance(); + mockResourceServer(f, 'https://example.com', '/mcp', DEFAULT_AUTHORIZATION_SERVER) + .postOnce('https://example.com/mcp', 401) + .postOnce('https://example.com/mcp', {content: [{type: 'text', text: 'hello world'}]}); + mockAuthorizationServer(f, DEFAULT_AUTHORIZATION_SERVER) + .get(`begin:${DEFAULT_AUTHORIZATION_SERVER}/authorize`, (req) => { + const state = new URL(req.args[0] as any).searchParams.get('state'); + return { + status: 301, + headers: {location: `https://atxp.ai?state=${state}&code=testCode`} + }; + }); + + const paymentMaker = { + makePayment: vi.fn().mockResolvedValue({ transactionId: 'testPaymentId', chain: 'solana' }), + generateJWT: vi.fn().mockResolvedValue('standardJWT'), + getSourceAddress: vi.fn().mockReturnValue('SolAddress123') + }; + + const fetcher = atxpFetcher(f.fetchHandler, [paymentMaker]); + await fetcher.fetch('https://example.com/mcp', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({}) }); + + // Should use local generateJWT + expect(paymentMaker.generateJWT).toHaveBeenCalled(); + + // Ensure no calls to resolve endpoint + const resolveCalls = f.callHistory.callLogs.filter(call => + call.url.includes('resolve_only=true') + ); + expect(resolveCalls.length).toBe(0); + }); + + it('should call resolve endpoint and accounts /sign when scopedSpendConfig is set', async () => { + const f = fetchMock.createInstance(); + + // Mock the resource server + mockResourceServer(f, 'https://example.com', '/mcp', DEFAULT_AUTHORIZATION_SERVER) + .postOnce('https://example.com/mcp', 401) + .postOnce('https://example.com/mcp', {content: [{type: 'text', text: 'hello world'}]}); + + // Mock auth server with all required endpoints + mockAuthorizationServer(f, DEFAULT_AUTHORIZATION_SERVER) + .get(`begin:${DEFAULT_AUTHORIZATION_SERVER}/authorize`, (req) => { + const url = new URL(req.args[0] as any); + const resolveOnly = url.searchParams.get('resolve_only'); + const state = url.searchParams.get('state'); + + if (resolveOnly === 'true') { + // Resolve endpoint - return destination account ID + return { destinationAccountId: 'atxp_acct_destination123' }; + } + + // Normal authorize - return redirect with code + return { + status: 301, + headers: {location: `https://atxp.ai?state=${state}&code=testCode`} + }; + }); + + // Mock accounts /sign endpoint + f.post('https://accounts.atxp.ai/sign', { + jwt: 'jwtFromAccounts', + scopedSpendToken: 'scopedSpendTokenXYZ', + scopedSpendTokenId: 'sst_test123', + scopedSpendDestinationAccountId: 'atxp_acct_destination123' + }); + + const paymentMaker = { + makePayment: vi.fn().mockResolvedValue({ transactionId: 'testPaymentId', chain: 'solana' }), + generateJWT: vi.fn().mockResolvedValue('localJWT'), + getSourceAddress: vi.fn().mockReturnValue('SolAddress123') + }; + + const fetcher = atxpFetcher(f.fetchHandler, [paymentMaker], undefined, { + atxpAccountsServer: 'https://accounts.atxp.ai', + scopedSpendConfig: { spendLimit: '100.00' } + }); + + await fetcher.fetch('https://example.com/mcp', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({}) }); + + // Should NOT use local generateJWT + expect(paymentMaker.generateJWT).not.toHaveBeenCalled(); + + // Should have called resolve endpoint + const resolveCalls = f.callHistory.callLogs.filter(call => + call.url.includes('resolve_only=true') + ); + expect(resolveCalls.length).toBe(1); + + // Should have called accounts /sign + const signCalls = f.callHistory.callLogs.filter(call => + call.url === 'https://accounts.atxp.ai/sign' + ); + expect(signCalls.length).toBe(1); + + // Verify the sign request body + const signBody = JSON.parse(signCalls[0].options?.body as string); + expect(signBody.destinationAccountId).toBe('atxp_acct_destination123'); + expect(signBody.spendLimit).toBe('100.00'); + + // Should have passed scoped_spend_token to authorize + const authCalls = f.callHistory.callLogs.filter(call => + call.url.includes('/authorize') && !call.url.includes('resolve_only=true') + ); + expect(authCalls.length).toBeGreaterThan(0); + const authUrl = authCalls[0].url; + expect(authUrl).toContain('scoped_spend_token=scopedSpendTokenXYZ'); + }); + + it('should throw error when resolve endpoint fails', async () => { + const f = fetchMock.createInstance(); + + mockResourceServer(f, 'https://example.com', '/mcp', DEFAULT_AUTHORIZATION_SERVER) + .postOnce('https://example.com/mcp', 401); + + // Mock auth server with all required endpoints + mockAuthorizationServer(f, DEFAULT_AUTHORIZATION_SERVER) + .get(`begin:${DEFAULT_AUTHORIZATION_SERVER}/authorize`, (req) => { + const url = new URL(req.args[0] as any); + const resolveOnly = url.searchParams.get('resolve_only'); + + if (resolveOnly === 'true') { + return { + status: 404, + body: JSON.stringify({ error: 'client_not_found' }) + }; + } + + return { status: 500 }; + }); + + const paymentMaker = { + makePayment: vi.fn().mockResolvedValue({ transactionId: 'testPaymentId', chain: 'solana' }), + generateJWT: vi.fn().mockResolvedValue('localJWT'), + getSourceAddress: vi.fn().mockReturnValue('SolAddress123') + }; + + const fetcher = atxpFetcher(f.fetchHandler, [paymentMaker], undefined, { + atxpAccountsServer: 'https://accounts.atxp.ai', + scopedSpendConfig: { spendLimit: '100.00' } + }); + + await expect( + fetcher.fetch('https://example.com/mcp', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({}) }) + ).rejects.toThrow('failed to resolve destination account'); + }); + + it('should throw error when accounts /sign fails', async () => { + const f = fetchMock.createInstance(); + + mockResourceServer(f, 'https://example.com', '/mcp', DEFAULT_AUTHORIZATION_SERVER) + .postOnce('https://example.com/mcp', 401); + + // Mock auth server with all required endpoints + mockAuthorizationServer(f, DEFAULT_AUTHORIZATION_SERVER) + .get(`begin:${DEFAULT_AUTHORIZATION_SERVER}/authorize`, (req) => { + const url = new URL(req.args[0] as any); + const resolveOnly = url.searchParams.get('resolve_only'); + + if (resolveOnly === 'true') { + return { destinationAccountId: 'atxp_acct_destination123' }; + } + + return { status: 500 }; + }); + + // Mock accounts /sign endpoint - fails + f.post('https://accounts.atxp.ai/sign', { + status: 500, + body: JSON.stringify({ error: 'Internal server error' }) + }); + + const paymentMaker = { + makePayment: vi.fn().mockResolvedValue({ transactionId: 'testPaymentId', chain: 'solana' }), + generateJWT: vi.fn().mockResolvedValue('localJWT'), + getSourceAddress: vi.fn().mockReturnValue('SolAddress123') + }; + + const fetcher = atxpFetcher(f.fetchHandler, [paymentMaker], undefined, { + atxpAccountsServer: 'https://accounts.atxp.ai', + scopedSpendConfig: { spendLimit: '100.00' } + }); + + await expect( + fetcher.fetch('https://example.com/mcp', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({}) }) + ).rejects.toThrow('accounts /sign failed'); + }); + + it('should use standard auth when atxpAccountsServer is not set even with scopedSpendConfig', async () => { + const f = fetchMock.createInstance(); + + mockResourceServer(f, 'https://example.com', '/mcp', DEFAULT_AUTHORIZATION_SERVER) + .postOnce('https://example.com/mcp', 401) + .postOnce('https://example.com/mcp', {content: [{type: 'text', text: 'hello world'}]}); + mockAuthorizationServer(f, DEFAULT_AUTHORIZATION_SERVER) + .get(`begin:${DEFAULT_AUTHORIZATION_SERVER}/authorize`, (req) => { + const state = new URL(req.args[0] as any).searchParams.get('state'); + return { + status: 301, + headers: {location: `https://atxp.ai?state=${state}&code=testCode`} + }; + }); + + const paymentMaker = { + makePayment: vi.fn().mockResolvedValue({ transactionId: 'testPaymentId', chain: 'solana' }), + generateJWT: vi.fn().mockResolvedValue('standardJWT'), + getSourceAddress: vi.fn().mockReturnValue('SolAddress123') + }; + + // Set scopedSpendConfig but NOT atxpAccountsServer + const fetcher = atxpFetcher(f.fetchHandler, [paymentMaker], undefined, { + scopedSpendConfig: { spendLimit: '100.00' } + // Note: atxpAccountsServer not set + }); + + await fetcher.fetch('https://example.com/mcp', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({}) }); + + // Should fall back to local generateJWT + expect(paymentMaker.generateJWT).toHaveBeenCalled(); + }); +}); diff --git a/packages/atxp-client/src/atxpFetcher.ts b/packages/atxp-client/src/atxpFetcher.ts index b7138a5..3e76461 100644 --- a/packages/atxp-client/src/atxpFetcher.ts +++ b/packages/atxp-client/src/atxpFetcher.ts @@ -18,7 +18,7 @@ import { Account, getErrorRecoveryHint } from '@atxp/common'; -import type { PaymentMaker, ProspectivePayment, ClientConfig, PaymentFailureContext } from './types.js'; +import type { PaymentMaker, ProspectivePayment, ClientConfig, PaymentFailureContext, ScopedSpendConfig } from './types.js'; import { InsufficientFundsError, ATXPPaymentError } from './errors.js'; import { getIsReactNative, createReactNativeSafeFetch, Destination } from '@atxp/common'; import { McpError } from '@modelcontextprotocol/sdk/types.js'; @@ -46,7 +46,9 @@ export function atxpFetch(config: ClientConfig): FetchLike { onAuthorizeFailure: config.onAuthorizeFailure, onPayment: config.onPayment, onPaymentFailure: config.onPaymentFailure, - onPaymentAttemptFailed: config.onPaymentAttemptFailed + onPaymentAttemptFailed: config.onPaymentAttemptFailed, + atxpAccountsServer: config.atxpAccountsServer, + scopedSpendConfig: config.scopedSpendConfig }); return fetcher.fetch; } @@ -68,6 +70,8 @@ export class ATXPFetcher { protected onPaymentAttemptFailed?: (args: { network: string, error: Error, remainingNetworks: string[] }) => Promise; protected strict: boolean; protected allowInsecureRequests: boolean; + protected atxpAccountsServer?: string; + protected scopedSpendConfig?: ScopedSpendConfig; constructor(config: { account: Account; db: OAuthDb; @@ -84,6 +88,8 @@ export class ATXPFetcher { onPayment?: (args: { payment: ProspectivePayment, transactionHash: string, network: string }) => Promise; onPaymentFailure?: (context: PaymentFailureContext) => Promise; onPaymentAttemptFailed?: (args: { network: string, error: Error, remainingNetworks: string[] }) => Promise; + atxpAccountsServer?: string; + scopedSpendConfig?: ScopedSpendConfig; }) { const { account, @@ -100,7 +106,9 @@ export class ATXPFetcher { onAuthorizeFailure = async () => {}, onPayment = async () => {}, onPaymentFailure, - onPaymentAttemptFailed + onPaymentAttemptFailed, + atxpAccountsServer, + scopedSpendConfig } = config; // Use React Native safe fetch if in React Native environment this.safeFetchFn = getIsReactNative() ? createReactNativeSafeFetch(fetchFn) : fetchFn; @@ -121,6 +129,8 @@ export class ATXPFetcher { this.onPayment = onPayment; this.onPaymentFailure = onPaymentFailure || this.defaultPaymentFailureHandler; this.onPaymentAttemptFailed = onPaymentAttemptFailed; + this.atxpAccountsServer = atxpAccountsServer; + this.scopedSpendConfig = scopedSpendConfig; } /** @@ -417,25 +427,96 @@ export class ATXPFetcher { } const accountId = await this.account.getAccountId(); - const authToken = await paymentMaker.generateJWT({paymentRequestId: '', codeChallenge: codeChallenge, accountId}); + + // If scoped spend config is set and we have accounts server, use accounts /sign endpoint + // to get both JWT and scoped spend token + let authToken: string; + let scopedSpendToken: string | undefined; + + if (this.scopedSpendConfig && this.atxpAccountsServer) { + this.logger.debug(`ATXP: using scoped spend token flow with limit ${this.scopedSpendConfig.spendLimit}`); + + // First, resolve the destination account ID from auth server + const clientId = authorizationUrl.searchParams.get('client_id'); + if (!clientId) { + throw new Error(`ATXP: client_id not found in authorization URL - cannot resolve destination for scoped spend token`); + } + + // Call auth's resolve endpoint to get destination account ID + const resolveUrl = new URL(authorizationUrl.origin + '/authorize'); + resolveUrl.searchParams.set('client_id', clientId); + resolveUrl.searchParams.set('resolve_only', 'true'); + + this.logger.debug(`ATXP: resolving destination account for client_id ${clientId}`); + const resolveResponse = await this.sideChannelFetch(resolveUrl.toString(), { + method: 'GET' + }); + + if (!resolveResponse.ok) { + const errorBody = await resolveResponse.text(); + throw new Error(`ATXP: failed to resolve destination account for client_id ${clientId}: ${resolveResponse.status} ${errorBody}`); + } + + const resolveData = await resolveResponse.json() as { destinationAccountId: string }; + const destinationAccountId = resolveData.destinationAccountId; + this.logger.debug(`ATXP: resolved destination account ${destinationAccountId} for client_id ${clientId}`); + + // Call accounts /sign endpoint to get JWT + scoped spend token + const signUrl = new URL('/sign', this.atxpAccountsServer); + const signBody = { + paymentRequestId: '', + codeChallenge: codeChallenge, + accountId: accountId, + destinationAccountId: destinationAccountId, + spendLimit: this.scopedSpendConfig.spendLimit + }; + + this.logger.debug(`ATXP: calling accounts /sign for JWT + scoped spend token`); + const signResponse = await this.sideChannelFetch(signUrl.toString(), { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(signBody) + }); + + if (!signResponse.ok) { + const errorBody = await signResponse.text(); + throw new Error(`ATXP: accounts /sign failed: ${signResponse.status} ${errorBody}`); + } + + const signData = await signResponse.json() as { jwt: string; scopedSpendToken?: string }; + authToken = signData.jwt; + scopedSpendToken = signData.scopedSpendToken; + this.logger.debug(`ATXP: got JWT${scopedSpendToken ? ' and scoped spend token' : ''} from accounts /sign`); + } else { + // Use original flow - generate JWT locally via paymentMaker + authToken = await paymentMaker.generateJWT({paymentRequestId: '', codeChallenge: codeChallenge, accountId}); + } + + // Build the authorization URL with optional scoped spend token + let finalAuthUrl = authorizationUrl.toString() + '&redirect=false'; + if (scopedSpendToken) { + finalAuthUrl += '&scoped_spend_token=' + encodeURIComponent(scopedSpendToken); + } // Make a fetch call to the authorization URL with the payment ID // redirect=false is a hack // The OAuth spec calls for the authorization url to return with a redirect, but fetch // on mobile will automatically follow the redirect (it doesn't support the redirect=manual option) - // We want the redirect URL so we can extract the code from it, not the contents of the + // We want the redirect URL so we can extract the code from it, not the contents of the // redirect URL (which might not even exist for agentic ATXP clients) // So ATXP servers are set up to instead return a 200 with the redirect URL in the body // if we pass redirect=false. // TODO: Remove the redirect=false hack once we have a way to handle the redirect on mobile - const response = await this.sideChannelFetch(authorizationUrl.toString()+'&redirect=false', { + const response = await this.sideChannelFetch(finalAuthUrl, { method: 'GET', redirect: 'manual', headers: { 'Authorization': `Bearer ${authToken}` } }); - // Check if we got a redirect response (301, 302, etc.) in case the server follows + // Check if we got a redirect response (301, 302, etc.) in case the server follows // the OAuth spec if (response.status >= 300 && response.status < 400) { const location = response.headers.get('Location'); diff --git a/packages/atxp-client/src/types.ts b/packages/atxp-client/src/types.ts index c5040c2..595f132 100644 --- a/packages/atxp-client/src/types.ts +++ b/packages/atxp-client/src/types.ts @@ -36,6 +36,19 @@ export interface PaymentFailureContext { timestamp: Date; } +/** + * Configuration for scoped spend tokens. + * When configured, the SDK will request scoped spend tokens during authorization, + * enabling async charging instead of blocking on payment before each operation. + */ +export interface ScopedSpendConfig { + /** + * Maximum amount the SDK is authorized to spend on behalf of the user. + * This is the spend limit for the scoped spend token, in USD (e.g., "50.00"). + */ + spendLimit: string; +} + export type ClientConfig = { mcpServer: string; account: Account; @@ -56,6 +69,12 @@ export type ClientConfig = { onPaymentFailure: (context: PaymentFailureContext) => Promise; /** Optional callback when a single payment attempt fails (before trying other networks) */ onPaymentAttemptFailed?: (args: { network: string, error: Error, remainingNetworks: string[] }) => Promise; + /** + * Optional scoped spend configuration. + * When provided, the SDK will request scoped spend tokens during authorization, + * enabling async charging instead of blocking on payment before each operation. + */ + scopedSpendConfig?: ScopedSpendConfig; } // ClientArgs for creating clients - required fields plus optional overrides