Skip to content
Merged
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
25 changes: 25 additions & 0 deletions src/Expires.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,28 @@
import { InvalidChallengeError, PaymentExpiredError } from './Errors.js'

/**
* Asserts that `expires` is present, well-formed, and not in the past.
*
* Throws `InvalidChallengeError` when missing or malformed,
* and `PaymentExpiredError` when the timestamp is in the past.
*/
export function assert(
expires: string | undefined,
challengeId?: string,
): asserts expires is string {
if (!expires)
throw new InvalidChallengeError({
...(challengeId && { id: challengeId }),
reason: 'missing required expires field',
})
if (Number.isNaN(new Date(expires).getTime()))
throw new InvalidChallengeError({
...(challengeId && { id: challengeId }),
reason: 'malformed expires timestamp',
})
if (new Date(expires) < new Date()) throw new PaymentExpiredError({ expires })
}

/** Returns an ISO 8601 datetime string `n` days from now. */
export function days(n: number) {
return new Date(Date.now() + n * 24 * 60 * 60 * 1000).toISOString()
Expand Down
86 changes: 86 additions & 0 deletions src/server/Mppx.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -422,6 +422,92 @@ describe('request handler', () => {
`)
expect((body as { detail: string }).detail).toContain('Payment expired at')
})
test('returns 402 when credential challenge has no expires (fail-closed)', async () => {
const handle = Mppx.create({ methods: [method], realm, secretKey }).charge({
amount: '1000',
currency: asset,
expires: new Date(Date.now() + 60_000).toISOString(),
recipient: accounts[0].address,
})

// Get a valid challenge from the server to capture the exact request shape
const firstResult = await handle(new Request('https://example.com/resource'))
expect(firstResult.status).toBe(402)
if (firstResult.status !== 402) throw new Error()

const serverChallenge = Challenge.fromResponse(firstResult.challenge)

// Re-create the same challenge WITHOUT expires, with a valid HMAC
const { expires: _, ...rest } = serverChallenge
const challengeNoExpires = Challenge.from({
secretKey,
realm: rest.realm,
method: rest.method,
intent: rest.intent,
request: rest.request,
...(rest.opaque && { meta: rest.opaque }),
})

const credential = Credential.from({
challenge: challengeNoExpires,
payload: { signature: '0x123', type: 'transaction' },
})

const result = await handle(
new Request('https://example.com/resource', {
headers: { Authorization: Credential.serialize(credential) },
}),
)

expect(result.status).toBe(402)
if (result.status !== 402) throw new Error()

const body = (await result.challenge.json()) as { title: string; detail: string }
expect(body.title).toBe('Invalid Challenge')
expect(body.detail).toContain('missing required expires')
})
test('returns 402 when credential challenge has malformed expires', async () => {
const handle = Mppx.create({ methods: [method], realm, secretKey }).charge({
amount: '1000',
currency: asset,
expires: new Date(Date.now() + 60_000).toISOString(),
recipient: accounts[0].address,
})

// Get a valid challenge from the server to capture the exact request shape
const firstResult = await handle(new Request('https://example.com/resource'))
expect(firstResult.status).toBe(402)
if (firstResult.status !== 402) throw new Error()

const serverChallenge = Challenge.fromResponse(firstResult.challenge)

// Re-create the challenge with a valid HMAC but inject a malformed expires
// by patching the challenge object after construction (bypasses zod at build time).
const challengeMalformed = {
...serverChallenge,
expires: 'not-a-timestamp',
}

const credential = Credential.from({
challenge: challengeMalformed as any,
payload: { signature: '0x123', type: 'transaction' },
})

// Credential.serialize does not re-validate, so the malformed expires
// reaches the server. Deserialization rejects it via zod schema.
const result = await handle(
new Request('https://example.com/resource', {
headers: { Authorization: Credential.serialize(credential) },
}),
)

expect(result.status).toBe(402)
if (result.status !== 402) throw new Error()

const body = (await result.challenge.json()) as { title: string; detail: string }
expect(body.title).toBe('Malformed Credential')
})

test('returns 402 when payload schema validation fails', async () => {
const handle = Mppx.create({ methods: [method], realm, secretKey }).charge({
amount: '1000',
Expand Down
10 changes: 5 additions & 5 deletions src/server/Mppx.ts
Original file line number Diff line number Diff line change
Expand Up @@ -418,14 +418,14 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R
}
}

// Reject expired credentials
if (credential.challenge.expires && new Date(credential.challenge.expires) < new Date()) {
// Reject credentials without expires (fail-closed) or with expired timestamp
try {
Expires.assert(credential.challenge.expires, credential.challenge.id)
} catch (error) {
const response = await transport.respondChallenge({
challenge,
input,
error: new Errors.PaymentExpiredError({
expires: credential.challenge.expires,
}),
error: error as Errors.PaymentError,
})
return { challenge: response, status: 402 }
}
Expand Down
10 changes: 3 additions & 7 deletions src/stripe/server/Charge.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,6 @@
import type * as Credential from '../../Credential.js'
import {
PaymentActionRequiredError,
PaymentExpiredError,
VerificationFailedError,
} from '../../Errors.js'
import { PaymentActionRequiredError, VerificationFailedError } from '../../Errors.js'
import * as Expires from '../../Expires.js'
import type { LooseOmit, OneOf } from '../../internal/types.js'
import * as Method from '../../Method.js'
import type { StripeClient } from '../internal/types.js'
Expand Down Expand Up @@ -66,8 +63,7 @@ export function charge<const parameters extends charge.Parameters>(parameters: p
const { challenge } = credential
const { request } = challenge

if (challenge.expires && new Date(challenge.expires) < new Date())
throw new PaymentExpiredError({ expires: challenge.expires })
Expires.assert(challenge.expires, challenge.id)

const parsed = Methods.charge.schema.credential.payload.safeParse(credential.payload)
if (!parsed.success) throw new Error('Invalid credential payload: missing or malformed spt')
Expand Down
4 changes: 2 additions & 2 deletions src/tempo/server/Charge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import {
import { tempo as tempo_chain } from 'viem/chains'
import { Abis, Transaction } from 'viem/tempo'

import { PaymentExpiredError } from '../../Errors.js'
import * as Expires from '../../Expires.js'
import type { LooseOmit, NoExtraKeys } from '../../internal/types.js'
import * as Method from '../../Method.js'
import * as Store from '../../Store.js'
Expand Down Expand Up @@ -118,7 +118,7 @@ export function charge<const parameters extends charge.Parameters>(
const currency = challengeRequest.currency as `0x${string}`
const recipient = challengeRequest.recipient as `0x${string}`

if (expires && new Date(expires) < new Date()) throw new PaymentExpiredError({ expires })
Expires.assert(expires, challenge.id)

const memo = methodDetails?.memo as `0x${string}` | undefined

Expand Down
Loading