diff --git a/src/Expires.ts b/src/Expires.ts index b64a30be..34de8c19 100644 --- a/src/Expires.ts +++ b/src/Expires.ts @@ -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() diff --git a/src/server/Mppx.test.ts b/src/server/Mppx.test.ts index 68e0feaa..eab12cd2 100644 --- a/src/server/Mppx.test.ts +++ b/src/server/Mppx.test.ts @@ -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', diff --git a/src/server/Mppx.ts b/src/server/Mppx.ts index 6df90eb5..59c5b4d2 100644 --- a/src/server/Mppx.ts +++ b/src/server/Mppx.ts @@ -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 } } diff --git a/src/stripe/server/Charge.ts b/src/stripe/server/Charge.ts index eac11803..096f05da 100644 --- a/src/stripe/server/Charge.ts +++ b/src/stripe/server/Charge.ts @@ -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' @@ -66,8 +63,7 @@ export function charge(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') diff --git a/src/tempo/server/Charge.ts b/src/tempo/server/Charge.ts index e99c8d45..bfdb88b0 100644 --- a/src/tempo/server/Charge.ts +++ b/src/tempo/server/Charge.ts @@ -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' @@ -118,7 +118,7 @@ export function charge( 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