Skip to content
Open
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
35 changes: 35 additions & 0 deletions packages/cli/src/__tests__/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -803,6 +803,20 @@ describe('production mode', () => {
'expires="2099-01-01T00:00:00Z"',
].join(' ');

const WWW_AUTHENTICATE_MULTI = [
'Payment id="tempo_001",',
'realm="127.0.0.1",',
'method="tempo",',
'intent="charge",',
'request="e30=",',
'Payment id="ch_001",',
'realm="127.0.0.1",',
'method="stripe",',
'intent="charge",',
`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(' ');

it('happy path: probes, gets 402, signs, retries, returns response', async () => {
setNextResponse(200, APPROVED_SPT_REQUEST);
setMerchantResponse(402, '{"error":"payment required"}', {
Expand Down Expand Up @@ -830,6 +844,27 @@ describe('production mode', () => {
expect(merchantRequests[1].headers.authorization).toMatch(/^Payment /);
});

it('selects the stripe challenge when the response advertises multiple methods', async () => {
setNextResponse(200, APPROVED_SPT_REQUEST);
setMerchantResponse(402, '{"error":"payment required"}', {
'www-authenticate': WWW_AUTHENTICATE_MULTI,
});
setMerchantResponse(200, '{"success":true}');

const result = await runProdCli(
'mpp',
'pay',
`http://127.0.0.1:${merchantPort}/api/charge`,
'--spend-request-id',
'lsrq_spt_001',
'--output-json',
);

expect(result.exitCode).toBe(0);
expect(merchantRequests).toHaveLength(2);
expect(merchantRequests[1].headers.authorization).toMatch(/^Payment /);
});

it('passthrough: no 402 returns response without signing', async () => {
setNextResponse(200, APPROVED_SPT_REQUEST);
setMerchantResponse(200, '{"ok":true}');
Expand Down
50 changes: 40 additions & 10 deletions packages/cli/src/commands/mpp/decode.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,17 @@
import { Challenge } from 'mppx';

type StripeChargeChallenge = Challenge.Challenge<
Record<string, unknown>,
'charge',
'stripe'
>;

type ResolvedStripeChallenge = {
challenge: StripeChargeChallenge;
networkId: string;
request: Record<string, unknown>;
};

export interface DecodedStripeChallenge {
id: string;
realm: string;
Expand Down Expand Up @@ -46,11 +58,9 @@ function getMethodDetails(
return methodDetails as Record<string, unknown>;
}

export function decodeStripeChallenge(
challengeHeader: string,
): DecodedStripeChallenge {
const challenges = Challenge.deserializeList(challengeHeader);

function resolveStripeChallenge(
challenges: Challenge.Challenge[],
): ResolvedStripeChallenge {
const stripeChallenge = challenges.find(
(challenge) =>
challenge.method === 'stripe' && challenge.intent === 'charge',
Expand Down Expand Up @@ -88,13 +98,33 @@ export function decodeStripeChallenge(
}

return {
id: stripeChallenge.id,
realm: stripeChallenge.realm,
challenge: stripeChallenge as StripeChargeChallenge,
networkId,
request,
};
}

export function getStripeChargeChallengeFromResponse(
response: Response,
): StripeChargeChallenge {
return resolveStripeChallenge(Challenge.fromResponseList(response)).challenge;
}

export function decodeStripeChallenge(
challengeHeader: string,
): DecodedStripeChallenge {
const { challenge, networkId, request } = resolveStripeChallenge(
Challenge.deserializeList(challengeHeader),
);

return {
id: challenge.id,
realm: challenge.realm,
method: 'stripe',
intent: 'charge',
description: stripeChallenge.description,
digest: stripeChallenge.digest,
expires: stripeChallenge.expires,
description: challenge.description,
digest: challenge.digest,
expires: challenge.expires,
network_id: networkId,
request_json: request,
};
Expand Down
83 changes: 41 additions & 42 deletions packages/cli/src/commands/mpp/pay.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import type { ISpendRequestResource } from '@stripe/link-sdk';
import { Box, Text } from 'ink';
import Spinner from 'ink-spinner';
import { Challenge, Credential } from 'mppx';
import { Credential, Method } from 'mppx';
import { Mppx, Transport } from 'mppx/client';
import { Methods as StripeMethods } from 'mppx/stripe';
import React, { useEffect, useState } from 'react';
import { outputError } from '../../utils/execute-command';
import { decodeStripeChallenge } from './decode';
import { getStripeChargeChallengeFromResponse } from './decode';

export type PayResult = {
status: number;
Expand All @@ -30,6 +32,36 @@ function buildHeaders(
return result;
}

function createStripePaymentClient(spt: string) {
const stripeCharge = Method.toClient(StripeMethods.charge, {
async createCredential({ challenge }) {
return Credential.serialize({
challenge,
payload: { spt },
});
},
});

return Mppx.create({
methods: [stripeCharge],
polyfill: false,
transport: Transport.from<RequestInit, Response>({
name: 'stripe-http',
isPaymentRequired(response) {
return response.status === 402;
},
getChallenge(response) {
return getStripeChargeChallengeFromResponse(response);
},
setCredential(request, credential) {
const nextHeaders = new Headers(request.headers);
nextHeaders.set('Authorization', credential);
return { ...request, headers: nextHeaders };
},
}),
});
}

export async function runMppPay(
url: string,
spendRequestId: string,
Expand Down Expand Up @@ -91,27 +123,10 @@ export async function runMppPay(
return { status: initialResponse.status, headers: responseHeaders, body };
}

// 5. Parse challenges, find stripe
const decoded = decodeStripeChallenge(
initialResponse.headers.get('www-authenticate') ?? '',
);
const stripeChallenge = Challenge.from({
id: decoded.id,
realm: decoded.realm,
method: decoded.method,
intent: decoded.intent,
request: decoded.request_json,
...(decoded.description ? { description: decoded.description } : {}),
...(decoded.digest ? { digest: decoded.digest } : {}),
...(decoded.expires ? { expires: decoded.expires } : {}),
});

// 6. Build and serialize credential
const credential = Credential.from({
challenge: stripeChallenge,
payload: { spt },
});
const authHeader = Credential.serialize(credential);
// 5. Select the Stripe challenge and build the payment credential
const authHeader = await createStripePaymentClient(
spt,
).createCredential(initialResponse);

// 7. Retry with Authorization header
const retryResponse = await fetch(url, {
Expand Down Expand Up @@ -211,25 +226,9 @@ export function MppPay({
}

setStep('signing');
const decoded = decodeStripeChallenge(
initialResponse.headers.get('www-authenticate') ?? '',
);
const stripeChallenge = Challenge.from({
id: decoded.id,
realm: decoded.realm,
method: decoded.method,
intent: decoded.intent,
request: decoded.request_json,
...(decoded.description ? { description: decoded.description } : {}),
...(decoded.digest ? { digest: decoded.digest } : {}),
...(decoded.expires ? { expires: decoded.expires } : {}),
});

const credential = Credential.from({
challenge: stripeChallenge,
payload: { spt },
});
const authHeader = Credential.serialize(credential);
const authHeader = await createStripePaymentClient(
spt,
).createCredential(initialResponse);

setStep('submitting');
const retryResponse = await fetch(url, {
Expand Down
Loading