diff --git a/.changeset/payment-authorization-header.md b/.changeset/payment-authorization-header.md new file mode 100644 index 00000000..19f881db --- /dev/null +++ b/.changeset/payment-authorization-header.md @@ -0,0 +1,5 @@ +--- +"@stripe/link-cli": patch +--- + +Honor Payment-Authorization in `mpp pay` so Payment credentials can coexist with ordinary Authorization. Challenges may select only Authorization (default) or Payment-Authorization. diff --git a/packages/cli/package.json b/packages/cli/package.json index 9e8d5d9b..44498e11 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -35,7 +35,7 @@ "incur": "^0.4.26", "ink": "^5.2.1", "ink-spinner": "^5.0.0", - "mppx": "0.8.15", + "mppx": "0.9.1", "qrcode": "^1.5.4", "react": "^18.3.1", "strip-ansi": "^7.2.0", diff --git a/packages/cli/src/__tests__/cli.test.ts b/packages/cli/src/__tests__/cli.test.ts index e717186f..3c3bcddf 100644 --- a/packages/cli/src/__tests__/cli.test.ts +++ b/packages/cli/src/__tests__/cli.test.ts @@ -2449,7 +2449,7 @@ describe('production mode', () => { ].join(' '); function decodeCredential(authorizationHeader: string): { - challenge: { intent: string }; + challenge: { intent: string; header?: string }; payload: Record; } { const encoded = authorizationHeader.replace(/^Payment\s+/i, ''); @@ -2484,6 +2484,51 @@ describe('production mode', () => { expect(merchantRequests[1].headers.authorization).toMatch(/^Payment /); }); + it('retries with Payment-Authorization when the challenge advertises that header', async () => { + const wwwAuthenticate = [ + 'Payment id="ch_001",', + 'realm="127.0.0.1",', + 'method="stripe",', + 'intent="charge",', + 'header="Payment-Authorization",', + `request="${Buffer.from(JSON.stringify({ networkId: 'net_001', amount: '1000', currency: 'usd', decimals: 2, paymentMethodTypes: ['card'] })).toString('base64')}",`, + 'expires="2099-01-01T00:00:00Z"', + ].join(' '); + + setNextResponse(200, APPROVED_SPT_REQUEST); + setMerchantResponse(402, '{"error":"payment required"}', { + 'www-authenticate': wwwAuthenticate, + }); + setMerchantResponse(200, '{"success":true}'); + + const result = await runProdCli( + 'mpp', + 'pay', + `http://127.0.0.1:${merchantPort}/api/charge`, + '--spend-request-id', + 'lsrq_spt_001', + '--header', + 'Authorization: Bearer app-token', + '--json', + ); + + expect(result.exitCode).toBe(0); + expect(merchantRequests).toHaveLength(2); + expect(merchantRequests[1].headers.authorization).toBe( + 'Bearer app-token', + ); + expect(merchantRequests[1].headers['payment-authorization']).toMatch( + /^Payment /, + ); + const credential = decodeCredential( + merchantRequests[1].headers['payment-authorization'] as string, + ); + expect(credential.challenge).toMatchObject({ + intent: 'charge', + header: 'Payment-Authorization', + }); + }); + it('returns structured response when the paid retry fails', async () => { setNextResponse(200, APPROVED_SPT_REQUEST); setMerchantResponse(402, '{"error":"payment required"}', { diff --git a/packages/cli/src/commands/mpp/credential-header.test.ts b/packages/cli/src/commands/mpp/credential-header.test.ts new file mode 100644 index 00000000..35ed7579 --- /dev/null +++ b/packages/cli/src/commands/mpp/credential-header.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest'; +import { + DEFAULT_CREDENTIAL_HEADER, + PAYMENT_AUTHORIZATION_HEADER, + canonicalizeCredentialHeader, + shouldEchoCredentialHeader, +} from './credential-header'; + +describe('canonicalizeCredentialHeader', () => { + it('treats omitted and Authorization values as the default', () => { + expect(canonicalizeCredentialHeader(undefined)).toBe( + DEFAULT_CREDENTIAL_HEADER, + ); + expect(canonicalizeCredentialHeader('authorization')).toBe( + DEFAULT_CREDENTIAL_HEADER, + ); + }); + + it('accepts Payment-Authorization', () => { + expect(canonicalizeCredentialHeader('Payment-Authorization')).toBe( + PAYMENT_AUTHORIZATION_HEADER, + ); + }); + + it('rejects an unsupported advertised header', () => { + expect(() => canonicalizeCredentialHeader('X-Payment')).toThrow( + /Unsupported payment credential header/i, + ); + }); + + it('echoes only non-default headers', () => { + expect(shouldEchoCredentialHeader(DEFAULT_CREDENTIAL_HEADER)).toBe(false); + expect(shouldEchoCredentialHeader(PAYMENT_AUTHORIZATION_HEADER)).toBe(true); + }); +}); diff --git a/packages/cli/src/commands/mpp/credential-header.ts b/packages/cli/src/commands/mpp/credential-header.ts new file mode 100644 index 00000000..99eaf95a --- /dev/null +++ b/packages/cli/src/commands/mpp/credential-header.ts @@ -0,0 +1,40 @@ +export const DEFAULT_CREDENTIAL_HEADER = 'Authorization'; +export const PAYMENT_AUTHORIZATION_HEADER = 'Payment-Authorization'; + +export type PaymentCredentialHeader = + | typeof DEFAULT_CREDENTIAL_HEADER + | typeof PAYMENT_AUTHORIZATION_HEADER; + +/** + * HTTP field the client must use for the Payment credential. + * + * `mppx` parses the challenge `header` auth-param; this only canonicalizes the + * advertised value. The protocol allows Authorization (omitted / default) or + * Payment-Authorization. + */ +export function canonicalizeCredentialHeader( + value: string | undefined, +): PaymentCredentialHeader { + if (value == null || value === '') { + return DEFAULT_CREDENTIAL_HEADER; + } + if (equalsHeaderName(value, DEFAULT_CREDENTIAL_HEADER)) { + return DEFAULT_CREDENTIAL_HEADER; + } + if (equalsHeaderName(value, PAYMENT_AUTHORIZATION_HEADER)) { + return PAYMENT_AUTHORIZATION_HEADER; + } + throw new Error( + `Unsupported payment credential header "${value}". Only Authorization (default) and Payment-Authorization are supported.`, + ); +} + +export function shouldEchoCredentialHeader( + header: PaymentCredentialHeader, +): boolean { + return header === PAYMENT_AUTHORIZATION_HEADER; +} + +function equalsHeaderName(left: string, right: string): boolean { + return left.toLowerCase() === right.toLowerCase(); +} diff --git a/packages/cli/src/commands/mpp/decode.test.ts b/packages/cli/src/commands/mpp/decode.test.ts index ca5a102d..b4e2d9b1 100644 --- a/packages/cli/src/commands/mpp/decode.test.ts +++ b/packages/cli/src/commands/mpp/decode.test.ts @@ -35,6 +35,86 @@ describe('decodeStripeChallenge', () => { }); }); + it('includes header when the stripe challenge advertises Payment-Authorization', () => { + const header = [ + 'Payment id="ch_001", realm="merchant.example", method="stripe", intent="charge",', + 'header="Payment-Authorization",', + `request="${encodeRequest({ + amount: '1000', + currency: 'usd', + methodDetails: { + networkId: 'net_001', + paymentMethodTypes: ['card'], + }, + })}"`, + ].join(' '); + + expect(decodeStripeChallenge(header)).toMatchObject({ + id: 'ch_001', + header: 'Payment-Authorization', + network_id: 'net_001', + }); + }); + + it('does not inherit header from a different Payment challenge', () => { + const header = [ + 'Payment id="tempo_001", realm="merchant.example", method="tempo", intent="charge",', + 'header="Payment-Authorization", request="e30=",', + 'Payment id="ch_001", realm="merchant.example", method="stripe", intent="charge",', + `request="${encodeRequest({ + amount: '1000', + currency: 'usd', + methodDetails: { + networkId: 'net_001', + paymentMethodTypes: ['card'], + }, + })}"`, + ].join(' '); + + expect(decodeStripeChallenge(header)).not.toHaveProperty('header'); + }); + + it('keeps header when a quoted description contains Payment', () => { + const header = [ + 'Payment id="ch_001", realm="merchant.example", method="stripe", intent="charge",', + 'description="Payment required",', + 'header="Payment-Authorization",', + `request="${encodeRequest({ + amount: '1000', + currency: 'usd', + methodDetails: { + networkId: 'net_001', + paymentMethodTypes: ['card'], + }, + })}"`, + ].join(' '); + + expect(decodeStripeChallenge(header)).toMatchObject({ + header: 'Payment-Authorization', + description: 'Payment required', + network_id: 'net_001', + }); + }); + + it('rejects an unsupported advertised header', () => { + const header = [ + 'Payment id="ch_001", realm="merchant.example", method="stripe", intent="charge",', + 'header="X-Payment",', + `request="${encodeRequest({ + amount: '1000', + currency: 'usd', + methodDetails: { + networkId: 'net_001', + paymentMethodTypes: ['card'], + }, + })}"`, + ].join(' '); + + expect(() => decodeStripeChallenge(header)).toThrow( + /Unsupported payment credential header/i, + ); + }); + it('handles escaped quoted-string values in challenge parameters', () => { const header = [ 'Payment id="ch_001",', diff --git a/packages/cli/src/commands/mpp/decode.ts b/packages/cli/src/commands/mpp/decode.ts index 55d4c21d..b58a0337 100644 --- a/packages/cli/src/commands/mpp/decode.ts +++ b/packages/cli/src/commands/mpp/decode.ts @@ -1,5 +1,9 @@ import { Challenge } from 'mppx'; import { sanitizeDeep } from '../../utils/sanitize-text'; +import { + canonicalizeCredentialHeader, + shouldEchoCredentialHeader, +} from './credential-header'; type StripeChargeChallenge = Challenge.Challenge< Record, @@ -21,6 +25,8 @@ export interface DecodedStripeChallenge { description?: string; digest?: string; expires?: string; + /** Present only when the challenge advertised Payment-Authorization. */ + header?: string; network_id: string; request_json: Record; } @@ -118,6 +124,7 @@ export function decodeStripeChallenge( const { challenge, networkId, request } = resolveStripeChallenge( Challenge.deserializeList(challengeHeader), ); + const credentialHeader = canonicalizeCredentialHeader(challenge.header); return sanitizeDeep({ id: challenge.id, @@ -127,6 +134,9 @@ export function decodeStripeChallenge( description: challenge.description, digest: challenge.digest, expires: challenge.expires, + ...(shouldEchoCredentialHeader(credentialHeader) + ? { header: credentialHeader } + : {}), network_id: networkId, request_json: request, }); diff --git a/packages/cli/src/commands/mpp/pay.tsx b/packages/cli/src/commands/mpp/pay.tsx index 0bb03062..60288d7e 100644 --- a/packages/cli/src/commands/mpp/pay.tsx +++ b/packages/cli/src/commands/mpp/pay.tsx @@ -10,6 +10,12 @@ import { Methods as StripeMethods } from 'mppx/stripe'; import React, { useEffect, useState } from 'react'; import { pollUntilApproved } from '../../utils/poll-until-approved'; import { sanitizeDeep } from '../../utils/sanitize-text'; +import { + DEFAULT_CREDENTIAL_HEADER, + PAYMENT_AUTHORIZATION_HEADER, + type PaymentCredentialHeader, + canonicalizeCredentialHeader, +} from './credential-header'; import { decodeStripeChallenge, getStripeChargeChallengeFromResponse, @@ -52,9 +58,22 @@ export async function readPayResult(response: Response): Promise { }); } +function setPaymentCredential( + headers: Headers, + credentialHeader: PaymentCredentialHeader, + credential: string, +): void { + if (credentialHeader === PAYMENT_AUTHORIZATION_HEADER) { + headers.set(PAYMENT_AUTHORIZATION_HEADER, credential); + return; + } + headers.set(DEFAULT_CREDENTIAL_HEADER, credential); +} + function createStripePaymentClient(spt: string) { const stripeCharge = Method.toClient(StripeMethods.charge, { async createCredential({ challenge }) { + canonicalizeCredentialHeader(challenge.header); return Credential.serialize({ challenge, payload: { spt }, @@ -66,6 +85,7 @@ function createStripePaymentClient(spt: string) { { ...StripeMethods.charge, intent: 'session' as const }, { async createCredential({ challenge }) { + canonicalizeCredentialHeader(challenge.header); return Credential.serialize({ challenge, payload: { action: 'open', grantedToken: spt }, @@ -82,12 +102,15 @@ function createStripePaymentClient(spt: string) { isPaymentRequired(response) { return response.status === 402; }, - getChallenge(response) { - return getStripeChargeChallengeFromResponse(response); + getChallenges(response) { + return [getStripeChargeChallengeFromResponse(response)]; }, - setCredential(request, credential) { + setCredential(request, credential, options) { + const credentialHeader = canonicalizeCredentialHeader( + options?.challenge?.header, + ); const nextHeaders = new Headers(request.headers); - nextHeaders.set('Authorization', credential); + setPaymentCredential(nextHeaders, credentialHeader, credential); return { ...request, headers: nextHeaders }; }, }), @@ -168,16 +191,23 @@ export async function payWithSpt( return readPayResult(initialResponse); } - const authHeader = + const wwwAuthenticate = initialResponse.headers.get('www-authenticate'); + if (!wwwAuthenticate) { + throw new Error('URL returned 402 but no WWW-Authenticate header'); + } + + const challenge = getStripeChargeChallengeFromResponse(initialResponse); + const credentialHeader = canonicalizeCredentialHeader(challenge.header); + const credential = await createStripePaymentClient(spt).createCredential(initialResponse); + const retryHeaders = new Headers(requestHeaders); + setPaymentCredential(retryHeaders, credentialHeader, credential); + const retryResponse = await fetch(url, { method: httpMethod, body: data, - headers: { - ...requestHeaders, - Authorization: authHeader, - }, + headers: retryHeaders, }); return readPayResult(retryResponse); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fe481498..f2232aa9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -33,8 +33,8 @@ importers: specifier: ^5.0.0 version: 5.0.0(ink@5.2.1(@types/react@18.3.29)(react@18.3.1))(react@18.3.1) mppx: - specifier: 0.8.15 - version: 0.8.15(@modelcontextprotocol/sdk@1.29.0(@cfworker/json-schema@4.1.1)(zod@4.4.3))(express@5.2.1)(hono@4.12.34)(typescript@5.9.3)(viem@2.55.10(typescript@5.9.3)(zod@4.4.3)) + specifier: 0.9.1 + version: 0.9.1(@modelcontextprotocol/sdk@1.29.0(@cfworker/json-schema@4.1.1)(zod@4.4.3))(express@5.2.1)(hono@4.12.34)(typescript@5.9.3)(viem@2.55.10(typescript@5.9.3)(zod@4.4.3)) qrcode: specifier: ^1.5.4 version: 1.5.4 @@ -886,8 +886,8 @@ packages: '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} - '@stripe/stripe-js@9.9.0': - resolution: {integrity: sha512-Vwqe6Q5cU4i82tPyAv2BpaW/fQSNdOSO4/J8EeDLPp5/oIZiMmdB+Hgh863zFH+rtoxpuWGvD1L7QPh8k1Rdvw==} + '@stripe/stripe-js@9.13.0': + resolution: {integrity: sha512-/0c72BUgzzVkVTlsw5uBn8x3waTdVJ/PZGfQ6jY1eu6K7olUPf4d9lgDPA9/0sIdsR8j7o3QIG8fOCO6ItcL7A==} engines: {node: '>=12.16'} '@toon-format/toon@2.3.0': @@ -1374,10 +1374,6 @@ packages: eventemitter3@5.0.1: resolution: {integrity: sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==} - eventsource-parser@3.1.0: - resolution: {integrity: sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==} - engines: {node: '>=18.0.0'} - eventsource-parser@3.1.1: resolution: {integrity: sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ==} engines: {node: '>=18.0.0'} @@ -1822,24 +1818,42 @@ packages: mlly@1.8.2: resolution: {integrity: sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==} - mppx@0.8.15: - resolution: {integrity: sha512-+4jQRYB3AbgATfsZZAen7SxDC4miAPhUokTmBgda5OORZKvPnbWKAKHMHK1oMKsxWTVMYBwfgxmiJYAEe6I47g==} + mppx@0.9.1: + resolution: {integrity: sha512-mmzOHcUnyvxXBFJQWWFeaT6+uwRuphqmzFZXfJjDKlmEQ559oEseQusaXs68husPKSYZU9WCwRElhi55+5I+5A==} hasBin: true peerDependencies: '@modelcontextprotocol/sdk': '>=1.25.0' + '@x402/core': '>=2.22.0' + '@x402/express': '>=2.22.0' + '@x402/hono': '>=2.22.0' + '@x402/mcp': '>=2.22.0' + '@x402/next': '>=2.22.0' elysia: '>=1' express: '>=5' hono: '>=4.12.25' + next: '>=16.2.6' viem: '>=2.54.0' peerDependenciesMeta: '@modelcontextprotocol/sdk': optional: true + '@x402/core': + optional: true + '@x402/express': + optional: true + '@x402/hono': + optional: true + '@x402/mcp': + optional: true + '@x402/next': + optional: true elysia: optional: true express: optional: true hono: optional: true + next: + optional: true mri@1.2.0: resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} @@ -2244,6 +2258,10 @@ packages: resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==} engines: {node: '>=0.10.0'} + structured-headers@2.0.3: + resolution: {integrity: sha512-4g5yxhlDMClRwCcfKfLeS7Z8yAVdOWGDADwm80Poh1iReU2KVKLGBlqwpHWJ2qovq0+ZIf1atAEO1eua2o9Rgg==} + engines: {node: '>=18', npm: '>=6'} + stubborn-fs@2.0.0: resolution: {integrity: sha512-Y0AvSwDw8y+nlSNFXMm2g6L51rBGdAQT20J3YSOqxC53Lo3bjWRtr2BKcfYoAf352WYpsZSTURrA0tqhfgudPA==} @@ -3159,7 +3177,7 @@ snapshots: '@standard-schema/spec@1.1.0': {} - '@stripe/stripe-js@9.9.0': {} + '@stripe/stripe-js@9.13.0': {} '@toon-format/toon@2.3.0': {} @@ -3662,10 +3680,7 @@ snapshots: eventemitter3@5.0.1: {} - eventsource-parser@3.1.0: {} - - eventsource-parser@3.1.1: - optional: true + eventsource-parser@3.1.1: {} eventsource@3.0.7: dependencies: @@ -4116,12 +4131,12 @@ snapshots: pkg-types: 1.3.1 ufo: 1.6.4 - mppx@0.8.15(@modelcontextprotocol/sdk@1.29.0(@cfworker/json-schema@4.1.1)(zod@4.4.3))(express@5.2.1)(hono@4.12.34)(typescript@5.9.3)(viem@2.55.10(typescript@5.9.3)(zod@4.4.3)): + mppx@0.9.1(@modelcontextprotocol/sdk@1.29.0(@cfworker/json-schema@4.1.1)(zod@4.4.3))(express@5.2.1)(hono@4.12.34)(typescript@5.9.3)(viem@2.55.10(typescript@5.9.3)(zod@4.4.3)): dependencies: - '@stripe/stripe-js': 9.9.0 - eventsource-parser: 3.1.0 - incur: 0.4.26 + '@stripe/stripe-js': 9.13.0 + eventsource-parser: 3.1.1 ox: 0.14.33(typescript@5.9.3)(zod@4.4.3) + structured-headers: 2.0.3 viem: 2.55.10(typescript@5.9.3)(zod@4.4.3) zod: 4.4.3 optionalDependencies: @@ -4570,6 +4585,8 @@ snapshots: strip-json-comments@2.0.1: {} + structured-headers@2.0.3: {} + stubborn-fs@2.0.0: dependencies: stubborn-utils: 1.0.2