diff --git a/.changeset/enforce-payment-request-expires-at.md b/.changeset/enforce-payment-request-expires-at.md new file mode 100644 index 0000000..91b55f3 --- /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 611723d..df62a22 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.test.ts b/packages/ack-pay/src/create-payment-request-token.test.ts index cf01912..9fa08ed 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 0e50a0c..c71dcc5 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,12 +36,33 @@ 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 + expiresIn?: number + } = { + issuer, + signer, + } + + // Mirror request.expiresAt into the JWT `exp` claim so verifiers that + // only check JWT expiry still reject stale quotes. + 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") + } + options.expiresIn = expiresIn + } + return createJwt( - { ...paymentRequest, sub: paymentRequest.id }, - { - issuer, - signer, - }, + { ...request, sub: request.id }, + 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 4838b3f..5c2a8ce 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,27 @@ describe("createSignedPaymentRequest()", () => { }, ) - expect(result.paymentRequest.expiresAt).toBe("2024-12-31T23:59:59.000Z") + 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) }) }) 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 f42dac5..c619f01 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 d1c3d28..88ace83 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,