Skip to content

Commit d71ad79

Browse files
authored
feat: add request-aware payment preparation (#893)
* feat: add request-aware payment preparation * fix: harden request-aware payment preparation
1 parent 3c14a65 commit d71ad79

5 files changed

Lines changed: 577 additions & 13 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'mppx': patch
3+
---
4+
5+
Added request-aware payment preparation with safe redirect handling and pinned credential delivery.

src/client/Mppx.test-d.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,20 @@ describe('Mppx', () => {
6363
expectTypeOf(prepared.setCredential({ headers: {} }, 'credential')).toEqualTypeOf<RequestInit>()
6464
})
6565

66+
test('prepares and pays a request-bound payment', async () => {
67+
const method = charge()
68+
const mppx = Mppx.create({ methods: [method] })
69+
70+
const prepared = await mppx.prepareRequest('https://example.com/resource', {
71+
method: 'POST',
72+
})
73+
74+
expectTypeOf(prepared.request).toEqualTypeOf<Request>()
75+
expectTypeOf(prepared.response).toEqualTypeOf<Response>()
76+
expectTypeOf(prepared.redirects).toEqualTypeOf<readonly Mppx.PreparedRequest.Redirect[]>()
77+
expectTypeOf(prepared.pay({ account: {} as Account })).toEqualTypeOf<Promise<Response>>()
78+
})
79+
6680
test('uses custom transport request and response types', async () => {
6781
type Request = { credential?: string | undefined }
6882
type Response = { challenges: Challenge.Challenge[] }

src/client/Mppx.test.ts

Lines changed: 274 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import * as Http from '~test/Http.js'
88
import { accounts, asset, client } from '~test/tempo/viem.js'
99

1010
import * as x402_ChallengeBrand from '../x402/internal/ChallengeBrand.js'
11+
import * as MethodChallenge from './internal/MethodChallenge.js'
1112

1213
const realm = 'api.example.com'
1314
const secretKey = 'test-secret-key-test-secret-key-32'
@@ -345,6 +346,279 @@ describe('preparePayment', () => {
345346
})
346347
})
347348

349+
describe('prepareRequest', () => {
350+
function setup(fetch: typeof globalThis.fetch) {
351+
const method = Method.toClient(
352+
Method.from({ name: 'test', intent: 'charge', schema: Methods.charge.schema }),
353+
{
354+
async createCredential({ challenge }) {
355+
return Credential.serialize({
356+
challenge,
357+
payload: { signature: '0xsignature', type: 'transaction' },
358+
})
359+
},
360+
},
361+
)
362+
return Mppx.create({ fetch, methods: [method], polyfill: false })
363+
}
364+
365+
function paymentRequired(header?: string) {
366+
const challenge = Challenge.from({
367+
expires: new Date(Date.now() + 60_000).toISOString(),
368+
header,
369+
id: 'prepared-request',
370+
intent: 'charge',
371+
method: 'test',
372+
realm,
373+
request: { amount: '100', currency: asset },
374+
})
375+
return new Response(null, {
376+
headers: { 'WWW-Authenticate': Challenge.serialize(challenge) },
377+
status: 402,
378+
})
379+
}
380+
381+
test('behavior: retains the redirected request and pins credential delivery', async () => {
382+
const requests: Request[] = []
383+
const fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
384+
const request = input instanceof Request ? input : new Request(input, init)
385+
requests.push(request)
386+
if (requests.length === 1)
387+
return new Response(null, { headers: { location: '/checkout' }, status: 303 })
388+
if (requests.length === 2) return paymentRequired('Payment-Credential')
389+
return new Response(null, { headers: { location: '/elsewhere' }, status: 307 })
390+
})
391+
const mppx = setup(fetch as typeof globalThis.fetch)
392+
393+
const prepared = await mppx.prepareRequest('https://shop.example/start', {
394+
body: 'item=book',
395+
headers: {
396+
Authorization: 'Bearer caller',
397+
'Content-Type': 'application/x-www-form-urlencoded',
398+
},
399+
method: 'POST',
400+
})
401+
402+
expect(prepared.request.url).toBe('https://shop.example/checkout')
403+
expect(prepared.request.method).toBe('GET')
404+
expect(prepared.request.headers.has('content-type')).toBe(false)
405+
expect(prepared.redirects).toEqual([
406+
{
407+
from: 'https://shop.example/start',
408+
status: 303,
409+
to: 'https://shop.example/checkout',
410+
},
411+
])
412+
expect(Object.isFrozen(prepared)).toBe(true)
413+
expect(Object.isFrozen(prepared.redirects)).toBe(true)
414+
415+
const response = await prepared.pay()
416+
417+
expect(response.status).toBe(307)
418+
expect(requests).toHaveLength(3)
419+
expect(requests[2]?.url).toBe('https://shop.example/checkout')
420+
expect(requests[2]?.redirect).toBe('manual')
421+
expect(requests[2]?.headers.get('Payment-Credential')).toMatch(/^Payment /)
422+
})
423+
424+
test('security: strips credentials on pre-payment cross-origin redirects', async () => {
425+
const requests: Request[] = []
426+
const fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
427+
const request = input instanceof Request ? input : new Request(input, init)
428+
requests.push(request)
429+
return requests.length === 1
430+
? new Response(null, {
431+
headers: { location: 'https://pay.example/resource' },
432+
status: 307,
433+
})
434+
: paymentRequired()
435+
})
436+
const mppx = setup(fetch as typeof globalThis.fetch)
437+
438+
const prepared = await mppx.prepareRequest('https://shop.example/start', {
439+
headers: {
440+
Authorization: 'Bearer secret',
441+
Cookie: 'session=secret',
442+
'Payment-Authorization': 'secret',
443+
'PAYMENT-SIGNATURE': 'secret',
444+
'X-Alternate-Credential': 'Payment secret',
445+
'X-PAYMENT': 'secret',
446+
'X-Public': 'value',
447+
},
448+
})
449+
450+
expect(prepared.request.url).toBe('https://pay.example/resource')
451+
expect(prepared.request.headers.get('authorization')).toBeNull()
452+
expect(prepared.request.headers.get('cookie')).toBeNull()
453+
expect(prepared.request.headers.get('payment-authorization')).toBeNull()
454+
expect(prepared.request.headers.get('payment-signature')).toBeNull()
455+
expect(prepared.request.headers.get('x-alternate-credential')).toBeNull()
456+
expect(prepared.request.headers.get('x-payment')).toBeNull()
457+
expect(prepared.request.headers.get('x-public')).toBe('value')
458+
})
459+
460+
test('behavior: preserves string bodies for MCP-over-HTTP', async () => {
461+
const requests: Request[] = []
462+
const challenge = Challenge.fromResponseList(paymentRequired())[0]!
463+
const fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
464+
const request = input instanceof Request ? input : new Request(input, init)
465+
requests.push(request)
466+
if (requests.length > 1) return new Response('paid')
467+
return new Response(
468+
JSON.stringify({
469+
error: {
470+
code: Mcp.paymentRequiredCode,
471+
data: { challenges: [challenge] },
472+
message: 'Payment Required',
473+
},
474+
id: 1,
475+
jsonrpc: '2.0',
476+
}),
477+
{ headers: { 'content-type': 'application/json' } },
478+
)
479+
})
480+
const mppx = setup(fetch as typeof globalThis.fetch)
481+
const body = JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/call', params: {} })
482+
483+
const prepared = await mppx.prepareRequest('https://mcp.example/messages', {
484+
body,
485+
headers: { accept: 'application/json, text/event-stream' },
486+
method: 'POST',
487+
})
488+
await prepared.pay()
489+
490+
const paidBody = JSON.parse(await requests[1]!.clone().text())
491+
expect(paidBody.params._meta[Mcp.credentialMetaKey]).toBeDefined()
492+
})
493+
494+
test('behavior: returns the attested request that produced the challenge', async () => {
495+
const requests: Request[] = []
496+
const fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
497+
const request = input instanceof Request ? input : new Request(input, init)
498+
requests.push(request)
499+
return paymentRequired()
500+
})
501+
const methods = setup(fetch as typeof globalThis.fetch).methods
502+
const mppx = Mppx.create({
503+
attestation: {
504+
test: {
505+
protocol: 'test',
506+
sign(request) {
507+
const headers = new Headers(request.headers)
508+
headers.set('Signature', 'test-signature')
509+
return new Request(request, { headers })
510+
},
511+
},
512+
},
513+
fetch: fetch as typeof globalThis.fetch,
514+
methods,
515+
polyfill: false,
516+
})
517+
518+
const prepared = await mppx.prepareRequest('https://shop.example/resource')
519+
520+
expect(prepared.request).toBe(requests[0])
521+
expect(prepared.request.headers.get('signature')).toBe('test-signature')
522+
})
523+
524+
test('behavior: bypasses previously installed payment wrappers', async () => {
525+
const originalFetch = globalThis.fetch
526+
const fetch = vi
527+
.fn()
528+
.mockResolvedValueOnce(paymentRequired())
529+
.mockResolvedValueOnce(new Response('paid'))
530+
const methods = setup(fetch as typeof globalThis.fetch).methods
531+
globalThis.fetch = fetch as typeof globalThis.fetch
532+
try {
533+
Mppx.create({ fetch: fetch as typeof globalThis.fetch, methods })
534+
const mppx = Mppx.create({ methods, polyfill: false })
535+
536+
const prepared = await mppx.prepareRequest('https://shop.example/resource')
537+
538+
expect(prepared.response.status).toBe(402)
539+
expect(fetch).toHaveBeenCalledOnce()
540+
} finally {
541+
Mppx.restore()
542+
globalThis.fetch = originalFetch
543+
}
544+
})
545+
546+
test('behavior: applies Accept-Payment policy after redirects', async () => {
547+
const requests: Request[] = []
548+
const fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
549+
const request = input instanceof Request ? input : new Request(input, init)
550+
requests.push(request)
551+
return requests.length === 1
552+
? new Response(null, {
553+
headers: { location: 'https://pay.example/resource' },
554+
status: 307,
555+
})
556+
: paymentRequired()
557+
})
558+
const methods = setup(fetch as typeof globalThis.fetch).methods
559+
const mppx = Mppx.create({
560+
acceptPaymentPolicy: { origins: ['https://shop.example'] },
561+
fetch: fetch as typeof globalThis.fetch,
562+
methods,
563+
polyfill: false,
564+
})
565+
566+
await mppx.prepareRequest('https://shop.example/resource')
567+
568+
expect(requests[0]!.headers.get('accept-payment')).toBe('test/charge')
569+
expect(requests[1]!.headers.get('accept-payment')).toBeNull()
570+
})
571+
572+
test('behavior: runs method preparation before creating a credential', async () => {
573+
const mppx = setup(vi.fn(async () => paymentRequired()) as typeof globalThis.fetch)
574+
const prepare = vi.fn()
575+
MethodChallenge.register(mppx.methods[0]!, prepare)
576+
const prepared = await mppx.prepareRequest('https://shop.example/resource')
577+
578+
await prepared.createCredential()
579+
580+
expect(prepare).toHaveBeenCalledOnce()
581+
expect(prepare.mock.calls[0]?.[0].input).toBeInstanceOf(Request)
582+
})
583+
584+
test('error: explains opaque browser redirects', async () => {
585+
const opaqueRedirect = {
586+
headers: new Headers(),
587+
status: 0,
588+
type: 'opaqueredirect',
589+
} as Response
590+
const mppx = setup(vi.fn(async () => opaqueRedirect) as typeof globalThis.fetch)
591+
592+
await expect(mppx.prepareRequest('https://shop.example/resource')).rejects.toThrow(
593+
/runtime that exposes manual redirect responses/,
594+
)
595+
})
596+
597+
test('security: rejects HTTPS downgrade redirects', async () => {
598+
const fetch = vi.fn(
599+
async () =>
600+
new Response(null, {
601+
headers: { location: 'http://shop.example/resource' },
602+
status: 302,
603+
}),
604+
)
605+
const mppx = setup(fetch as typeof globalThis.fetch)
606+
607+
await expect(mppx.prepareRequest('https://shop.example/start')).rejects.toThrow(
608+
/HTTPS downgrade/,
609+
)
610+
expect(fetch).toHaveBeenCalledOnce()
611+
})
612+
613+
test('error: validates the redirect limit', async () => {
614+
const mppx = setup(vi.fn() as typeof globalThis.fetch)
615+
616+
await expect(
617+
mppx.prepareRequest('https://shop.example/start', undefined, { maxRedirects: -1 }),
618+
).rejects.toThrow(/non-negative integer/)
619+
})
620+
})
621+
348622
describe('createCredential', () => {
349623
function sessionChallenge(id: string, sessionProtocol?: string) {
350624
return {

0 commit comments

Comments
 (0)