From a676788b7f3858d6740c36ffd26318c8846cb686 Mon Sep 17 00:00:00 2001 From: kutluhaneth46 Date: Sun, 27 Sep 2026 20:38:37 +0000 Subject: [PATCH 1/3] fix(ack-pay): enforce payment request expiresAt Mint JWT exp from expiresAt and reject past expiresAt during verification unless verifyExpiry is disabled (#222). AI disclosure: prepared with Cursor assistance; I reviewed the token issue/verify path and tests. --- .../enforce-payment-request-expires-at.md | 5 ++++ .../src/create-payment-receipt.test.ts | 2 +- .../src/create-payment-request-token.ts | 25 +++++++++++++--- .../src/create-signed-payment-request.test.ts | 4 +-- .../src/verify-payment-request-token.test.ts | 29 +++++++++++++++++++ .../src/verify-payment-request-token.ts | 9 ++++++ 6 files changed, 67 insertions(+), 7 deletions(-) create mode 100644 .changeset/enforce-payment-request-expires-at.md diff --git a/.changeset/enforce-payment-request-expires-at.md b/.changeset/enforce-payment-request-expires-at.md new file mode 100644 index 00000000..91b55f33 --- /dev/null +++ b/.changeset/enforce-payment-request-expires-at.md @@ -0,0 +1,5 @@ +--- +"@agentcommercekit/ack-pay": patch +--- + +Set JWT `exp` from `paymentRequest.expiresAt` when issuing payment request tokens, and reject tokens whose `expiresAt` is in the past during verification (unless `verifyExpiry` is disabled). diff --git a/packages/ack-pay/src/create-payment-receipt.test.ts b/packages/ack-pay/src/create-payment-receipt.test.ts index 611723d9..df62a226 100644 --- a/packages/ack-pay/src/create-payment-receipt.test.ts +++ b/packages/ack-pay/src/create-payment-receipt.test.ts @@ -20,7 +20,7 @@ describe("createPaymentReceipt", () => { const paymentRequest: PaymentRequestInit = { id: "test-payment-request-id", - expiresAt: new Date("2024-12-31T23:59:59Z"), + expiresAt: new Date("2025-01-01T00:00:00Z"), paymentOptions: [ { id: "test-payment-option-id", diff --git a/packages/ack-pay/src/create-payment-request-token.ts b/packages/ack-pay/src/create-payment-request-token.ts index 0e50a0c6..8fb065b2 100644 --- a/packages/ack-pay/src/create-payment-request-token.ts +++ b/packages/ack-pay/src/create-payment-request-token.ts @@ -34,12 +34,29 @@ export async function createPaymentRequestToken( paymentRequest: PaymentRequest, { issuer, signer, algorithm }: PaymentRequestTokenOptions, ): Promise { + const options: { + issuer: DidUri + signer: JwtSigner + expiresIn?: number + } = { + issuer, + signer, + } + + // Mirror paymentRequest.expiresAt into the JWT `exp` claim so verifiers that + // only check JWT expiry still reject stale quotes. + if (paymentRequest.expiresAt) { + const expiresAtMs = new Date(paymentRequest.expiresAt).getTime() + const expiresIn = Math.floor((expiresAtMs - Date.now()) / 1000) + if (expiresIn <= 0) { + throw new Error("Payment request expiresAt must be in the future") + } + options.expiresIn = expiresIn + } + return createJwt( { ...paymentRequest, sub: paymentRequest.id }, - { - issuer, - signer, - }, + options, { alg: algorithm, }, diff --git a/packages/ack-pay/src/create-signed-payment-request.test.ts b/packages/ack-pay/src/create-signed-payment-request.test.ts index 4838b3f0..237450f7 100644 --- a/packages/ack-pay/src/create-signed-payment-request.test.ts +++ b/packages/ack-pay/src/create-signed-payment-request.test.ts @@ -93,7 +93,7 @@ describe("createSignedPaymentRequest()", () => { }) it("includes expiresAt in ISO string format when provided", async () => { - const expiresAt = new Date("2024-12-31T23:59:59Z") + const expiresAt = new Date(Date.now() + 60 * 60 * 1000) const result = await createSignedPaymentRequest( { ...paymentRequest, expiresAt }, { @@ -103,6 +103,6 @@ describe("createSignedPaymentRequest()", () => { }, ) - expect(result.paymentRequest.expiresAt).toBe("2024-12-31T23:59:59.000Z") + expect(result.paymentRequest.expiresAt).toBe(expiresAt.toISOString()) }) }) diff --git a/packages/ack-pay/src/verify-payment-request-token.test.ts b/packages/ack-pay/src/verify-payment-request-token.test.ts index f42dac56..c619f01f 100644 --- a/packages/ack-pay/src/verify-payment-request-token.test.ts +++ b/packages/ack-pay/src/verify-payment-request-token.test.ts @@ -206,4 +206,33 @@ describe("verifyPaymentRequestToken", () => { ) expect(error.cause).toBeUndefined() }) + + it("throws when paymentRequest.expiresAt is in the past even without JWT exp", async () => { + const expiredPayload = { + ...paymentRequest, + sub: paymentRequest.id, + expiresAt: new Date(Date.now() - 60_000).toISOString(), + } + + const expiredToken = await createJwt( + expiredPayload, + { + issuer: issuerDid, + signer, + }, + { + alg: curveToJwtAlgorithm(keypair.curve), + }, + ) + + const resolver = getDidResolver() + resolver.addToCache(issuerDid, issuerDidDocument) + + const error = await verifyPaymentRequestToken(expiredToken, { + resolver, + }).catch((e) => e) + + expect(error).toBeInstanceOf(InvalidPaymentRequestTokenError) + expect(error.message).toMatch(/expired/i) + }) }) diff --git a/packages/ack-pay/src/verify-payment-request-token.ts b/packages/ack-pay/src/verify-payment-request-token.ts index d1c3d286..88ace83a 100644 --- a/packages/ack-pay/src/verify-payment-request-token.ts +++ b/packages/ack-pay/src/verify-payment-request-token.ts @@ -58,6 +58,15 @@ export async function verifyPaymentRequestToken( ) } + // Enforce payload expiresAt even when the JWT was minted without an `exp` + // claim (historical tokens) or when callers only set expiresAt on the request. + if ((options.verifyExpiry ?? true) && output.expiresAt) { + const expiresAtMs = new Date(output.expiresAt).getTime() + if (Number.isNaN(expiresAtMs) || expiresAtMs <= Date.now()) { + throw new InvalidPaymentRequestTokenError("Payment request has expired") + } + } + return { paymentRequest: output, parsed: parsedPaymentRequestToken, From f5d26258c3cb8d5e5afa6e8ee233f5a9f1c4882c Mon Sep 17 00:00:00 2001 From: kutluhaneth46 Date: Mon, 28 Sep 2026 15:42:45 +0000 Subject: [PATCH 2/3] fix(ack-pay): project payment request before JWT signing Strip reserved JWT claims from untyped caller objects so a smuggled `exp` cannot override the expiresAt-derived expiresIn value. --- .../src/create-payment-request-token.test.ts | 38 +++++++++++++++++++ .../src/create-payment-request-token.ts | 14 +++++-- 2 files changed, 48 insertions(+), 4 deletions(-) diff --git a/packages/ack-pay/src/create-payment-request-token.test.ts b/packages/ack-pay/src/create-payment-request-token.test.ts index cf019127..9fa08edc 100644 --- a/packages/ack-pay/src/create-payment-request-token.test.ts +++ b/packages/ack-pay/src/create-payment-request-token.test.ts @@ -85,4 +85,42 @@ describe("createPaymentRequestToken()", () => { expect(result.payload.iss).toBe(issuerDid) expect(result.payload.sub).toBe(paymentRequest.id) }) + + it("does not let a smuggled exp claim override expiresAt-derived JWT exp", async () => { + const expiresAt = new Date(Date.now() + 60 * 60 * 1000) + const farFutureExp = Math.floor(Date.now() / 1000) + 60 * 60 * 24 * 365 + const requestWithSmuggledExp = { + ...v.parse(paymentRequestSchema, { ...paymentRequestInit, expiresAt }), + exp: farFutureExp, + } as PaymentRequestInit & { exp: number } + + const paymentRequestToken = await createPaymentRequestToken( + // Simulate an unprojected caller object that includes reserved JWT claims. + requestWithSmuggledExp as unknown as typeof paymentRequest, + { + issuer: issuerDid, + signer, + algorithm: curveToJwtAlgorithm(keypair.curve), + }, + ) + + const resolver = getDidResolver() + resolver.addToCache( + issuerDid, + createDidDocumentFromKeypair({ + did: issuerDid, + keypair, + }), + ) + + const result = await verifyJwt(paymentRequestToken, { resolver }) + const expectedExp = Math.floor(expiresAt.getTime() / 1000) + + expect(result.payload.exp).toBeDefined() + expect(result.payload.exp).not.toBe(farFutureExp) + // Allow a few seconds of clock skew between mint and assertion. + expect(Math.abs((result.payload.exp as number) - expectedExp)).toBeLessThan( + 5, + ) + }) }) diff --git a/packages/ack-pay/src/create-payment-request-token.ts b/packages/ack-pay/src/create-payment-request-token.ts index 8fb065b2..c71dcc5e 100644 --- a/packages/ack-pay/src/create-payment-request-token.ts +++ b/packages/ack-pay/src/create-payment-request-token.ts @@ -5,8 +5,10 @@ import { type JwtSigner, type JwtString, } from "@agentcommercekit/jwt" +import * as v from "valibot" import type { PaymentRequest } from "./payment-request" +import { paymentRequestSchema } from "./schemas/valibot" export interface PaymentRequestTokenOptions { /** @@ -34,6 +36,10 @@ export async function createPaymentRequestToken( paymentRequest: PaymentRequest, { issuer, signer, algorithm }: PaymentRequestTokenOptions, ): Promise { + // Project through the schema so reserved JWT claims (exp/iat/nbf/…) cannot + // ride along on an untyped caller object and override `expiresIn`-derived exp. + const request = v.parse(paymentRequestSchema, paymentRequest) + const options: { issuer: DidUri signer: JwtSigner @@ -43,10 +49,10 @@ export async function createPaymentRequestToken( signer, } - // Mirror paymentRequest.expiresAt into the JWT `exp` claim so verifiers that + // Mirror request.expiresAt into the JWT `exp` claim so verifiers that // only check JWT expiry still reject stale quotes. - if (paymentRequest.expiresAt) { - const expiresAtMs = new Date(paymentRequest.expiresAt).getTime() + if (request.expiresAt) { + const expiresAtMs = new Date(request.expiresAt).getTime() const expiresIn = Math.floor((expiresAtMs - Date.now()) / 1000) if (expiresIn <= 0) { throw new Error("Payment request expiresAt must be in the future") @@ -55,7 +61,7 @@ export async function createPaymentRequestToken( } return createJwt( - { ...paymentRequest, sub: paymentRequest.id }, + { ...request, sub: request.id }, options, { alg: algorithm, From 103ecc381ad3aec8397a05f7b6ca907886df818f Mon Sep 17 00:00:00 2001 From: kutluhaneth46 Date: Mon, 28 Sep 2026 17:45:05 +0000 Subject: [PATCH 3/3] test(ack-pay): assert JWT exp for expiresAt signed requests Cover the createSignedPaymentRequest expiresAt path so a missing or corrupted JWT exp claim cannot slip through unnoticed. --- .../src/create-signed-payment-request.test.ts | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/packages/ack-pay/src/create-signed-payment-request.test.ts b/packages/ack-pay/src/create-signed-payment-request.test.ts index 237450f7..5c2a8ce9 100644 --- a/packages/ack-pay/src/create-signed-payment-request.test.ts +++ b/packages/ack-pay/src/create-signed-payment-request.test.ts @@ -104,5 +104,26 @@ describe("createSignedPaymentRequest()", () => { ) expect(result.paymentRequest.expiresAt).toBe(expiresAt.toISOString()) + + const resolver = getDidResolver() + resolver.addToCache( + issuerDid, + createDidDocumentFromKeypair({ + did: issuerDid, + keypair, + }), + ) + + const verified = await verifyPaymentRequestToken( + result.paymentRequestToken, + { resolver }, + ) + const expectedExp = Math.floor(expiresAt.getTime() / 1000) + + expect(verified.parsed.payload.exp).toBeDefined() + // Allow a few seconds of clock skew between mint and assertion. + expect( + Math.abs((verified.parsed.payload.exp as number) - expectedExp), + ).toBeLessThan(5) }) })