diff --git a/tools/api-utils/src/exceptions.ts b/tools/api-utils/src/exceptions.ts index 8cdba94e..b6069608 100644 --- a/tools/api-utils/src/exceptions.ts +++ b/tools/api-utils/src/exceptions.ts @@ -1,3 +1,4 @@ +import type { createSignedPaymentRequest } from "@agentcommercekit/ack-pay" import { HTTPException } from "hono/http-exception" /** @@ -16,6 +17,12 @@ export function unauthorized(message = "Unauthorized"): never { }) } +export function forbidden(message = "Forbidden"): never { + throw new HTTPException(403, { + message, + }) +} + export function notFound(message = "Not Found"): never { throw new HTTPException(404, { message, @@ -27,3 +34,16 @@ export function internalServerError(message = "Internal Server Error"): never { message, }) } + +type PaymentChallenge = Awaited> + +export function paymentRequired(challenge: PaymentChallenge): never { + throw new HTTPException(402, { + res: new Response(JSON.stringify(challenge), { + status: 402, + headers: { + "Content-Type": "application/json", + }, + }), + }) +} diff --git a/tools/api-utils/src/middleware/payment-required-validator.test.ts b/tools/api-utils/src/middleware/payment-required-validator.test.ts new file mode 100644 index 00000000..cc0e53d3 --- /dev/null +++ b/tools/api-utils/src/middleware/payment-required-validator.test.ts @@ -0,0 +1,268 @@ +import { createPaymentReceipt } from "@agentcommercekit/ack-pay" +import { + createDidKeyUri, + createDidPkhUri, + getDidResolver, + type DidUri, +} from "@agentcommercekit/did" +import { + createJwtSigner, + curveToJwtAlgorithm, + type JwtString, +} from "@agentcommercekit/jwt" +import { generateKeypair } from "@agentcommercekit/keys" +import { signCredential } from "@agentcommercekit/vc" +import { Hono } from "hono" +import { beforeEach, describe, expect, it } from "vitest" + +import { errorHandler } from "./error-handler" +import { + paymentRequiredValidator, + type PaymentRequiredEnv, +} from "./payment-required-validator" + +const RESOURCE_PAYMENT_REQUEST = { + id: "test-request-id", + paymentOptions: [ + { + id: "test-option", + amount: 100, + decimals: 2, + currency: "USD", + recipient: "did:placeholder", + }, + ], +} + +describe("paymentRequiredValidator", () => { + let app: Hono + let paymentRequestIssuerDid: DidUri + let paymentRequestIssuerKeypair: Awaited> + let receiptIssuerKeypair: Awaited> + let receiptIssuerDid: DidUri + let signedReceiptJwt: JwtString + + beforeEach(async () => { + paymentRequestIssuerKeypair = await generateKeypair("secp256k1") + paymentRequestIssuerDid = createDidKeyUri(paymentRequestIssuerKeypair) + receiptIssuerKeypair = await generateKeypair("secp256k1") + receiptIssuerDid = createDidKeyUri(receiptIssuerKeypair) + + const resolver = getDidResolver() + const paymentRequest = { + ...RESOURCE_PAYMENT_REQUEST, + paymentOptions: [ + { + ...RESOURCE_PAYMENT_REQUEST.paymentOptions[0], + recipient: paymentRequestIssuerDid, + }, + ], + } + + app = new Hono() + app.onError(errorHandler) + app.get( + "/resource", + paymentRequiredValidator({ + resolver, + issuer: paymentRequestIssuerDid, + signer: createJwtSigner(paymentRequestIssuerKeypair), + algorithm: curveToJwtAlgorithm(paymentRequestIssuerKeypair.curve), + trustedReceiptIssuers: [receiptIssuerDid], + paymentRequest, + }), + (c) => c.json({ ok: true }), + ) + + const { createSignedPaymentRequest } = await import( + "@agentcommercekit/ack-pay" + ) + const { paymentRequestToken: token } = await createSignedPaymentRequest( + paymentRequest, + { + issuer: paymentRequestIssuerDid, + signer: createJwtSigner(paymentRequestIssuerKeypair), + algorithm: curveToJwtAlgorithm(paymentRequestIssuerKeypair.curve), + }, + ) + + const unsignedReceipt = createPaymentReceipt({ + paymentRequestToken: token, + paymentOptionId: "test-option", + issuer: receiptIssuerDid, + payerDid: createDidPkhUri( + "eip155:84532", + "0x7B3D8F2E1C9A4B5D6E7F8A9B0C1D2E3F4A5B6C", + ), + }) + + signedReceiptJwt = await signCredential(unsignedReceipt, { + did: receiptIssuerDid, + signer: createJwtSigner(receiptIssuerKeypair), + }) + }) + + it("returns 402 with a signed payment challenge when no receipt is supplied", async () => { + const response = await app.request("/resource") + + expect(response.status).toBe(402) + + const body = await response.json() + expect(body.paymentRequestToken).toBeTypeOf("string") + expect(body.paymentRequest.paymentOptions).toHaveLength(1) + }) + + it("returns 400 for a blank X-ACK-Payment-Proof header", async () => { + const response = await app.request("/resource", { + headers: { + "X-ACK-Payment-Proof": " ", + }, + }) + + expect(response.status).toBe(400) + }) + + it("returns 400 for a blank Authorization Bearer token", async () => { + const response = await app.request("/resource", { + headers: { + Authorization: "Bearer ", + }, + }) + + expect(response.status).toBe(400) + }) + + it("allows access when a valid receipt is supplied via Authorization", async () => { + const response = await app.request("/resource", { + headers: { + Authorization: `Bearer ${signedReceiptJwt}`, + }, + }) + + expect(response.status).toBe(200) + const body = await response.json() + expect(body.ok).toBe(true) + }) + + it("allows access when a valid receipt is supplied via X-ACK-Payment-Proof", async () => { + const response = await app.request("/resource", { + headers: { + "X-ACK-Payment-Proof": signedReceiptJwt, + }, + }) + + expect(response.status).toBe(200) + }) + + it("returns 400 for a malformed receipt", async () => { + const response = await app.request("/resource", { + headers: { + Authorization: "Bearer not-a-valid-jwt", + }, + }) + + expect(response.status).toBe(400) + }) + + it("returns 400 for a receipt bound to a different payment request", async () => { + const { createSignedPaymentRequest } = await import( + "@agentcommercekit/ack-pay" + ) + const { paymentRequestToken } = await createSignedPaymentRequest( + { + id: "other-request-id", + paymentOptions: [ + { + id: "test-option", + amount: 100, + decimals: 2, + currency: "USD", + recipient: paymentRequestIssuerDid, + }, + ], + }, + { + issuer: paymentRequestIssuerDid, + signer: createJwtSigner(paymentRequestIssuerKeypair), + algorithm: curveToJwtAlgorithm(paymentRequestIssuerKeypair.curve), + }, + ) + + const mismatchedReceipt = await signCredential( + createPaymentReceipt({ + paymentRequestToken, + paymentOptionId: "test-option", + issuer: receiptIssuerDid, + payerDid: createDidPkhUri( + "eip155:84532", + "0x7B3D8F2E1C9A4B5D6E7F8A9B0C1D2E3F4A5B6C", + ), + }), + { + did: receiptIssuerDid, + signer: createJwtSigner(receiptIssuerKeypair), + }, + ) + + const response = await app.request("/resource", { + headers: { + Authorization: `Bearer ${mismatchedReceipt}`, + }, + }) + + expect(response.status).toBe(400) + }) + + it("returns 403 for a receipt from an untrusted issuer", async () => { + const untrustedKeypair = await generateKeypair("secp256k1") + const untrustedDid = createDidKeyUri(untrustedKeypair) + + const { createSignedPaymentRequest } = await import( + "@agentcommercekit/ack-pay" + ) + const { paymentRequestToken } = await createSignedPaymentRequest( + { + id: "test-request-id", + paymentOptions: [ + { + id: "test-option", + amount: 100, + decimals: 2, + currency: "USD", + recipient: paymentRequestIssuerDid, + }, + ], + }, + { + issuer: paymentRequestIssuerDid, + signer: createJwtSigner(paymentRequestIssuerKeypair), + algorithm: curveToJwtAlgorithm(paymentRequestIssuerKeypair.curve), + }, + ) + + const forgedReceipt = await signCredential( + createPaymentReceipt({ + paymentRequestToken, + paymentOptionId: "test-option", + issuer: untrustedDid, + payerDid: createDidPkhUri( + "eip155:84532", + "0x7B3D8F2E1C9A4B5D6E7F8A9B0C1D2E3F4A5B6C", + ), + }), + { + did: untrustedDid, + signer: createJwtSigner(untrustedKeypair), + }, + ) + + const response = await app.request("/resource", { + headers: { + Authorization: `Bearer ${forgedReceipt}`, + }, + }) + + expect(response.status).toBe(403) + }) +}) + \ No newline at end of file diff --git a/tools/api-utils/src/middleware/payment-required-validator.ts b/tools/api-utils/src/middleware/payment-required-validator.ts new file mode 100644 index 00000000..3b95d0dd --- /dev/null +++ b/tools/api-utils/src/middleware/payment-required-validator.ts @@ -0,0 +1,153 @@ +import { + createSignedPaymentRequest, + verifyPaymentReceipt, + type PaymentRequest, + type PaymentRequestInit, +} from "@agentcommercekit/ack-pay" +import type { DidUri, Resolvable } from "@agentcommercekit/did" +import type { JwtAlgorithm, JwtSigner } from "@agentcommercekit/jwt" +import { + CredentialVerificationError, + InvalidCredentialError, + UntrustedIssuerError, +} from "@agentcommercekit/vc" +import type { Context, MiddlewareHandler } from "hono" +import { HTTPException } from "hono/http-exception" + +import { badRequest, forbidden, paymentRequired } from "../exceptions" + +export type AckPayment = Awaited> + +export interface PaymentRequiredEnv { + Variables: { + ackPayment: AckPayment + } +} + +export interface PaymentRequiredOptions { + resolver: Resolvable + issuer: DidUri + signer: JwtSigner + algorithm: JwtAlgorithm + trustedReceiptIssuers?: string[] + /** + * Static payment request fields or a per-request resolver. + */ + paymentRequest: + | PaymentRequestInit + | ((c: Context) => PaymentRequestInit | Promise) +} + +const RECEIPT_HEADER = "x-ack-payment-proof" + +/** + * Extract a payment receipt JWT from request headers. + * Distinguishes "header absent" from "header present but blank". + */ +function extractReceipt(c: Context): + | { kind: "absent" } + | { kind: "blank" } + | { kind: "present"; receipt: string } { + const proofHeader = c.req.header(RECEIPT_HEADER) + if (proofHeader !== undefined) { + const trimmed = proofHeader.trim() + return trimmed + ? { kind: "present", receipt: trimmed } + : { kind: "blank" } + } + + const authorization = c.req.header("Authorization") + if (authorization !== undefined) { + if (!authorization.startsWith("Bearer ")) { + return { kind: "blank" } + } + const trimmed = authorization.slice("Bearer ".length).trim() + return trimmed + ? { kind: "present", receipt: trimmed } + : { kind: "blank" } + } + + return { kind: "absent" } +} + +function receiptMatchesRequest( + paymentRequest: PaymentRequest | null, + expected: PaymentRequestInit, +): boolean { + if (!paymentRequest) { + return false + } + if (paymentRequest.id !== expected.id) { + return false + } + const expectedOptionIds = new Set( + expected.paymentOptions.map((option) => option.id), + ) + return paymentRequest.paymentOptions.some((option) => + expectedOptionIds.has(option.id), + ) +} + +/** + * ACK-Pay HTTP 402 middleware. Issues a signed payment challenge when no valid + * receipt is supplied; otherwise verifies the receipt and injects `ackPayment`. + */ +export const paymentRequiredValidator = ( + options: PaymentRequiredOptions, +): MiddlewareHandler => { + return async (c, next) => { + const extracted = extractReceipt(c) + + if (extracted.kind === "blank") { + badRequest("Invalid receipt") + } + + const paymentRequestInit = + typeof options.paymentRequest === "function" + ? await options.paymentRequest(c) + : options.paymentRequest + + if (extracted.kind === "absent") { + const challenge = await createSignedPaymentRequest(paymentRequestInit, { + issuer: options.issuer, + signer: options.signer, + algorithm: options.algorithm, + }) + + throw paymentRequired(challenge) + } + + try { + const ackPayment = await verifyPaymentReceipt(extracted.receipt, { + resolver: options.resolver, + trustedReceiptIssuers: options.trustedReceiptIssuers, + paymentRequestIssuer: options.issuer, + }) + + if (!receiptMatchesRequest(ackPayment.paymentRequest, paymentRequestInit)) { + badRequest("Invalid receipt") + } + + c.set("ackPayment", ackPayment) + await next() + } catch (error) { + if (error instanceof HTTPException) { + throw error + } + + if (error instanceof UntrustedIssuerError) { + forbidden("Untrusted receipt issuer") + } + + if ( + error instanceof InvalidCredentialError || + error instanceof CredentialVerificationError + ) { + badRequest("Invalid receipt") + } + + throw error + } + } +} + \ No newline at end of file