-
Notifications
You must be signed in to change notification settings - Fork 131
feat(api-utils): add paymentRequiredValidator middleware for HTTP 402 ACK-Pay challenges #172
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
vhtgzzl
wants to merge
1
commit into
agentcommercekit:main
Choose a base branch
from
vhtgzzl:feat/payment-required-middleware
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
185 changes: 185 additions & 0 deletions
185
tools/api-utils/src/middleware/payment-required-validator.test.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,185 @@ | ||
| import { DidResolver } from "@agentcommercekit/did" | ||
| import { createJwtSigner } from "@agentcommercekit/jwt" | ||
| import { generateKeypair } from "@agentcommercekit/keys" | ||
| import * as ackPay from "@agentcommercekit/ack-pay" | ||
| import { Hono } from "hono" | ||
| import { beforeEach, describe, expect, it, vi } from "vitest" | ||
|
|
||
| import { | ||
| paymentRequiredValidator, | ||
| type PaymentRequiredEnv, | ||
| } from "./payment-required-validator" | ||
|
|
||
| describe("paymentRequiredValidator", () => { | ||
| const mockPaymentRequestInit: ackPay.PaymentRequestInit = { | ||
| id: "test_req_001", | ||
| description: "API access fee", | ||
| paymentOptions: [ | ||
| { | ||
| id: "usdc-opt-1", | ||
| amount: 50000, | ||
| decimals: 6, | ||
| currency: "USDC", | ||
| recipient: "0x1234567890abcdef1234567890abcdef12345678", | ||
| }, | ||
| ], | ||
| } | ||
|
|
||
| let serverKeypair: any | ||
| let serverSigner: any | ||
| let resolver: DidResolver | ||
|
|
||
| beforeEach(async () => { | ||
| serverKeypair = await generateKeypair("secp256k1") | ||
| serverSigner = createJwtSigner(serverKeypair) | ||
| resolver = new DidResolver() | ||
| }) | ||
|
|
||
| it("returns HTTP 402 with signed payment request when no receipt is present", async () => { | ||
| const app = new Hono<PaymentRequiredEnv>() | ||
| app.use("*", async (c, next) => { | ||
| c.set("resolver", resolver) | ||
| await next() | ||
| }) | ||
|
|
||
| app.get( | ||
| "/protected", | ||
| paymentRequiredValidator({ | ||
| paymentRequest: mockPaymentRequestInit, | ||
| signerOptions: { | ||
| issuer: "did:web:server.catena.com", | ||
| signer: serverSigner, | ||
| }, | ||
| }), | ||
| (c) => c.json({ access: "granted" }), | ||
| ) | ||
|
|
||
| const res = await app.request("/protected") | ||
| expect(res.status).toBe(402) | ||
|
|
||
| const data = await res.json() | ||
| expect(data.paymentRequest).toBeDefined() | ||
| expect(data.paymentRequest.id).toBe("test_req_001") | ||
| expect(data.paymentRequestToken).toBeDefined() | ||
| expect(typeof data.paymentRequestToken).toBe("string") | ||
| }) | ||
|
|
||
| it("verifies valid receipt in Authorization header and allows access", async () => { | ||
| const mockVerifiedPayment = { | ||
| receipt: { id: "receipt_vc_001" }, | ||
| paymentRequestToken: "mock.jwt.token", | ||
| paymentRequest: mockPaymentRequestInit as any, | ||
| } | ||
|
|
||
| vi.spyOn(ackPay, "verifyPaymentReceipt").mockResolvedValue( | ||
| mockVerifiedPayment as any, | ||
| ) | ||
|
|
||
| const app = new Hono<PaymentRequiredEnv>() | ||
| app.use("*", async (c, next) => { | ||
| c.set("resolver", resolver) | ||
| await next() | ||
| }) | ||
|
|
||
| app.get( | ||
| "/protected", | ||
| paymentRequiredValidator({ | ||
| paymentRequest: mockPaymentRequestInit, | ||
| signerOptions: { | ||
| issuer: "did:web:server.catena.com", | ||
| signer: serverSigner, | ||
| }, | ||
| trustedReceiptIssuers: ["did:web:receipt.catena.com"], | ||
| }), | ||
| (c) => { | ||
| const payment = c.get("ackPayment") | ||
| return c.json({ access: "granted", payment }) | ||
| }, | ||
| ) | ||
|
|
||
| const res = await app.request("/protected", { | ||
| headers: { | ||
| Authorization: "Bearer mock.valid.jwt.receipt", | ||
| }, | ||
| }) | ||
|
|
||
| expect(res.status).toBe(200) | ||
| const data = await res.json() | ||
| expect(data.access).toBe("granted") | ||
| expect(data.payment).toEqual(mockVerifiedPayment) | ||
| }) | ||
|
|
||
| it("accepts payment receipt from X-ACK-Payment-Proof header", async () => { | ||
| const mockVerifiedPayment = { | ||
| receipt: { id: "receipt_vc_002" }, | ||
| paymentRequestToken: "mock.jwt.token", | ||
| paymentRequest: mockPaymentRequestInit as any, | ||
| } | ||
|
|
||
| vi.spyOn(ackPay, "verifyPaymentReceipt").mockResolvedValue( | ||
| mockVerifiedPayment as any, | ||
| ) | ||
|
|
||
| const app = new Hono<PaymentRequiredEnv>() | ||
| app.use("*", async (c, next) => { | ||
| c.set("resolver", resolver) | ||
| await next() | ||
| }) | ||
|
|
||
| app.get( | ||
| "/protected", | ||
| paymentRequiredValidator({ | ||
| paymentRequest: mockPaymentRequestInit, | ||
| signerOptions: { | ||
| issuer: "did:web:server.catena.com", | ||
| signer: serverSigner, | ||
| }, | ||
| }), | ||
| (c) => c.json({ access: "granted", payment: c.get("ackPayment") }), | ||
| ) | ||
|
|
||
| const res = await app.request("/protected", { | ||
| headers: { | ||
| "X-ACK-Payment-Proof": "mock.proof.header.receipt", | ||
| }, | ||
| }) | ||
|
|
||
| expect(res.status).toBe(200) | ||
| const data = await res.json() | ||
| expect(data.access).toBe("granted") | ||
| }) | ||
|
|
||
| it("returns HTTP 400 when invalid receipt is provided", async () => { | ||
| vi.spyOn(ackPay, "verifyPaymentReceipt").mockRejectedValue( | ||
| new Error("Invalid cryptographic receipt signature"), | ||
| ) | ||
|
|
||
| const app = new Hono<PaymentRequiredEnv>() | ||
| app.use("*", async (c, next) => { | ||
| c.set("resolver", resolver) | ||
| await next() | ||
| }) | ||
|
|
||
| app.get( | ||
| "/protected", | ||
| paymentRequiredValidator({ | ||
| paymentRequest: mockPaymentRequestInit, | ||
| signerOptions: { | ||
| issuer: "did:web:server.catena.com", | ||
| signer: serverSigner, | ||
| }, | ||
| }), | ||
| (c) => c.json({ access: "granted" }), | ||
| ) | ||
|
|
||
| const res = await app.request("/protected", { | ||
| headers: { | ||
| Authorization: "Bearer invalid.receipt.token", | ||
| }, | ||
| }) | ||
|
|
||
| expect(res.status).toBe(400) | ||
| const data = await res.json() | ||
| expect(data.message).toBe("Invalid receipt") | ||
| }) | ||
| }) |
149 changes: 149 additions & 0 deletions
149
tools/api-utils/src/middleware/payment-required-validator.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,149 @@ | ||
| import type { Resolvable } from "@agentcommercekit/did" | ||
| import type { JwtAlgorithm, JwtSigner } from "@agentcommercekit/jwt" | ||
| import { | ||
| createSignedPaymentRequest, | ||
| verifyPaymentReceipt, | ||
| type PaymentRequest, | ||
| type PaymentRequestInit, | ||
| } from "@agentcommercekit/ack-pay" | ||
| import type { Context, MiddlewareHandler } from "hono" | ||
| import { HTTPException } from "hono/http-exception" | ||
|
|
||
| export interface PaymentRequiredEnv { | ||
| Variables: { | ||
| resolver?: Resolvable | ||
| ackPayment: { | ||
| receipt: unknown | ||
| paymentRequestToken: string | ||
| paymentRequest: PaymentRequest | null | ||
| } | ||
| } | ||
| } | ||
|
|
||
| export interface PaymentRequestSignerOptions { | ||
| issuer: string | ||
| signer: JwtSigner | ||
| algorithm?: JwtAlgorithm | ||
| } | ||
|
|
||
| export interface PaymentRequiredValidatorOptions { | ||
| /** | ||
| * The payment request configuration or a dynamic resolver function | ||
| */ | ||
| paymentRequest: | ||
| | PaymentRequestInit | ||
| | ((c: Context) => Promise<PaymentRequestInit> | PaymentRequestInit) | ||
|
|
||
| /** | ||
| * The signer configuration for signing the payment request token JWT | ||
| */ | ||
| signerOptions: | ||
| | PaymentRequestSignerOptions | ||
| | ((c: Context) => Promise<PaymentRequestSignerOptions> | PaymentRequestSignerOptions) | ||
|
|
||
| /** | ||
| * The list of trusted receipt issuer DIDs | ||
| */ | ||
| trustedReceiptIssuers?: | ||
| | string[] | ||
| | ((c: Context) => Promise<string[]> | string[]) | ||
|
|
||
| /** | ||
| * The expected issuer of the original payment request token | ||
| */ | ||
| paymentRequestIssuer?: string | ||
|
|
||
| /** | ||
| * Whether to verify the payment request token as a JWT (defaults to true) | ||
| */ | ||
| verifyPaymentRequestTokenJwt?: boolean | ||
| } | ||
|
|
||
| /** | ||
| * Middleware that enforces an ACK-Pay HTTP 402 challenge. | ||
| * | ||
| * If no receipt is present in the `Authorization: Bearer <receipt>` or | ||
| * `X-ACK-Payment-Proof` headers, it automatically issues an HTTP 402 status code | ||
| * and returns the signed ACK-Pay payment request body. | ||
| * | ||
| * When a receipt is present, it verifies the receipt against trusted issuers and | ||
| * attaches the verified payment details to `c.get("ackPayment")`. | ||
| * | ||
| * @example | ||
| * ```ts | ||
| * app.get( | ||
| * "/resource", | ||
| * paymentRequiredValidator({ | ||
| * paymentRequest: paymentRequestConfig, | ||
| * signerOptions: serverSignerConfig, | ||
| * trustedReceiptIssuers: ["did:web:receipt.catena.com"], | ||
| * }), | ||
| * (c) => { | ||
| * const payment = c.get("ackPayment") | ||
| * return c.json({ access: "granted", payment }) | ||
| * } | ||
| * ) | ||
| * ``` | ||
| */ | ||
| export const paymentRequiredValidator = ( | ||
| options: PaymentRequiredValidatorOptions, | ||
| ): MiddlewareHandler<PaymentRequiredEnv> => { | ||
| return async (c, next) => { | ||
| const authorizationHeader = c.req.header("Authorization") | ||
| const proofHeader = c.req.header("X-ACK-Payment-Proof") | ||
|
|
||
| const receipt = authorizationHeader?.startsWith("Bearer ") | ||
| ? authorizationHeader.replace("Bearer ", "").trim() | ||
| : proofHeader?.trim() | ||
|
|
||
| if (!receipt) { | ||
| const init = | ||
| typeof options.paymentRequest === "function" | ||
| ? await options.paymentRequest(c) | ||
| : options.paymentRequest | ||
|
|
||
| const signer = | ||
| typeof options.signerOptions === "function" | ||
| ? await options.signerOptions(c) | ||
| : options.signerOptions | ||
|
|
||
| const signedPaymentRequest = await createSignedPaymentRequest( | ||
| init, | ||
| signer, | ||
| ) | ||
|
|
||
| const res = new Response(JSON.stringify(signedPaymentRequest), { | ||
| status: 402, | ||
| headers: { | ||
| "Content-Type": "application/json", | ||
| }, | ||
| }) | ||
|
|
||
| throw new HTTPException(402, { res }) | ||
| } | ||
|
|
||
| const didResolver = c.get("resolver") | ||
| const trustedReceiptIssuers = | ||
| typeof options.trustedReceiptIssuers === "function" | ||
| ? await options.trustedReceiptIssuers(c) | ||
| : options.trustedReceiptIssuers | ||
|
|
||
| try { | ||
| const verified = await verifyPaymentReceipt(receipt, { | ||
| resolver: didResolver!, | ||
| trustedReceiptIssuers, | ||
| paymentRequestIssuer: options.paymentRequestIssuer, | ||
| verifyPaymentRequestTokenJwt: | ||
| options.verifyPaymentRequestTokenJwt ?? true, | ||
| }) | ||
|
|
||
| c.set("ackPayment", verified) | ||
| } catch (_e) { | ||
| throw new HTTPException(400, { | ||
| message: "Invalid receipt", | ||
| }) | ||
| } | ||
|
|
||
| await next() | ||
| } | ||
| } | ||
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
Repository: agentcommercekit/ack
Length of output: 22258
🏁 Script executed:
Repository: agentcommercekit/ack
Length of output: 50379
🏁 Script executed:
Repository: agentcommercekit/ack
Length of output: 17961
🏁 Script executed:
Repository: agentcommercekit/ack
Length of output: 50376
Map
UntrustedIssuerErrorto HTTP 403.Keep HTTP 400 for malformed receipts and other proof failures. Add tests for both mappings.
🤖 Prompt for AI Agents