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
5 changes: 5 additions & 0 deletions .changeset/ack-pay-expires-at-verification.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@agentcommercekit/ack-pay": patch
---

Reject payment requests whose `expiresAt` timestamp has passed when expiry verification is enabled.
54 changes: 54 additions & 0 deletions packages/ack-pay/src/verify-payment-receipt.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@ import type { PaymentRequestInit } from "./payment-request"
import { isPaymentReceiptCredential } from "./receipt-claim-verifier"
import { verifyPaymentReceipt } from "./verify-payment-receipt"

/** A clearly past ISO 8601 timestamp, fixed for determinism. */
const PAST_EXPIRES_AT = "2000-01-01T00:00:00.000Z"

describe("verifyPaymentReceipt()", () => {
let resolver: Resolvable
let unsignedReceipt: W3CCredential
Expand Down Expand Up @@ -95,6 +98,57 @@ describe("verifyPaymentReceipt()", () => {
expect(result.paymentRequest).toBeDefined()
})

it("validates a receipt over an expired payment request", async () => {
const expiredRequestIssuerKeypair = await generateKeypair("secp256k1")
const expiredRequestIssuerDid = createDidKeyUri(expiredRequestIssuerKeypair)

const expiredRequest = await createSignedPaymentRequest(
{
id: "test-expired-request-id",
paymentOptions: [
{
id: "test-payment-option-id",
amount: 100,
decimals: 2,
currency: "USD",
network: "eip155:84532",
recipient: "0x592D4858DE40BC81A77E5B373238B70D7C79D3C79",
},
],
expiresAt: PAST_EXPIRES_AT,
},
{
issuer: expiredRequestIssuerDid,
signer: createJwtSigner(expiredRequestIssuerKeypair),
algorithm: curveToJwtAlgorithm(expiredRequestIssuerKeypair.curve),
},
)

const unsignedExpiredReceipt = createPaymentReceipt({
paymentRequestToken: expiredRequest.paymentRequestToken,
paymentOptionId: expiredRequest.paymentRequest.paymentOptions[0].id,
issuer: receiptIssuerDid,
payerDid: createDidPkhUri(
"eip155:84532",
"0x7B3D8F2E1C9A4B5D6E7F8A9B0C1D2E3F4A5B6C",
),
})
const signedExpiredReceiptJwt = await signCredential(
unsignedExpiredReceipt,
{
did: receiptIssuerDid,
signer: createJwtSigner(receiptIssuerKeypair),
},
)

const result = await verifyPaymentReceipt(signedExpiredReceiptJwt, {
resolver,
})
expect(result.receipt).toBeDefined()
expect(result.paymentRequestToken).toBe(expiredRequest.paymentRequestToken)
expect(result.paymentRequest).toBeDefined()
})

it("validates a parsed credential", async () => {
const result = await verifyPaymentReceipt(signedReceipt, {
resolver,
Expand Down
67 changes: 67 additions & 0 deletions packages/ack-pay/src/verify-payment-request-token.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,11 @@ import { InvalidPaymentRequestTokenError } from "./errors"
import type { PaymentRequestInit } from "./payment-request"
import { verifyPaymentRequestToken } from "./verify-payment-request-token"

/** A clearly past ISO 8601 timestamp, fixed for determinism. */
const PAST_EXPIRES_AT = "2000-01-01T00:00:00.000Z"
/** A clearly future ISO 8601 timestamp, fixed for determinism. */
const FUTURE_EXPIRES_AT = "2999-12-31T23:59:59.000Z"

/**
* Removes undefined values from the payment request
*/
Expand Down Expand Up @@ -206,4 +211,66 @@ describe("verifyPaymentRequestToken", () => {
)
expect(error.cause).toBeUndefined()
})

it("throws for a payment request whose expiresAt has passed", async () => {
const body = await createSignedPaymentRequest(
{ ...paymentRequest, expiresAt: PAST_EXPIRES_AT },
{
issuer: issuerDid,
signer,
algorithm: curveToJwtAlgorithm(keypair.curve),
},
)

const resolver = getDidResolver()
resolver.addToCache(issuerDid, issuerDidDocument)

const error = await verifyPaymentRequestToken(body.paymentRequestToken, {
resolver,
}).catch((e) => e)

expect(error).toBeInstanceOf(InvalidPaymentRequestTokenError)
expect(error.message).toBe("Payment request has expired")
})

it("accepts a payment request whose expiresAt is in the future", async () => {
const body = await createSignedPaymentRequest(
{ ...paymentRequest, expiresAt: FUTURE_EXPIRES_AT },
{
issuer: issuerDid,
signer,
algorithm: curveToJwtAlgorithm(keypair.curve),
},
)

const resolver = getDidResolver()
resolver.addToCache(issuerDid, issuerDidDocument)

const result = await verifyPaymentRequestToken(body.paymentRequestToken, {
resolver,
})

expect(result.paymentRequest.id).toBe(paymentRequest.id)
})

it("accepts an expired payment request when expiry verification is disabled", async () => {
const body = await createSignedPaymentRequest(
{ ...paymentRequest, expiresAt: PAST_EXPIRES_AT },
{
issuer: issuerDid,
signer,
algorithm: curveToJwtAlgorithm(keypair.curve),
},
)

const resolver = getDidResolver()
resolver.addToCache(issuerDid, issuerDidDocument)

const result = await verifyPaymentRequestToken(body.paymentRequestToken, {
resolver,
verifyExpiry: false,
})

expect(result.paymentRequest.id).toBe(paymentRequest.id)
})
})
11 changes: 10 additions & 1 deletion packages/ack-pay/src/verify-payment-request-token.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ interface ValidatePaymentRequestTokenOptions {
*/
resolver?: Resolvable
/**
* Whether to verify the expiry of the payment request token
* Whether to verify JWT `exp` and PaymentRequest `expiresAt`
*/
verifyExpiry?: boolean
/**
Expand Down Expand Up @@ -58,6 +58,15 @@ export async function verifyPaymentRequestToken(
)
}

// `expiresAt` is a business-level expiry distinct from JWT `exp`.
if (
options.verifyExpiry !== false &&
output.expiresAt !== undefined &&
Date.parse(output.expiresAt) <= Date.now()
) {
throw new InvalidPaymentRequestTokenError("Payment request has expired")
}

return {
paymentRequest: output,
parsed: parsedPaymentRequestToken,
Expand Down