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/enforce-payment-request-expires-at.md
Original file line number Diff line number Diff line change
@@ -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).
2 changes: 1 addition & 1 deletion packages/ack-pay/src/create-payment-receipt.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
38 changes: 38 additions & 0 deletions packages/ack-pay/src/create-payment-request-token.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
})
})
33 changes: 28 additions & 5 deletions packages/ack-pay/src/create-payment-request-token.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
/**
Expand Down Expand Up @@ -34,12 +36,33 @@ export async function createPaymentRequestToken(
paymentRequest: PaymentRequest,
{ issuer, signer, algorithm }: PaymentRequestTokenOptions,
): Promise<JwtString> {
// 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
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

return createJwt(
{ ...paymentRequest, sub: paymentRequest.id },
{
issuer,
signer,
},
{ ...request, sub: request.id },
options,
{
alg: algorithm,
},
Expand Down
25 changes: 23 additions & 2 deletions packages/ack-pay/src/create-signed-payment-request.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
{
Expand All @@ -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)
})
})
29 changes: 29 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 @@ -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)
})
})
9 changes: 9 additions & 0 deletions packages/ack-pay/src/verify-payment-request-token.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down