diff --git a/.changeset/request-aware-preparation.md b/.changeset/request-aware-preparation.md new file mode 100644 index 000000000..ea88fa0d2 --- /dev/null +++ b/.changeset/request-aware-preparation.md @@ -0,0 +1,5 @@ +--- +'mppx': patch +--- + +Added request-aware payment preparation with safe redirect handling and pinned credential delivery. diff --git a/src/client/Mppx.test-d.ts b/src/client/Mppx.test-d.ts index 2cbf77d9e..7fb1dfcad 100644 --- a/src/client/Mppx.test-d.ts +++ b/src/client/Mppx.test-d.ts @@ -63,6 +63,20 @@ describe('Mppx', () => { expectTypeOf(prepared.setCredential({ headers: {} }, 'credential')).toEqualTypeOf() }) + test('prepares and pays a request-bound payment', async () => { + const method = charge() + const mppx = Mppx.create({ methods: [method] }) + + const prepared = await mppx.prepareRequest('https://example.com/resource', { + method: 'POST', + }) + + expectTypeOf(prepared.request).toEqualTypeOf() + expectTypeOf(prepared.response).toEqualTypeOf() + expectTypeOf(prepared.redirects).toEqualTypeOf() + expectTypeOf(prepared.pay({ account: {} as Account })).toEqualTypeOf>() + }) + test('uses custom transport request and response types', async () => { type Request = { credential?: string | undefined } type Response = { challenges: Challenge.Challenge[] } diff --git a/src/client/Mppx.test.ts b/src/client/Mppx.test.ts index d1e4fff07..dace977d7 100644 --- a/src/client/Mppx.test.ts +++ b/src/client/Mppx.test.ts @@ -8,6 +8,7 @@ import * as Http from '~test/Http.js' import { accounts, asset, client } from '~test/tempo/viem.js' import * as x402_ChallengeBrand from '../x402/internal/ChallengeBrand.js' +import * as MethodChallenge from './internal/MethodChallenge.js' const realm = 'api.example.com' const secretKey = 'test-secret-key-test-secret-key-32' @@ -345,6 +346,279 @@ describe('preparePayment', () => { }) }) +describe('prepareRequest', () => { + function setup(fetch: typeof globalThis.fetch) { + const method = Method.toClient( + Method.from({ name: 'test', intent: 'charge', schema: Methods.charge.schema }), + { + async createCredential({ challenge }) { + return Credential.serialize({ + challenge, + payload: { signature: '0xsignature', type: 'transaction' }, + }) + }, + }, + ) + return Mppx.create({ fetch, methods: [method], polyfill: false }) + } + + function paymentRequired(header?: string) { + const challenge = Challenge.from({ + expires: new Date(Date.now() + 60_000).toISOString(), + header, + id: 'prepared-request', + intent: 'charge', + method: 'test', + realm, + request: { amount: '100', currency: asset }, + }) + return new Response(null, { + headers: { 'WWW-Authenticate': Challenge.serialize(challenge) }, + status: 402, + }) + } + + test('behavior: retains the redirected request and pins credential delivery', async () => { + const requests: Request[] = [] + const fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const request = input instanceof Request ? input : new Request(input, init) + requests.push(request) + if (requests.length === 1) + return new Response(null, { headers: { location: '/checkout' }, status: 303 }) + if (requests.length === 2) return paymentRequired('Payment-Credential') + return new Response(null, { headers: { location: '/elsewhere' }, status: 307 }) + }) + const mppx = setup(fetch as typeof globalThis.fetch) + + const prepared = await mppx.prepareRequest('https://shop.example/start', { + body: 'item=book', + headers: { + Authorization: 'Bearer caller', + 'Content-Type': 'application/x-www-form-urlencoded', + }, + method: 'POST', + }) + + expect(prepared.request.url).toBe('https://shop.example/checkout') + expect(prepared.request.method).toBe('GET') + expect(prepared.request.headers.has('content-type')).toBe(false) + expect(prepared.redirects).toEqual([ + { + from: 'https://shop.example/start', + status: 303, + to: 'https://shop.example/checkout', + }, + ]) + expect(Object.isFrozen(prepared)).toBe(true) + expect(Object.isFrozen(prepared.redirects)).toBe(true) + + const response = await prepared.pay() + + expect(response.status).toBe(307) + expect(requests).toHaveLength(3) + expect(requests[2]?.url).toBe('https://shop.example/checkout') + expect(requests[2]?.redirect).toBe('manual') + expect(requests[2]?.headers.get('Payment-Credential')).toMatch(/^Payment /) + }) + + test('security: strips credentials on pre-payment cross-origin redirects', async () => { + const requests: Request[] = [] + const fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const request = input instanceof Request ? input : new Request(input, init) + requests.push(request) + return requests.length === 1 + ? new Response(null, { + headers: { location: 'https://pay.example/resource' }, + status: 307, + }) + : paymentRequired() + }) + const mppx = setup(fetch as typeof globalThis.fetch) + + const prepared = await mppx.prepareRequest('https://shop.example/start', { + headers: { + Authorization: 'Bearer secret', + Cookie: 'session=secret', + 'Payment-Authorization': 'secret', + 'PAYMENT-SIGNATURE': 'secret', + 'X-Alternate-Credential': 'Payment secret', + 'X-PAYMENT': 'secret', + 'X-Public': 'value', + }, + }) + + expect(prepared.request.url).toBe('https://pay.example/resource') + expect(prepared.request.headers.get('authorization')).toBeNull() + expect(prepared.request.headers.get('cookie')).toBeNull() + expect(prepared.request.headers.get('payment-authorization')).toBeNull() + expect(prepared.request.headers.get('payment-signature')).toBeNull() + expect(prepared.request.headers.get('x-alternate-credential')).toBeNull() + expect(prepared.request.headers.get('x-payment')).toBeNull() + expect(prepared.request.headers.get('x-public')).toBe('value') + }) + + test('behavior: preserves string bodies for MCP-over-HTTP', async () => { + const requests: Request[] = [] + const challenge = Challenge.fromResponseList(paymentRequired())[0]! + const fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const request = input instanceof Request ? input : new Request(input, init) + requests.push(request) + if (requests.length > 1) return new Response('paid') + return new Response( + JSON.stringify({ + error: { + code: Mcp.paymentRequiredCode, + data: { challenges: [challenge] }, + message: 'Payment Required', + }, + id: 1, + jsonrpc: '2.0', + }), + { headers: { 'content-type': 'application/json' } }, + ) + }) + const mppx = setup(fetch as typeof globalThis.fetch) + const body = JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/call', params: {} }) + + const prepared = await mppx.prepareRequest('https://mcp.example/messages', { + body, + headers: { accept: 'application/json, text/event-stream' }, + method: 'POST', + }) + await prepared.pay() + + const paidBody = JSON.parse(await requests[1]!.clone().text()) + expect(paidBody.params._meta[Mcp.credentialMetaKey]).toBeDefined() + }) + + test('behavior: returns the attested request that produced the challenge', async () => { + const requests: Request[] = [] + const fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const request = input instanceof Request ? input : new Request(input, init) + requests.push(request) + return paymentRequired() + }) + const methods = setup(fetch as typeof globalThis.fetch).methods + const mppx = Mppx.create({ + attestation: { + test: { + protocol: 'test', + sign(request) { + const headers = new Headers(request.headers) + headers.set('Signature', 'test-signature') + return new Request(request, { headers }) + }, + }, + }, + fetch: fetch as typeof globalThis.fetch, + methods, + polyfill: false, + }) + + const prepared = await mppx.prepareRequest('https://shop.example/resource') + + expect(prepared.request).toBe(requests[0]) + expect(prepared.request.headers.get('signature')).toBe('test-signature') + }) + + test('behavior: bypasses previously installed payment wrappers', async () => { + const originalFetch = globalThis.fetch + const fetch = vi + .fn() + .mockResolvedValueOnce(paymentRequired()) + .mockResolvedValueOnce(new Response('paid')) + const methods = setup(fetch as typeof globalThis.fetch).methods + globalThis.fetch = fetch as typeof globalThis.fetch + try { + Mppx.create({ fetch: fetch as typeof globalThis.fetch, methods }) + const mppx = Mppx.create({ methods, polyfill: false }) + + const prepared = await mppx.prepareRequest('https://shop.example/resource') + + expect(prepared.response.status).toBe(402) + expect(fetch).toHaveBeenCalledOnce() + } finally { + Mppx.restore() + globalThis.fetch = originalFetch + } + }) + + test('behavior: applies Accept-Payment policy after redirects', async () => { + const requests: Request[] = [] + const fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const request = input instanceof Request ? input : new Request(input, init) + requests.push(request) + return requests.length === 1 + ? new Response(null, { + headers: { location: 'https://pay.example/resource' }, + status: 307, + }) + : paymentRequired() + }) + const methods = setup(fetch as typeof globalThis.fetch).methods + const mppx = Mppx.create({ + acceptPaymentPolicy: { origins: ['https://shop.example'] }, + fetch: fetch as typeof globalThis.fetch, + methods, + polyfill: false, + }) + + await mppx.prepareRequest('https://shop.example/resource') + + expect(requests[0]!.headers.get('accept-payment')).toBe('test/charge') + expect(requests[1]!.headers.get('accept-payment')).toBeNull() + }) + + test('behavior: runs method preparation before creating a credential', async () => { + const mppx = setup(vi.fn(async () => paymentRequired()) as typeof globalThis.fetch) + const prepare = vi.fn() + MethodChallenge.register(mppx.methods[0]!, prepare) + const prepared = await mppx.prepareRequest('https://shop.example/resource') + + await prepared.createCredential() + + expect(prepare).toHaveBeenCalledOnce() + expect(prepare.mock.calls[0]?.[0].input).toBeInstanceOf(Request) + }) + + test('error: explains opaque browser redirects', async () => { + const opaqueRedirect = { + headers: new Headers(), + status: 0, + type: 'opaqueredirect', + } as Response + const mppx = setup(vi.fn(async () => opaqueRedirect) as typeof globalThis.fetch) + + await expect(mppx.prepareRequest('https://shop.example/resource')).rejects.toThrow( + /runtime that exposes manual redirect responses/, + ) + }) + + test('security: rejects HTTPS downgrade redirects', async () => { + const fetch = vi.fn( + async () => + new Response(null, { + headers: { location: 'http://shop.example/resource' }, + status: 302, + }), + ) + const mppx = setup(fetch as typeof globalThis.fetch) + + await expect(mppx.prepareRequest('https://shop.example/start')).rejects.toThrow( + /HTTPS downgrade/, + ) + expect(fetch).toHaveBeenCalledOnce() + }) + + test('error: validates the redirect limit', async () => { + const mppx = setup(vi.fn() as typeof globalThis.fetch) + + await expect( + mppx.prepareRequest('https://shop.example/start', undefined, { maxRedirects: -1 }), + ).rejects.toThrow(/non-negative integer/) + }) +}) + describe('createCredential', () => { function sessionChallenge(id: string, sessionProtocol?: string) { return { diff --git a/src/client/Mppx.ts b/src/client/Mppx.ts index 843d59b39..a71414514 100644 --- a/src/client/Mppx.ts +++ b/src/client/Mppx.ts @@ -1,17 +1,20 @@ import * as AttestationClient from '../attestation/Client.js' import type * as Attestation from '../attestation/Types.js' import type * as Challenge from '../Challenge.js' +import * as Constants from '../Constants.js' import * as Expires from '../Expires.js' import * as AcceptPayment from '../internal/AcceptPayment.js' import type * as Method from '../Method.js' import type * as z from '../zod.js' import * as Fetch from './internal/Fetch.js' +import * as MethodChallenge from './internal/MethodChallenge.js' import * as Transport from './Transport.js' export type Methods = readonly (Method.AnyClient | readonly Method.AnyClient[])[] type EventResponseOf = | Response | Transport.ResponseOf +const preparedPaymentMethods = new WeakMap() /** * A selected payment that can be inspected before its credential is created. @@ -37,6 +40,29 @@ export type PreparedPayment< ) => Transport.RequestOf }> +/** A payment challenge prepared together with the exact HTTP request that produced it. */ +export type PreparedRequest = Readonly< + PreparedPayment> & { + /** Exact request that returned the selected payment challenge. */ + request: Request + /** Payment-required response returned for {@link request}. */ + response: Response + /** Redirects followed before receiving the payment challenge. */ + redirects: readonly PreparedRequest.Redirect[] + /** Creates and sends a credential to the prepared request without following redirects. */ + pay: (context?: AnyContextFor | undefined) => Promise + } +> + +export declare namespace PreparedRequest { + /** A redirect followed while discovering a payment challenge. */ + type Redirect = Readonly<{ + from: string + status: number + to: string + }> +} + /** * Client-side payment handler. */ @@ -69,6 +95,18 @@ export type Mppx< response: Transport.ResponseOf, options?: preparePayment.Options, transport> | undefined, ) => Promise, transport>> + /** + * Follows safe pre-payment redirects and prepares the challenge with the exact request that + * produced it. Credential-bearing requests never follow redirects. Requires a runtime that + * exposes manual redirect responses; browsers return opaque redirects and are not supported. + */ + prepareRequest: transport extends Transport.Transport + ? ( + input: RequestInfo | URL, + init?: RequestInit | undefined, + options?: prepareRequest.Options> | undefined, + ) => Promise>> + : never /** Creates a credential from a payment-required response by routing to the correct method. */ createCredential: ( response: Transport.ResponseOf, @@ -154,9 +192,12 @@ export function create< transport = Transport.http() as transport, } = config - const rawFetch = config.fetch ?? globalThis.fetch - const attestedFetch = attestation - ? createAttestedFetch(rawFetch, attestation as Attestation.SignerMap) + const rawFetch = Fetch.unwrapFetch(config.fetch ?? globalThis.fetch) + const attestationSigner = attestation + ? createAttestationSigner(attestation as Attestation.SignerMap) + : undefined + const attestedFetch = attestationSigner + ? AttestationClient.wrapFetch(rawFetch, attestationSigner) : rawFetch const methods = config.methods.flat() as unknown as FlattenMethods const acceptPayment = AcceptPayment.resolve(methods, config.paymentPreferences) @@ -305,7 +346,7 @@ export function create< } }, ) - return Object.freeze({ + const prepared = Object.freeze({ challenge: selectedChallenge, challenges: challengeSnapshots, createCredential: createPreparedCredential, @@ -315,6 +356,8 @@ export function create< return transport.setCredential(request, credential, { challenge: transportChallenge }) }, }) + preparedPaymentMethods.set(prepared, selectedMethod) + return prepared } catch (error) { await events.emit( 'payment.failed', @@ -330,6 +373,65 @@ export function create< } } + async function prepareRequest( + input: RequestInfo | URL, + init?: RequestInit, + options?: prepareRequest.Options>, + ): Promise>> { + const { maxRedirects = 20, ...paymentOptions } = options ?? {} + const preparedHttp = await prepareHttpRequest({ + acceptPayment: acceptPayment.header, + acceptPaymentPolicy, + fetch: rawFetch, + init, + input, + maxRedirects, + signer: attestationSigner, + }) + const requestInit = requestToInit(preparedHttp.replayRequest, preparedHttp.body) + if (!(await transport.isPaymentRequired(preparedHttp.response as never, requestInit as never))) + throw new Error('Response does not require payment.') + + const payment = (await preparePayment(preparedHttp.response as never, { + ...paymentOptions, + request: requestInit as never, + })) as unknown as PreparedPayment< + FlattenMethods, + Transport.Transport + > + + const paymentMethod = preparedPaymentMethods.get(payment) ?? payment.method + const createRequestCredential = memoizeCreateCredential(async (context) => { + if (MethodChallenge.has(paymentMethod)) + await MethodChallenge.handle(paymentMethod, { + challenge: payment.challenge, + context, + fetch: attestedFetch, + input: preparedHttp.replayRequest, + }) + return payment.createCredential(context) + }) + + return Object.freeze({ + ...payment, + createCredential: createRequestCredential, + request: preparedHttp.request, + response: preparedHttp.response, + redirects: preparedHttp.redirects, + async pay(context?: AnyContextFor>) { + const credential = await createRequestCredential(context) + const paidInit = payment.setCredential( + requestToInit(preparedHttp.replayRequest, preparedHttp.body), + credential, + ) + return attestedFetch(preparedHttp.replayRequest.url, { + ...paidInit, + redirect: 'manual', + }) + }, + }) + } + return { fetch, rawFetch, @@ -341,6 +443,7 @@ export function create< onPaymentFailed, onPaymentResponse, preparePayment, + prepareRequest: prepareRequest as never, async createCredential( response: Transport.ResponseOf, context?: AnyContextFor>, @@ -360,6 +463,17 @@ export declare namespace preparePayment { > = createCredential.Options } +export declare namespace prepareRequest { + /** Options for preparing a request-bound payment. */ + type Options = Omit< + preparePayment.Options>, + 'request' + > & { + /** Maximum redirects followed before rejecting the request. @default 20 */ + maxRedirects?: number | undefined + } +} + export declare namespace createCredential { type Options< methods extends readonly Method.AnyClient[] = readonly Method.AnyClient[], @@ -429,19 +543,174 @@ export declare namespace create { } } -function createAttestedFetch( - fetch: typeof globalThis.fetch, - signers: Attestation.SignerMap, -): typeof globalThis.fetch { +function createAttestationSigner(signers: Attestation.SignerMap): Attestation.Signer { const values = Object.values(signers) if (values.length === 0) throw new TypeError('Mppx client attestation must configure at least one signer.') - return AttestationClient.wrapFetch( - fetch, - AttestationClient.composeSigners(...(values as [Attestation.Signer, ...Attestation.Signer[]])), + return AttestationClient.composeSigners( + ...(values as [Attestation.Signer, ...Attestation.Signer[]]), ) } +const redirectStatuses = new Set([301, 302, 303, 307, 308]) +const bodyHeaders = [ + 'content-encoding', + 'content-language', + 'content-length', + 'content-location', + 'content-type', + 'transfer-encoding', +] +const crossOriginHeaders = [ + 'authorization', + 'cookie', + 'cookie2', + 'host', + 'payment-authorization', + 'payment-signature', + 'proxy-authorization', + 'x-payment', +] + +async function prepareHttpRequest(parameters: { + acceptPayment: string + acceptPaymentPolicy: NonNullable + fetch: typeof globalThis.fetch + init: RequestInit | undefined + input: RequestInfo | URL + maxRedirects: number + signer: Attestation.Signer | undefined +}): Promise<{ + body: BodyInit | undefined + replayRequest: Request + request: Request + response: Response + redirects: readonly PreparedRequest.Redirect[] +}> { + const { acceptPayment, acceptPaymentPolicy, fetch, init, input, maxRedirects, signer } = + parameters + if (!Number.isInteger(maxRedirects) || maxRedirects < 0) + throw new TypeError('maxRedirects must be a non-negative integer.') + + let request = new Request(input, { ...init, redirect: 'manual' }) + const explicitAcceptPayment = request.headers.has(Constants.Headers.acceptPayment) + let body = await replayBody(request, init?.body) + const redirects: PreparedRequest.Redirect[] = [] + + for (;;) { + request = withAcceptPayment(request, acceptPayment, explicitAcceptPayment, acceptPaymentPolicy) + const sentRequest = signer ? await signer.sign(request.clone()) : request.clone() + const response = await fetch(sentRequest) + if (response.type === 'opaqueredirect' || response.status === 0) + throw new Error('prepareRequest requires a runtime that exposes manual redirect responses.') + if (!redirectStatuses.has(response.status)) + return { + body, + replayRequest: request, + request: sentRequest, + response, + redirects: Object.freeze(redirects), + } + + const location = response.headers.get('location') + if (!location) + return { + body, + replayRequest: request, + request: sentRequest, + response, + redirects: Object.freeze(redirects), + } + if (redirects.length >= maxRedirects) { + await response.body?.cancel() + throw new Error(`Payment request exceeded ${maxRedirects} redirects.`) + } + + const from = new URL(request.url) + const to = new URL(location, from) + if (from.protocol === 'https:' && to.protocol !== 'https:') { + await response.body?.cancel() + throw new Error(`Payment request refused HTTPS downgrade redirect to ${to.href}`) + } + + const headers = new Headers(request.headers) + let method = request.method + const switchesToGet = + ((response.status === 301 || response.status === 302) && method === 'POST') || + (response.status === 303 && method !== 'GET' && method !== 'HEAD') + if (switchesToGet) { + method = 'GET' + body = undefined + for (const header of bodyHeaders) headers.delete(header) + } + if (from.origin !== to.origin) + for (const header of [...headers.keys()]) { + const value = headers.get(header) ?? '' + if (crossOriginHeaders.includes(header) || value.startsWith('Payment ')) + headers.delete(header) + } + + redirects.push(Object.freeze({ from: from.href, status: response.status, to: to.href })) + await response.body?.cancel() + request = new Request(to, requestInit(request, headers, method, body)) + } +} + +function requestToInit(request: Request, body: BodyInit | undefined): RequestInit { + return requestInit(request, new Headers(request.headers), request.method, body) +} + +async function replayBody( + request: Request, + suppliedBody: BodyInit | null | undefined, +): Promise { + if (typeof suppliedBody === 'string') return suppliedBody + if (!request.body) return undefined + const accept = request.headers.get('accept')?.toLowerCase() ?? '' + if ( + request.headers.has('mcp-method') || + (accept.includes('application/json') && accept.includes('text/event-stream')) + ) + return request.clone().text() + return request.clone().arrayBuffer() +} + +function withAcceptPayment( + request: Request, + acceptPayment: string, + explicit: boolean, + policy: NonNullable, +): Request { + if (explicit) return request + const headers = new Headers(request.headers) + headers.delete(Constants.Headers.acceptPayment) + if (acceptPayment && Fetch.shouldInjectForPolicy(request, policy)) + headers.set(Constants.Headers.acceptPayment, acceptPayment) + return new Request(request, { headers }) +} + +function requestInit( + request: Request, + headers: Headers, + method: string, + body: BodyInit | undefined, +): RequestInit { + return { + ...(body ? { body } : {}), + cache: request.cache, + credentials: request.credentials, + headers, + integrity: request.integrity, + keepalive: request.keepalive, + method, + mode: request.mode, + redirect: 'manual', + referrer: request.referrer, + referrerPolicy: request.referrerPolicy, + signal: request.signal, + } +} + /** * Union of all context types from all methods that have context schemas. * @internal diff --git a/src/client/internal/Fetch.ts b/src/client/internal/Fetch.ts index a53d0b381..e9614dc73 100644 --- a/src/client/internal/Fetch.ts +++ b/src/client/internal/Fetch.ts @@ -938,7 +938,8 @@ function getCallerHeaders(input: RequestInfo | URL, headers: HeadersInit | undef } /** @internal */ -function unwrapFetch(fetch: typeof globalThis.fetch): typeof globalThis.fetch { +/** @internal */ +export function unwrapFetch(fetch: typeof globalThis.fetch): typeof globalThis.fetch { let current = fetch as WrappedFetch while (current[MPPX_FETCH_WRAPPER]) { current = current[MPPX_FETCH_WRAPPER] as WrappedFetch @@ -1018,7 +1019,8 @@ async function resolveChallengeOrder, ): boolean {