From 9aa0921e1ea39fd21c3fef355855195de023113f Mon Sep 17 00:00:00 2001 From: Revinand Date: Mon, 14 Sep 2026 12:18:15 +0200 Subject: [PATCH 01/11] feat(core): add resource authorization contracts --- docs/contract-surface.txt | 77 ++++- docs/contracts.md | 17 +- src/config/schema.ts | 36 +-- src/core/domain/authorization.ts | 118 +++++++ src/core/domain/common.ts | 12 +- src/core/domain/index.ts | 1 + src/core/domain/request.ts | 12 + src/core/domain/resource.ts | 19 +- src/core/domain/wire.ts | 188 ++++++++++- src/core/errors/codes.ts | 16 + src/core/execution/pipeline.ts | 12 +- src/core/public-types.ts | 16 + src/gateway/routes.ts | 11 + src/protocols/a2a/adapter.ts | 38 ++- src/protocols/a2a/message-mapping.ts | 29 +- src/protocols/mcp/adapter.ts | 13 +- src/protocols/mcp/result-mapping.ts | 29 -- .../integration/authorization-carrier.test.ts | 287 +++++++++++++++++ tests/unit/config/schema.test.ts | 37 ++- .../core/domain/authorization-wire.test.ts | 302 ++++++++++++++++++ tests/unit/core/execution/pipeline.test.ts | 35 ++ 21 files changed, 1171 insertions(+), 134 deletions(-) create mode 100644 src/core/domain/authorization.ts create mode 100644 tests/integration/authorization-carrier.test.ts create mode 100644 tests/unit/core/domain/authorization-wire.test.ts diff --git a/docs/contract-surface.txt b/docs/contract-surface.txt index cfd6f6d..bd46b77 100644 --- a/docs/contract-surface.txt +++ b/docs/contract-surface.txt @@ -1,11 +1,11 @@ # Semantic surface of src/core/public-types.ts # Generated by scripts/contract-surface.mjs — do not edit by hand. -# 71 exported symbols. +# 85 exported symbols. interface AdapterDescriptor { readonly capabilities: ReadonlyArray; readonly implementationVersion: string; - readonly kind: "protocol" | "payment" | "storage"; + readonly kind: "protocol" | "payment" | "storage" | "authorization"; readonly name: string; readonly status: "stable" | "experimental" | "planned"; readonly supportedSpec: string; @@ -25,6 +25,47 @@ interface AdapterHttpRoute { readonly path: string; } +interface AuthorizationFinalizeContext { + readonly requestId: string; + readonly resourceId: string; + } + +interface AuthorizationProvider { + consume: (reservationId: string, context: AuthorizationFinalizeContext) => Promise; + health: () => Promise; + readonly descriptor: AdapterDescriptor; + readonly name: "ap2"; + release: (reservationId: string, context: AuthorizationFinalizeContext) => Promise; + verifyAndReserve: (context: AuthorizationVerificationContext) => Promise; + } + +interface AuthorizationRequirement { + readonly method: "ap2"; + readonly profile?: string; + readonly version: string; + } + +interface AuthorizationSubmission { + readonly method: "ap2"; + readonly payload: string; + } + +interface AuthorizationVerification { + readonly metadata?: Readonly>; + readonly method: "ap2"; + readonly reference: string; + readonly reservationId: string; + readonly status: "verified"; + } + +interface AuthorizationVerificationContext { + readonly input: unknown; + readonly requestId: string; + readonly requirement: PaymentRequirement; + readonly resourceId: string; + readonly submission: AuthorizationSubmission; + } + interface BackendExecutor { call: (handler: BackendHandler, request: BackendRequest) => Promise; } @@ -52,6 +93,7 @@ interface BackendResponse { } interface CanonicalRequest { + readonly authorization?: AuthorizationSubmission; readonly input: unknown; readonly metadata?: Readonly>; readonly payment?: PaymentSubmission; @@ -68,7 +110,7 @@ interface Clock { } interface CommerceErrorInfo { - readonly code: "CONFIG_INVALID" | "RESOURCE_NOT_FOUND" | "INPUT_INVALID" | "PAYMENT_REQUIRED" | "PAYMENT_INVALID" | "PAYMENT_REPLAYED" | "PAYMENT_PROVIDER_UNAVAILABLE" | "PAYMENT_SETTLEMENT_FAILED" | "BACKEND_TIMEOUT" | "BACKEND_ERROR" | "PROTOCOL_UNSUPPORTED" | "GATEWAY_BUSY" | "STORAGE_ERROR" | "INTERNAL_ERROR"; + readonly code: "CONFIG_INVALID" | "RESOURCE_NOT_FOUND" | "INPUT_INVALID" | "PAYMENT_REQUIRED" | "PAYMENT_INVALID" | "PAYMENT_REPLAYED" | "PAYMENT_PROVIDER_UNAVAILABLE" | "PAYMENT_SETTLEMENT_FAILED" | "AUTHORIZATION_REQUIRED" | "AUTHORIZATION_INVALID" | "AUTHORIZATION_REPLAYED" | "AUTHORIZATION_PROVIDER_UNAVAILABLE" | "BACKEND_TIMEOUT" | "BACKEND_ERROR" | "PROTOCOL_UNSUPPORTED" | "GATEWAY_BUSY" | "STORAGE_ERROR" | "INTERNAL_ERROR"; readonly details?: Readonly>; readonly httpStatus: number; readonly message: string; @@ -111,6 +153,7 @@ interface CommerceReceipt { } interface CommerceResource { + readonly authorization?: { readonly required: readonly AuthorizationMethodName[]; }; readonly description?: string; readonly exposedVia: ReadonlyArray; readonly handler: BackendHandler; @@ -143,7 +186,7 @@ interface DeliverySummary { } interface ErrorEnvelope { - readonly code: "CONFIG_INVALID" | "RESOURCE_NOT_FOUND" | "INPUT_INVALID" | "PAYMENT_REQUIRED" | "PAYMENT_INVALID" | "PAYMENT_REPLAYED" | "PAYMENT_PROVIDER_UNAVAILABLE" | "PAYMENT_SETTLEMENT_FAILED" | "BACKEND_TIMEOUT" | "BACKEND_ERROR" | "PROTOCOL_UNSUPPORTED" | "GATEWAY_BUSY" | "STORAGE_ERROR" | "INTERNAL_ERROR"; + readonly code: "CONFIG_INVALID" | "RESOURCE_NOT_FOUND" | "INPUT_INVALID" | "PAYMENT_REQUIRED" | "PAYMENT_INVALID" | "PAYMENT_REPLAYED" | "PAYMENT_PROVIDER_UNAVAILABLE" | "PAYMENT_SETTLEMENT_FAILED" | "AUTHORIZATION_REQUIRED" | "AUTHORIZATION_INVALID" | "AUTHORIZATION_REPLAYED" | "AUTHORIZATION_PROVIDER_UNAVAILABLE" | "BACKEND_TIMEOUT" | "BACKEND_ERROR" | "PROTOCOL_UNSUPPORTED" | "GATEWAY_BUSY" | "STORAGE_ERROR" | "INTERNAL_ERROR"; readonly details?: Readonly>; readonly message: string; readonly requestId?: string; @@ -249,6 +292,7 @@ interface PaymentProvider { } interface PaymentRequiredEnvelope { + readonly authorization?: { readonly required: readonly AuthorizationRequirement[]; }; readonly code: "PAYMENT_REQUIRED"; readonly message: string; readonly payment: { readonly provider: PaymentMethodName; readonly version: string; readonly amount: DecimalAmount; readonly currency: string; readonly destination: string; readonly network?: string; readonly asset?: string; readonly expiresAt?: IsoTimestamp; readonly accepts: readonly Readonly>[]; readonly envelope?: Readonly>; }; @@ -258,6 +302,7 @@ interface PaymentRequiredEnvelope { } interface PaymentRequiredOutcome { + readonly authorization?: ReadonlyArray; readonly kind: "payment-required"; readonly requestId: string; readonly requirement: PaymentRequirement; @@ -357,9 +402,11 @@ interface ResourceRegistry { listExposedVia: (protocol: ProtocolName) => readonly CommerceResource[]; } +type AuthorizationMethodName = "ap2" + type BackendMethod = BackendMethod -type CommerceErrorCode = "CONFIG_INVALID" | "RESOURCE_NOT_FOUND" | "INPUT_INVALID" | "PAYMENT_REQUIRED" | "PAYMENT_INVALID" | "PAYMENT_REPLAYED" | "PAYMENT_PROVIDER_UNAVAILABLE" | "PAYMENT_SETTLEMENT_FAILED" | "BACKEND_TIMEOUT" | "BACKEND_ERROR" | "PROTOCOL_UNSUPPORTED" | "GATEWAY_BUSY" | "STORAGE_ERROR" | "INTERNAL_ERROR" +type CommerceErrorCode = "CONFIG_INVALID" | "RESOURCE_NOT_FOUND" | "INPUT_INVALID" | "PAYMENT_REQUIRED" | "PAYMENT_INVALID" | "PAYMENT_REPLAYED" | "PAYMENT_PROVIDER_UNAVAILABLE" | "PAYMENT_SETTLEMENT_FAILED" | "AUTHORIZATION_REQUIRED" | "AUTHORIZATION_INVALID" | "AUTHORIZATION_REPLAYED" | "AUTHORIZATION_PROVIDER_UNAVAILABLE" | "BACKEND_TIMEOUT" | "BACKEND_ERROR" | "PROTOCOL_UNSUPPORTED" | "GATEWAY_BUSY" | "STORAGE_ERROR" | "INTERNAL_ERROR" type CommerceEventType = "resource.discovered" | "resource.requested" | "payment.required" | "payment.rejected" | "payment.verified" | "payment.settled" | "backend.called" | "backend.failed" | "resource.delivered" @@ -377,9 +424,13 @@ type Pricing = Pricing type ProtocolName = ProtocolName -value COMMERCE_ERROR_CODES: readonly ["CONFIG_INVALID", "RESOURCE_NOT_FOUND", "INPUT_INVALID", "PAYMENT_REQUIRED", "PAYMENT_INVALID", "PAYMENT_REPLAYED", "PAYMENT_PROVIDER_UNAVAILABLE", "PAYMENT_SETTLEMENT_FAILED", "BACKEND_TIMEOUT", "BACKEND_ERROR", "PROTOCOL_UNSUPPORTED", "GATEWAY_BUSY", "STORAGE_ERROR", "INTERNAL_ERROR"] +value AUTHORIZATION_HEADER: "agent-authorization" -value COMMERCE_ERROR_HTTP_STATUS: Readonly> +value AUTHORIZATION_INPUT_FIELD: "_authorization" + +value COMMERCE_ERROR_CODES: readonly ["CONFIG_INVALID", "RESOURCE_NOT_FOUND", "INPUT_INVALID", "PAYMENT_REQUIRED", "PAYMENT_INVALID", "PAYMENT_REPLAYED", "PAYMENT_PROVIDER_UNAVAILABLE", "PAYMENT_SETTLEMENT_FAILED", "AUTHORIZATION_REQUIRED", "AUTHORIZATION_INVALID", "AUTHORIZATION_REPLAYED", "AUTHORIZATION_PROVIDER_UNAVAILABLE", "BACKEND_TIMEOUT", "BACKEND_ERROR", "PROTOCOL_UNSUPPORTED", "GATEWAY_BUSY", "STORAGE_ERROR", "INTERNAL_ERROR"] + +value COMMERCE_ERROR_HTTP_STATUS: Readonly> value COMMERCE_EVENT_TYPES: readonly ["resource.discovered", "resource.requested", "payment.required", "payment.rejected", "payment.verified", "payment.settled", "backend.called", "backend.failed", "resource.delivered"] @@ -389,6 +440,8 @@ value DEFAULT_BACKEND_TIMEOUT_MS: 10000 value DELIVERY_SUMMARY_META_KEY: "agent-commerce/delivery" +value MAX_AUTHORIZATION_HEADER_BYTES: 8192 + value NOOP_LOGGER: Logger value PAYMENT_HEADER: "payment-signature" @@ -401,7 +454,11 @@ value PAYMENT_RESPONSE_HEADER: "payment-response" value PROTOCOL_NAMES: ReadonlyArray -value RETRYABLE_ERROR_CODES: ReadonlySet<"CONFIG_INVALID" | "RESOURCE_NOT_FOUND" | "INPUT_INVALID" | "PAYMENT_REQUIRED" | "PAYMENT_INVALID" | "PAYMENT_REPLAYED" | "PAYMENT_PROVIDER_UNAVAILABLE" | "PAYMENT_SETTLEMENT_FAILED" | "BACKEND_TIMEOUT" | "BACKEND_ERROR" | "PROTOCOL_UNSUPPORTED" | "GATEWAY_BUSY" | "STORAGE_ERROR" | "INTERNAL_ERROR"> +value RESERVED_INPUT_FIELDS: ReadonlyArray + +value RETRYABLE_ERROR_CODES: ReadonlySet<"CONFIG_INVALID" | "RESOURCE_NOT_FOUND" | "INPUT_INVALID" | "PAYMENT_REQUIRED" | "PAYMENT_INVALID" | "PAYMENT_REPLAYED" | "PAYMENT_PROVIDER_UNAVAILABLE" | "PAYMENT_SETTLEMENT_FAILED" | "AUTHORIZATION_REQUIRED" | "AUTHORIZATION_INVALID" | "AUTHORIZATION_REPLAYED" | "AUTHORIZATION_PROVIDER_UNAVAILABLE" | "BACKEND_TIMEOUT" | "BACKEND_ERROR" | "PROTOCOL_UNSUPPORTED" | "GATEWAY_BUSY" | "STORAGE_ERROR" | "INTERNAL_ERROR"> + +value extractReservedInputFields: (rawInput: Record, resource: CommerceResource | undefined, requestId?: string) => { input: Record; payment?: PaymentSubmission; authorization?: AuthorizationSubmission; } value isCommerceError: (value: unknown) => value is CommerceError @@ -409,6 +466,10 @@ value isHttpProtocolAdapter: (adapter: ProtocolAdapter) => adapter is HttpProtoc value isPaymentRequiredEnvelope: (value: unknown) => value is PaymentRequiredEnvelope +value parseAuthorizationHeader: (raw: string | readonly string[] | undefined, requestId?: string) => AuthorizationSubmission | undefined + +value parseAuthorizationSubmission: (value: unknown, requestId?: string) => AuthorizationSubmission | undefined + value systemClock: Clock value toCommerceError: (value: unknown, fallbackCode?: CommerceErrorCode, fallbackMessage?: string) => CommerceError diff --git a/docs/contracts.md b/docs/contracts.md index f33cca2..5a301be 100644 --- a/docs/contracts.md +++ b/docs/contracts.md @@ -17,17 +17,18 @@ The cross-package contract is `src/core/public-types.ts`. | `CommerceReceipt`, `PaymentAttempt` | `domain/receipt.ts` | receipt-store, gateway, cli, dashboard | | `CommerceEvent`, `CommerceEventType`, `EventSink` | `domain/event.ts` | everything | | `CanonicalRequest`, `ExecutionOutcome`, `DeliveredOutcome`, `PaymentRequiredOutcome`, `ExecutionPipeline` | `domain/request.ts` | gateway, mcp | -| `AdapterDescriptor`, `AdapterHealth`, `JsonSchema`, `ProtocolName`, `PaymentMethodName`, `DecimalAmount`, `IsoTimestamp` | `domain/common.ts` | everything | +| `AuthorizationSubmission`, `AuthorizationRequirement`, `AuthorizationVerification`, `AuthorizationProvider`, `AuthorizationVerificationContext`, `AuthorizationFinalizeContext` | `domain/authorization.ts` | gateway, ap2, mcp, a2a | +| `AdapterDescriptor`, `AdapterHealth`, `JsonSchema`, `ProtocolName`, `PaymentMethodName`, `AuthorizationMethodName`, `DecimalAmount`, `IsoTimestamp` | `domain/common.ts` | everything | | `CommerceError`, `CommerceErrorCode`, `COMMERCE_ERROR_HTTP_STATUS`, `toCommerceError`, `isCommerceError` | `errors/**` | everything | | `ProtocolAdapter`, `HttpProtocolAdapter`, `ProtocolAdapterContext` | `interfaces/protocol-adapter.ts` | gateway, mcp | | `ReceiptStore`, `PaymentAttemptReservation`, `PaymentAttemptUpdate`, `ListOptions` | `interfaces/store.ts` | receipt-store, gateway, cli | | `BackendExecutor`, `BackendRequest`, `BackendResponse` | `interfaces/backend.ts` | core, gateway | | `Logger`, `NOOP_LOGGER`, `Clock`, `IdGenerator`, `systemClock` | `interfaces/logger.ts`, `interfaces/runtime.ts` | everything | -| `PaymentRequiredEnvelope`, `toPaymentRequiredEnvelope`, `isPaymentRequiredEnvelope`, `DeliverySummary`, `toDeliverySummary`, `DELIVERY_SUMMARY_META_KEY`, `ErrorEnvelope`, `toErrorEnvelope`, `PAYMENT_HEADER`, `PAYMENT_RESPONSE_HEADER`, `PAYMENT_INPUT_FIELD` | `domain/wire.ts` | gateway, mcp, dx, demo | +| `PaymentRequiredEnvelope`, `toPaymentRequiredEnvelope`, `isPaymentRequiredEnvelope`, `DeliverySummary`, `toDeliverySummary`, `DELIVERY_SUMMARY_META_KEY`, `ErrorEnvelope`, `toErrorEnvelope`, `PAYMENT_HEADER`, `PAYMENT_RESPONSE_HEADER`, `PAYMENT_INPUT_FIELD`, `AUTHORIZATION_INPUT_FIELD`, `AUTHORIZATION_HEADER`, `MAX_AUTHORIZATION_HEADER_BYTES`, `RESERVED_INPUT_FIELDS`, `parseAuthorizationSubmission`, `parseAuthorizationHeader`, `extractReservedInputFields` | `domain/wire.ts` | gateway, mcp, dx, demo | | `COMMERCE_ERROR_CODES`, `COMMERCE_EVENT_TYPES`, `RETRYABLE_ERROR_CODES`, `DEFAULT_BACKEND_TIMEOUT_MS`, `isHttpProtocolAdapter`, `BackendMethod`, `CommerceErrorInfo`, `CommerceErrorOptions` | `errors/**`, `domain/**`, `interfaces/**` | everything | **The authoritative enumeration is [`contract-surface.txt`](contract-surface.txt)** -- 68 symbols, generated by `scripts/contract-surface.mjs` from the barrel +- 85 symbols, generated by `scripts/contract-surface.mjs` from the barrel itself and enforced by `npm run check:contract`. The table above groups them for orientation; it is written by hand and was found under-enumerating in round 6 (the whole `domain/wire.ts` group was missing). If the two ever disagree, @@ -51,7 +52,14 @@ the generated file is right and this table is stale. always applies a timeout. 7. Amounts are decimal strings in display units ("0.01"); conversion to base units belongs to the payment provider. -8. `exactOptionalPropertyTypes` is on: build optional fields conditionally +8. `AuthorizationSubmission.payload` is preserved byte-for-byte from the wire. + Providers derive a replay identity by hashing it, so decoding and + reserialising it would give one proof two identities. +9. An authorization failure is never reported with a `PAYMENT_*` code. A 402 + tells a client to pay and retry, which cannot fix a missing or rejected + mandate, and an auto-paying client would be charged for a request that was + never going to be delivered. +10. `exactOptionalPropertyTypes` is on: build optional fields conditionally (`...(x !== undefined ? { x }: {})`), do not assign `undefined`. ## Change log @@ -79,6 +87,7 @@ the generated file is right and this table is stale. - **Additive:** `PROTOCOL_NAMES`, the `ProtocolName` values as a runtime array. *Use case:* config validation and the OpenAPI importer's `--expose` both have to check a protocol name at runtime, and config was carrying its own hardcoded `new Set(['http','mcp','a2a'])`. *Alternative considered:* deriving `ProtocolName` from the array instead; rejected because it makes the surface printer expand the type into a literal union at every use site, turning a no-op into a noisy contract diff. *Compatibility:* additive value export, typed `readonly ProtocolName[]` so an unsupported name cannot enter it. No consumer changes. - **Additive:** `ProtocolName` gains `'acp'` (experimental); config gains `protocols.acp` (disabled by default, mount `/acp`) and accepts `expose: [acp]`. *Use case:* the ACP checkout adapter. *Shape:* unlike `mcp`/`a2a`, the normalised `protocols.acp` is discriminated on `enabled` - an enabled block carries `auth`, `idempotency` and all five `checkout.operations` mappings, so the adapter needs no optional-field assertions and a half-configured checkout lifecycle is refused at load rather than advertised through ACP discovery. *Compatibility:* additive union member; a config with no `protocols.acp` block parses unchanged. - **Additive (main entry):** `createAcpAdapter`, `AcpAdapterOptions`, `ACP_SPEC_VERSION`, `ACP_API_VERSION`, `ACP_WELL_KNOWN_PATH`. *Use case:* a consumer running `createGateway` needs the adapter to mount. *Why the main entry and not a subpath:* a subpath is a peer-dependency boundary, not a category - the ACP adapter needs no peer, only `ajv`/`ajv-formats` (real dependencies) and its own vendored schema. *Cost:* the pinned schema is inlined into `dist/index.js` (+~124 kB; package 396 kB -> 479 kB). The CLI bundle is unaffected - `doctor` reads only the ACP constants and descriptor, never the validator. +- **Additive:** the generic authorization contract - `AuthorizationMethodName` (`'ap2'`), `AuthorizationSubmission`, `AuthorizationRequirement`, `AuthorizationVerification`, `AuthorizationProvider` and its two contexts; optional `CanonicalRequest.authorization`, optional `CommerceResource.authorization`, optional `PaymentRequiredOutcome.authorization` and the matching `PaymentRequiredEnvelope.authorization`; `AdapterDescriptor.kind` gains `'authorization'`; four `AUTHORIZATION_*` error codes (403 / 403 / 409 / 503, the last retryable); and the wire carriers `AUTHORIZATION_INPUT_FIELD` (`_authorization`), `AUTHORIZATION_HEADER` (`agent-authorization`), `MAX_AUTHORIZATION_HEADER_BYTES` and `RESERVED_INPUT_FIELDS`. *Use case:* AP2 mandate verification - proving the human behind an agent approved this exact purchase, a separate question from whether the payment verified. *Why generic:* AP2 is the first implementation, not the abstraction. Core states that a resource requires authorization and when the pipeline checks it, and knows nothing about SD-JWTs. An authorization method is deliberately neither a `ProtocolName` nor a `PaymentMethodName`, because it is not a transport and must never be selectable as a payment rail. *Compatibility:* every field is optional and every consumer that sets none behaves exactly as before; a resource with no `authorization` policy is unchanged end to end. `extractReservedInputFields` replaces the two hand-written `_payment` extractors in the MCP and A2A adapters with one path in core, so the reserved-field list cannot drift between surfaces. `_payment` handling is byte-identical, including dropping a proof for a resource with no configured rail. --- # Integration contract - exact factory signatures diff --git a/src/config/schema.ts b/src/config/schema.ts index 330ccc6..b1bbb52 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -33,9 +33,9 @@ import { import { CommerceError, type CommerceResource, - PAYMENT_INPUT_FIELD, PROTOCOL_NAMES, type Pricing, + RESERVED_INPUT_FIELDS, } from '../core/index.js'; import { resolveX402Deployment, type X402FacilitatorConfig } from '../payments/x402/guardrails.js'; import { @@ -948,21 +948,20 @@ function normaliseResource( if (entry.input !== undefined) validateResourceSchemaKeywords(id, 'input', entry.input); const inputProperties = entry.input?.['properties']; - if ( - inputProperties && - typeof inputProperties === 'object' && - PAYMENT_INPUT_FIELD in inputProperties - ) { - throw new CommerceError( - 'CONFIG_INVALID', - `Resource "${id}" declares an input property "${PAYMENT_INPUT_FIELD}", which is reserved for payment proofs`, - { - details: { - path: `resources.${id}.input.properties.${PAYMENT_INPUT_FIELD}`, - resourceId: id, + if (inputProperties && typeof inputProperties === 'object') { + for (const reserved of RESERVED_INPUT_FIELDS) { + if (!(reserved in inputProperties)) continue; + throw new CommerceError( + 'CONFIG_INVALID', + `Resource "${id}" declares an input property "${reserved}", which is reserved by the gateway`, + { + details: { + path: `resources.${id}.input.properties.${reserved}`, + resourceId: id, + }, }, - }, - ); + ); + } } for (const protocol of entry.expose) { @@ -1412,11 +1411,8 @@ function validateInputBindings( ); for (const [location, property] of entries) { - if (property === PAYMENT_INPUT_FIELD) { - fail( - `binds "${location}" to "${PAYMENT_INPUT_FIELD}", which is reserved for payment proofs`, - { location }, - ); + if (RESERVED_INPUT_FIELDS.includes(property)) { + fail(`binds "${location}" to "${property}", which is reserved by the gateway`, { location }); } const other = seen.get(property); if (other !== undefined) { diff --git a/src/core/domain/authorization.ts b/src/core/domain/authorization.ts new file mode 100644 index 0000000..bc97360 --- /dev/null +++ b/src/core/domain/authorization.ts @@ -0,0 +1,118 @@ +/** + * Canonical authorization model. + * + * FROZEN CONTRACT. + * + * Authorization answers a different question from payment. Payment proves + * that funds moved; authorization proves that the human behind the agent + * approved *this exact purchase*. A valid authorization never unlocks a paid + * resource on its own and never moves money. It gates settlement, and a + * resource that requires one still needs a real payment proof as well. + * + * Core decides *that* a resource requires authorization and *when* in the + * pipeline it is checked. Authorization providers decide how a submission is + * parsed, verified and bound to the purchase. No AP2, SD-JWT or JWT type may + * appear in this file. + */ +import type { AdapterDescriptor, AdapterHealth, AuthorizationMethodName } from './common.js'; +import type { PaymentRequirement } from './payment.js'; + +/** + * Opaque authorization proof supplied by the buyer's client. + * + * Over HTTP this arrives base64url-JSON-encoded in the `Agent-Authorization` + * header; over MCP and A2A it is the same `{ method, payload }` object carried + * in the reserved `_authorization` input field. + * + * `payload` is preserved byte-for-byte from the wire. Providers derive replay + * identities by hashing it, so decoding and reserialising it before it reaches + * the provider would change the identity of an otherwise identical proof. + */ +export interface AuthorizationSubmission { + readonly method: AuthorizationMethodName; + readonly payload: string; +} + +/** + * What a buyer must present, advertised alongside the payment challenge so a + * client learns before it pays that a proof of payment alone will not do. + * + * The gateway never issues the authorization itself. It states the method, + * the spec version it verifies against, and the payload profile it expects. + */ +export interface AuthorizationRequirement { + readonly method: AuthorizationMethodName; + readonly version: string; + readonly profile?: string; +} + +/** + * A verified, reserved authorization. + * + * `reference` is a safe stable identity (a digest, never the proof itself) fit + * for a receipt. `reservationId` is the handle the pipeline later consumes or + * releases depending on how settlement went. + */ +export interface AuthorizationVerification { + readonly status: 'verified'; + readonly method: AuthorizationMethodName; + readonly reference: string; + readonly reservationId: string; + /** Safe audit summary only. Never the proof, its disclosures, or PII. */ + readonly metadata?: Readonly>; +} + +/** Input to {@link AuthorizationProvider.verifyAndReserve}. */ +export interface AuthorizationVerificationContext { + readonly requestId: string; + readonly resourceId: string; + /** + * The validated resource input, reserved fields already stripped: the same + * bytes the merchant backend will be called with. A provider that binds a + * proof to the request hashes this, so it must not include `_payment`, + * `_authorization`, the request id or any transport metadata. + */ + readonly input: unknown; + readonly submission: AuthorizationSubmission; + /** The resolved price and destination the proof must match. */ + readonly requirement: PaymentRequirement; +} + +/** Input to {@link AuthorizationProvider.consume} and `release`. */ +export interface AuthorizationFinalizeContext { + readonly requestId: string; + readonly resourceId: string; +} + +/** + * Contract every authorization method implements. + * + * The lifecycle is verify -> reserve -> settlement outcome -> consume/release. + * The two halves straddle settlement because a proof has to be reserved + * *before* funds move, so a replay cannot race a settlement, and its fate is + * only known *after*. Releasing is for failures that provably moved no money. + * Anything ambiguous is consumed rather than handed back: an authorization + * handed back after an uncertain settlement can be spent a second time. + */ +export interface AuthorizationProvider { + readonly name: AuthorizationMethodName; + readonly descriptor: AdapterDescriptor; + + /** + * Verify a submission against the resolved purchase and atomically reserve + * it against reuse. + * + * Throws a `CommerceError` with an `AUTHORIZATION_*` code, never a payment + * code. A verifier or store outage is + * `AUTHORIZATION_PROVIDER_UNAVAILABLE`, not the buyer's fault. + */ + verifyAndReserve(context: AuthorizationVerificationContext): Promise; + + /** Mark a reservation permanently spent. Called after settlement succeeds. */ + consume(reservationId: string, context: AuthorizationFinalizeContext): Promise; + + /** Return a reservation to unused. Only for failures that moved no funds. */ + release(reservationId: string, context: AuthorizationFinalizeContext): Promise; + + health(): Promise; +} diff --git a/src/core/domain/common.ts b/src/core/domain/common.ts index 14517ec..f2aee68 100644 --- a/src/core/domain/common.ts +++ b/src/core/domain/common.ts @@ -35,6 +35,16 @@ export const PROTOCOL_NAMES: readonly ProtocolName[] = ['http', 'mcp', 'a2a', 'a /** Payment methods a resource can accept in this release. */ export type PaymentMethodName = 'x402'; +/** + * Authorization methods a resource can require in this release. + * + * Deliberately not a `ProtocolName` and not a `PaymentMethodName`: an + * authorization method is neither a transport nor a payment rail. It proves + * the purchase was approved, and sits beside the payment rather than + * replacing it. + */ +export type AuthorizationMethodName = 'ap2'; + /** ISO-8601 timestamp string, always UTC with millisecond precision. */ export type IsoTimestamp = string; @@ -62,7 +72,7 @@ export interface AdapterHealth { */ export interface AdapterDescriptor { readonly name: string; - readonly kind: 'protocol' | 'payment' | 'storage'; + readonly kind: 'protocol' | 'payment' | 'storage' | 'authorization'; /** Version of this adapter implementation (independent of the spec). */ readonly implementationVersion: string; /** Exact pinned specification revision this adapter targets. */ diff --git a/src/core/domain/index.ts b/src/core/domain/index.ts index 2a4fa23..65107a6 100644 --- a/src/core/domain/index.ts +++ b/src/core/domain/index.ts @@ -1,3 +1,4 @@ +export * from './authorization.js'; export * from './common.js'; export * from './event.js'; export * from './payment.js'; diff --git a/src/core/domain/request.ts b/src/core/domain/request.ts index 1247986..81d22ff 100644 --- a/src/core/domain/request.ts +++ b/src/core/domain/request.ts @@ -5,6 +5,7 @@ * `ExecutionOutcome` (or a thrown `CommerceError`) back into their own wire * format. Adapters must never call merchant backends directly. */ +import type { AuthorizationRequirement, AuthorizationSubmission } from './authorization.js'; import type { IsoTimestamp, ProtocolName } from './common.js'; import type { PaymentRequirement, PaymentResult, PaymentSubmission } from './payment.js'; import type { CommerceReceipt } from './receipt.js'; @@ -18,6 +19,12 @@ export interface CanonicalRequest { readonly protocol: ProtocolName; /** Payment proof, when the client is retrying after a challenge. */ readonly payment?: PaymentSubmission; + /** + * Authorization proof, when the resource requires one. Independent of + * `payment`: neither substitutes for the other, and a resource requiring + * authorization needs both. + */ + readonly authorization?: AuthorizationSubmission; readonly receivedAt: IsoTimestamp; /** Non-secret transport metadata (client id, user agent, …). */ readonly metadata?: Readonly>; @@ -42,6 +49,11 @@ export interface PaymentRequiredOutcome { readonly requestId: string; readonly resourceId: string; readonly requirement: PaymentRequirement; + /** + * Authorization the buyer must also present on the retry, when the resource + * requires one. Absent for the ordinary paid resource. + */ + readonly authorization?: readonly AuthorizationRequirement[]; } /** diff --git a/src/core/domain/resource.ts b/src/core/domain/resource.ts index 9cf744b..6190328 100644 --- a/src/core/domain/resource.ts +++ b/src/core/domain/resource.ts @@ -3,7 +3,13 @@ * * FROZEN CONTRACT. */ -import type { DecimalAmount, JsonSchema, PaymentMethodName, ProtocolName } from './common.js'; +import type { + AuthorizationMethodName, + DecimalAmount, + JsonSchema, + PaymentMethodName, + ProtocolName, +} from './common.js'; /** HTTP methods a backend handler may use. */ export type BackendMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'; @@ -79,6 +85,17 @@ export interface CommerceResource { readonly pricing: Pricing; readonly exposedVia: readonly ProtocolName[]; readonly paymentMethods: readonly PaymentMethodName[]; + /** + * Authorization the buyer must present in addition to payment. + * + * Opt-in per resource and absent by default, so every resource configured + * before this existed behaves exactly as it did. It sits beside + * `paymentMethods` rather than inside it: an authorization method is not a + * payment rail and must never be selectable as one. + */ + readonly authorization?: { + readonly required: readonly AuthorizationMethodName[]; + }; } /** Read-only view of every configured resource. */ diff --git a/src/core/domain/wire.ts b/src/core/domain/wire.ts index 39c385d..a68ad1c 100644 --- a/src/core/domain/wire.ts +++ b/src/core/domain/wire.ts @@ -7,9 +7,12 @@ * three can never drift apart. */ -import type { CommerceError, CommerceErrorCode } from '../errors/index.js'; +import { CommerceError, type CommerceErrorCode } from '../errors/index.js'; +import type { AuthorizationRequirement, AuthorizationSubmission } from './authorization.js'; import type { DecimalAmount, IsoTimestamp, PaymentMethodName } from './common.js'; +import type { PaymentSubmission } from './payment.js'; import type { DeliveredOutcome, PaymentRequiredOutcome } from './request.js'; +import type { CommerceResource } from './resource.js'; /** * Reserved input property carrying a payment proof on protocols that have no @@ -31,6 +34,54 @@ export const PAYMENT_HEADER = 'payment-signature'; /** HTTP response header carrying the settlement result. */ export const PAYMENT_RESPONSE_HEADER = 'payment-response'; +/** + * Reserved input property carrying an authorization proof, the authorization + * counterpart of {@link PAYMENT_INPUT_FIELD}. + * + * Unlike `_payment`, which is a bare string, this one carries an object: + * `{ method, payload }`. There is exactly one payment rail per resource, so a + * payment proof's method can be inferred from the resource; an authorization + * proof cannot lean on that, and guessing the method of a security control is + * not a thing to do implicitly. + */ +export const AUTHORIZATION_INPUT_FIELD = '_authorization'; + +/** + * Every input property name the gateway claims for itself. + * + * One list, read by the config loader (which rejects a resource declaring any + * of them), by the protocol adapters that lift them out of client input, and + * by the pipeline that strips them again as defence in depth. Those three + * agree by construction instead of by three copies of two strings. + */ +export const RESERVED_INPUT_FIELDS: readonly string[] = [ + PAYMENT_INPUT_FIELD, + AUTHORIZATION_INPUT_FIELD, +]; + +/** + * HTTP request header carrying the authorization proof, base64url-encoded + * JSON of the same `{ method, payload }` envelope the reserved input field + * carries. + * + * This is an Agent Commerce transport carrier, not a header defined by any + * authorization specification, so it is namespaced rather than borrowing + * `Authorization`, which already means something else on every one of these + * routes. + */ +export const AUTHORIZATION_HEADER = 'agent-authorization'; + +/** + * Hard cap on the encoded `Agent-Authorization` header. + * + * Node's own limit is ~16 KiB across *all* request headers, so an + * authorization near that size would start evicting everything else and fail + * as an unreadable transport error rather than a legible one. Capping well + * below it means an oversized proof gets a deterministic + * AUTHORIZATION_INVALID naming the limit. + */ +export const MAX_AUTHORIZATION_HEADER_BYTES = 8192; + /** * HTTP response header carrying the base64 payment challenge on a 402. * @@ -78,6 +129,19 @@ export interface PaymentRequiredEnvelope { */ readonly envelope?: Readonly>; }; + /** + * Present only when the resource requires an authorization proof as well. + * + * Additive: a client that does not understand the field sees exactly the + * envelope it saw before. A client that does learns, before it spends + * anything, that paying alone will not get the resource delivered. + * + * This advertises a requirement; it does not issue anything. The proof is + * obtained from the merchant's own approval flow, outside the gateway. + */ + readonly authorization?: { + readonly required: readonly AuthorizationRequirement[]; + }; } export interface ErrorEnvelope { @@ -164,6 +228,9 @@ export function toPaymentRequiredEnvelope( accepts: r.challenge.accepts, ...(r.challenge.envelope !== undefined ? { envelope: r.challenge.envelope } : {}), }, + ...(outcome.authorization !== undefined && outcome.authorization.length > 0 + ? { authorization: { required: outcome.authorization } } + : {}), }; } @@ -188,3 +255,122 @@ export function isPaymentRequiredEnvelope(value: unknown): value is PaymentRequi typeof (value as PaymentRequiredEnvelope).payment === 'object' ); } + +/** + * Parses the `{ method, payload }` authorization envelope, whichever carrier + * brought it. + * + * Absent is `undefined`. Present but malformed throws, rather than being + * dropped the way an unusable `_payment` is. A dropped payment leaves the + * buyer holding a 402 they can act on. A silently dropped authorization would + * come back as "not authorized" for a proof the client believes it sent, with + * no way to tell a rejected mandate from a typo in the envelope around it. + */ +export function parseAuthorizationSubmission( + value: unknown, + requestId?: string, +): AuthorizationSubmission | undefined { + if (value === undefined || value === null) return undefined; + + const fail = (detail: string): never => { + throw new CommerceError('AUTHORIZATION_INVALID', `Malformed authorization: ${detail}`, { + ...(requestId !== undefined ? { requestId } : {}), + }); + }; + + if (typeof value !== 'object' || Array.isArray(value)) { + fail(`expected an object with "method" and "payload", got ${typeof value}`); + } + const record = value as Record; + const method = record['method']; + const payload = record['payload']; + + // 'ap2' is the only method in this release. Compared against the literal + // rather than a registry: a second method means a second verifier, and that + // is a deliberate addition, not a string that should start working because + // a client sent it. + if (method !== 'ap2') { + fail(`unsupported method ${typeof method === 'string' ? `"${method}"` : typeof method}`); + } + if (typeof payload !== 'string' || payload.length === 0) { + fail('"payload" must be a non-empty string'); + } + + return { method: 'ap2', payload: payload as string }; +} + +/** + * Decodes the base64url-JSON `Agent-Authorization` header. + * + * The size check runs on the encoded value before any decoding, so an + * oversized header costs a length comparison rather than a base64 decode and + * a JSON parse of whatever a caller chose to send. + */ +export function parseAuthorizationHeader( + raw: string | readonly string[] | undefined, + requestId?: string, +): AuthorizationSubmission | undefined { + const value = Array.isArray(raw) ? raw[0] : (raw as string | undefined); + if (value === undefined || value.length === 0) return undefined; + + const fail = (detail: string): never => { + throw new CommerceError('AUTHORIZATION_INVALID', `Malformed authorization: ${detail}`, { + ...(requestId !== undefined ? { requestId } : {}), + }); + }; + + if (Buffer.byteLength(value, 'utf8') > MAX_AUTHORIZATION_HEADER_BYTES) { + fail(`header exceeds the ${MAX_AUTHORIZATION_HEADER_BYTES}-byte limit`); + } + + let decoded: unknown; + try { + decoded = JSON.parse(Buffer.from(value, 'base64url').toString('utf8')); + } catch { + // Nothing from the exception is repeated back: a JSON parse error quotes + // the input it choked on, which here is whatever the caller sent. + fail('header is not base64url-encoded JSON'); + } + return parseAuthorizationSubmission(decoded, requestId); +} + +/** + * Splits raw client input into resource input plus the reserved fields the + * gateway claims, for every protocol that carries them in the input object + * (MCP tool arguments, A2A message data). HTTP carries both in headers and + * uses the two parse helpers directly. + * + * The payment method comes from the resource's own declaration. This is not + * the place that decides which rail a resource uses, so a proof for a + * resource with no configured rail is dropped rather than forwarded under an + * invented method. The pipeline then treats the request as unpaid and decides + * on its own terms. + */ +export function extractReservedInputFields( + rawInput: Record, + resource: CommerceResource | undefined, + requestId?: string, +): { + input: Record; + payment?: PaymentSubmission; + authorization?: AuthorizationSubmission; +} { + const { + [PAYMENT_INPUT_FIELD]: paymentValue, + [AUTHORIZATION_INPUT_FIELD]: authorizationValue, + ...input + } = rawInput; + + const method = resource?.paymentMethods[0]; + const payment = + typeof paymentValue === 'string' && paymentValue.length > 0 && method !== undefined + ? ({ method, payload: paymentValue } satisfies PaymentSubmission) + : undefined; + const authorization = parseAuthorizationSubmission(authorizationValue, requestId); + + return { + input, + ...(payment !== undefined ? { payment } : {}), + ...(authorization !== undefined ? { authorization } : {}), + }; +} diff --git a/src/core/errors/codes.ts b/src/core/errors/codes.ts index f1c5d6b..24225ed 100644 --- a/src/core/errors/codes.ts +++ b/src/core/errors/codes.ts @@ -13,6 +13,10 @@ export const COMMERCE_ERROR_CODES = [ 'PAYMENT_REPLAYED', 'PAYMENT_PROVIDER_UNAVAILABLE', 'PAYMENT_SETTLEMENT_FAILED', + 'AUTHORIZATION_REQUIRED', + 'AUTHORIZATION_INVALID', + 'AUTHORIZATION_REPLAYED', + 'AUTHORIZATION_PROVIDER_UNAVAILABLE', 'BACKEND_TIMEOUT', 'BACKEND_ERROR', 'PROTOCOL_UNSUPPORTED', @@ -38,6 +42,14 @@ export const COMMERCE_ERROR_HTTP_STATUS: Readonly = new Set([ 'PAYMENT_PROVIDER_UNAVAILABLE', + // Our verifier or our replay store was unreachable, so no verdict on the + // mandate was ever reached. That is an outage on our side, and the same + // proof will verify once it clears. + 'AUTHORIZATION_PROVIDER_UNAVAILABLE', 'BACKEND_TIMEOUT', // Load shedding is transient by definition: the caller should back off and // try again. Before this code existed the MCP adapter's queue-full path threw diff --git a/src/core/execution/pipeline.ts b/src/core/execution/pipeline.ts index d0b356c..ecf1b33 100644 --- a/src/core/execution/pipeline.ts +++ b/src/core/execution/pipeline.ts @@ -3,7 +3,7 @@ * * Order is a security boundary — do not reorder: * 1. resolve resource RESOURCE_NOT_FOUND - * 2. strip `_payment`, validate input INPUT_INVALID + * 2. strip reserved input fields, validate input INPUT_INVALID * 3. resolve price * 4. free -> straight to backend * 5. paid -> pick provider -> createRequirement -> (challenge | verify -> @@ -18,7 +18,7 @@ import type { PaymentProvider, PaymentRequirement, PaymentResult } from '../doma import type { CommerceReceipt } from '../domain/receipt.js'; import type { CanonicalRequest, ExecutionOutcome, ExecutionPipeline } from '../domain/request.js'; import type { CommerceResource, ResourceRegistry } from '../domain/resource.js'; -import { PAYMENT_INPUT_FIELD } from '../domain/wire.js'; +import { RESERVED_INPUT_FIELDS } from '../domain/wire.js'; import { CommerceError, isCommerceError, toCommerceError } from '../errors/index.js'; import type { BackendExecutor, BackendResponse } from '../interfaces/backend.js'; import type { Logger } from '../interfaces/logger.js'; @@ -110,8 +110,8 @@ export function createExecutionPipeline( }), ); - // 2. strip reserved payment field, then validate input - const strippedInput = stripPaymentField(request.input); + // 2. strip reserved gateway fields, then validate input + const strippedInput = stripReservedFields(request.input); const validation = getValidator(resource)(strippedInput); if (!validation.valid) { throw new CommerceError( @@ -625,9 +625,9 @@ function pickProvider( return undefined; } -const RESERVED_INPUT_KEYS = new Set([PAYMENT_INPUT_FIELD, '__proto__']); +const RESERVED_INPUT_KEYS = new Set([...RESERVED_INPUT_FIELDS, '__proto__']); -function stripPaymentField(input: unknown): unknown { +function stripReservedFields(input: unknown): unknown { if (typeof input !== 'object' || input === null || Array.isArray(input)) return input; const record = input as Record; // Object.hasOwn: `in` would also match inherited names and strip things diff --git a/src/core/public-types.ts b/src/core/public-types.ts index ff692cd..4317de3 100644 --- a/src/core/public-types.ts +++ b/src/core/public-types.ts @@ -14,10 +14,19 @@ * See docs/contracts.md for the freeze record. */ +export type { + AuthorizationFinalizeContext, + AuthorizationProvider, + AuthorizationRequirement, + AuthorizationSubmission, + AuthorizationVerification, + AuthorizationVerificationContext, +} from './domain/authorization.js'; // --- canonical domain ------------------------------------------------------ export type { AdapterDescriptor, AdapterHealth, + AuthorizationMethodName, DecimalAmount, IsoTimestamp, JsonSchema, @@ -61,12 +70,19 @@ export type { PaymentRequiredEnvelope, } from './domain/wire.js'; export { + AUTHORIZATION_HEADER, + AUTHORIZATION_INPUT_FIELD, DELIVERY_SUMMARY_META_KEY, + extractReservedInputFields, isPaymentRequiredEnvelope, + MAX_AUTHORIZATION_HEADER_BYTES, PAYMENT_HEADER, PAYMENT_INPUT_FIELD, PAYMENT_REQUIRED_HEADER, PAYMENT_RESPONSE_HEADER, + parseAuthorizationHeader, + parseAuthorizationSubmission, + RESERVED_INPUT_FIELDS, toDeliverySummary, toErrorEnvelope, toPaymentRequiredEnvelope, diff --git a/src/gateway/routes.ts b/src/gateway/routes.ts index aab3096..8564ea2 100644 --- a/src/gateway/routes.ts +++ b/src/gateway/routes.ts @@ -7,6 +7,7 @@ import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'; import type { GatewayConfig } from '../config/index.js'; import type { EventBus } from '../core/execution/index.js'; import { + AUTHORIZATION_HEADER, type CanonicalRequest, type Clock, CommerceError, @@ -16,6 +17,7 @@ import { PAYMENT_REQUIRED_HEADER, PAYMENT_RESPONSE_HEADER, type PaymentProvider, + parseAuthorizationHeader, type ReceiptStore, type ResourceRegistry, toCommerceError, @@ -222,6 +224,14 @@ async function handleInvoke( ? { method: paymentMethod, payload: paymentValue } : undefined; + // Its own header rather than a reserved body field: HTTP already carries + // the payment proof out of band, and an authorization inside the body + // would have to survive every backend input-binding mode intact. + const authorization = parseAuthorizationHeader( + request.headers[AUTHORIZATION_HEADER], + request.id, + ); + const canonicalRequest: CanonicalRequest = { requestId: request.id, resourceId, @@ -229,6 +239,7 @@ async function handleInvoke( protocol: 'http', receivedAt: options.clock.nowIso(), ...(payment !== undefined ? { payment } : {}), + ...(authorization !== undefined ? { authorization } : {}), }; const outcome = await options.pipeline.execute(canonicalRequest); diff --git a/src/protocols/a2a/adapter.ts b/src/protocols/a2a/adapter.ts index 33b85a1..5edf546 100644 --- a/src/protocols/a2a/adapter.ts +++ b/src/protocols/a2a/adapter.ts @@ -18,6 +18,7 @@ import { CommerceError, type CommerceResource, type ExecutionOutcome, + extractReservedInputFields, type HttpProtocolAdapter, type ProtocolAdapterContext, toCommerceError, @@ -47,11 +48,7 @@ import { jsonRpcResult, parseJsonRpcRequest, } from './jsonrpc.js'; -import { - type A2aInvocation, - extractPaymentSubmission, - parseInvocation, -} from './message-mapping.js'; +import { type A2aInvocation, parseInvocation } from './message-mapping.js'; import { completedTask, failedTask, @@ -314,18 +311,27 @@ export class A2aProtocolAdapter implements HttpProtocolAdapter { ); } - const { input, payment } = extractPaymentSubmission(invocation.input, resource); - const request: CanonicalRequest = { - requestId: context.ids.next('a2a'), - resourceId: invocation.resourceId, - input, - protocol: 'a2a', - receivedAt: context.clock.nowIso(), - ...(payment !== undefined ? { payment } : {}), - }; - const identity = this.taskIdentity(context, request.requestId); + const requestId = context.ids.next('a2a'); + const identity = this.taskIdentity(context, requestId); + // Reserved-field extraction sits inside the try: a malformed + // `_authorization` envelope is rejected there, and that rejection is a + // commerce outcome for the caller like any other, not an escaped throw. try { + const { input, payment, authorization } = extractReservedInputFields( + invocation.input, + resource, + requestId, + ); + const request: CanonicalRequest = { + requestId, + resourceId: invocation.resourceId, + input, + protocol: 'a2a', + receivedAt: context.clock.nowIso(), + ...(payment !== undefined ? { payment } : {}), + ...(authorization !== undefined ? { authorization } : {}), + }; const outcome: ExecutionOutcome = await context.pipeline.execute(request); return this.taskResult( id, @@ -339,7 +345,7 @@ export class A2aProtocolAdapter implements HttpProtocolAdapter { // nothing internal reaches the artifact. const error = toCommerceError(err); context.logger.warn( - { resourceId: invocation.resourceId, requestId: request.requestId, err: error.toInfo() }, + { resourceId: invocation.resourceId, requestId, err: error.toInfo() }, 'a2a adapter: execution failed', ); return this.taskResult(id, failedTask(error, identity)); diff --git a/src/protocols/a2a/message-mapping.ts b/src/protocols/a2a/message-mapping.ts index 9cb619f..efafec0 100644 --- a/src/protocols/a2a/message-mapping.ts +++ b/src/protocols/a2a/message-mapping.ts @@ -25,12 +25,7 @@ * successful, possibly *paid*, call for something they did not ask for. */ import { z } from 'zod'; -import { - CommerceError, - type CommerceResource, - PAYMENT_INPUT_FIELD, - type PaymentSubmission, -} from '../../core/index.js'; +import { CommerceError } from '../../core/index.js'; import { A2A_JSON_MEDIA_TYPE } from './constants.js'; /** The only role a request message may carry. A2A v1 spells roles this way. */ @@ -185,25 +180,3 @@ export function parseInvocation(rawParams: unknown): A2aInvocation { ...(message.messageId !== undefined ? { messageId: message.messageId } : {}), }; } - -/** - * Lifts a payment proof out of the reserved input field into the canonical - * `PaymentSubmission` the pipeline reads, leaving the rest of the input alone. - * - * The *convention* is shared with MCP — one reserved field named once in - * `core` — but the code is not: a cross-adapter import would make an A2A - * deployment's payment retry depend on the MCP SDK being installed. The - * adapter decides nothing about the payment here; it only moves it to where - * the pipeline looks, and the rail comes from the resource's own declaration. - */ -export function extractPaymentSubmission( - rawInput: Record, - resource: CommerceResource | undefined, -): { input: Record; payment?: PaymentSubmission } { - const { [PAYMENT_INPUT_FIELD]: proof, ...input } = rawInput; - const method = resource?.paymentMethods[0]; - if (typeof proof === 'string' && proof.length > 0 && method !== undefined) { - return { input, payment: { method, payload: proof } }; - } - return { input }; -} diff --git a/src/protocols/mcp/adapter.ts b/src/protocols/mcp/adapter.ts index 64e8b2a..2840f42 100644 --- a/src/protocols/mcp/adapter.ts +++ b/src/protocols/mcp/adapter.ts @@ -66,12 +66,13 @@ import { type CanonicalRequest, CommerceError, type CommerceResource, + extractReservedInputFields, type HttpProtocolAdapter, type ProtocolAdapterContext, toCommerceError, } from '../../core/index.js'; import { buildDescriptor, PACKAGE_VERSION } from './descriptor.js'; -import { errorResult, extractPaymentSubmission, mapOutcome } from './result-mapping.js'; +import { errorResult, mapOutcome } from './result-mapping.js'; import { buildInputSchema, buildToolDescription, @@ -379,14 +380,20 @@ export class McpProtocolAdapter implements HttpProtocolAdapter { new CommerceError('RESOURCE_NOT_FOUND', `Unknown tool "${resourceId}".`, { resourceId }), ); } - const { input, payment } = extractPaymentSubmission(rawArgs, resource); + const requestId = context.ids.next('mcp'); + const { input, payment, authorization } = extractReservedInputFields( + rawArgs, + resource, + requestId, + ); const request: CanonicalRequest = { - requestId: context.ids.next('mcp'), + requestId, resourceId, input, protocol: 'mcp', receivedAt: context.clock.nowIso(), ...(payment !== undefined ? { payment } : {}), + ...(authorization !== undefined ? { authorization } : {}), }; await this.acquireToolCallSlot(signal); try { diff --git a/src/protocols/mcp/result-mapping.ts b/src/protocols/mcp/result-mapping.ts index 459390c..428ef30 100644 --- a/src/protocols/mcp/result-mapping.ts +++ b/src/protocols/mcp/result-mapping.ts @@ -8,13 +8,11 @@ import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; import { type CommerceError, - type CommerceResource, DELIVERY_SUMMARY_META_KEY, type DeliveredOutcome, type ExecutionOutcome, PAYMENT_INPUT_FIELD, type PaymentRequiredOutcome, - type PaymentSubmission, toDeliverySummary, toErrorEnvelope, toPaymentRequiredEnvelope, @@ -24,33 +22,6 @@ function toRecord(value: object): Record { return value as Record; } -/** - * Splits raw MCP tool arguments into resource input and an optional payment - * submission. `_payment` never leaks into the input handed to the pipeline. - * - * The payment method is derived from the resource's own `paymentMethods` - * (never hard-coded) — this adapter does not decide which rail a resource - * uses, that is core/config's job. If a `_payment` proof is supplied for a - * resource with no configured payment method (or an unrecognised resource), - * it is dropped rather than forwarded under an invented method: the pipeline - * then treats the request as unpaid and decides for itself (free delivery, - * or a rejection) — never a payment-method guess made in this adapter. - */ -export function extractPaymentSubmission( - rawArgs: Record, - resource: CommerceResource | undefined, -): { - input: Record; - payment?: PaymentSubmission; -} { - const { [PAYMENT_INPUT_FIELD]: paymentValue, ...input } = rawArgs; - const method = resource?.paymentMethods[0]; - if (typeof paymentValue === 'string' && paymentValue.length > 0 && method !== undefined) { - return { input, payment: { method, payload: paymentValue } }; - } - return { input }; -} - export function deliveredResult(outcome: DeliveredOutcome): CallToolResult { const body = outcome.body; const text = typeof body === 'string' ? body : JSON.stringify(body ?? null); diff --git a/tests/integration/authorization-carrier.test.ts b/tests/integration/authorization-carrier.test.ts new file mode 100644 index 0000000..2a2ee08 --- /dev/null +++ b/tests/integration/authorization-carrier.test.ts @@ -0,0 +1,287 @@ +/** + * The same authorization proof over all three transports. + * + * The carrier differs per surface: a header over HTTP, a reserved input field + * over MCP and A2A. All three have to reach the pipeline as the same + * `AuthorizationSubmission` with the proof untouched, and a per-adapter copy + * of the extraction is the drift this catches. So every assertion runs + * against the real gateway with the real adapters mounted, not against the + * extraction helper on its own. + * + * Nothing verifies the proof yet. What is asserted here is transport + * behaviour: it reaches the pipeline intact, it never reaches the resource + * input, and a malformed envelope is refused before the pipeline runs. + */ +import { afterEach, describe, expect, it } from 'vitest'; +import type { GatewayConfig } from '../../src/config/index.js'; +import type { BackendExecutor, CanonicalRequest, ExecutionPipeline } from '../../src/core/index.js'; +import { + AUTHORIZATION_HEADER, + AUTHORIZATION_INPUT_FIELD, + MAX_AUTHORIZATION_HEADER_BYTES, +} from '../../src/core/index.js'; +import { createGateway, type GatewayInstance } from '../../src/gateway/index.js'; +import { createA2aAdapter } from '../../src/protocols/a2a/index.js'; +import { createMcpAdapter } from '../../src/protocols/mcp/index.js'; +import { createFakeStore } from '../unit/gateway/helpers.js'; + +process.env['NODE_ENV'] = 'test'; + +const PROOF = 'eyJhbGciOiJFUzI1NiJ9.checkout-mandate~disclosure-0~'; +const ENVELOPE = { method: 'ap2', payload: PROOF }; + +let gateway: GatewayInstance | undefined; + +afterEach(async () => { + await gateway?.close().catch(() => {}); + gateway = undefined; +}); + +function config(): GatewayConfig { + return { + version: 1, + merchant: { id: 'demo-store', name: 'Demo Store', publicBaseUrl: 'http://localhost:8080' }, + server: { port: 0, host: '127.0.0.1', allowedOrigins: [] }, + storage: { receipts: { driver: 'sqlite', path: ':memory:' } }, + protocols: { + http: { enabled: true }, + mcp: { enabled: true, mountPath: '/mcp' }, + a2a: { enabled: true, mountPath: '/a2a' }, + acp: { enabled: false, mountPath: '/acp' }, + }, + resources: [ + { + id: 'weather_basic', + name: 'Basic Weather', + description: 'Current weather for a city.', + inputSchema: { + type: 'object', + properties: { city: { type: 'string' } }, + required: ['city'], + // Closed schema: if a reserved field survived extraction it would + // fail here as INPUT_INVALID rather than reaching the backend. + additionalProperties: false, + }, + handler: { type: 'http', method: 'GET', url: 'http://backend.local/weather/{city}' }, + pricing: { type: 'free' }, + exposedVia: ['http', 'mcp', 'a2a'], + paymentMethods: [], + }, + ], + payments: {}, + }; +} + +const backendInputs: unknown[] = []; +const backend: BackendExecutor = { + async call(_handler, request) { + backendInputs.push(request.input); + return { status: 200, body: { forecast: 'sunny' }, headers: {}, durationMs: 1 }; + }, +}; + +/** Captures the exact CanonicalRequest each surface built, without re-routing. */ +function spyOnPipeline(gw: GatewayInstance): CanonicalRequest[] { + const captured: CanonicalRequest[] = []; + const original = gw.pipeline.execute.bind(gw.pipeline); + (gw.pipeline as { execute: ExecutionPipeline['execute'] }).execute = async (request) => { + captured.push(request); + return original(request); + }; + return captured; +} + +async function startGateway(): Promise { + backendInputs.length = 0; + gateway = await createGateway({ + config: config(), + store: createFakeStore(), + paymentProviders: [], + protocolAdapters: [createMcpAdapter(), createA2aAdapter()], + backend, + }); + return gateway; +} + +function encodeHeader(value: unknown): string { + return Buffer.from(JSON.stringify(value), 'utf8').toString('base64url'); +} + +// --- one call per surface -------------------------------------------------- + +async function callHttp( + gw: GatewayInstance, + authorization?: string, +): Promise<{ statusCode: number; body: unknown }> { + const res = await gw.server.inject({ + method: 'POST', + url: '/api/resources/weather_basic/invoke', + headers: { + 'content-type': 'application/json', + ...(authorization !== undefined ? { [AUTHORIZATION_HEADER]: authorization } : {}), + }, + payload: { city: 'Berlin' }, + }); + return { statusCode: res.statusCode, body: res.json() }; +} + +interface McpToolResult { + isError?: boolean; + content: { type: string; text?: string }[]; + structuredContent?: { code?: string }; +} + +async function callMcp(gw: GatewayInstance, args: Record): Promise { + const res = await gw.server.inject({ + method: 'POST', + url: '/mcp', + headers: { + 'content-type': 'application/json', + accept: 'application/json, text/event-stream', + }, + payload: { + jsonrpc: '2.0', + id: 1, + method: 'tools/call', + params: { name: 'weather_basic', arguments: { city: 'Berlin', ...args } }, + }, + }); + // The adapter answers over Streamable HTTP, which can frame the reply as a + // single SSE event rather than a bare JSON body. + const raw = res.body.startsWith('event:') + ? (res.body.split('\n').find((line) => line.startsWith('data:')) ?? '').slice(5) + : res.body; + return (JSON.parse(raw) as { result: McpToolResult }).result; +} + +interface A2aTaskResult { + result?: { task?: { artifacts: { parts: { data: Record }[] }[] } }; +} + +async function callA2a( + gw: GatewayInstance, + input: Record, +): Promise | undefined> { + const res = await gw.server.inject({ + method: 'POST', + url: '/a2a', + headers: { 'content-type': 'application/json', 'a2a-version': '1.0' }, + payload: JSON.stringify({ + jsonrpc: '2.0', + id: 'req-1', + method: 'SendMessage', + params: { + message: { + role: 'ROLE_USER', + messageId: 'msg-1', + parts: [ + { + data: { resource: 'weather_basic', input: { city: 'Berlin', ...input } }, + mediaType: 'application/json', + }, + ], + }, + }, + }), + }); + const body = res.json(); + return body.result?.task?.artifacts[0]?.parts[0]?.data; +} + +// --- tests ----------------------------------------------------------------- + +describe('generic authorization carrier across every surface', () => { + it('normalises an HTTP header, an MCP argument and an A2A input field to the same submission', async () => { + const gw = await startGateway(); + const captured = spyOnPipeline(gw); + + await callHttp(gw, encodeHeader(ENVELOPE)); + await callMcp(gw, { [AUTHORIZATION_INPUT_FIELD]: ENVELOPE }); + await callA2a(gw, { [AUTHORIZATION_INPUT_FIELD]: ENVELOPE }); + + expect(captured.map((r) => r.protocol)).toEqual(['http', 'mcp', 'a2a']); + for (const request of captured) { + expect(request.authorization).toEqual({ method: 'ap2', payload: PROOF }); + } + }); + + it('keeps the reserved field out of the resource input on every surface', async () => { + const gw = await startGateway(); + const captured = spyOnPipeline(gw); + + const http = await callHttp(gw, encodeHeader(ENVELOPE)); + const mcp = await callMcp(gw, { [AUTHORIZATION_INPUT_FIELD]: ENVELOPE }); + const a2a = await callA2a(gw, { [AUTHORIZATION_INPUT_FIELD]: ENVELOPE }); + + // The resource schema is closed, so a leaked field would surface as + // INPUT_INVALID. All three deliver instead. + expect(http.statusCode).toBe(200); + expect(mcp.isError).not.toBe(true); + expect(a2a).toEqual({ forecast: 'sunny' }); + + for (const request of captured) { + expect(request.input).toEqual({ city: 'Berlin' }); + } + // And the merchant backend never sees a gateway-reserved field. + expect(backendInputs).toEqual([{ city: 'Berlin' }, { city: 'Berlin' }, { city: 'Berlin' }]); + }); + + it('leaves a request carrying no authorization exactly as it was', async () => { + const gw = await startGateway(); + const captured = spyOnPipeline(gw); + + const http = await callHttp(gw); + const mcp = await callMcp(gw, {}); + const a2a = await callA2a(gw, {}); + + expect(http.statusCode).toBe(200); + expect(mcp.isError).not.toBe(true); + expect(a2a).toEqual({ forecast: 'sunny' }); + for (const request of captured) { + expect(request.authorization).toBeUndefined(); + } + }); + + it('refuses a malformed envelope before the pipeline runs, on every surface', async () => { + const gw = await startGateway(); + const captured = spyOnPipeline(gw); + + const http = await callHttp(gw, encodeHeader({ method: 'ap2' })); + expect(http.statusCode).toBe(403); + expect(http.body).toMatchObject({ code: 'AUTHORIZATION_INVALID', retryable: false }); + + const mcp = await callMcp(gw, { [AUTHORIZATION_INPUT_FIELD]: 'a-bare-string' }); + expect(mcp.isError).toBe(true); + expect(mcp.structuredContent?.code).toBe('AUTHORIZATION_INVALID'); + + const a2a = await callA2a(gw, { + [AUTHORIZATION_INPUT_FIELD]: { method: 'ap3', payload: PROOF }, + }); + expect(a2a?.['code']).toBe('AUTHORIZATION_INVALID'); + + expect(captured).toHaveLength(0); + expect(backendInputs).toEqual([]); + }); + + it('refuses an oversized HTTP carrier deterministically', async () => { + const gw = await startGateway(); + const captured = spyOnPipeline(gw); + + const { statusCode, body } = await callHttp(gw, 'a'.repeat(MAX_AUTHORIZATION_HEADER_BYTES + 1)); + + expect(statusCode).toBe(403); + expect(body).toMatchObject({ code: 'AUTHORIZATION_INVALID' }); + expect(captured).toHaveLength(0); + }); + + it('never reports an authorization failure as a payment failure', async () => { + // A 402 would tell an auto-paying client to spend money on a request that + // was never going to be delivered. + const gw = await startGateway(); + + const { statusCode, body } = await callHttp(gw, encodeHeader({ method: 'ap2', payload: 42 })); + + expect(statusCode).not.toBe(402); + expect((body as { code: string }).code).not.toMatch(/^PAYMENT_/); + }); +}); diff --git a/tests/unit/config/schema.test.ts b/tests/unit/config/schema.test.ts index 88eb6ad..115f0ec 100644 --- a/tests/unit/config/schema.test.ts +++ b/tests/unit/config/schema.test.ts @@ -4,7 +4,7 @@ import { compileJsonSchema, validateBackendRequestShape, } from '../../../src/core/execution/index.js'; -import { isCommerceError, PAYMENT_INPUT_FIELD } from '../../../src/core/index.js'; +import { isCommerceError, RESERVED_INPUT_FIELDS } from '../../../src/core/index.js'; import { validRawConfig } from './fixtures.js'; function expectConfigInvalid(fn: () => unknown): void { @@ -608,21 +608,24 @@ describe('parseConfig', () => { }); }); - it(`rejects a resource whose input.properties declares the reserved "${PAYMENT_INPUT_FIELD}" field`, () => { - const raw = validRawConfig(); - ( - raw['resources'] as { weather_basic: { input: { properties: Record } } } - ).weather_basic.input.properties[PAYMENT_INPUT_FIELD] = { type: 'string' }; - expectConfigInvalid(() => parseConfig(raw, {})); - try { - parseConfig(raw, {}); - } catch (error) { - if (isCommerceError(error)) { - expect(error.message).toContain(PAYMENT_INPUT_FIELD); - expect(error.message).toContain('reserved'); + it.each(RESERVED_INPUT_FIELDS)( + 'rejects a resource whose input.properties declares the reserved "%s" field', + (reserved) => { + const raw = validRawConfig(); + ( + raw['resources'] as { weather_basic: { input: { properties: Record } } } + ).weather_basic.input.properties[reserved] = { type: 'string' }; + expectConfigInvalid(() => parseConfig(raw, {})); + try { + parseConfig(raw, {}); + } catch (error) { + if (isCommerceError(error)) { + expect(error.message).toContain(reserved); + expect(error.message).toContain('reserved'); + } } - } - }); + }, + ); it('rejects a paid resource declaring no payment methods', () => { const raw = validRawConfig(); @@ -1795,9 +1798,9 @@ describe('parseConfig backend.inputBindings', () => { ); }); - it('rejects a binding to the reserved payment input field', () => { + it.each(RESERVED_INPUT_FIELDS)('rejects a binding to the reserved "%s" input field', (field) => { expectConfigInvalid(() => - parseConfig(bindingConfig({ bindings: { ...bindings, body: PAYMENT_INPUT_FIELD } }), {}), + parseConfig(bindingConfig({ bindings: { ...bindings, body: field } }), {}), ); }); diff --git a/tests/unit/core/domain/authorization-wire.test.ts b/tests/unit/core/domain/authorization-wire.test.ts new file mode 100644 index 0000000..e94d9b6 --- /dev/null +++ b/tests/unit/core/domain/authorization-wire.test.ts @@ -0,0 +1,302 @@ +/** + * The generic authorization carrier, at the one place every surface shares. + * + * MCP and A2A both call `extractReservedInputFields` and HTTP calls + * `parseAuthorizationHeader`. The envelope rules therefore only have to be + * right once, which is why the extraction lives in core rather than being + * written out per adapter. + */ +import { describe, expect, it } from 'vitest'; +import type { CommerceResource, PaymentRequiredOutcome } from '../../../../src/core/index.js'; +import { + AUTHORIZATION_INPUT_FIELD, + type CommerceError, + extractReservedInputFields, + isCommerceError, + MAX_AUTHORIZATION_HEADER_BYTES, + PAYMENT_INPUT_FIELD, + parseAuthorizationHeader, + parseAuthorizationSubmission, + RESERVED_INPUT_FIELDS, + toPaymentRequiredEnvelope, +} from '../../../../src/core/index.js'; + +const PROOF = 'eyJhbGciOiJFUzI1NiJ9.mandate~disclosure~'; + +const paidResource: CommerceResource = { + id: 'premium_report', + name: 'Premium Report', + handler: { type: 'http', method: 'GET', url: 'http://backend.local/report' }, + pricing: { type: 'fixed', amount: '0.10', currency: 'USDC' }, + exposedVia: ['http', 'mcp', 'a2a'], + paymentMethods: ['x402'], +}; + +const freeResource: CommerceResource = { + ...paidResource, + id: 'free_report', + pricing: { type: 'free' }, + paymentMethods: [], +}; + +function encodeHeader(value: unknown): string { + return Buffer.from(JSON.stringify(value), 'utf8').toString('base64url'); +} + +function codeOf(fn: () => unknown): string { + try { + fn(); + } catch (error) { + return isCommerceError(error) ? error.code : `unexpected: ${String(error)}`; + } + return 'no error thrown'; +} + +describe('reserved input fields', () => { + it('names both gateway-reserved fields in one list', () => { + expect(RESERVED_INPUT_FIELDS).toEqual([PAYMENT_INPUT_FIELD, AUTHORIZATION_INPUT_FIELD]); + }); +}); + +describe('parseAuthorizationSubmission', () => { + it('accepts a well-formed envelope', () => { + expect(parseAuthorizationSubmission({ method: 'ap2', payload: PROOF })).toEqual({ + method: 'ap2', + payload: PROOF, + }); + }); + + it('preserves the payload byte for byte', () => { + // Providers hash this string to derive a replay identity, so any + // normalisation here would give the same proof two identities. + const awkward = ' a~b.c \n'; + expect(parseAuthorizationSubmission({ method: 'ap2', payload: awkward })?.payload).toBe( + awkward, + ); + }); + + it.each([ + ['absent', undefined], + ['null', null], + ])('treats %s as no authorization at all', (_label, value) => { + expect(parseAuthorizationSubmission(value)).toBeUndefined(); + }); + + it.each([ + ['a bare string', 'just-the-proof'], + ['an array', [{ method: 'ap2', payload: PROOF }]], + ['a number', 42], + ['an unknown method', { method: 'ap3', payload: PROOF }], + ['a missing method', { payload: PROOF }], + ['a non-string payload', { method: 'ap2', payload: { jwt: PROOF } }], + ['an empty payload', { method: 'ap2', payload: '' }], + ['a missing payload', { method: 'ap2' }], + ])('rejects %s as AUTHORIZATION_INVALID', (_label, value) => { + expect(codeOf(() => parseAuthorizationSubmission(value))).toBe('AUTHORIZATION_INVALID'); + }); + + it('never reports an authorization failure as a payment failure', () => { + // The distinction is the point of the feature: a buyer whose mandate is + // malformed has not paid wrongly, and must not be told to pay again. + try { + parseAuthorizationSubmission({ method: 'ap2' }); + expect.unreachable(); + } catch (error) { + const commerce = error as CommerceError; + expect(commerce.code).not.toMatch(/^PAYMENT_/); + expect(commerce.httpStatus).toBe(403); + expect(commerce.retryable).toBe(false); + } + }); + + it('carries the request id so the failure correlates with the rest of the flow', () => { + try { + parseAuthorizationSubmission({ method: 'ap2' }, 'req-7'); + expect.unreachable(); + } catch (error) { + expect((error as CommerceError).requestId).toBe('req-7'); + } + }); +}); + +describe('parseAuthorizationHeader', () => { + it('decodes a base64url JSON envelope', () => { + expect(parseAuthorizationHeader(encodeHeader({ method: 'ap2', payload: PROOF }))).toEqual({ + method: 'ap2', + payload: PROOF, + }); + }); + + it.each([ + ['absent', undefined], + ['empty', ''], + ])('treats an %s header as no authorization', (_label, value) => { + expect(parseAuthorizationHeader(value)).toBeUndefined(); + }); + + it('reads the first value when a client sends the header twice', () => { + const first = encodeHeader({ method: 'ap2', payload: PROOF }); + const second = encodeHeader({ method: 'ap2', payload: 'other' }); + expect(parseAuthorizationHeader([first, second])?.payload).toBe(PROOF); + }); + + it.each([ + ['not base64url', '!!!not base64!!!'], + ['base64url of something that is not JSON', Buffer.from('nope').toString('base64url')], + ['base64url of a valid JSON non-envelope', encodeHeader(['ap2', PROOF])], + ])('rejects a header that is %s', (_label, value) => { + expect(codeOf(() => parseAuthorizationHeader(value))).toBe('AUTHORIZATION_INVALID'); + }); + + it('rejects an oversized header and names the limit', () => { + const oversized = 'a'.repeat(MAX_AUTHORIZATION_HEADER_BYTES + 1); + try { + parseAuthorizationHeader(oversized); + expect.unreachable(); + } catch (error) { + const commerce = error as CommerceError; + expect(commerce.code).toBe('AUTHORIZATION_INVALID'); + expect(commerce.message).toContain(String(MAX_AUTHORIZATION_HEADER_BYTES)); + } + }); + + it('accepts a header exactly at the limit', () => { + // base64url expands by 4/3, so the payload that fits is the limit scaled + // down, minus room for the JSON envelope around it. + const payload = 'x'.repeat(Math.floor((MAX_AUTHORIZATION_HEADER_BYTES * 3) / 4) - 64); + const header = encodeHeader({ method: 'ap2', payload }); + expect(header.length).toBeLessThanOrEqual(MAX_AUTHORIZATION_HEADER_BYTES); + expect(parseAuthorizationHeader(header)?.payload).toBe(payload); + }); + + it('does not echo the caller input back in the message', () => { + // A JSON parse error quotes what it choked on; relaying that would put + // attacker-chosen bytes into our own error response. + const probe = ''; + try { + parseAuthorizationHeader(Buffer.from(probe).toString('base64url')); + expect.unreachable(); + } catch (error) { + expect((error as CommerceError).message).not.toContain(probe); + } + }); +}); + +describe('extractReservedInputFields', () => { + it('leaves ordinary input completely alone', () => { + const result = extractReservedInputFields({ city: 'Berlin' }, paidResource); + expect(result).toEqual({ input: { city: 'Berlin' } }); + expect(result.payment).toBeUndefined(); + expect(result.authorization).toBeUndefined(); + }); + + it('lifts a payment proof out of the input and takes the rail from the resource', () => { + const result = extractReservedInputFields( + { city: 'Berlin', [PAYMENT_INPUT_FIELD]: 'proof-abc' }, + paidResource, + ); + expect(result.input).toEqual({ city: 'Berlin' }); + expect(result.payment).toEqual({ method: 'x402', payload: 'proof-abc' }); + }); + + it.each([ + ['the resource has no payment rail', freeResource], + ['the resource is unknown', undefined], + ])('drops a payment proof when %s rather than inventing a rail', (_label, resource) => { + const result = extractReservedInputFields( + { city: 'Berlin', [PAYMENT_INPUT_FIELD]: 'proof-abc' }, + resource, + ); + expect(result.input).toEqual({ city: 'Berlin' }); + expect(result.payment).toBeUndefined(); + }); + + it('lifts an authorization envelope out of the input', () => { + const result = extractReservedInputFields( + { city: 'Berlin', [AUTHORIZATION_INPUT_FIELD]: { method: 'ap2', payload: PROOF } }, + paidResource, + ); + expect(result.input).toEqual({ city: 'Berlin' }); + expect(result.authorization).toEqual({ method: 'ap2', payload: PROOF }); + }); + + it('strips the reserved field even when the envelope is unusable', () => { + // An adapter that forwarded the raw field would fail schema validation + // with INPUT_INVALID, hiding the real reason from the caller. + expect( + codeOf(() => + extractReservedInputFields( + { city: 'Berlin', [AUTHORIZATION_INPUT_FIELD]: 'bare-string' }, + paidResource, + ), + ), + ).toBe('AUTHORIZATION_INVALID'); + }); + + it('carries both reserved fields at once', () => { + const result = extractReservedInputFields( + { + city: 'Berlin', + [PAYMENT_INPUT_FIELD]: 'proof-abc', + [AUTHORIZATION_INPUT_FIELD]: { method: 'ap2', payload: PROOF }, + }, + paidResource, + ); + expect(result.input).toEqual({ city: 'Berlin' }); + expect(result.payment).toEqual({ method: 'x402', payload: 'proof-abc' }); + expect(result.authorization).toEqual({ method: 'ap2', payload: PROOF }); + }); + + it('keeps an authorization even for a resource that requires none', () => { + // Whether one is required is the pipeline's decision, made against the + // resource policy. The carrier does not get to pre-empt it. + const result = extractReservedInputFields( + { [AUTHORIZATION_INPUT_FIELD]: { method: 'ap2', payload: PROOF } }, + freeResource, + ); + expect(result.authorization).toEqual({ method: 'ap2', payload: PROOF }); + }); +}); + +describe('payment-required envelope', () => { + const outcome: PaymentRequiredOutcome = { + kind: 'payment-required', + requestId: 'req-1', + resourceId: 'premium_report', + requirement: { + id: 'pr-1', + requestId: 'req-1', + resourceId: 'premium_report', + provider: 'x402', + amount: '0.10', + currency: 'USDC', + destination: '0xmerchant', + challenge: { provider: 'x402', version: '2', accepts: [] }, + }, + }; + + it('is byte-identical to before when the resource requires no authorization', () => { + expect(toPaymentRequiredEnvelope(outcome)).not.toHaveProperty('authorization'); + }); + + it('omits the field for an empty requirement list rather than advertising nothing', () => { + expect(toPaymentRequiredEnvelope({ ...outcome, authorization: [] })).not.toHaveProperty( + 'authorization', + ); + }); + + it('advertises the requirement so a buyer learns before paying that payment alone will not do', () => { + const envelope = toPaymentRequiredEnvelope({ + ...outcome, + authorization: [ + { method: 'ap2', version: '0.2.0', profile: 'https://example.test/checkout/v1' }, + ], + }); + expect(envelope.authorization).toEqual({ + required: [{ method: 'ap2', version: '0.2.0', profile: 'https://example.test/checkout/v1' }], + }); + // Still a 402 challenge in every other respect. + expect(envelope.code).toBe('PAYMENT_REQUIRED'); + expect(envelope.payment.amount).toBe('0.10'); + }); +}); diff --git a/tests/unit/core/execution/pipeline.test.ts b/tests/unit/core/execution/pipeline.test.ts index 0f93943..bc8cbaa 100644 --- a/tests/unit/core/execution/pipeline.test.ts +++ b/tests/unit/core/execution/pipeline.test.ts @@ -471,6 +471,41 @@ describe('createExecutionPipeline', () => { expect(outcome.kind).toBe('delivered'); }); + it('strips every reserved field, not only _payment, before validating input', async () => { + // Defence in depth. Adapters lift these out already, so reaching here + // means one of them stopped doing so - and the closed schema below would + // then turn a gateway-reserved field into the caller's INPUT_INVALID. + const resource = makeResource({ + id: 'res-1', + pricing: { type: 'free' }, + inputSchema: { type: 'object', properties: {}, additionalProperties: false }, + }); + const store = createFakeStore(); + const backendInputs: unknown[] = []; + const pipeline = createExecutionPipeline({ + resources: createResourceRegistry([resource]), + paymentProviders: [], + store, + backend: createFakeBackendExecutor(async (_handler, request) => { + backendInputs.push(request.input); + return { status: 200, headers: {}, body: { ok: true }, durationMs: 1 }; + }), + events: store, + logger: createCapturingLogger(), + clock: createFakeClock(), + ids: createFakeIdGenerator(), + }); + + const outcome = await pipeline.execute( + makeRequest({ + input: { _payment: 'whatever', _authorization: { method: 'ap2', payload: 'proof' } }, + }), + ); + + expect(outcome.kind).toBe('delivered'); + expect(backendInputs).toEqual([{}]); + }); + it('returns a PaymentRequiredOutcome and emits payment.required when no proof is supplied', async () => { const resource = makeResource({ id: 'res-1', From 451d302e14663eddcf0b38ff8d8614933e94f308 Mon Sep 17 00:00:00 2001 From: Revinand Date: Mon, 14 Sep 2026 14:28:00 +0200 Subject: [PATCH 02/11] feat(config): add ap2 authorization policy --- src/authorization/ap2/constants.ts | 65 +++++ src/config/schema.ts | 408 ++++++++++++++++++++++++++++- tests/unit/config/ap2.test.ts | 349 ++++++++++++++++++++++++ 3 files changed, 821 insertions(+), 1 deletion(-) create mode 100644 src/authorization/ap2/constants.ts create mode 100644 tests/unit/config/ap2.test.ts diff --git a/src/authorization/ap2/constants.ts b/src/authorization/ap2/constants.ts new file mode 100644 index 0000000..c4b39a0 --- /dev/null +++ b/src/authorization/ap2/constants.ts @@ -0,0 +1,65 @@ +/** + * Pinned AP2 identifiers and key policy. + * + * The pin is deliberate. AP2 v0.2.0 (released 2026-04-28, commit b4587ac) is + * the tagged release this gateway verifies against; unversioned `main` is + * never implemented against, because a mandate signed under one set of rules + * has to be checked under that same set. Config, the verifier and `doctor` + * all read these, so a bump lands in one place and changes every one of them + * together. + */ + +/** The only AP2 release this gateway verifies mandates against. */ +export const AP2_SPEC_VERSION = '0.2.0'; + +/** + * Operating modes implemented so far. + * + * Autonomous mode needs an open mandate, a cnf-bound agent key, selective + * disclosures and deterministic constraint evaluation over + * `checkout.line_items`. Half of that model would be worse than a clearly + * declared Direct-only profile, so it is refused rather than partly served. + */ +export const AP2_MODES = ['direct'] as const; +export type Ap2Mode = (typeof AP2_MODES)[number]; + +/** + * The only signature algorithm accepted, for mandates and for the merchant + * checkout JWT alike. + * + * The allowlist holds one entry, so `alg=none` and the HMAC family are + * excluded by construction rather than by a check that has to remember them. + * AP2 v0.2 recommends non-deterministic signing for checkout JWTs, which + * ES256 satisfies, and it matches the reference examples. + */ +export const AP2_SIGNING_ALGORITHM = 'ES256'; + +/** The key type and curve ES256 implies. Any other pair is refused at load. */ +export const AP2_KEY_TYPE = 'EC'; +export const AP2_JWK_CURVE = 'P-256'; + +/** Byte length of a P-256 coordinate, before base64url encoding. */ +export const AP2_JWK_COORDINATE_BYTES = 32; + +/** + * JWK members a verification key may carry. + * + * An allowlist, so private material (`d`) and the members that point at a URL + * (`x5u`, and `jku` if someone smuggles the header parameter in here) are + * refused without this list having to name them. The static trust model + * exists to rule out fetching a key from a location a mandate can influence; + * see docs/security.md. + */ +export const AP2_JWK_MEMBERS = ['kty', 'crv', 'x', 'y', 'kid', 'alg', 'use'] as const; + +/** Seconds of clock skew tolerated on mandate and checkout time claims. */ +export const AP2_DEFAULT_CLOCK_SKEW_SECONDS = 60; + +/** + * Ceiling on configured skew. + * + * Skew wide enough to cover a mandate's whole validity window stops `exp` + * from rejecting anything. Five minutes covers an unsynchronised server; an + * operator needing more has a clock to fix, not a config value to raise. + */ +export const AP2_MAX_CLOCK_SKEW_SECONDS = 300; diff --git a/src/config/schema.ts b/src/config/schema.ts index b1bbb52..3ac3b07 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -25,12 +25,25 @@ */ import { type ZodError, type ZodIssue, type ZodTypeAny, z } from 'zod'; +import { + AP2_DEFAULT_CLOCK_SKEW_SECONDS, + AP2_JWK_COORDINATE_BYTES, + AP2_JWK_CURVE, + AP2_JWK_MEMBERS, + AP2_KEY_TYPE, + AP2_MAX_CLOCK_SKEW_SECONDS, + AP2_MODES, + AP2_SIGNING_ALGORITHM, + AP2_SPEC_VERSION, + type Ap2Mode, +} from '../authorization/ap2/constants.js'; import { extractPathParameterNames, findUnparsedBraceToken, isObjectSchemaNode, } from '../core/execution/index.js'; import { + type AuthorizationMethodName, CommerceError, type CommerceResource, PROTOCOL_NAMES, @@ -301,6 +314,10 @@ const ResourceEntrySchema = z pricing: PricingSchema, expose: z.array(z.string().min(1)).min(1), payments: z.array(z.string().min(1)).optional(), + authorization: z + .object({ required: z.array(z.string().min(1)).min(1) }) + .strict() + .optional(), }) .strict(); @@ -371,6 +388,64 @@ const PaymentsSchema = z }) .strict(); +/** + * A public verification key, given inline. + * + * Inline only: there is no `jwksUri`, no `jku`, no discovery URL. AP2 still + * has an open standardisation question around secure key distribution, and + * inventing dynamic trust here would mean fetching keys from a location a + * mandate can influence. The JWK's own members are checked against + * `AP2_JWK_MEMBERS` in the business pass, which stops `x5u` reintroducing the + * same fetch one level down. + */ +const Ap2KeySchema = z + .object({ + kid: z.string().min(1), + jwk: z.record(z.string(), z.unknown()), + }) + .strict(); + +const Ap2IssuerSchema = z + .object({ + issuer: z.string().min(1), + /** + * Required, not defaulted. Without it, a mandate minted for another + * merchant would verify here, and there is no value worth guessing for + * something that decides that. + */ + audience: z.string().min(1), + keys: z.array(Ap2KeySchema).min(1), + }) + .strict(); + +const Ap2Schema = z + .object({ + enabled: BooleanOrString, + specVersion: z.string().min(1).optional(), + mode: z.string().min(1).optional(), + trust: z + .object({ + mandateIssuers: z.array(Ap2IssuerSchema).optional(), + checkoutIssuers: z.array(Ap2IssuerSchema).optional(), + }) + .strict() + .optional(), + clockSkewSeconds: NumberOrString.optional(), + replay: z + .object({ path: z.string().min(1) }) + .strict() + .optional(), + }) + .strict(); + +/** + * Optional block, off by default. A config predating AP2 stays valid, and the + * sub-blocks are optional here so a disabled placeholder is writable; the + * business pass requires each of them only once AP2 is enabled, and names the + * missing piece. + */ +const AuthorizationSchema = z.object({ ap2: Ap2Schema.optional() }).strict(); + const RawConfigSchema = z .object({ version: z.literal(SUPPORTED_CONFIG_VERSION), @@ -380,6 +455,7 @@ const RawConfigSchema = z protocols: ProtocolsSchema, resources: ResourcesMapSchema, payments: PaymentsSchema, + authorization: AuthorizationSchema.optional(), }) .strict(); @@ -416,6 +492,41 @@ export type AcpProtocolConfig = readonly discovery?: AcpDiscoveryConfig; }; +/** One inline public verification key, trusted because an operator wrote it here. */ +export interface Ap2TrustedKey { + readonly kid: string; + readonly jwk: Readonly>; +} + +/** One trusted issuer and the keys it signs with. */ +export interface Ap2TrustedIssuer { + readonly issuer: string; + readonly audience: string; + readonly keys: readonly Ap2TrustedKey[]; +} + +/** + * Discriminated on `enabled`, like `AcpProtocolConfig`: an enabled AP2 config + * carries everything the verifier needs, so nothing downstream asserts on an + * optional field, and a half-configured trust policy is rejected at load. + */ +export type Ap2AuthorizationConfig = + | { readonly enabled: false } + | { + readonly enabled: true; + readonly specVersion: typeof AP2_SPEC_VERSION; + readonly mode: Ap2Mode; + readonly trust: { + /** Signers of the Checkout Mandate itself. */ + readonly mandateIssuers: readonly Ap2TrustedIssuer[]; + /** Signers of the merchant checkout JWT the mandate binds. */ + readonly checkoutIssuers: readonly Ap2TrustedIssuer[]; + }; + readonly clockSkewSeconds: number; + /** Its own SQLite file. An authorization replay is not a payment replay. */ + readonly replay: { readonly path: string }; + }; + export interface GatewayConfig { readonly version: 1; readonly merchant: { readonly id: string; readonly name: string; readonly publicBaseUrl: string }; @@ -450,6 +561,11 @@ export interface GatewayConfig { readonly allowUnauthenticatedFacilitator?: boolean; }; }; + /** + * Absent when no `authorization:` block is configured, which is how every + * config written before AP2 existed reads. + */ + readonly authorization?: { readonly ap2: Ap2AuthorizationConfig }; } // --------------------------------------------------------------------------- @@ -585,6 +701,7 @@ function toBoolean(value: boolean | string, path: string): boolean { const SUPPORTED_PROTOCOLS: ReadonlySet = new Set(PROTOCOL_NAMES); const SUPPORTED_PAYMENT_METHODS = new Set(['x402']); +const SUPPORTED_AUTHORIZATION_METHODS: ReadonlySet = new Set(['ap2']); function normalise(raw: RawConfig): GatewayConfig { const protocols = { @@ -668,8 +785,13 @@ function normalise(raw: RawConfig): GatewayConfig { }); } + const ap2 = normaliseAp2(raw.authorization?.ap2); + if (ap2?.enabled) { + validateReplayStoreIsolated(ap2.replay.path, raw.storage.receipts.path, protocols.acp); + } + const resources = Object.entries(raw.resources).map(([id, entry]) => - normaliseResource(id, entry, protocols, x402), + normaliseResource(id, entry, protocols, x402, ap2), ); if (protocols.acp.enabled) validateAcpCheckoutMapping(protocols.acp, resources); @@ -694,6 +816,7 @@ function normalise(raw: RawConfig): GatewayConfig { payments: { ...(x402 !== undefined ? { x402 } : {}), }, + ...(ap2 !== undefined ? { authorization: { ap2 } } : {}), }; } @@ -733,6 +856,285 @@ function validateMountPaths(protocols: NormalisedProtocols): void { } } +// --------------------------------------------------------------------------- +// AP2 authorization (experimental) +// --------------------------------------------------------------------------- + +type RawAp2 = NonNullable['ap2']>; + +function ap2Invalid( + path: string, + message: string, + extra: Record = {}, +): CommerceError { + return new CommerceError('CONFIG_INVALID', message, { details: { path, ...extra } }); +} + +function normaliseAp2(raw: RawAp2 | undefined): Ap2AuthorizationConfig | undefined { + if (raw === undefined) return undefined; + if (!toBoolean(raw.enabled, 'authorization.ap2.enabled')) return { enabled: false }; + + const specVersion = raw.specVersion ?? AP2_SPEC_VERSION; + if (specVersion !== AP2_SPEC_VERSION) { + throw ap2Invalid( + 'authorization.ap2.specVersion', + `authorization.ap2.specVersion "${specVersion}" is not supported - this gateway verifies against the tagged AP2 release ${AP2_SPEC_VERSION} only`, + ); + } + + const mode = raw.mode ?? AP2_MODES[0]; + if (!(AP2_MODES as readonly string[]).includes(mode)) { + throw ap2Invalid( + 'authorization.ap2.mode', + `authorization.ap2.mode "${mode}" is not supported. Supported: ${AP2_MODES.join(', ')}. Autonomous mode needs open mandates, agent key binding and constraint evaluation, none of which this release implements.`, + ); + } + + if (raw.replay === undefined) { + throw ap2Invalid( + 'authorization.ap2.replay', + 'authorization.ap2.replay is required when AP2 is enabled - it names the SQLite file recording which mandates have been spent, and without it a verified mandate could authorise a second settlement', + ); + } + + const mandateIssuers = normaliseIssuers( + raw.trust?.mandateIssuers, + 'authorization.ap2.trust.mandateIssuers', + ); + const checkoutIssuers = normaliseIssuers( + raw.trust?.checkoutIssuers, + 'authorization.ap2.trust.checkoutIssuers', + ); + + return { + enabled: true, + specVersion: AP2_SPEC_VERSION, + mode: mode as Ap2Mode, + trust: { mandateIssuers, checkoutIssuers }, + clockSkewSeconds: toNumber( + raw.clockSkewSeconds ?? AP2_DEFAULT_CLOCK_SKEW_SECONDS, + 'authorization.ap2.clockSkewSeconds', + { min: 0, max: AP2_MAX_CLOCK_SKEW_SECONDS }, + ), + replay: { path: raw.replay.path }, + }; +} + +/** + * Both issuer lists are required and non-empty when AP2 is on. + * + * Two signatures have to be checked: the issuer's over the Checkout Mandate, + * and the merchant's over the checkout JWT it binds. An empty list would make + * every mandate fail verification, which reads as a broken deployment rather + * than as the misconfiguration it is. + */ +function normaliseIssuers( + raw: readonly z.infer[] | undefined, + path: string, +): readonly Ap2TrustedIssuer[] { + if (raw === undefined || raw.length === 0) { + throw ap2Invalid( + path, + `${path} is required when AP2 is enabled and must name at least one issuer - trust is operator-configured and static, so an empty list means no mandate can ever verify`, + ); + } + + const seen = new Set(); + return raw.map((entry, index) => { + const entryPath = `${path}.${index}`; + if (seen.has(entry.issuer)) { + throw ap2Invalid( + `${entryPath}.issuer`, + `${entryPath}.issuer "${entry.issuer}" is listed twice - key lookup resolves by issuer, so a second entry for the same one is silently unreachable. Merge the keys into one entry instead (which is also how a rotation overlaps an old and a new key).`, + { issuer: entry.issuer }, + ); + } + seen.add(entry.issuer); + + const kids = new Set(); + const keys = entry.keys.map((key, keyIndex) => { + const keyPath = `${entryPath}.keys.${keyIndex}`; + if (kids.has(key.kid)) { + throw ap2Invalid( + `${keyPath}.kid`, + `${keyPath}.kid "${key.kid}" is listed twice for issuer "${entry.issuer}" - a kid selects exactly one key, so which of the two verifies a signature would be undefined`, + { issuer: entry.issuer, kid: key.kid }, + ); + } + kids.add(key.kid); + return { kid: key.kid, jwk: validateJwk(key.jwk, key.kid, keyPath) }; + }); + + return { issuer: entry.issuer, audience: entry.audience, keys }; + }); +} + +/** + * Checks a configured verification key against the one shape this release + * accepts: a public P-256 key, nothing else. + * + * Every member is checked here rather than passed through to the JOSE library + * at first purchase, so a key that is wrong is wrong at deploy time. The + * alternative is learning about it from a buyer whose valid mandate was + * refused. + */ +function validateJwk( + jwk: Record, + kid: string, + path: string, +): Readonly> { + const fail = (detail: string): never => { + throw ap2Invalid(`${path}.jwk`, `${path}.jwk (kid "${kid}") ${detail}`, { kid }); + }; + + for (const member of Object.keys(jwk)) { + if ((AP2_JWK_MEMBERS as readonly string[]).includes(member)) continue; + // `d` is the private scalar; naming it is worth the extra branch, because + // an operator who pasted a full key pair into the gateway has put signing + // material where only verification material belongs, and needs to know + // that rather than read "unsupported member". + if (member === 'd' || member === 'k') { + fail( + `carries private key material ("${member}"). The gateway verifies signatures and never produces them; publish only the public half. Treat the pasted key as compromised and rotate it.`, + ); + } + fail( + `has unsupported member "${member}". Allowed: ${AP2_JWK_MEMBERS.join(', ')}. Members naming a URL are refused on purpose - keys are configured inline and never fetched.`, + ); + } + + const value = (member: string): string => { + const raw = jwk[member]; + if (typeof raw !== 'string' || raw.length === 0) { + fail(`must give "${member}" as a non-empty string`); + } + return raw as string; + }; + + if (value('kty') !== AP2_KEY_TYPE) { + fail( + `must have kty "${AP2_KEY_TYPE}" (got "${value('kty')}") - ${AP2_SIGNING_ALGORITHM} is the only accepted algorithm`, + ); + } + if (value('crv') !== AP2_JWK_CURVE) { + fail( + `must be on curve "${AP2_JWK_CURVE}" (got "${value('crv')}") - ${AP2_SIGNING_ALGORITHM} is the only accepted algorithm`, + ); + } + for (const coordinate of ['x', 'y'] as const) { + const encoded = value(coordinate); + if (!/^[A-Za-z0-9_-]+$/.test(encoded)) { + fail(`coordinate "${coordinate}" is not base64url (no padding, no "+" or "/")`); + } + if (Buffer.from(encoded, 'base64url').length !== AP2_JWK_COORDINATE_BYTES) { + fail( + `coordinate "${coordinate}" decodes to ${Buffer.from(encoded, 'base64url').length} bytes; a ${AP2_JWK_CURVE} coordinate is ${AP2_JWK_COORDINATE_BYTES}`, + ); + } + } + if (jwk['alg'] !== undefined && value('alg') !== AP2_SIGNING_ALGORITHM) { + fail(`declares alg "${value('alg')}"; only ${AP2_SIGNING_ALGORITHM} is accepted`); + } + if (jwk['use'] !== undefined && value('use') !== 'sig') { + fail(`declares use "${value('use')}"; a verification key must be "sig"`); + } + if (jwk['kid'] !== undefined && value('kid') !== kid) { + fail(`declares kid "${value('kid')}", which disagrees with the configured kid "${kid}"`); + } + + const normalised: Record = {}; + for (const member of AP2_JWK_MEMBERS) { + if (jwk[member] !== undefined) normalised[member] = value(member); + } + return normalised; +} + +/** + * The AP2 replay store gets its own SQLite file. + * + * Reserved mandates, payment attempts and ACP idempotency records have + * different schemas and different retention rules. Pointing two of them at one + * file gives either a migration conflict at startup or a shared write lock on + * the settlement path. + */ +function validateReplayStoreIsolated( + replayPath: string, + receiptsPath: string, + acp: AcpProtocolConfig, +): void { + // Every `:memory:` handle is its own private database, so two of them are + // not the collision the literal comparison would call them. + if (replayPath === ':memory:') return; + + const others: [string, string][] = [['storage.receipts.path', receiptsPath]]; + if (acp.enabled) others.push(['protocols.acp.idempotency.path', acp.idempotency.path]); + + for (const [otherPath, other] of others) { + if (replayPath !== other) continue; + throw ap2Invalid( + 'authorization.ap2.replay.path', + `authorization.ap2.replay.path is the same file as ${otherPath} ("${replayPath}") - the AP2 replay store keeps its own schema and must not share a database with another store`, + ); + } +} + +/** + * Resolves a resource's `authorization.required` list against the configured + * provider. + * + * A requirement that cannot be enforced is worse than none, because the + * resource looks protected in config and settles unprotected in production. + * Everything that would produce that gap is refused here. + */ +function normaliseResourceAuthorization( + id: string, + entry: RawResourceEntry, + pricing: Pricing, + ap2: Ap2AuthorizationConfig | undefined, +): CommerceResource['authorization'] | undefined { + const required = entry.authorization?.required; + if (required === undefined) return undefined; + + const path = `resources.${id}.authorization.required`; + const methods: AuthorizationMethodName[] = []; + for (const method of required) { + if (!SUPPORTED_AUTHORIZATION_METHODS.has(method)) { + throw ap2Invalid( + path, + `Resource "${id}" requires unsupported authorization method "${method}". Supported: ${[...SUPPORTED_AUTHORIZATION_METHODS].join(', ')}.`, + { resourceId: id, method }, + ); + } + if (methods.includes(method as AuthorizationMethodName)) { + throw ap2Invalid(path, `Resource "${id}" lists authorization method "${method}" twice`, { + resourceId: id, + method, + }); + } + if (method === 'ap2' && (ap2 === undefined || !ap2.enabled)) { + throw ap2Invalid( + path, + `Resource "${id}" requires authorization method "ap2", which is not configured or not enabled under authorization.ap2`, + { resourceId: id, method }, + ); + } + methods.push(method as AuthorizationMethodName); + } + + // A mandate binds an exact amount and currency, so there has to be one. It + // also never unlocks a resource by itself, which makes requiring one on a + // free resource a statement the pipeline has no way to act on. + if (pricing.type !== 'fixed') { + throw ap2Invalid( + path, + `Resource "${id}" requires authorization but its pricing is "${pricing.type}" - authorization proves a purchase was approved and never replaces payment, so it applies to fixed-price paid resources only in this release`, + { resourceId: id }, + ); + } + + return { required: methods }; +} + // --------------------------------------------------------------------------- // ACP (experimental) // --------------------------------------------------------------------------- @@ -944,6 +1346,7 @@ function normaliseResource( entry: RawResourceEntry, protocols: NormalisedProtocols, x402: NormalisedX402 | undefined, + ap2: Ap2AuthorizationConfig | undefined, ): CommerceResource { if (entry.input !== undefined) validateResourceSchemaKeywords(id, 'input', entry.input); @@ -1061,6 +1464,8 @@ function normaliseResource( ? { type: 'free' } : { type: 'fixed', amount: entry.pricing.amount, currency: entry.pricing.currency }; + const authorization = normaliseResourceAuthorization(id, entry, pricing, ap2); + return { id, name: entry.name, @@ -1089,6 +1494,7 @@ function normaliseResource( pricing, exposedVia: entry.expose as CommerceResource['exposedVia'], paymentMethods: paymentMethods as CommerceResource['paymentMethods'], + ...(authorization !== undefined ? { authorization } : {}), }; } diff --git a/tests/unit/config/ap2.test.ts b/tests/unit/config/ap2.test.ts new file mode 100644 index 0000000..ed90bc0 --- /dev/null +++ b/tests/unit/config/ap2.test.ts @@ -0,0 +1,349 @@ +/** + * AP2 trust and resource policy, validated at load. + * + * A trust policy that cannot be enforced has to fail at startup, not at the + * first purchase. The two shapes worth catching are an AP2-required resource + * that settles unprotected because the provider was off, and a verification + * key that turns out to be malformed only once a buyer presents a valid + * mandate. + */ +import { describe, expect, it } from 'vitest'; +import { parseConfig } from '../../../src/config/schema.js'; +import { isCommerceError } from '../../../src/core/index.js'; +import { validRawConfig } from './fixtures.js'; + +/** + * The P-256 public key from RFC 7515 appendix A.3.1. A published example, so + * it is a genuine point on the curve with no private half anyone has to keep. + */ +const PUBLIC_JWK = { + kty: 'EC', + crv: 'P-256', + x: 'f83OJ3D2xF1Bg8vub9tLe1gHMzV76e8Tus9uPHvRVEU', + y: 'x_FEzRu9m36HLN_tue659LNpXW6pCyStikYjKIWI5a0', +}; + +function issuer(overrides: Record = {}): Record { + return { + issuer: 'https://trusted-surface.example', + audience: 'merchant.example', + keys: [{ kid: 'key-2026-01', jwk: { ...PUBLIC_JWK } }], + ...overrides, + }; +} + +function withAp2( + ap2: Record = {}, + resourceAuthorization?: Record, +): Record { + const raw = validRawConfig(); + raw['authorization'] = { + ap2: { + enabled: true, + replay: { path: './data/ap2-authorizations.sqlite' }, + trust: { + mandateIssuers: [issuer()], + checkoutIssuers: [ + issuer({ issuer: 'https://merchant.example', audience: 'agent-commerce' }), + ], + }, + ...ap2, + }, + }; + if (resourceAuthorization !== undefined) { + (raw['resources'] as Record>)['market_report'] = { + ...(raw['resources'] as Record>)['market_report'], + authorization: resourceAuthorization, + }; + } + return raw; +} + +function messageFor(raw: Record): string { + try { + parseConfig(raw, {}); + } catch (error) { + expect(isCommerceError(error)).toBe(true); + return (error as Error).message; + } + return expect.unreachable('expected config to be rejected') as never; +} + +function expectRejected(raw: Record): string { + const message = messageFor(raw); + try { + parseConfig(raw, {}); + } catch (error) { + if (isCommerceError(error)) expect(error.code).toBe('CONFIG_INVALID'); + } + return message; +} + +describe('configs without AP2', () => { + it('parse unchanged and report no authorization block at all', () => { + const config = parseConfig(validRawConfig(), {}); + expect(config.authorization).toBeUndefined(); + expect(config.resources.every((r) => r.authorization === undefined)).toBe(true); + }); +}); + +describe('authorization.ap2 trust policy', () => { + it('normalises an enabled block, defaulting the version, mode and skew', () => { + const config = parseConfig(withAp2(), {}); + const ap2 = config.authorization?.ap2; + expect(ap2).toMatchObject({ + enabled: true, + specVersion: '0.2.0', + mode: 'direct', + clockSkewSeconds: 60, + replay: { path: './data/ap2-authorizations.sqlite' }, + }); + }); + + it('keeps a disabled block as a placeholder without demanding a trust policy', () => { + const raw = validRawConfig(); + raw['authorization'] = { ap2: { enabled: false } }; + expect(parseConfig(raw, {}).authorization?.ap2).toEqual({ enabled: false }); + }); + + it('carries both issuer lists through with their keys', () => { + const ap2 = parseConfig(withAp2(), {}).authorization?.ap2; + if (ap2?.enabled !== true) return expect.unreachable(); + expect(ap2.trust.mandateIssuers[0]).toEqual({ + issuer: 'https://trusted-surface.example', + audience: 'merchant.example', + keys: [{ kid: 'key-2026-01', jwk: PUBLIC_JWK }], + }); + expect(ap2.trust.checkoutIssuers[0]?.audience).toBe('agent-commerce'); + }); + + it.each([ + ['an unsupported spec version', { specVersion: '0.1.0' }, '0.2.0'], + ['autonomous mode', { mode: 'autonomous' }, 'direct'], + ])('rejects %s', (_label, override, hint) => { + expect(expectRejected(withAp2(override))).toContain(hint); + }); + + it('rejects an enabled block with no replay store', () => { + const raw = withAp2(); + const ap2 = (raw['authorization'] as { ap2: Record }).ap2; + delete ap2['replay']; + expect(expectRejected(raw)).toContain('replay'); + }); + + it.each([ + ['no mandate issuers', 'mandateIssuers'], + ['no checkout issuers', 'checkoutIssuers'], + ])('rejects a trust policy with %s', (_label, list) => { + const raw = withAp2(); + const trust = (raw['authorization'] as { ap2: { trust: Record } }).ap2.trust; + trust[list] = []; + expect(expectRejected(raw)).toContain(list); + }); + + it('rejects the same issuer listed twice, pointing at key rotation instead', () => { + const message = expectRejected( + withAp2({ trust: { mandateIssuers: [issuer(), issuer()], checkoutIssuers: [issuer()] } }), + ); + expect(message).toContain('listed twice'); + expect(message).toContain('rotation'); + }); + + it('rejects two keys sharing a kid, which would make signature selection undefined', () => { + const duplicate = issuer({ + keys: [ + { kid: 'key-2026-01', jwk: { ...PUBLIC_JWK } }, + { kid: 'key-2026-01', jwk: { ...PUBLIC_JWK } }, + ], + }); + expect( + expectRejected( + withAp2({ trust: { mandateIssuers: [duplicate], checkoutIssuers: [issuer()] } }), + ), + ).toContain('listed twice'); + }); + + it('accepts an overlapping old and new key under one issuer, which is how rotation works', () => { + const rotating = issuer({ + keys: [ + { kid: 'key-2025-07', jwk: { ...PUBLIC_JWK } }, + { kid: 'key-2026-01', jwk: { ...PUBLIC_JWK } }, + ], + }); + const ap2 = parseConfig( + withAp2({ trust: { mandateIssuers: [rotating], checkoutIssuers: [issuer()] } }), + {}, + ).authorization?.ap2; + if (ap2?.enabled !== true) return expect.unreachable(); + expect(ap2.trust.mandateIssuers[0]?.keys.map((k) => k.kid)).toEqual([ + 'key-2025-07', + 'key-2026-01', + ]); + }); + + it('rejects an issuer with no audience', () => { + const raw = withAp2(); + const trust = ( + raw['authorization'] as { ap2: { trust: { mandateIssuers: [Record] } } } + ).ap2.trust; + delete trust.mandateIssuers[0]['audience']; + expect(expectRejected(raw)).toContain('audience'); + }); + + it('clamps nothing silently: a skew past the ceiling is refused', () => { + expect(expectRejected(withAp2({ clockSkewSeconds: 3600 }))).toContain('300'); + }); + + it('accepts a skew inside the ceiling', () => { + const ap2 = parseConfig(withAp2({ clockSkewSeconds: 120 }), {}).authorization?.ap2; + if (ap2?.enabled !== true) return expect.unreachable(); + expect(ap2.clockSkewSeconds).toBe(120); + }); +}); + +describe('verification key validation', () => { + function withJwk(jwk: Record): Record { + return withAp2({ + trust: { + mandateIssuers: [issuer({ keys: [{ kid: 'key-2026-01', jwk }] })], + checkoutIssuers: [issuer()], + }, + }); + } + + it('rejects private key material and says to rotate the key', () => { + const message = expectRejected(withJwk({ ...PUBLIC_JWK, d: 'not-a-real-private-scalar' })); + expect(message).toContain('private key material'); + expect(message).toContain('rotate'); + }); + + it.each([ + ['a URL-valued member', { ...PUBLIC_JWK, x5u: 'https://attacker.example/keys.json' }], + ['a smuggled jku', { ...PUBLIC_JWK, jku: 'https://attacker.example/jwks' }], + ])('refuses %s rather than ever fetching it', (_label, jwk) => { + expect(expectRejected(withJwk(jwk))).toContain('never fetched'); + }); + + it.each([ + ['an RSA key', { ...PUBLIC_JWK, kty: 'RSA' }], + ['a symmetric key', { ...PUBLIC_JWK, kty: 'oct' }], + ['the wrong curve', { ...PUBLIC_JWK, crv: 'P-384' }], + ['a non-ES256 alg', { ...PUBLIC_JWK, alg: 'ES384' }], + ])('rejects %s', (_label, jwk) => { + expectRejected(withJwk(jwk)); + }); + + it.each([ + ['a truncated coordinate', { ...PUBLIC_JWK, x: 'f83OJ3D2xF1Bg8vub9tLe1gHMzV76e8' }], + [ + 'standard base64 rather than base64url', + { ...PUBLIC_JWK, y: `x/FEzRu9m36HLN+tue659LNpXW6pCyStikYjKIWI5a0` }, + ], + ['a missing coordinate', { kty: 'EC', crv: 'P-256', x: PUBLIC_JWK.x }], + ['a non-string coordinate', { ...PUBLIC_JWK, y: 42 }], + ])('rejects %s', (_label, jwk) => { + expectRejected(withJwk(jwk as Record)); + }); + + it('rejects a key whose own kid disagrees with the configured one', () => { + expect(expectRejected(withJwk({ ...PUBLIC_JWK, kid: 'something-else' }))).toContain( + 'disagrees', + ); + }); + + it('rejects an encryption key offered for verification', () => { + expect(expectRejected(withJwk({ ...PUBLIC_JWK, use: 'enc' }))).toContain('sig'); + }); + + it('accepts the optional members it does allow', () => { + const config = parseConfig( + withJwk({ ...PUBLIC_JWK, kid: 'key-2026-01', alg: 'ES256', use: 'sig' }), + {}, + ); + const ap2 = config.authorization?.ap2; + if (ap2?.enabled !== true) return expect.unreachable(); + expect(ap2.trust.mandateIssuers[0]?.keys[0]?.jwk).toMatchObject({ alg: 'ES256', use: 'sig' }); + }); +}); + +describe('replay store isolation', () => { + it('rejects a replay path shared with the receipt store', () => { + expect(expectRejected(withAp2({ replay: { path: './data/receipts.sqlite' } }))).toContain( + 'storage.receipts.path', + ); + }); + + it('rejects a replay path shared with the ACP idempotency store', () => { + const raw = withAp2({ replay: { path: './data/acp.sqlite' } }); + const protocols = raw['protocols'] as Record; + protocols['acp'] = { + enabled: true, + mountPath: '/acp', + auth: { type: 'bearer', token: 'tok' }, + idempotency: { path: './data/acp.sqlite' }, + checkout: { + operations: { + createCheckoutSession: 'c1', + updateCheckoutSession: 'c2', + getCheckoutSession: 'c3', + completeCheckoutSession: 'c4', + cancelCheckoutSession: 'c5', + }, + }, + }; + expect(expectRejected(raw)).toContain('protocols.acp.idempotency.path'); + }); + + it('allows two in-memory stores, which are separate databases', () => { + const raw = withAp2({ replay: { path: ':memory:' } }); + (raw['storage'] as { receipts: { path: string } }).receipts.path = ':memory:'; + expect(parseConfig(raw, {}).authorization?.ap2.enabled).toBe(true); + }); +}); + +describe('resource authorization policy', () => { + it('attaches the requirement to the canonical resource', () => { + const config = parseConfig(withAp2({}, { required: ['ap2'] }), {}); + const resource = config.resources.find((r) => r.id === 'market_report'); + expect(resource?.authorization).toEqual({ required: ['ap2'] }); + // And leaves every other resource untouched. + expect(config.resources.find((r) => r.id === 'weather_basic')?.authorization).toBeUndefined(); + }); + + it('rejects a required method the gateway does not implement', () => { + expect(expectRejected(withAp2({}, { required: ['ap3'] }))).toContain('ap3'); + }); + + it('rejects the same method listed twice', () => { + expect(expectRejected(withAp2({}, { required: ['ap2', 'ap2'] }))).toContain('twice'); + }); + + it('rejects an empty requirement list rather than reading it as "none"', () => { + expectRejected(withAp2({}, { required: [] })); + }); + + it('rejects a resource requiring AP2 while the provider is disabled', () => { + const raw = withAp2({}, { required: ['ap2'] }); + (raw['authorization'] as { ap2: Record }).ap2['enabled'] = false; + expect(expectRejected(raw)).toContain('not configured or not enabled'); + }); + + it('rejects a resource requiring AP2 with no authorization block configured at all', () => { + const raw = validRawConfig(); + (raw['resources'] as Record>)['market_report'] = { + ...(raw['resources'] as Record>)['market_report'], + authorization: { required: ['ap2'] }, + }; + expect(expectRejected(raw)).toContain('not configured or not enabled'); + }); + + it('rejects AP2 required on a free resource', () => { + const raw = withAp2(); + (raw['resources'] as Record>)['weather_basic'] = { + ...(raw['resources'] as Record>)['weather_basic'], + authorization: { required: ['ap2'] }, + }; + const message = expectRejected(raw); + expect(message).toContain('never replaces payment'); + }); +}); From 2eab2af31263884bc21bae37cf1b718b1482ab83 Mon Sep 17 00:00:00 2001 From: Revinand Date: Mon, 14 Sep 2026 16:16:56 +0200 Subject: [PATCH 03/11] feat(ap2): verify direct checkout mandates --- package-lock.json | 36 +- package.json | 19 +- src/authorization/ap2/checkout-jwt.ts | 131 +++++ src/authorization/ap2/constants.ts | 39 ++ src/authorization/ap2/errors.ts | 91 ++++ src/authorization/ap2/sd-jwt.ts | 193 ++++++++ src/authorization/ap2/trust.ts | 93 ++++ src/authorization/ap2/types.ts | 83 ++++ src/authorization/ap2/verifier.ts | 72 +++ src/config/schema.ts | 41 +- tests/unit/authorization-ap2/fixtures.ts | 198 ++++++++ tests/unit/authorization-ap2/verifier.test.ts | 466 ++++++++++++++++++ tests/unit/cli/packaging.test.ts | 20 + 13 files changed, 1440 insertions(+), 42 deletions(-) create mode 100644 src/authorization/ap2/checkout-jwt.ts create mode 100644 src/authorization/ap2/errors.ts create mode 100644 src/authorization/ap2/sd-jwt.ts create mode 100644 src/authorization/ap2/trust.ts create mode 100644 src/authorization/ap2/types.ts create mode 100644 src/authorization/ap2/verifier.ts create mode 100644 tests/unit/authorization-ap2/fixtures.ts create mode 100644 tests/unit/authorization-ap2/verifier.test.ts diff --git a/package-lock.json b/package-lock.json index c60c8a8..436384f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -29,6 +29,7 @@ "@biomejs/biome": "2.5.9", "@coinbase/x402": "2.1.0", "@modelcontextprotocol/sdk": "1.30.0", + "@sd-jwt/core": "0.20.1", "@types/better-sqlite3": "9.6.0", "@types/node": "24.13.3", "@types/react": "19.2.18", @@ -37,6 +38,7 @@ "@vitest/coverage-v8": "4.1.10", "@x402/core": "2.23.0", "@x402/evm": "2.23.0", + "jose": "6.2.12", "pino-pretty": "13.1.2", "react": "19.0.8", "react-dom": "19.0.8", @@ -54,8 +56,10 @@ "peerDependencies": { "@coinbase/x402": "2.1.0", "@modelcontextprotocol/sdk": "1.30.0", + "@sd-jwt/core": "0.20.1", "@x402/core": "2.23.0", "@x402/evm": "2.23.0", + "jose": "6.2.12", "viem": "2.55.18" }, "peerDependenciesMeta": { @@ -65,12 +69,18 @@ "@modelcontextprotocol/sdk": { "optional": true }, + "@sd-jwt/core": { + "optional": true + }, "@x402/core": { "optional": true }, "@x402/evm": { "optional": true }, + "jose": { + "optional": true + }, "viem": { "optional": true } @@ -1161,6 +1171,13 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/@owf/identity-common": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/@owf/identity-common/-/identity-common-0.3.2.tgz", + "integrity": "sha512-XH5Bg6zuXc9SeimTe2ZD/+54GnhSyenFSy2Qe/z2HY1ul5j+6/lDNIhI83xct5aOkCD0uZ7zHbMhTI4Nq8YJOQ==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/@oxc-project/types": { "version": "0.144.0", "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.144.0.tgz", @@ -1947,6 +1964,19 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/@sd-jwt/core": { + "version": "0.20.1", + "resolved": "https://registry.npmjs.org/@sd-jwt/core/-/core-0.20.1.tgz", + "integrity": "sha512-RUBZ3WxnjicKgQyUysaFNMuC88xXSQlNLK7V1vxSBKYHAoi/yCXtCGlxxj7JXISAJgFTkmPSV5ZEZp0Dt5bXyw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@owf/identity-common": "^0.3.1" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/@solana-program/system": { "version": "0.10.0", "resolved": "https://registry.npmjs.org/@solana-program/system/-/system-0.10.0.tgz", @@ -4680,9 +4710,9 @@ } }, "node_modules/jose": { - "version": "6.2.9", - "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.9.tgz", - "integrity": "sha512-XrchZOFZUl/T3vTwRe8XK+cJrGtMF4th1ARnDfwbBXFKThGhlsxEE4Zu03AD/bjJSt/9jT/mxrOCkJWOg77aPA==", + "version": "6.2.12", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.12.tgz", + "integrity": "sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw==", "dev": true, "license": "MIT", "funding": { diff --git a/package.json b/package.json index a8a9f56..7464d9f 100644 --- a/package.json +++ b/package.json @@ -102,8 +102,10 @@ "peerDependencies": { "@coinbase/x402": "2.1.0", "@modelcontextprotocol/sdk": "1.30.0", + "@sd-jwt/core": "0.20.1", "@x402/core": "2.23.0", "@x402/evm": "2.23.0", + "jose": "6.2.12", "viem": "2.55.18" }, "peerDependenciesMeta": { @@ -113,12 +115,18 @@ "@modelcontextprotocol/sdk": { "optional": true }, + "@sd-jwt/core": { + "optional": true + }, "@x402/core": { "optional": true }, "@x402/evm": { "optional": true }, + "jose": { + "optional": true + }, "viem": { "optional": true } @@ -128,6 +136,7 @@ "@biomejs/biome": "2.5.9", "@coinbase/x402": "2.1.0", "@modelcontextprotocol/sdk": "1.30.0", + "@sd-jwt/core": "0.20.1", "@types/better-sqlite3": "9.6.0", "@types/node": "24.13.3", "@types/react": "19.2.18", @@ -136,6 +145,7 @@ "@vitest/coverage-v8": "4.1.10", "@x402/core": "2.23.0", "@x402/evm": "2.23.0", + "jose": "6.2.12", "pino-pretty": "13.1.2", "react": "19.0.8", "react-dom": "19.0.8", @@ -147,13 +157,16 @@ "vitest": "4.1.10" }, "overrides": { - "@x402/core": { + "@coinbase/x402": { "zod": "3.25.76" }, - "@x402/evm": { + "@sd-jwt/core": { + "@owf/identity-common": "0.3.2" + }, + "@x402/core": { "zod": "3.25.76" }, - "@coinbase/x402": { + "@x402/evm": { "zod": "3.25.76" } } diff --git a/src/authorization/ap2/checkout-jwt.ts b/src/authorization/ap2/checkout-jwt.ts new file mode 100644 index 0000000..41bf12c --- /dev/null +++ b/src/authorization/ap2/checkout-jwt.ts @@ -0,0 +1,131 @@ +/** + * Stage two: the merchant checkout JWT the mandate binds. + * + * AP2 leaves this document's payload outside its own scope, so all the mandate + * guarantees is `checkout_hash`: a digest of the exact compact JWT the buyer + * approved. Two things have to hold, and neither substitutes for the other. + * + * The hash proves the buyer approved *this* document. The signature proves the + * merchant issued it. Checking only the hash accepts any document a buyer + * chose to approve, including one they wrote themselves. Checking only the + * signature accepts a genuine merchant document this mandate never covered. + */ +import { type JWTVerifyOptions, jwtVerify } from 'jose'; +import type { Clock } from '../../core/index.js'; +import { AP2_SIGNING_ALGORITHM } from './constants.js'; +import { type Ap2ErrorContext, ap2Rejected } from './errors.js'; +import type { TrustStore } from './trust.js'; + +export interface VerifiedCheckoutJwt { + readonly issuer: string; + readonly jwtId: string; + readonly claims: Readonly>; +} + +function decodeSegment(jwt: string, index: 0 | 1): Record | undefined { + const segment = jwt.split('.')[index]; + if (segment === undefined) return undefined; + try { + const parsed: unknown = JSON.parse(Buffer.from(segment, 'base64url').toString('utf8')); + return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed) + ? (parsed as Record) + : undefined; + } catch { + return undefined; + } +} + +/** + * Verifies the checkout JWT carried by an already-verified mandate. + * + * `mandateClaims` must come from a mandate whose own signature has been + * checked, because `checkout_hash` is only worth anything if the issuer signed + * it. + */ +export async function verifyCheckoutJwt( + mandateClaims: Readonly>, + deps: { readonly trust: TrustStore; readonly clock: Clock; readonly clockSkewSeconds: number }, + context: Ap2ErrorContext, +): Promise { + const compact = mandateClaims['checkout_jwt']; + const expectedHash = mandateClaims['checkout_hash']; + if (typeof compact !== 'string' || compact.length === 0) { + throw ap2Rejected('invalid_claims', context); + } + if (typeof expectedHash !== 'string' || expectedHash.length === 0) { + throw ap2Rejected('invalid_claims', context); + } + + await requireMatchingHash(compact, expectedHash, context); + + const header = decodeSegment(compact, 0); + const unverifiedPayload = decodeSegment(compact, 1); + if (header === undefined || unverifiedPayload === undefined) { + throw ap2Rejected('checkout_binding_failed', context); + } + + const { issuer, key } = await deps.trust.resolve( + unverifiedPayload['iss'], + header['kid'], + context, + ); + + const options: JWTVerifyOptions = { + algorithms: [AP2_SIGNING_ALGORITHM], + issuer: issuer.issuer, + audience: issuer.audience, + clockTolerance: deps.clockSkewSeconds, + currentDate: deps.clock.now(), + }; + + let claims: Record; + try { + const result = await jwtVerify(compact, key, options); + claims = result.payload as Record; + } catch (cause) { + throw ap2Rejected('checkout_binding_failed', { ...context, cause }); + } + + const exp = claims['exp']; + const iat = claims['iat']; + const jti = claims['jti']; + // Same reasoning as the mandate's own freshness check: jose validates a time + // claim only when it is present, so requiring them is this file's job. + if (typeof exp !== 'number' || typeof iat !== 'number') { + throw ap2Rejected('invalid_claims', context); + } + // `jti` is what a later settlement is recorded against, so a checkout + // document without one cannot be told apart from another. + if (typeof jti !== 'string' || jti.length === 0) { + throw ap2Rejected('invalid_claims', context); + } + const nowSeconds = Math.floor(deps.clock.now().getTime() / 1000); + if (iat > nowSeconds + deps.clockSkewSeconds) throw ap2Rejected('expired', context); + + return { issuer: issuer.issuer, jwtId: jti, claims }; +} + +/** + * Recomputes the digest over the exact compact string the mandate carried. + * + * Hashed as the bytes that arrived, never re-encoded from parsed claims. Two + * JSON serialisations of one payload differ in whitespace and key order, so + * they differ in digest, and every valid mandate would fail. Normalising first + * would be worse: it hashes a document other than the one being verified. + */ +async function requireMatchingHash( + compact: string, + expected: string, + context: Ap2ErrorContext, +): Promise { + const digest = await crypto.subtle.digest( + // sha-256 only. The mandate's `_sd_alg` has already been pinned to it by + // the time this runs, so there is no second algorithm to dispatch on. + 'SHA-256', + new TextEncoder().encode(compact), + ); + const actual = Buffer.from(digest).toString('base64url'); + // A plain comparison: both sides are public values an attacker holding the + // presentation already knows, so there is no secret for timing to leak. + if (actual !== expected) throw ap2Rejected('checkout_binding_failed', context); +} diff --git a/src/authorization/ap2/constants.ts b/src/authorization/ap2/constants.ts index c4b39a0..b3071ae 100644 --- a/src/authorization/ap2/constants.ts +++ b/src/authorization/ap2/constants.ts @@ -52,6 +52,45 @@ export const AP2_JWK_COORDINATE_BYTES = 32; */ export const AP2_JWK_MEMBERS = ['kty', 'crv', 'x', 'y', 'kid', 'alg', 'use'] as const; +/** + * The exact credential type of a closed Checkout Mandate. + * + * Compared against this literal, never matched as a prefix. `mandate.checkout` + * and `mandate.checkout.2` are different credentials with different rules, and + * `mandate.checkout.open.1` carries spending constraints this release does not + * evaluate - accepting it would tell a buyer their limits were checked when + * nothing read them. + */ +export const AP2_CHECKOUT_MANDATE_VCT = 'mandate.checkout.1'; + +/** + * The Agent Commerce checkout profile every merchant checkout JWT must declare. + * + * This profile is ours, not AP2's: AP2 leaves the checkout document's payload + * outside its scope, so the claims a generic paid-resource invocation needs + * (resource id, input hash, amount, currency, payment method) had to be + * specified somewhere. + * + * A bare name rather than a URL, joining the set in CLAUDE.md 5b - + * `agent-commerce/delivery`, `agent-commerce/v1.0.0`, + * `resource://agent-commerce/...`. A profile id is a namespace and is never + * dereferenced, so a URL would buy nothing and would tie the wire format to a + * domain that has to outlive it. Compared exactly, never by prefix, and frozen + * once a release exists: merchants sign it into every checkout JWT, so + * changing it later breaks every deployed signer at once. + */ +export const AP2_CHECKOUT_PROFILE = 'agent-commerce/ap2/checkout/v1'; + +/** + * The only digest algorithm accepted for SD-JWT disclosures and for the + * `checkout_hash` binding. + * + * AP2 takes this from the SD-JWT `_sd_alg`, defaulting to sha-256 when it is + * absent. A presentation naming anything else is refused rather than hashed + * as sha-256 anyway, which would check it under an algorithm it never claimed. + */ +export const AP2_DIGEST_ALGORITHM = 'sha-256'; + /** Seconds of clock skew tolerated on mandate and checkout time claims. */ export const AP2_DEFAULT_CLOCK_SKEW_SECONDS = 60; diff --git a/src/authorization/ap2/errors.ts b/src/authorization/ap2/errors.ts new file mode 100644 index 0000000..7af5703 --- /dev/null +++ b/src/authorization/ap2/errors.ts @@ -0,0 +1,91 @@ +/** + * Every failure the AP2 verifier can report, and the rule for reporting it. + * + * `CommerceError.message` reaches the client over HTTP, MCP and A2A, so + * nothing derived from the presentation may appear in one. A mandate carries + * the buyer's approved purchase and, in the general case, personal data; an + * error that quoted the claim it choked on would publish it to whoever sent + * the request. The reason is a fixed phrase from the list below, the machine + * -readable code goes in `details.reason`, and the underlying exception goes + * on `cause`, which is never serialised. + * + * Two codes. A buyer presenting a bad mandate gets AUTHORIZATION_INVALID. Our + * own verifier failing to work gets AUTHORIZATION_PROVIDER_UNAVAILABLE, + * because no verdict was reached, and blaming the buyer for our outage is how + * a perfectly good mandate ends up refused. + */ +import { CommerceError } from '../../core/index.js'; + +/** + * Machine-readable rejection reasons. + * + * Coarse on purpose. A client learns that its mandate was refused and roughly + * where, not which check failed at what offset. The finer version turns an + * error response into an oracle for probing the trust policy. + */ +export const AP2_REJECTION_REASONS = [ + 'malformed_presentation', + 'untrusted_issuer', + 'unknown_key', + 'invalid_signature', + 'unsupported_algorithm', + 'invalid_claims', + 'expired', + 'wrong_audience', + 'unsupported_mandate_type', + 'checkout_binding_failed', +] as const; + +export type Ap2RejectionReason = (typeof AP2_REJECTION_REASONS)[number]; + +const MESSAGES: Readonly> = { + malformed_presentation: 'The authorization is not a well-formed SD-JWT presentation.', + untrusted_issuer: 'The mandate was issued by a party this merchant does not trust.', + unknown_key: 'The mandate names a signing key this merchant does not trust.', + invalid_signature: 'The mandate signature did not verify.', + unsupported_algorithm: 'The mandate uses a signature or digest algorithm that is not accepted.', + invalid_claims: 'The mandate is missing required claims or they are malformed.', + expired: 'The mandate is expired or not yet valid.', + wrong_audience: 'The mandate is addressed to a different audience.', + unsupported_mandate_type: 'The mandate is not a closed Direct Checkout Mandate.', + checkout_binding_failed: 'The merchant checkout document bound to the mandate did not verify.', +}; + +export interface Ap2ErrorContext { + readonly requestId?: string; + readonly resourceId?: string; + readonly cause?: unknown; +} + +/** The buyer's mandate is bad. Fail closed, and say only which stage refused it. */ +export function ap2Rejected( + reason: Ap2RejectionReason, + context: Ap2ErrorContext = {}, +): CommerceError { + return new CommerceError('AUTHORIZATION_INVALID', MESSAGES[reason], { + details: { method: 'ap2', reason }, + ...(context.requestId !== undefined ? { requestId: context.requestId } : {}), + ...(context.resourceId !== undefined ? { resourceId: context.resourceId } : {}), + ...(context.cause !== undefined ? { cause: context.cause } : {}), + }); +} + +/** + * Our verifier could not reach a verdict. + * + * A configured key that will not import, or anything else that means the + * check never ran. Retryable, and never recorded against the payer: the + * mandate may well be perfectly good. + */ +export function ap2Unavailable(detail: string, context: Ap2ErrorContext = {}): CommerceError { + return new CommerceError( + 'AUTHORIZATION_PROVIDER_UNAVAILABLE', + 'Authorization could not be verified right now. This is a merchant-side fault, not a problem with the presented mandate.', + { + details: { method: 'ap2', reason: 'verifier_unavailable', detail }, + ...(context.requestId !== undefined ? { requestId: context.requestId } : {}), + ...(context.resourceId !== undefined ? { resourceId: context.resourceId } : {}), + ...(context.cause !== undefined ? { cause: context.cause } : {}), + }, + ); +} diff --git a/src/authorization/ap2/sd-jwt.ts b/src/authorization/ap2/sd-jwt.ts new file mode 100644 index 0000000..5433120 --- /dev/null +++ b/src/authorization/ap2/sd-jwt.ts @@ -0,0 +1,193 @@ +/** + * Stage one: parse the SD-JWT presentation, verify the issuer's signature and + * resolve the disclosures into a claim set. + * + * The disclosure mechanics come from `@sd-jwt/core` rather than being written + * out here. Three attacks live in that algorithm: a disclosure appended that + * no digest in the payload references, the same disclosure presented twice, + * and a disclosure that will not decode. The library refuses all three, and a + * hand-rolled digest walk would be reimplementing exactly that, with less + * coverage, on the path deciding whether a purchase was authorised. + * + * The cryptography is `jose`'s. This file supplies the policy around it: which + * key, which algorithm, which audience, and what counts as a fresh mandate. + */ +import { decodeSdJwt, getClaims, splitSdJwt } from '@sd-jwt/core'; +import { type JWTVerifyOptions, jwtVerify } from 'jose'; +import type { Clock } from '../../core/index.js'; +import { + AP2_CHECKOUT_MANDATE_VCT, + AP2_DIGEST_ALGORITHM, + AP2_SIGNING_ALGORITHM, +} from './constants.js'; +import { type Ap2ErrorContext, ap2Rejected } from './errors.js'; +import type { TrustStore } from './trust.js'; + +export interface VerifiedMandate { + readonly issuer: string; + /** Every claim, with the presented disclosures resolved into place. */ + readonly claims: Readonly>; +} + +/** + * SHA-256 over a disclosure string. + * + * `@sd-jwt/core` passes the algorithm it read from `_sd_alg`, so this doubles + * as the enforcement point: anything but sha-256 throws instead of being + * quietly computed as sha-256, which would let a presentation declare one + * algorithm and be checked under another. + */ +async function hasher(data: string | ArrayBuffer, algorithm: string): Promise { + if (algorithm.toLowerCase() !== AP2_DIGEST_ALGORITHM) { + throw new Error(`unsupported digest algorithm ${algorithm}`); + } + const bytes = typeof data === 'string' ? new TextEncoder().encode(data) : new Uint8Array(data); + return new Uint8Array(await crypto.subtle.digest('SHA-256', bytes)); +} + +function asRecord(value: unknown): Record | undefined { + return typeof value === 'object' && value !== null && !Array.isArray(value) + ? (value as Record) + : undefined; +} + +/** + * Parses and verifies the mandate half of a presentation. + * + * Order is the security boundary of this file: nothing is read out of the + * payload as *trusted* until `jwtVerify` has returned. The header's `kid` and + * the payload's `iss` are read before that, but only to choose which + * configured key to try, and choosing wrong can only make the signature fail. + */ +export async function verifyMandate( + presentation: string, + deps: { readonly trust: TrustStore; readonly clock: Clock; readonly clockSkewSeconds: number }, + context: Ap2ErrorContext, +): Promise { + let decoded: Awaited>; + let encodedJws: string; + try { + // Covers a malformed base JWT, a disclosure that will not decode, a + // duplicate digest, and an `_sd_alg` this release does not implement. + decoded = await decodeSdJwt(presentation, hasher); + encodedJws = splitSdJwt(presentation).jwt; + } catch (cause) { + throw ap2Rejected('malformed_presentation', { ...context, cause }); + } + + // A key-binding JWT proves possession of the key a mandate was bound to. + // Direct mode issues no bound mandates, so one arriving here belongs to a + // flow this release does not verify, and ignoring it would mean silently + // not checking a proof that was sent. + if (decoded.kbJwt !== undefined) { + throw ap2Rejected('unsupported_mandate_type', context); + } + + const rawPayload = asRecord(decoded.jwt.payload); + const header = asRecord(decoded.jwt.header); + if (rawPayload === undefined || header === undefined) { + throw ap2Rejected('malformed_presentation', context); + } + + const { issuer, key } = await deps.trust.resolve(rawPayload['iss'], header['kid'], context); + + const options: JWTVerifyOptions = { + // Redundant today and kept anyway: the resolved key is an EC public key, + // so jose already refuses `alg: none` and an HMAC forged against it. The + // allowlist is what keeps that true if this ever resolves to a key set + // rather than one key, where the header would get to pick. + algorithms: [AP2_SIGNING_ALGORITHM], + issuer: issuer.issuer, + audience: issuer.audience, + clockTolerance: deps.clockSkewSeconds, + // The injected clock, not jose's own `Date.now()`, so an expiry test is a + // test rather than a race against the wall clock. + currentDate: deps.clock.now(), + }; + + let verifiedPayload: Record; + try { + const result = await jwtVerify(encodedJws, key, options); + verifiedPayload = result.payload as Record; + } catch (cause) { + throw ap2Rejected(classifyJoseFailure(cause), { ...context, cause }); + } + + requireFreshness(verifiedPayload, deps, context); + + let claims: Record; + try { + // Resolved against the VERIFIED payload. Handing `getClaims` the decoded + // one would match disclosures against digests nobody signed. + claims = (await getClaims(verifiedPayload, decoded.disclosures, hasher)) as Record< + string, + unknown + >; + } catch (cause) { + // Where an appended disclosure that no digest references is refused. + throw ap2Rejected('malformed_presentation', { ...context, cause }); + } + + requireClosedCheckoutMandate(claims, context); + + return { issuer: issuer.issuer, claims }; +} + +/** + * Maps a jose verification failure onto one of our coarse reasons. + * + * Matched on jose's stable error `code`, not its message. A client is owed + * the difference between "your mandate has expired" and "it did not verify at + * all"; anything finer describes our checks back to whoever is probing + * them. + */ +function classifyJoseFailure(cause: unknown): 'expired' | 'wrong_audience' | 'invalid_signature' { + const code = (cause as { code?: unknown })?.code; + if (code === 'ERR_JWT_EXPIRED') return 'expired'; + if (code === 'ERR_JWT_CLAIM_VALIDATION_FAILED') { + const claim = (cause as { claim?: unknown }).claim; + if (claim === 'aud') return 'wrong_audience'; + if (claim === 'nbf' || claim === 'exp') return 'expired'; + } + return 'invalid_signature'; +} + +/** + * `exp` and `iat` are required, not merely checked when present. + * + * jose validates both only if the claim is there, so a mandate omitting `exp` + * verifies and then never expires. An authorisation to spend money that is + * valid forever is not something to accept because a field was absent. + */ +function requireFreshness( + payload: Record, + deps: { readonly clock: Clock; readonly clockSkewSeconds: number }, + context: Ap2ErrorContext, +): void { + const exp = payload['exp']; + const iat = payload['iat']; + if (typeof exp !== 'number' || typeof iat !== 'number') { + throw ap2Rejected('invalid_claims', context); + } + const nowSeconds = Math.floor(deps.clock.now().getTime() / 1000); + // An issuance timestamp in the future is either a broken signer or a mandate + // minted to outlive the window its own `exp` describes. + if (iat > nowSeconds + deps.clockSkewSeconds) throw ap2Rejected('expired', context); +} + +/** + * Exactly `mandate.checkout.1`, compared against the literal. + * + * A `startsWith` test would accept `mandate.checkout.1x`. Accepting the open + * variant would be worse: it carries `allowed_merchants` and `line_items` + * constraints this release does not evaluate, so a buyer would read their + * spending limits as enforced when nothing had looked at them. + */ +function requireClosedCheckoutMandate( + claims: Record, + context: Ap2ErrorContext, +): void { + if (claims['vct'] !== AP2_CHECKOUT_MANDATE_VCT) { + throw ap2Rejected('unsupported_mandate_type', context); + } +} diff --git a/src/authorization/ap2/trust.ts b/src/authorization/ap2/trust.ts new file mode 100644 index 0000000..97e3c3b --- /dev/null +++ b/src/authorization/ap2/trust.ts @@ -0,0 +1,93 @@ +/** + * Static key resolution. + * + * Every key this verifier will ever use was written into `config.yaml` by an + * operator. There is no JWKS fetch, no `jku`, no `x5u`, no discovery from an + * issuer-controlled URL. A mandate chooses *which* trusted key verifies it, + * through `iss` and `kid`, and nothing more: an unrecognised pair is refused + * rather than resolved. That is what keeps a mandate from nominating its own + * signer, and it is why the config loader refuses a JWK member naming a URL. + * + * The lookup is deliberately not a "try every key" loop. Trying keys until + * one verifies would make `kid` advisory and would quietly accept a mandate + * that named a key it was not signed with. + */ +import { importJWK, type JWK } from 'jose'; +import { AP2_SIGNING_ALGORITHM } from './constants.js'; +import { type Ap2ErrorContext, ap2Rejected, ap2Unavailable } from './errors.js'; +import type { Ap2TrustedIssuer } from './types.js'; + +/** + * Whatever `importJWK` hands back on this runtime, named off the function + * itself rather than spelled out. `CryptoKey` is a DOM type and server code + * here does not load the DOM lib on purpose. + */ +export type VerificationKey = Awaited>; + +export interface ResolvedKey { + readonly issuer: Ap2TrustedIssuer; + readonly key: VerificationKey; +} + +/** + * Resolves `(iss, kid)` against one configured issuer list. + * + * Imported keys are cached because `importJWK` does real work and the same + * handful of keys verifies every mandate. The cache holds the promise rather + * than the resolved key, so two concurrent requests for a cold key do one + * import between them. + */ +export function createTrustStore(issuers: readonly Ap2TrustedIssuer[]) { + const byIssuer = new Map(issuers.map((entry) => [entry.issuer, entry])); + const imported = new Map>(); + + return { + /** Every trusted issuer id, for diagnostics. Never the keys themselves. */ + issuerIds(): readonly string[] { + return [...byIssuer.keys()]; + }, + + async resolve(iss: unknown, kid: unknown, context: Ap2ErrorContext): Promise { + if (typeof iss !== 'string' || iss.length === 0) { + throw ap2Rejected('invalid_claims', context); + } + const issuer = byIssuer.get(iss); + if (issuer === undefined) throw ap2Rejected('untrusted_issuer', context); + + // A missing kid is refused rather than defaulted to the issuer's only + // key. An issuer mid-rotation has two, and a presentation that declines + // to say which one it used should not have the gateway guess. + if (typeof kid !== 'string' || kid.length === 0) { + throw ap2Rejected('unknown_key', context); + } + const trusted = issuer.keys.find((candidate) => candidate.kid === kid); + if (trusted === undefined) throw ap2Rejected('unknown_key', context); + + // JSON rather than a joined string: an issuer id and a kid are both + // operator-chosen, and any separator picked out of the air is one they + // could contain. + const cacheKey = JSON.stringify([iss, kid]); + let pending = imported.get(cacheKey); + if (pending === undefined) { + pending = importJWK(trusted.jwk as JWK, AP2_SIGNING_ALGORITHM); + imported.set(cacheKey, pending); + } + + try { + return { issuer, key: await pending }; + } catch (cause) { + // The config loader already checked this JWK member by member, so + // reaching here means our own configuration is broken rather than the + // buyer's mandate. Drop the cached rejection so a fixed config is not + // still failing against a poisoned cache entry. + imported.delete(cacheKey); + throw ap2Unavailable('configured verification key could not be imported', { + ...context, + cause, + }); + } + }, + }; +} + +export type TrustStore = ReturnType; diff --git a/src/authorization/ap2/types.ts b/src/authorization/ap2/types.ts new file mode 100644 index 0000000..9746859 --- /dev/null +++ b/src/authorization/ap2/types.ts @@ -0,0 +1,83 @@ +/** + * AP2 trust configuration and the shape a successful verification produces. + * + * The trust types live here rather than in `src/config` so the dependency + * points the same way `X402FacilitatorConfig` does: the subsystem owns the + * shape of its own configuration and the config loader imports it. The + * alternative is config owning a type the verifier has to re-describe, which + * is how two definitions of one thing start. + */ +import type { AP2_SPEC_VERSION, Ap2Mode } from './constants.js'; + +export type { Ap2Mode }; + +/** One inline public verification key, trusted because an operator wrote it here. */ +export interface Ap2TrustedKey { + readonly kid: string; + /** A public P-256 JWK. Validated member by member at config load. */ + readonly jwk: Readonly>; +} + +/** One trusted issuer and the keys it signs with. */ +export interface Ap2TrustedIssuer { + readonly issuer: string; + /** + * The audience this issuer must address. + * + * Per issuer rather than one gateway-wide value: the Checkout Mandate is + * addressed to the merchant while the checkout JWT it binds is addressed to + * the gateway, so a single audience could not be right for both. + */ + readonly audience: string; + readonly keys: readonly Ap2TrustedKey[]; +} + +/** + * Discriminated on `enabled`, like `AcpProtocolConfig`: an enabled AP2 config + * carries everything the verifier needs, so nothing downstream asserts on an + * optional field, and a half-configured trust policy is rejected at load. + */ +export type Ap2AuthorizationConfig = + | { readonly enabled: false } + | { + readonly enabled: true; + readonly specVersion: typeof AP2_SPEC_VERSION; + readonly mode: Ap2Mode; + readonly trust: { + /** Signers of the Checkout Mandate itself. */ + readonly mandateIssuers: readonly Ap2TrustedIssuer[]; + /** Signers of the merchant checkout JWT the mandate binds. */ + readonly checkoutIssuers: readonly Ap2TrustedIssuer[]; + }; + readonly clockSkewSeconds: number; + /** Its own SQLite file. An authorization replay is not a payment replay. */ + readonly replay: { readonly path: string }; + }; + +/** The enabled half, which is all the verifier ever runs against. */ +export type EnabledAp2Config = Extract; + +/** + * A Checkout Mandate that has passed every cryptographic check. + * + * Cryptographically valid is not the same as authorising *this* purchase. + * Binding the mandate to the resolved resource, input and price is a separate + * step, and nothing here should be read as having done it. + */ +export interface VerifiedCheckoutMandate { + /** Issuer of the Checkout Mandate, as verified against its signature. */ + readonly mandateIssuer: string; + /** Issuer of the merchant checkout JWT the mandate binds. */ + readonly checkoutIssuer: string; + /** `jti` of the checkout JWT. Safe to record: it is an opaque identifier. */ + readonly checkoutJwtId: string; + /** + * Claims of the merchant checkout JWT, signature verified. + * + * This is where the Agent Commerce checkout profile lives, and what the + * purchase binding reads. + */ + readonly checkoutClaims: Readonly>; + /** Mandate claims with every presented disclosure resolved into place. */ + readonly mandateClaims: Readonly>; +} diff --git a/src/authorization/ap2/verifier.ts b/src/authorization/ap2/verifier.ts new file mode 100644 index 0000000..4a7ce03 --- /dev/null +++ b/src/authorization/ap2/verifier.ts @@ -0,0 +1,72 @@ +/** + * The Direct Checkout Mandate verifier. + * + * Runs the two stages in the one order they work in: the mandate's own + * signature first, then the merchant checkout JWT it binds. A failure at + * either stage is a refusal, and there is no partial result, because "the + * mandate verified but the checkout document did not" authorises nothing. + * + * What this proves is narrow, and worth stating so nobody reads more into it: + * a trusted issuer signed this mandate, it has not expired, it is addressed to + * us, and it binds a checkout document the merchant really signed. It does NOT + * prove the mandate authorises the purchase in front of us. That comparison + * runs the checkout profile against the resolved resource, input and price, + * and it is a separate step. A caller treating this result as permission to + * settle has skipped it. + */ +import type { Clock } from '../../core/index.js'; +import { verifyCheckoutJwt } from './checkout-jwt.js'; +import { type Ap2ErrorContext, ap2Rejected } from './errors.js'; +import { verifyMandate } from './sd-jwt.js'; +import { createTrustStore } from './trust.js'; +import type { EnabledAp2Config, VerifiedCheckoutMandate } from './types.js'; + +export interface Ap2VerifierOptions { + readonly config: EnabledAp2Config; + readonly clock: Clock; +} + +export interface Ap2MandateVerifier { + verify(presentation: string, context?: Ap2ErrorContext): Promise; + /** Trusted issuer ids, for `doctor`. Counts and names only, never keys. */ + trustedIssuers(): { readonly mandate: readonly string[]; readonly checkout: readonly string[] }; +} + +export function createAp2MandateVerifier(options: Ap2VerifierOptions): Ap2MandateVerifier { + // Two stores, not one shared list. A party trusted to sign checkout + // documents is not thereby trusted to issue mandates, and merging the lists + // would silently grant each the other's authority. + const mandateTrust = createTrustStore(options.config.trust.mandateIssuers); + const checkoutTrust = createTrustStore(options.config.trust.checkoutIssuers); + const deps = { clock: options.clock, clockSkewSeconds: options.config.clockSkewSeconds }; + + return { + async verify( + presentation: string, + context: Ap2ErrorContext = {}, + ): Promise { + if (typeof presentation !== 'string' || presentation.length === 0) { + throw ap2Rejected('malformed_presentation', context); + } + + const mandate = await verifyMandate(presentation, { ...deps, trust: mandateTrust }, context); + const checkout = await verifyCheckoutJwt( + mandate.claims, + { ...deps, trust: checkoutTrust }, + context, + ); + + return { + mandateIssuer: mandate.issuer, + checkoutIssuer: checkout.issuer, + checkoutJwtId: checkout.jwtId, + checkoutClaims: checkout.claims, + mandateClaims: mandate.claims, + }; + }, + + trustedIssuers() { + return { mandate: mandateTrust.issuerIds(), checkout: checkoutTrust.issuerIds() }; + }, + }; +} diff --git a/src/config/schema.ts b/src/config/schema.ts index 3ac3b07..205688b 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -35,8 +35,12 @@ import { AP2_MODES, AP2_SIGNING_ALGORITHM, AP2_SPEC_VERSION, - type Ap2Mode, } from '../authorization/ap2/constants.js'; +import type { + Ap2AuthorizationConfig, + Ap2Mode, + Ap2TrustedIssuer, +} from '../authorization/ap2/types.js'; import { extractPathParameterNames, findUnparsedBraceToken, @@ -492,41 +496,6 @@ export type AcpProtocolConfig = readonly discovery?: AcpDiscoveryConfig; }; -/** One inline public verification key, trusted because an operator wrote it here. */ -export interface Ap2TrustedKey { - readonly kid: string; - readonly jwk: Readonly>; -} - -/** One trusted issuer and the keys it signs with. */ -export interface Ap2TrustedIssuer { - readonly issuer: string; - readonly audience: string; - readonly keys: readonly Ap2TrustedKey[]; -} - -/** - * Discriminated on `enabled`, like `AcpProtocolConfig`: an enabled AP2 config - * carries everything the verifier needs, so nothing downstream asserts on an - * optional field, and a half-configured trust policy is rejected at load. - */ -export type Ap2AuthorizationConfig = - | { readonly enabled: false } - | { - readonly enabled: true; - readonly specVersion: typeof AP2_SPEC_VERSION; - readonly mode: Ap2Mode; - readonly trust: { - /** Signers of the Checkout Mandate itself. */ - readonly mandateIssuers: readonly Ap2TrustedIssuer[]; - /** Signers of the merchant checkout JWT the mandate binds. */ - readonly checkoutIssuers: readonly Ap2TrustedIssuer[]; - }; - readonly clockSkewSeconds: number; - /** Its own SQLite file. An authorization replay is not a payment replay. */ - readonly replay: { readonly path: string }; - }; - export interface GatewayConfig { readonly version: 1; readonly merchant: { readonly id: string; readonly name: string; readonly publicBaseUrl: string }; diff --git a/tests/unit/authorization-ap2/fixtures.ts b/tests/unit/authorization-ap2/fixtures.ts new file mode 100644 index 0000000..3eb6c58 --- /dev/null +++ b/tests/unit/authorization-ap2/fixtures.ts @@ -0,0 +1,198 @@ +/** + * Builds AP2 Direct Checkout Mandate presentations for the verifier tests. + * + * PROVENANCE, because it decides what these tests are worth. These are not + * golden vectors from the AP2 repository. They are built here to the v0.2.0 + * closed Checkout Mandate shape (`vct` `mandate.checkout.1`, a `checkout_hash` + * over the exact compact checkout JWT, SD-JWT disclosures under `_sd` with + * `_sd_alg` sha-256), with real ES256 keys and real signatures from `jose`. + * + * So they show the verifier enforces the rules as this repository reads them. + * They do not show interoperability with a mandate minted by the reference + * implementation. Vectors generated from upstream, with the commit recorded, + * are what would show that, and they belong here before anyone calls this + * feature stable. + * + * Keys are generated per test run and never written down. Nothing here signs + * anything outside the test process. + */ +import { exportJWK, generateKeyPair, SignJWT } from 'jose'; +import { + AP2_CHECKOUT_MANDATE_VCT, + AP2_CHECKOUT_PROFILE, +} from '../../../src/authorization/ap2/constants.js'; +import type { Ap2TrustedIssuer } from '../../../src/authorization/ap2/types.js'; + +export const MANDATE_ISSUER = 'https://trusted-surface.example'; +export const MANDATE_AUDIENCE = 'merchant.example'; +export const CHECKOUT_ISSUER = 'https://merchant.example'; +export const CHECKOUT_AUDIENCE = 'agent-commerce'; + +/** Fixed instant every fixture is minted against, so nothing races a real clock. */ +export const NOW = new Date('2026-09-14T12:00:00.000Z'); +const NOW_SECONDS = Math.floor(NOW.getTime() / 1000); + +/** `CryptoKey` is a DOM type and server code here does not load the DOM lib. */ +type PrivateKey = Awaited>['privateKey']; + +export interface SigningIdentity { + readonly kid: string; + readonly privateKey: PrivateKey; + readonly publicJwk: Readonly>; +} + +async function identity(kid: string): Promise { + const { privateKey, publicKey } = await generateKeyPair('ES256', { extractable: true }); + const jwk = (await exportJWK(publicKey)) as Record; + return { + kid, + privateKey, + // Only the four members the config loader accepts. `exportJWK` also emits + // `key_ops`/`ext` on some runtimes, which config rightly refuses. + publicJwk: { + kty: jwk['kty'] as string, + crv: jwk['crv'] as string, + x: jwk['x'] as string, + y: jwk['y'] as string, + }, + }; +} + +export function trustedIssuer( + issuer: string, + audience: string, + signer: SigningIdentity, +): Ap2TrustedIssuer { + return { issuer, audience, keys: [{ kid: signer.kid, jwk: signer.publicJwk }] }; +} + +function base64url(bytes: ArrayBuffer | Uint8Array): string { + return Buffer.from(bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes)).toString( + 'base64url', + ); +} + +async function sha256(input: string): Promise { + return base64url(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(input))); +} + +/** base64url(SHA-256(utf8)), the digest AP2 uses everywhere. */ +export const sha256Base64url = sha256; + +/** One SD-JWT disclosure for an object property: `[salt, name, value]`. */ +export function disclosure(salt: string, name: string, value: unknown): string { + return Buffer.from(JSON.stringify([salt, name, value]), 'utf8').toString('base64url'); +} + +/** The Agent Commerce checkout profile payload, as the plan specifies it. */ +export function checkoutPayload(overrides: Record = {}): Record { + return { + iss: CHECKOUT_ISSUER, + aud: CHECKOUT_AUDIENCE, + iat: NOW_SECONDS - 30, + exp: NOW_SECONDS + 300, + jti: 'checkout_01KTEST', + agent_commerce: { + profile: AP2_CHECKOUT_PROFILE, + resource_id: 'market_report', + input_hash: 'PLACEHOLDER_UNTIL_PURCHASE_BINDING', + amount: '0.01', + currency: 'USDC', + payment_method: 'x402', + }, + ...overrides, + }; +} + +export async function signCheckoutJwt( + signer: SigningIdentity, + payload: Record = checkoutPayload(), + header: Record = {}, +): Promise { + return new SignJWT(payload) + .setProtectedHeader({ alg: 'ES256', kid: signer.kid, ...header }) + .sign(signer.privateKey); +} + +export interface MandateOptions { + /** Replaces the compact checkout JWT after `checkout_hash` has been computed. */ + readonly checkoutJwtOverride?: string; + readonly payloadOverrides?: Record; + readonly header?: Record; + /** Extra disclosure strings appended to the presentation. */ + readonly extraDisclosures?: readonly string[]; + /** Omit the disclosure that carries the checkout JWT. */ + readonly withholdCheckoutDisclosure?: boolean; +} + +/** + * Mints a closed Checkout Mandate presentation carrying `checkout_jwt` as a + * selectively disclosed claim, which is the shape a Direct presentation takes. + */ +export async function mintMandate( + mandateSigner: SigningIdentity, + checkoutJwt: string, + options: MandateOptions = {}, +): Promise { + const checkoutDisclosure = disclosure('salt-checkout', 'checkout_jwt', checkoutJwt); + const payload: Record = { + vct: AP2_CHECKOUT_MANDATE_VCT, + iss: MANDATE_ISSUER, + aud: MANDATE_AUDIENCE, + iat: NOW_SECONDS - 10, + exp: NOW_SECONDS + 300, + checkout_hash: await sha256(checkoutJwt), + _sd_alg: 'sha-256', + _sd: [await sha256(checkoutDisclosure)], + ...options.payloadOverrides, + }; + + const jws = await new SignJWT(payload) + .setProtectedHeader({ alg: 'ES256', kid: mandateSigner.kid, ...options.header }) + .sign(mandateSigner.privateKey); + + const presented = [ + ...(options.withholdCheckoutDisclosure + ? [] + : [ + options.checkoutJwtOverride !== undefined + ? disclosure('salt-checkout', 'checkout_jwt', options.checkoutJwtOverride) + : checkoutDisclosure, + ]), + ...(options.extraDisclosures ?? []), + ]; + return `${jws}~${presented.map((d) => `${d}~`).join('')}`; +} + +export interface Party { + readonly mandateSigner: SigningIdentity; + readonly checkoutSigner: SigningIdentity; + readonly stranger: SigningIdentity; + readonly mandateIssuers: readonly Ap2TrustedIssuer[]; + readonly checkoutIssuers: readonly Ap2TrustedIssuer[]; +} + +/** Key generation is the slow part, so a suite builds this once. */ +export async function createParties(): Promise { + const [mandateSigner, checkoutSigner, stranger] = await Promise.all([ + identity('mandate-key-2026-01'), + identity('checkout-key-2026-01'), + identity('stranger-key'), + ]); + return { + mandateSigner, + checkoutSigner, + stranger, + mandateIssuers: [trustedIssuer(MANDATE_ISSUER, MANDATE_AUDIENCE, mandateSigner)], + checkoutIssuers: [trustedIssuer(CHECKOUT_ISSUER, CHECKOUT_AUDIENCE, checkoutSigner)], + }; +} + +/** A `Clock` pinned to {@link NOW}, or to an offset from it. */ +export function fixedClock(at: Date = NOW) { + return { + now: () => at, + nowIso: () => at.toISOString(), + monotonicMs: () => 0, + }; +} diff --git a/tests/unit/authorization-ap2/verifier.test.ts b/tests/unit/authorization-ap2/verifier.test.ts new file mode 100644 index 0000000..c95e6f5 --- /dev/null +++ b/tests/unit/authorization-ap2/verifier.test.ts @@ -0,0 +1,466 @@ +/** + * The Direct Checkout Mandate verifier, exercised with real ES256 signatures. + * + * See fixtures.ts for what these vectors are and, more importantly, what they + * are not: mandates built to the v0.2.0 shape by this repository, not golden + * vectors from the reference implementation. + * + * Every negative case asserts the error CODE as well as the rejection, because + * the one thing this feature must never do is report a bad mandate as a + * payment problem. A 402 tells an auto-paying client to spend money on a + * request that was never going to be delivered. + */ +import { beforeAll, describe, expect, it } from 'vitest'; +import type { EnabledAp2Config } from '../../../src/authorization/ap2/types.js'; +import { + type Ap2MandateVerifier, + createAp2MandateVerifier, +} from '../../../src/authorization/ap2/verifier.js'; +import { type CommerceError, isCommerceError } from '../../../src/core/index.js'; +import { + CHECKOUT_AUDIENCE, + CHECKOUT_ISSUER, + checkoutPayload, + createParties, + disclosure, + fixedClock, + MANDATE_AUDIENCE, + MANDATE_ISSUER, + mintMandate, + NOW, + type Party, + sha256Base64url, + signCheckoutJwt, + trustedIssuer, +} from './fixtures.js'; + +let parties: Party; +let verifier: Ap2MandateVerifier; +let validPresentation: string; +let checkoutJwt: string; + +function configFor(party: Party, overrides: Partial = {}): EnabledAp2Config { + return { + enabled: true, + specVersion: '0.2.0', + mode: 'direct', + trust: { mandateIssuers: party.mandateIssuers, checkoutIssuers: party.checkoutIssuers }, + clockSkewSeconds: 60, + replay: { path: ':memory:' }, + ...overrides, + }; +} + +function verifierFor(config: EnabledAp2Config, at: Date = NOW): Ap2MandateVerifier { + return createAp2MandateVerifier({ config, clock: fixedClock(at) }); +} + +/** Returns the CommerceError a rejected verification produced. */ +async function rejection(run: Promise): Promise { + try { + await run; + } catch (error) { + expect(isCommerceError(error)).toBe(true); + return error as CommerceError; + } + return expect.unreachable('expected the mandate to be refused') as never; +} + +/** Every refusal here must be an authorization failure, never a payment one. */ +async function expectRefused(run: Promise, reason?: string): Promise { + const error = await rejection(run); + expect(error.code).toBe('AUTHORIZATION_INVALID'); + expect(error.httpStatus).toBe(403); + expect(error.retryable).toBe(false); + if (reason !== undefined) expect(error.details?.['reason']).toBe(reason); + return error; +} + +beforeAll(async () => { + parties = await createParties(); + checkoutJwt = await signCheckoutJwt(parties.checkoutSigner); + validPresentation = await mintMandate(parties.mandateSigner, checkoutJwt); + verifier = verifierFor(configFor(parties)); +}); + +describe('a valid Direct closed Checkout Mandate', () => { + it('verifies and reports both issuers and the checkout identity', async () => { + const result = await verifier.verify(validPresentation); + expect(result.mandateIssuer).toBe(MANDATE_ISSUER); + expect(result.checkoutIssuer).toBe(CHECKOUT_ISSUER); + expect(result.checkoutJwtId).toBe('checkout_01KTEST'); + }); + + it('resolves the selectively disclosed checkout JWT into the mandate claims', async () => { + const result = await verifier.verify(validPresentation); + expect(result.mandateClaims['checkout_jwt']).toBe(checkoutJwt); + expect(result.mandateClaims['vct']).toBe('mandate.checkout.1'); + }); + + it('hands back the checkout profile the purchase binding will read', async () => { + const result = await verifier.verify(validPresentation); + expect(result.checkoutClaims['agent_commerce']).toMatchObject({ + resource_id: 'market_report', + amount: '0.01', + currency: 'USDC', + payment_method: 'x402', + }); + }); + + it('verifies a second time without the key cache changing the answer', async () => { + await expect(verifier.verify(validPresentation)).resolves.toBeDefined(); + await expect(verifier.verify(validPresentation)).resolves.toBeDefined(); + }); + + it('reports its trusted issuers for diagnostics without exposing keys', () => { + const issuers = verifier.trustedIssuers(); + expect(issuers.mandate).toEqual([MANDATE_ISSUER]); + expect(issuers.checkout).toEqual([CHECKOUT_ISSUER]); + expect(JSON.stringify(issuers)).not.toContain('"x"'); + }); +}); + +describe('malformed presentations', () => { + it.each([ + ['empty', ''], + ['not a JWT at all', 'hello~'], + ['a JWT with no disclosure separator', 'a.b.c'], + ['a truncated JWS', 'eyJhbGciOiJFUzI1NiJ9.eyJ2Y3QiOiJ4In0~'], + ])('refuses one that is %s', async (_label, value) => { + await expectRefused(verifier.verify(value), 'malformed_presentation'); + }); + + it('refuses an undecodable disclosure', async () => { + const presentation = await mintMandate(parties.mandateSigner, checkoutJwt, { + extraDisclosures: ['!!!not-base64!!!'], + }); + await expectRefused(verifier.verify(presentation), 'malformed_presentation'); + }); + + it('refuses a disclosure appended that no digest in the payload references', async () => { + // The forged-claim attack: append `[salt, "amount", "0.01"]` and hope the + // verifier merges it in without checking it was ever committed to. + const forged = disclosure('salt-forged', 'amount', '0.01'); + const presentation = await mintMandate(parties.mandateSigner, checkoutJwt, { + extraDisclosures: [forged], + }); + await expectRefused(verifier.verify(presentation), 'malformed_presentation'); + }); + + it('refuses the same disclosure presented twice', async () => { + const twice = disclosure('salt-checkout', 'checkout_jwt', checkoutJwt); + const presentation = await mintMandate(parties.mandateSigner, checkoutJwt, { + extraDisclosures: [twice], + }); + await expectRefused(verifier.verify(presentation), 'malformed_presentation'); + }); + + it('refuses a digest algorithm other than sha-256 rather than assuming sha-256', async () => { + const presentation = await mintMandate(parties.mandateSigner, checkoutJwt, { + payloadOverrides: { _sd_alg: 'sha-512' }, + }); + await expectRefused(verifier.verify(presentation), 'malformed_presentation'); + }); +}); + +describe('signature and trust', () => { + it('refuses a tampered payload', async () => { + const [header, payload, signature, ...rest] = validPresentation.split(/[.~]/); + const patched = Buffer.from( + JSON.stringify({ + ...JSON.parse(Buffer.from(payload as string, 'base64url').toString()), + aud: 'someone.else', + }), + ).toString('base64url'); + await expectRefused(verifier.verify(`${header}.${patched}.${signature}~${rest.join('~')}`)); + }); + + it('refuses a mandate from an issuer that is not configured', async () => { + const presentation = await mintMandate(parties.mandateSigner, checkoutJwt, { + payloadOverrides: { iss: 'https://attacker.example' }, + }); + await expectRefused(verifier.verify(presentation), 'untrusted_issuer'); + }); + + it('refuses a kid the configured issuer does not have', async () => { + const presentation = await mintMandate(parties.mandateSigner, checkoutJwt, { + header: { kid: 'some-other-key' }, + }); + await expectRefused(verifier.verify(presentation), 'unknown_key'); + }); + + it('refuses a presentation with no kid rather than guessing the only key', async () => { + const presentation = await mintMandate(parties.mandateSigner, checkoutJwt, { + header: { kid: undefined }, + }); + await expectRefused(verifier.verify(presentation), 'unknown_key'); + }); + + it('refuses a mandate signed by a key that is trusted for checkout documents only', async () => { + // Key confusion across the two trust lists. Being allowed to sign the + // merchant's own checkout documents must not confer the power to issue + // mandates authorising purchases from them. + const presentation = await mintMandate(parties.checkoutSigner, checkoutJwt, { + header: { kid: parties.mandateSigner.kid }, + }); + await expectRefused(verifier.verify(presentation)); + }); + + it('refuses a mandate signed by a stranger under a trusted issuer and kid', async () => { + const presentation = await mintMandate( + { ...parties.stranger, kid: parties.mandateSigner.kid }, + checkoutJwt, + ); + await expectRefused(verifier.verify(presentation), 'invalid_signature'); + }); + + it('refuses alg=none', async () => { + const payload = Buffer.from( + JSON.stringify({ + vct: 'mandate.checkout.1', + iss: MANDATE_ISSUER, + aud: MANDATE_AUDIENCE, + iat: Math.floor(NOW.getTime() / 1000), + exp: Math.floor(NOW.getTime() / 1000) + 300, + }), + ).toString('base64url'); + const header = Buffer.from( + JSON.stringify({ alg: 'none', kid: parties.mandateSigner.kid }), + ).toString('base64url'); + await expectRefused(verifier.verify(`${header}.${payload}.~`), 'invalid_signature'); + }); + + it('refuses HS256 forged against the public key', async () => { + // The classic confusion: take the public EC key, treat it as an HMAC + // secret, and sign. Refused twice over - by the algorithm allowlist and by + // the key being an EC key that cannot do HMAC - and this asserts the + // outcome rather than which of the two got there first. + const header = Buffer.from( + JSON.stringify({ alg: 'HS256', kid: parties.mandateSigner.kid }), + ).toString('base64url'); + const payload = Buffer.from( + JSON.stringify({ vct: 'mandate.checkout.1', iss: MANDATE_ISSUER, aud: MANDATE_AUDIENCE }), + ).toString('base64url'); + await expectRefused(verifier.verify(`${header}.${payload}.deadbeef~`), 'invalid_signature'); + }); +}); + +describe('mandate claims', () => { + it.each([ + ['the open variant', 'mandate.checkout.open.1'], + ['an unversioned type', 'mandate.checkout'], + ['a future version', 'mandate.checkout.2'], + ['a prefix extension', 'mandate.checkout.1x'], + ])('refuses %s', async (_label, vct) => { + const presentation = await mintMandate(parties.mandateSigner, checkoutJwt, { + payloadOverrides: { vct }, + }); + await expectRefused(verifier.verify(presentation), 'unsupported_mandate_type'); + }); + + it('refuses a mandate addressed to a different merchant', async () => { + const presentation = await mintMandate(parties.mandateSigner, checkoutJwt, { + payloadOverrides: { aud: 'other-merchant.example' }, + }); + await expectRefused(verifier.verify(presentation), 'wrong_audience'); + }); + + it('refuses an expired mandate', async () => { + const presentation = await mintMandate(parties.mandateSigner, checkoutJwt, { + payloadOverrides: { exp: Math.floor(NOW.getTime() / 1000) - 600 }, + }); + await expectRefused(verifier.verify(presentation), 'expired'); + }); + + it('refuses a mandate that is not yet valid', async () => { + const presentation = await mintMandate(parties.mandateSigner, checkoutJwt, { + payloadOverrides: { nbf: Math.floor(NOW.getTime() / 1000) + 600 }, + }); + await expectRefused(verifier.verify(presentation), 'expired'); + }); + + it('refuses a mandate with no exp, which would otherwise never expire', async () => { + const presentation = await mintMandate(parties.mandateSigner, checkoutJwt, { + payloadOverrides: { exp: undefined }, + }); + await expectRefused(verifier.verify(presentation), 'invalid_claims'); + }); + + it('refuses a mandate issued in the future', async () => { + const presentation = await mintMandate(parties.mandateSigner, checkoutJwt, { + payloadOverrides: { iat: Math.floor(NOW.getTime() / 1000) + 600 }, + }); + await expectRefused(verifier.verify(presentation), 'expired'); + }); + + it('accepts an expiry inside the configured clock skew', async () => { + const presentation = await mintMandate(parties.mandateSigner, checkoutJwt, { + payloadOverrides: { exp: Math.floor(NOW.getTime() / 1000) - 30 }, + }); + await expect(verifier.verify(presentation)).resolves.toBeDefined(); + }); + + it('refuses an expiry just outside it', async () => { + const presentation = await mintMandate(parties.mandateSigner, checkoutJwt, { + payloadOverrides: { exp: Math.floor(NOW.getTime() / 1000) - 90 }, + }); + await expectRefused(verifier.verify(presentation), 'expired'); + }); + + it('refuses a key-binding JWT rather than ignoring a proof that was sent', async () => { + await expectRefused( + verifier.verify(`${validPresentation}eyJhbGciOiJFUzI1NiJ9.eyJub25jZSI6IngifQ.sig`), + 'unsupported_mandate_type', + ); + }); +}); + +describe('the merchant checkout JWT', () => { + it('refuses a mandate whose checkout disclosure was withheld', async () => { + const presentation = await mintMandate(parties.mandateSigner, checkoutJwt, { + withholdCheckoutDisclosure: true, + }); + await expectRefused(verifier.verify(presentation), 'invalid_claims'); + }); + + it('refuses a swapped checkout JWT, caught by checkout_hash', async () => { + // A genuine, correctly signed merchant document - for a different + // purchase. The signature verifies; the hash the buyer approved does not. + const other = await signCheckoutJwt( + parties.checkoutSigner, + checkoutPayload({ jti: 'checkout_OTHER' }), + ); + const presentation = await mintMandate(parties.mandateSigner, checkoutJwt, { + checkoutJwtOverride: other, + }); + await expectRefused(verifier.verify(presentation), 'malformed_presentation'); + }); + + it('refuses a checkout_hash that does not match the bound document', async () => { + const presentation = await mintMandate(parties.mandateSigner, checkoutJwt, { + payloadOverrides: { checkout_hash: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' }, + }); + await expectRefused(verifier.verify(presentation), 'checkout_binding_failed'); + }); + + it('refuses a mandate carrying no checkout_hash at all', async () => { + const presentation = await mintMandate(parties.mandateSigner, checkoutJwt, { + payloadOverrides: { checkout_hash: undefined }, + }); + await expectRefused(verifier.verify(presentation), 'invalid_claims'); + }); + + it('refuses a checkout JWT signed by an untrusted party', async () => { + const forged = await signCheckoutJwt( + { ...parties.stranger, kid: parties.checkoutSigner.kid }, + checkoutPayload(), + ); + const presentation = await mintMandate(parties.mandateSigner, forged); + await expectRefused(verifier.verify(presentation), 'checkout_binding_failed'); + }); + + it('refuses a checkout JWT from an issuer that is not configured', async () => { + const foreign = await signCheckoutJwt( + parties.checkoutSigner, + checkoutPayload({ iss: 'https://not-the-merchant.example' }), + ); + const presentation = await mintMandate(parties.mandateSigner, foreign); + await expectRefused(verifier.verify(presentation), 'untrusted_issuer'); + }); + + it('refuses a checkout JWT addressed to someone other than the gateway', async () => { + const misaddressed = await signCheckoutJwt( + parties.checkoutSigner, + checkoutPayload({ aud: 'somewhere.else' }), + ); + const presentation = await mintMandate(parties.mandateSigner, misaddressed); + await expectRefused(verifier.verify(presentation), 'checkout_binding_failed'); + }); + + it('refuses an expired checkout JWT even under a live mandate', async () => { + const stale = await signCheckoutJwt( + parties.checkoutSigner, + checkoutPayload({ exp: Math.floor(NOW.getTime() / 1000) - 600 }), + ); + const presentation = await mintMandate(parties.mandateSigner, stale); + await expectRefused(verifier.verify(presentation), 'checkout_binding_failed'); + }); + + it.each([ + ['no exp', { exp: undefined }], + ['no iat', { iat: undefined }], + ['no jti', { jti: undefined }], + ['an empty jti', { jti: '' }], + ])('refuses a checkout JWT with %s', async (_label, override) => { + const jwt = await signCheckoutJwt(parties.checkoutSigner, checkoutPayload(override)); + const presentation = await mintMandate(parties.mandateSigner, jwt); + await expectRefused(verifier.verify(presentation), 'invalid_claims'); + }); + + it('refuses a mandate and a checkout JWT that are each valid but unrelated', async () => { + // Both documents genuine, neither binding the other. + const unrelated = await signCheckoutJwt( + parties.checkoutSigner, + checkoutPayload({ jti: 'checkout_UNRELATED' }), + ); + const presentation = await mintMandate(parties.mandateSigner, checkoutJwt, { + payloadOverrides: { checkout_hash: await sha256Base64url(unrelated) }, + }); + await expectRefused(verifier.verify(presentation), 'checkout_binding_failed'); + }); +}); + +describe('error reporting', () => { + it('never leaks mandate content into a client-visible message', async () => { + const secret = 'buyer@example.com-and-their-order-history'; + const jwt = await signCheckoutJwt( + parties.checkoutSigner, + checkoutPayload({ buyer_email: secret, aud: 'wrong' }), + ); + const presentation = await mintMandate(parties.mandateSigner, jwt); + const error = await expectRefused(verifier.verify(presentation)); + const onTheWire = JSON.stringify(error.toInfo()); + expect(onTheWire).not.toContain(secret); + expect(onTheWire).not.toContain(presentation.slice(0, 40)); + }); + + it('carries the request id so the refusal correlates with the rest of the flow', async () => { + const error = await expectRefused(verifier.verify('nonsense~', { requestId: 'req-9' })); + expect(error.requestId).toBe('req-9'); + }); + + it('reports a broken configured key as our fault, not the buyer', async () => { + // The config loader would refuse this key, so reaching the verifier with + // one means our deployment is broken. Blaming the payer would burn a + // mandate that is very likely fine. + const broken = configFor(parties, { + trust: { + mandateIssuers: [ + { + issuer: MANDATE_ISSUER, + audience: MANDATE_AUDIENCE, + keys: [{ kid: parties.mandateSigner.kid, jwk: { kty: 'EC', crv: 'P-256', x: 'nope' } }], + }, + ], + checkoutIssuers: parties.checkoutIssuers, + }, + }); + const error = await rejection(verifierFor(broken).verify(validPresentation)); + expect(error.code).toBe('AUTHORIZATION_PROVIDER_UNAVAILABLE'); + expect(error.httpStatus).toBe(503); + expect(error.retryable).toBe(true); + }); +}); + +describe('trust list separation', () => { + it('does not accept a mandate issuer as a checkout issuer', async () => { + const config = configFor(parties, { + trust: { + mandateIssuers: parties.mandateIssuers, + // Only the mandate issuer is trusted for checkout documents now. + checkoutIssuers: [trustedIssuer(MANDATE_ISSUER, CHECKOUT_AUDIENCE, parties.mandateSigner)], + }, + }); + await expectRefused(verifierFor(config).verify(validPresentation), 'untrusted_issuer'); + }); +}); diff --git a/tests/unit/cli/packaging.test.ts b/tests/unit/cli/packaging.test.ts index 8e1b590..b503868 100644 --- a/tests/unit/cli/packaging.test.ts +++ b/tests/unit/cli/packaging.test.ts @@ -159,6 +159,26 @@ describe('published package metadata', () => { expect(manifest.pnpm).toBeUndefined(); }); + it('keeps the AP2 crypto libraries optional and exactly pinned', () => { + // A mandate decides whether a purchase was authorised, so nothing in that + // path floats. `jose` and `@sd-jwt/core` are optional peers because a + // consumer serving a free HTTP resource should not install a JOSE stack, + // and they are pinned because a signature verifier is not somewhere to + // accept whatever a fresh install resolves to. + for (const peer of ['jose', '@sd-jwt/core']) { + expect(manifest.peerDependencies?.[peer]).toBeDefined(); + expect(manifest.peerDependenciesMeta?.[peer]?.optional).toBe(true); + expect(manifest.dependencies?.[peer]).toBeUndefined(); + expect(manifest.devDependencies?.[peer]).toBe(manifest.peerDependencies?.[peer]); + } + // `@sd-jwt/core` ships a caret range on a 0.x package, which is the one + // transitive in the whole graph that sits inside signature verification. + const overrides = manifest.overrides as Record | undefined; + expect( + (overrides?.['@sd-jwt/core'] as Record | undefined)?.['@owf/identity-common'], + ).toMatch(/^\d+\.\d+\.\d+$/); + }); + it('ships a library entry alongside the CLI', () => { const exportsField = manifest.exports as Record | undefined; expect(exportsField?.['.']).toBeDefined(); From 80a0c57f367af3a7b68f609a9d07cda08514d87c Mon Sep 17 00:00:00 2001 From: Revinand Date: Mon, 14 Sep 2026 18:03:22 +0200 Subject: [PATCH 04/11] feat(ap2): bind mandates to purchases and prevent replay --- package-lock.json | 18 + package.json | 5 + src/authorization/ap2/checkout-jwt.ts | 46 +-- src/authorization/ap2/constants.ts | 85 ++-- src/authorization/ap2/errors.ts | 38 +- src/authorization/ap2/profile.ts | 127 ++++++ src/authorization/ap2/replay-store.ts | 198 ++++++++++ src/authorization/ap2/sd-jwt.ts | 99 ++--- src/authorization/ap2/trust.ts | 48 +-- src/authorization/ap2/types.ts | 61 ++- src/authorization/ap2/verifier.ts | 35 +- tests/unit/authorization-ap2/fixtures.ts | 106 +++-- .../purchase-binding.test.ts | 369 ++++++++++++++++++ .../authorization-ap2/replay-store.test.ts | 217 ++++++++++ tests/unit/authorization-ap2/verifier.test.ts | 45 +-- 15 files changed, 1185 insertions(+), 312 deletions(-) create mode 100644 src/authorization/ap2/profile.ts create mode 100644 src/authorization/ap2/replay-store.ts create mode 100644 tests/unit/authorization-ap2/purchase-binding.test.ts create mode 100644 tests/unit/authorization-ap2/replay-store.test.ts diff --git a/package-lock.json b/package-lock.json index 436384f..fcacf3f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -38,6 +38,7 @@ "@vitest/coverage-v8": "4.1.10", "@x402/core": "2.23.0", "@x402/evm": "2.23.0", + "canonicalize": "5.0.0", "jose": "6.2.12", "pino-pretty": "13.1.2", "react": "19.0.8", @@ -59,6 +60,7 @@ "@sd-jwt/core": "0.20.1", "@x402/core": "2.23.0", "@x402/evm": "2.23.0", + "canonicalize": "5.0.0", "jose": "6.2.12", "viem": "2.55.18" }, @@ -78,6 +80,9 @@ "@x402/evm": { "optional": true }, + "canonicalize": { + "optional": true + }, "jose": { "optional": true }, @@ -3565,6 +3570,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/canonicalize": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/canonicalize/-/canonicalize-5.0.0.tgz", + "integrity": "sha512-O/NCg79G0/TWoD3Fo6scOMfP4p7/TsxRXVmRo9mEfD6h/5y5o1wtVbKyBO0E2i7FEcqe5tRijyAH/IWHIHMH4w==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "canonicalize": "bin/canonicalize.js" + }, + "engines": { + "node": ">=22" + } + }, "node_modules/chai": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", diff --git a/package.json b/package.json index 7464d9f..580141a 100644 --- a/package.json +++ b/package.json @@ -105,6 +105,7 @@ "@sd-jwt/core": "0.20.1", "@x402/core": "2.23.0", "@x402/evm": "2.23.0", + "canonicalize": "5.0.0", "jose": "6.2.12", "viem": "2.55.18" }, @@ -124,6 +125,9 @@ "@x402/evm": { "optional": true }, + "canonicalize": { + "optional": true + }, "jose": { "optional": true }, @@ -145,6 +149,7 @@ "@vitest/coverage-v8": "4.1.10", "@x402/core": "2.23.0", "@x402/evm": "2.23.0", + "canonicalize": "5.0.0", "jose": "6.2.12", "pino-pretty": "13.1.2", "react": "19.0.8", diff --git a/src/authorization/ap2/checkout-jwt.ts b/src/authorization/ap2/checkout-jwt.ts index 41bf12c..aa39c2c 100644 --- a/src/authorization/ap2/checkout-jwt.ts +++ b/src/authorization/ap2/checkout-jwt.ts @@ -1,14 +1,10 @@ /** * Stage two: the merchant checkout JWT the mandate binds. * - * AP2 leaves this document's payload outside its own scope, so all the mandate - * guarantees is `checkout_hash`: a digest of the exact compact JWT the buyer - * approved. Two things have to hold, and neither substitutes for the other. - * - * The hash proves the buyer approved *this* document. The signature proves the - * merchant issued it. Checking only the hash accepts any document a buyer - * chose to approve, including one they wrote themselves. Checking only the - * signature accepts a genuine merchant document this mandate never covered. + * Two things have to hold. The hash proves the buyer approved *this* document; + * the signature proves the merchant issued it. Hash alone accepts a document + * the buyer wrote themselves; signature alone accepts a genuine merchant + * document this mandate never covered. */ import { type JWTVerifyOptions, jwtVerify } from 'jose'; import type { Clock } from '../../core/index.js'; @@ -36,11 +32,8 @@ function decodeSegment(jwt: string, index: 0 | 1): Record | und } /** - * Verifies the checkout JWT carried by an already-verified mandate. - * - * `mandateClaims` must come from a mandate whose own signature has been - * checked, because `checkout_hash` is only worth anything if the issuer signed - * it. + * `mandateClaims` must come from an already-verified mandate: `checkout_hash` + * is worth nothing unless the issuer signed it */ export async function verifyCheckoutJwt( mandateClaims: Readonly>, @@ -89,13 +82,11 @@ export async function verifyCheckoutJwt( const exp = claims['exp']; const iat = claims['iat']; const jti = claims['jti']; - // Same reasoning as the mandate's own freshness check: jose validates a time - // claim only when it is present, so requiring them is this file's job. + // jose validates a time claim only when present, so requiring them is ours if (typeof exp !== 'number' || typeof iat !== 'number') { throw ap2Rejected('invalid_claims', context); } - // `jti` is what a later settlement is recorded against, so a checkout - // document without one cannot be told apart from another. + // `jti` is what replay defence and the receipt record, so it is required if (typeof jti !== 'string' || jti.length === 0) { throw ap2Rejected('invalid_claims', context); } @@ -106,26 +97,19 @@ export async function verifyCheckoutJwt( } /** - * Recomputes the digest over the exact compact string the mandate carried. - * - * Hashed as the bytes that arrived, never re-encoded from parsed claims. Two - * JSON serialisations of one payload differ in whitespace and key order, so - * they differ in digest, and every valid mandate would fail. Normalising first - * would be worse: it hashes a document other than the one being verified. + * Hashed as the bytes that arrived, never re-encoded from parsed claims: a + * re-serialised payload has a different digest, and normalising first would + * hash a document other than the one being verified */ async function requireMatchingHash( compact: string, expected: string, context: Ap2ErrorContext, ): Promise { - const digest = await crypto.subtle.digest( - // sha-256 only. The mandate's `_sd_alg` has already been pinned to it by - // the time this runs, so there is no second algorithm to dispatch on. - 'SHA-256', - new TextEncoder().encode(compact), - ); + // sha-256 only: `_sd_alg` was already pinned to it upstream + const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(compact)); const actual = Buffer.from(digest).toString('base64url'); - // A plain comparison: both sides are public values an attacker holding the - // presentation already knows, so there is no secret for timing to leak. + // Plain comparison: both sides are public to anyone holding the + // presentation, so there is no secret for timing to leak if (actual !== expected) throw ap2Rejected('checkout_binding_failed', context); } diff --git a/src/authorization/ap2/constants.ts b/src/authorization/ap2/constants.ts index b3071ae..e4e8009 100644 --- a/src/authorization/ap2/constants.ts +++ b/src/authorization/ap2/constants.ts @@ -1,104 +1,75 @@ /** * Pinned AP2 identifiers and key policy. * - * The pin is deliberate. AP2 v0.2.0 (released 2026-04-28, commit b4587ac) is - * the tagged release this gateway verifies against; unversioned `main` is - * never implemented against, because a mandate signed under one set of rules - * has to be checked under that same set. Config, the verifier and `doctor` - * all read these, so a bump lands in one place and changes every one of them - * together. + * AP2 v0.2.0 (2026-04-28, commit b4587ac) is the tagged release this gateway + * verifies against; unversioned `main` is never implemented against. Config, + * the verifier and `doctor` all read these, so a bump lands in one place. */ -/** The only AP2 release this gateway verifies mandates against. */ export const AP2_SPEC_VERSION = '0.2.0'; /** - * Operating modes implemented so far. - * - * Autonomous mode needs an open mandate, a cnf-bound agent key, selective - * disclosures and deterministic constraint evaluation over - * `checkout.line_items`. Half of that model would be worse than a clearly + * Autonomous mode needs open mandates, cnf-bound agent keys and constraint + * evaluation over `checkout.line_items`. Half of that would be worse than a * declared Direct-only profile, so it is refused rather than partly served. */ export const AP2_MODES = ['direct'] as const; export type Ap2Mode = (typeof AP2_MODES)[number]; /** - * The only signature algorithm accepted, for mandates and for the merchant - * checkout JWT alike. - * - * The allowlist holds one entry, so `alg=none` and the HMAC family are - * excluded by construction rather than by a check that has to remember them. - * AP2 v0.2 recommends non-deterministic signing for checkout JWTs, which - * ES256 satisfies, and it matches the reference examples. + * One entry, so `alg=none` and the HMAC family are excluded by construction + * rather than by a check that has to remember them */ export const AP2_SIGNING_ALGORITHM = 'ES256'; -/** The key type and curve ES256 implies. Any other pair is refused at load. */ +/** The key type and curve ES256 implies. Any other pair is refused at load */ export const AP2_KEY_TYPE = 'EC'; export const AP2_JWK_CURVE = 'P-256'; -/** Byte length of a P-256 coordinate, before base64url encoding. */ +/** Byte length of a P-256 coordinate, before base64url encoding */ export const AP2_JWK_COORDINATE_BYTES = 32; /** * JWK members a verification key may carry. * - * An allowlist, so private material (`d`) and the members that point at a URL - * (`x5u`, and `jku` if someone smuggles the header parameter in here) are - * refused without this list having to name them. The static trust model - * exists to rule out fetching a key from a location a mandate can influence; - * see docs/security.md. + * An allowlist, so private material (`d`) and anything naming a URL (`x5u`, + * or a smuggled `jku`) is refused without this list having to name it. Keys + * are configured inline and never fetched; see docs/security.md. */ export const AP2_JWK_MEMBERS = ['kty', 'crv', 'x', 'y', 'kid', 'alg', 'use'] as const; /** - * The exact credential type of a closed Checkout Mandate. - * - * Compared against this literal, never matched as a prefix. `mandate.checkout` - * and `mandate.checkout.2` are different credentials with different rules, and - * `mandate.checkout.open.1` carries spending constraints this release does not - * evaluate - accepting it would tell a buyer their limits were checked when - * nothing read them. + * Compared against this literal, never as a prefix. `mandate.checkout.open.1` + * carries spending constraints this release does not evaluate, so accepting it + * would tell a buyer their limits were checked when nothing read them. */ export const AP2_CHECKOUT_MANDATE_VCT = 'mandate.checkout.1'; /** - * The Agent Commerce checkout profile every merchant checkout JWT must declare. + * The checkout profile every merchant checkout JWT must declare. * - * This profile is ours, not AP2's: AP2 leaves the checkout document's payload - * outside its scope, so the claims a generic paid-resource invocation needs - * (resource id, input hash, amount, currency, payment method) had to be - * specified somewhere. + * Ours, not AP2's: AP2 leaves the checkout payload outside its scope, so the + * claims a paid-resource invocation needs had to be specified somewhere. * - * A bare name rather than a URL, joining the set in CLAUDE.md 5b - - * `agent-commerce/delivery`, `agent-commerce/v1.0.0`, - * `resource://agent-commerce/...`. A profile id is a namespace and is never - * dereferenced, so a URL would buy nothing and would tie the wire format to a - * domain that has to outlive it. Compared exactly, never by prefix, and frozen - * once a release exists: merchants sign it into every checkout JWT, so - * changing it later breaks every deployed signer at once. + * A bare name, matching the gateway's other wire identifiers + * (`agent-commerce/delivery`, `agent-commerce/v1.0.0`). A profile id is a + * namespace and is never dereferenced, so a URL would only tie the wire format + * to a domain. Frozen once released: merchants sign it into every checkout JWT. */ export const AP2_CHECKOUT_PROFILE = 'agent-commerce/ap2/checkout/v1'; /** - * The only digest algorithm accepted for SD-JWT disclosures and for the - * `checkout_hash` binding. - * - * AP2 takes this from the SD-JWT `_sd_alg`, defaulting to sha-256 when it is - * absent. A presentation naming anything else is refused rather than hashed - * as sha-256 anyway, which would check it under an algorithm it never claimed. + * Taken from the SD-JWT `_sd_alg`, which defaults to sha-256 when absent. + * Anything else is refused rather than hashed as sha-256 anyway, which would + * check a presentation under an algorithm it never claimed. */ export const AP2_DIGEST_ALGORITHM = 'sha-256'; -/** Seconds of clock skew tolerated on mandate and checkout time claims. */ +/** Seconds of clock skew tolerated on mandate and checkout time claims */ export const AP2_DEFAULT_CLOCK_SKEW_SECONDS = 60; /** - * Ceiling on configured skew. - * - * Skew wide enough to cover a mandate's whole validity window stops `exp` - * from rejecting anything. Five minutes covers an unsynchronised server; an - * operator needing more has a clock to fix, not a config value to raise. + * Skew wide enough to cover a mandate's validity window stops `exp` rejecting + * anything. An operator needing more than five minutes has a clock to fix. */ export const AP2_MAX_CLOCK_SKEW_SECONDS = 300; diff --git a/src/authorization/ap2/errors.ts b/src/authorization/ap2/errors.ts index 7af5703..e45d013 100644 --- a/src/authorization/ap2/errors.ts +++ b/src/authorization/ap2/errors.ts @@ -1,27 +1,20 @@ /** - * Every failure the AP2 verifier can report, and the rule for reporting it. + * Every failure the AP2 verifier can report. * - * `CommerceError.message` reaches the client over HTTP, MCP and A2A, so - * nothing derived from the presentation may appear in one. A mandate carries - * the buyer's approved purchase and, in the general case, personal data; an - * error that quoted the claim it choked on would publish it to whoever sent - * the request. The reason is a fixed phrase from the list below, the machine - * -readable code goes in `details.reason`, and the underlying exception goes - * on `cause`, which is never serialised. + * `CommerceError.message` reaches the client, and a mandate carries the + * buyer's purchase and often personal data, so messages are fixed phrases from + * the list below. The machine-readable code goes in `details.reason` and the + * original exception on `cause`, which is never serialised. * - * Two codes. A buyer presenting a bad mandate gets AUTHORIZATION_INVALID. Our - * own verifier failing to work gets AUTHORIZATION_PROVIDER_UNAVAILABLE, - * because no verdict was reached, and blaming the buyer for our outage is how - * a perfectly good mandate ends up refused. + * Two codes: AUTHORIZATION_INVALID when the buyer's mandate is bad, + * AUTHORIZATION_PROVIDER_UNAVAILABLE when our verifier never reached a + * verdict. Blaming the buyer for our outage refuses a good mandate. */ import { CommerceError } from '../../core/index.js'; /** - * Machine-readable rejection reasons. - * - * Coarse on purpose. A client learns that its mandate was refused and roughly - * where, not which check failed at what offset. The finer version turns an - * error response into an oracle for probing the trust policy. + * Coarse on purpose: a client learns roughly where its mandate was refused, + * not which check failed. Finer detail is an oracle for probing trust policy. */ export const AP2_REJECTION_REASONS = [ 'malformed_presentation', @@ -34,6 +27,7 @@ export const AP2_REJECTION_REASONS = [ 'wrong_audience', 'unsupported_mandate_type', 'checkout_binding_failed', + 'purchase_mismatch', ] as const; export type Ap2RejectionReason = (typeof AP2_REJECTION_REASONS)[number]; @@ -49,6 +43,7 @@ const MESSAGES: Readonly> = { wrong_audience: 'The mandate is addressed to a different audience.', unsupported_mandate_type: 'The mandate is not a closed Direct Checkout Mandate.', checkout_binding_failed: 'The merchant checkout document bound to the mandate did not verify.', + purchase_mismatch: 'The mandate does not authorize this purchase.', }; export interface Ap2ErrorContext { @@ -57,7 +52,7 @@ export interface Ap2ErrorContext { readonly cause?: unknown; } -/** The buyer's mandate is bad. Fail closed, and say only which stage refused it. */ +/** The buyer's mandate is bad. Fail closed */ export function ap2Rejected( reason: Ap2RejectionReason, context: Ap2ErrorContext = {}, @@ -71,11 +66,8 @@ export function ap2Rejected( } /** - * Our verifier could not reach a verdict. - * - * A configured key that will not import, or anything else that means the - * check never ran. Retryable, and never recorded against the payer: the - * mandate may well be perfectly good. + * The check never ran (a configured key that will not import, say). Retryable, + * and never recorded against the payer: the mandate may be perfectly good. */ export function ap2Unavailable(detail: string, context: Ap2ErrorContext = {}): CommerceError { return new CommerceError( diff --git a/src/authorization/ap2/profile.ts b/src/authorization/ap2/profile.ts new file mode 100644 index 0000000..9e79d34 --- /dev/null +++ b/src/authorization/ap2/profile.ts @@ -0,0 +1,127 @@ +/** + * Binds a verified mandate to the purchase in front of us. + * + * A perfect mandate authorises exactly one purchase; without this comparison a + * mandate approved for a $0.01 report would settle a $500 one. + * + * Everything is compared against the ALREADY RESOLVED request. Nothing is + * taken from the mandate and used to shape the purchase, which would invert + * the control. + */ +import canonicalize from 'canonicalize'; +import type { AuthorizationVerificationContext } from '../../core/index.js'; +import { AP2_CHECKOUT_PROFILE } from './constants.js'; +import { type Ap2ErrorContext, ap2Rejected } from './errors.js'; +import type { VerifiedCheckoutMandate } from './types.js'; + +/** + * All required. Absent is a mismatch, never a skipped check: a mandate that + * will not say which resource or how much authorises nothing in particular. + */ +const REQUIRED_PROFILE_CLAIMS = [ + 'profile', + 'resource_id', + 'input_hash', + 'amount', + 'currency', + 'payment_method', +] as const; + +/** + * RFC 8785 (JCS) digest of the validated resource input. + * + * Binds a mandate to the exact request, not just to a resource and a price: + * without it, one mandate for `translate` would authorise any translation. + * + * `canonicalize` rather than a sorted-key `JSON.stringify`, which differs + * exactly where it matters. The merchant's signer computes this same digest, + * probably in another language, and the two agree only if both follow RFC + * 8785's number formatting and UTF-16 key ordering. + * + * The caller passes what the backend will receive: validated, reserved fields + * stripped, no request id or transport metadata (the buyer could not have + * known those when they approved). + */ +export async function computeInputHash(input: unknown): Promise { + const canonical = canonicalize(input ?? {}); + // undefined means JSON cannot represent it. Input has already passed schema + // validation, so this is something exotic getting past it, not a buyer error. + if (canonical === undefined) { + throw new TypeError('resource input is not canonicalizable JSON'); + } + const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(canonical)); + return Buffer.from(digest).toString('base64url'); +} + +/** What the mandate turned out to authorise, once it matched */ +export interface BoundPurchase { + readonly resourceId: string; + readonly amount: string; + readonly currency: string; + readonly paymentMethod: string; +} + +function asRecord(value: unknown): Record | undefined { + return typeof value === 'object' && value !== null && !Array.isArray(value) + ? (value as Record) + : undefined; +} + +/** + * Throws on the first mismatch with one coarse reason. Which field disagreed + * is not reported: a caller able to ask about one field at a time can read a + * mandate's contents out of the gateway by elimination. + */ +export async function bindMandateToPurchase( + mandate: VerifiedCheckoutMandate, + context: AuthorizationVerificationContext, + errorContext: Ap2ErrorContext, +): Promise { + const profile = asRecord(mandate.checkoutClaims['agent_commerce']); + if (profile === undefined) throw ap2Rejected('purchase_mismatch', errorContext); + + for (const claim of REQUIRED_PROFILE_CLAIMS) { + const value = profile[claim]; + if (typeof value !== 'string' || value.length === 0) { + throw ap2Rejected('purchase_mismatch', errorContext); + } + } + + const requirement = context.requirement; + const expectedInputHash = await computeInputHash(context.input); + + const mustMatch: readonly (readonly [string, unknown])[] = [ + ['profile', AP2_CHECKOUT_PROFILE], + ['resource_id', context.resourceId], + ['input_hash', expectedInputHash], + // Decimal strings on both sides. Comparing numerically would make "0.10" + // and "0.1" equal, and a mandate says what it says. + ['amount', requirement.amount], + ['currency', requirement.currency], + ['payment_method', requirement.provider], + ]; + + for (const [claim, expected] of mustMatch) { + if (profile[claim] !== expected) throw ap2Rejected('purchase_mismatch', errorContext); + } + + // Checked whenever EITHER side names one, which is the fail-closed reading. + // A mandate silent about the chain must not unlock a mainnet settlement, and + // one naming a chain the requirement lacks was approved for another rail. + for (const [claim, expected] of [ + ['destination', requirement.destination], + ['network', requirement.network], + ['asset', requirement.asset], + ] as const) { + const declared = profile[claim]; + if (declared === undefined && expected === undefined) continue; + if (declared !== expected) throw ap2Rejected('purchase_mismatch', errorContext); + } + + return { + resourceId: profile['resource_id'] as string, + amount: profile['amount'] as string, + currency: profile['currency'] as string, + paymentMethod: profile['payment_method'] as string, + }; +} diff --git a/src/authorization/ap2/replay-store.ts b/src/authorization/ap2/replay-store.ts new file mode 100644 index 0000000..25cffbb --- /dev/null +++ b/src/authorization/ap2/replay-store.ts @@ -0,0 +1,198 @@ +/** + * Durable record of which mandates have been spent. + * + * Its own table in its own file: an x402 payment replay key expires with its + * on-chain authorisation, while a consumed mandate must stay consumed for as + * long as the merchant can be asked what they delivered. Hence no retention + * sweep here, unlike the ACP idempotency store next door - deleting a + * `consumed` row makes that mandate spendable again. If the table ever needs + * bounding, archive `released` rows and leave the rest. + * + * Settlement and a local commit are not one transaction. If the process dies + * between them the row stays `reserved` and that mandate is refused from then + * on: a refused retry costs a round trip, the other direction costs a second + * payment. + */ +import type { Database } from 'better-sqlite3'; +import { type Logger, NOOP_LOGGER } from '../../core/index.js'; +import { openSqliteDatabase } from '../../storage/sqlite.js'; + +/** + * `released` is "nothing happened" and the only state a mandate can be + * presented from again. `uncertain` is a settlement whose outcome we never + * learned: not reusable, but findable by an operator reconciling by hand. + */ +export type Ap2AuthorizationState = 'reserved' | 'consumed' | 'released' | 'uncertain'; + +/** + * A digest and a few identifiers: everything replay defence needs, and nothing + * a leaked database would hand an attacker + */ +export interface Ap2ReservationRequest { + /** + * Digest of the ISSUER-SIGNED TOKEN, not of the presentation. Selective + * disclosure gives one mandate many presentation strings, so keying on the + * presentation would let it be spent once per disclosed subset. + */ + readonly reference: string; + /** `jti` of the merchant checkout JWT the mandate binds */ + readonly checkoutJti: string; + readonly mandateIssuer: string; + readonly checkoutIssuer: string; + readonly resourceId: string; + readonly requestId: string; +} + +export type Ap2ReservationResult = + | { readonly kind: 'reserved' } + /** Already reserved, consumed, or of uncertain outcome. Never re-spendable */ + | { readonly kind: 'replayed'; readonly state: Ap2AuthorizationState }; + +export interface Ap2ReplayStore { + /** Atomically claim a mandate, or report that something already holds it */ + reserve(request: Ap2ReservationRequest): Ap2ReservationResult; + /** Settlement succeeded: spend it permanently */ + consume(reference: string): void; + /** Nothing happened: hand it back so a corrected retry can use it */ + release(reference: string): void; + /** Settlement outcome unknown. Not reusable, and flagged for a human */ + markUncertain(reference: string): void; + /** Current state, for tests and diagnostics */ + stateOf(reference: string): Ap2AuthorizationState | undefined; + close(): void; +} + +export interface Ap2ReplayStoreOptions { + /** File path, or ':memory:' for tests */ + readonly path: string; + readonly logger?: Logger; + /** Injectable so tests need not move the wall clock */ + readonly now?: () => number; +} + +interface StateRow { + state: string; +} + +export function createAp2ReplayStore(options: Ap2ReplayStoreOptions): Ap2ReplayStore { + const logger = options.logger ?? NOOP_LOGGER; + const now = options.now ?? (() => Date.now()); + + const db: Database = openSqliteDatabase({ + path: options.path, + label: 'AP2 authorization database', + logger, + }); + migrate(db); + + const selectByReference = db.prepare<[string], StateRow>( + 'SELECT state FROM ap2_authorizations WHERE reference = ?', + ); + // Two mandates can bind one checkout document, giving two references, so + // the reference alone would not catch the second one + const selectLiveByJti = db.prepare<[string, string], StateRow>( + `SELECT state FROM ap2_authorizations + WHERE checkout_jti = ? AND reference != ? AND state != 'released'`, + ); + const insert = db.prepare( + `INSERT INTO ap2_authorizations + (reference, checkout_jti, mandate_issuer, checkout_issuer, resource_id, request_id, + state, created_at, updated_at) + VALUES (@reference, @checkout_jti, @mandate_issuer, @checkout_issuer, @resource_id, + @request_id, 'reserved', @at, @at)`, + ); + const reReserve = db.prepare( + `UPDATE ap2_authorizations + SET state = 'reserved', request_id = @request_id, updated_at = @at + WHERE reference = @reference AND state = 'released'`, + ); + // Guarded on `reserved`: releasing a `consumed` row would hand back a + // mandate whose money has already moved + const transition = db.prepare( + `UPDATE ap2_authorizations SET state = @state, updated_at = @at + WHERE reference = @reference AND state = 'reserved'`, + ); + + const reserveTx = db.transaction((request: Ap2ReservationRequest): Ap2ReservationResult => { + const at = new Date(now()).toISOString(); + + const existing = selectByReference.get(request.reference); + if (existing !== undefined) { + if (existing.state !== 'released') { + return { kind: 'replayed', state: existing.state as Ap2AuthorizationState }; + } + reReserve.run({ reference: request.reference, request_id: request.requestId, at }); + return { kind: 'reserved' }; + } + + const sameCheckout = selectLiveByJti.get(request.checkoutJti, request.reference); + if (sameCheckout !== undefined) { + return { kind: 'replayed', state: sameCheckout.state as Ap2AuthorizationState }; + } + + insert.run({ + reference: request.reference, + checkout_jti: request.checkoutJti, + mandate_issuer: request.mandateIssuer, + checkout_issuer: request.checkoutIssuer, + resource_id: request.resourceId, + request_id: request.requestId, + at, + }); + return { kind: 'reserved' }; + }); + + const move = (reference: string, state: Ap2AuthorizationState): void => { + transition.run({ reference, state, at: new Date(now()).toISOString() }); + }; + + let closed = false; + return { + reserve(request) { + return reserveTx(request); + }, + consume(reference) { + move(reference, 'consumed'); + }, + release(reference) { + move(reference, 'released'); + }, + markUncertain(reference) { + move(reference, 'uncertain'); + }, + stateOf(reference) { + return selectByReference.get(reference)?.state as Ap2AuthorizationState | undefined; + }, + close() { + if (closed) return; + closed = true; + db.close(); + }, + }; +} + +// `PRAGMA user_version`, so reopening an existing file is a no-op check +function migrate(db: Database): void { + const current = db.pragma('user_version', { simple: true }) as number; + if (current >= 1) return; + const apply = db.transaction(() => { + db.exec(` + CREATE TABLE IF NOT EXISTS ap2_authorizations ( + reference TEXT PRIMARY KEY, + checkout_jti TEXT NOT NULL, + mandate_issuer TEXT NOT NULL, + checkout_issuer TEXT NOT NULL, + resource_id TEXT NOT NULL, + request_id TEXT NOT NULL, + state TEXT NOT NULL + CHECK (state IN ('reserved', 'consumed', 'released', 'uncertain')), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_ap2_authorizations_checkout_jti + ON ap2_authorizations(checkout_jti); + `); + db.pragma('user_version = 1'); + }); + apply(); +} diff --git a/src/authorization/ap2/sd-jwt.ts b/src/authorization/ap2/sd-jwt.ts index 5433120..0dac7a5 100644 --- a/src/authorization/ap2/sd-jwt.ts +++ b/src/authorization/ap2/sd-jwt.ts @@ -1,16 +1,12 @@ /** - * Stage one: parse the SD-JWT presentation, verify the issuer's signature and - * resolve the disclosures into a claim set. + * Stage one: parse the presentation, verify the issuer's signature, resolve + * the disclosures. * - * The disclosure mechanics come from `@sd-jwt/core` rather than being written - * out here. Three attacks live in that algorithm: a disclosure appended that - * no digest in the payload references, the same disclosure presented twice, - * and a disclosure that will not decode. The library refuses all three, and a - * hand-rolled digest walk would be reimplementing exactly that, with less - * coverage, on the path deciding whether a purchase was authorised. - * - * The cryptography is `jose`'s. This file supplies the policy around it: which - * key, which algorithm, which audience, and what counts as a fresh mandate. + * Disclosure mechanics come from `@sd-jwt/core`, which refuses the three + * attacks that live in that algorithm: an appended disclosure nothing + * references, the same disclosure twice, and one that will not decode. + * Cryptography is `jose`'s. This file supplies the policy: which key, which + * algorithm, which audience, what counts as fresh. */ import { decodeSdJwt, getClaims, splitSdJwt } from '@sd-jwt/core'; import { type JWTVerifyOptions, jwtVerify } from 'jose'; @@ -25,17 +21,21 @@ import type { TrustStore } from './trust.js'; export interface VerifiedMandate { readonly issuer: string; - /** Every claim, with the presented disclosures resolved into place. */ + /** Every claim, with the presented disclosures resolved into place */ readonly claims: Readonly>; + /** + * The issuer-signed token on its own, without the disclosures. + * + * This, not the presentation string, is the stable identity of a mandate: + * disclosing or withholding an optional claim rewrites the presentation and + * leaves the signed token untouched. Replay defence keys on a digest of it. + */ + readonly signedToken: string; } /** - * SHA-256 over a disclosure string. - * - * `@sd-jwt/core` passes the algorithm it read from `_sd_alg`, so this doubles - * as the enforcement point: anything but sha-256 throws instead of being - * quietly computed as sha-256, which would let a presentation declare one - * algorithm and be checked under another. + * `@sd-jwt/core` passes the algorithm it read from `_sd_alg`, so this is also + * where a presentation declaring anything but sha-256 is refused */ async function hasher(data: string | ArrayBuffer, algorithm: string): Promise { if (algorithm.toLowerCase() !== AP2_DIGEST_ALGORITHM) { @@ -52,12 +52,9 @@ function asRecord(value: unknown): Record | undefined { } /** - * Parses and verifies the mandate half of a presentation. - * - * Order is the security boundary of this file: nothing is read out of the - * payload as *trusted* until `jwtVerify` has returned. The header's `kid` and - * the payload's `iss` are read before that, but only to choose which - * configured key to try, and choosing wrong can only make the signature fail. + * Order is the security boundary here: nothing is trusted until `jwtVerify` + * returns. `kid` and `iss` are read before that only to pick which configured + * key to try, and picking wrong just makes the signature fail. */ export async function verifyMandate( presentation: string, @@ -68,17 +65,16 @@ export async function verifyMandate( let encodedJws: string; try { // Covers a malformed base JWT, a disclosure that will not decode, a - // duplicate digest, and an `_sd_alg` this release does not implement. + // duplicate digest, and an `_sd_alg` this release does not implement decoded = await decodeSdJwt(presentation, hasher); encodedJws = splitSdJwt(presentation).jwt; } catch (cause) { throw ap2Rejected('malformed_presentation', { ...context, cause }); } - // A key-binding JWT proves possession of the key a mandate was bound to. - // Direct mode issues no bound mandates, so one arriving here belongs to a - // flow this release does not verify, and ignoring it would mean silently - // not checking a proof that was sent. + // Direct mode issues no key-bound mandates, so one arriving here belongs to + // a flow we do not verify. Ignoring it would mean silently not checking a + // proof that was sent. if (decoded.kbJwt !== undefined) { throw ap2Rejected('unsupported_mandate_type', context); } @@ -92,16 +88,14 @@ export async function verifyMandate( const { issuer, key } = await deps.trust.resolve(rawPayload['iss'], header['kid'], context); const options: JWTVerifyOptions = { - // Redundant today and kept anyway: the resolved key is an EC public key, - // so jose already refuses `alg: none` and an HMAC forged against it. The - // allowlist is what keeps that true if this ever resolves to a key set - // rather than one key, where the header would get to pick. + // Redundant today (the key is EC, so jose already refuses `alg: none` and + // a forged HMAC) and kept for the day this resolves a key set instead of + // one key, where the header would get to pick algorithms: [AP2_SIGNING_ALGORITHM], issuer: issuer.issuer, audience: issuer.audience, clockTolerance: deps.clockSkewSeconds, - // The injected clock, not jose's own `Date.now()`, so an expiry test is a - // test rather than a race against the wall clock. + // Injected clock, not jose's `Date.now()`, so expiry tests are tests currentDate: deps.clock.now(), }; @@ -117,29 +111,26 @@ export async function verifyMandate( let claims: Record; try { - // Resolved against the VERIFIED payload. Handing `getClaims` the decoded - // one would match disclosures against digests nobody signed. + // Against the VERIFIED payload: the decoded one would match disclosures + // against digests nobody signed claims = (await getClaims(verifiedPayload, decoded.disclosures, hasher)) as Record< string, unknown >; } catch (cause) { - // Where an appended disclosure that no digest references is refused. + // Where an appended disclosure that no digest references is refused throw ap2Rejected('malformed_presentation', { ...context, cause }); } requireClosedCheckoutMandate(claims, context); - return { issuer: issuer.issuer, claims }; + return { issuer: issuer.issuer, claims, signedToken: encodedJws }; } /** - * Maps a jose verification failure onto one of our coarse reasons. - * * Matched on jose's stable error `code`, not its message. A client is owed - * the difference between "your mandate has expired" and "it did not verify at - * all"; anything finer describes our checks back to whoever is probing - * them. + * "expired" versus "did not verify"; anything finer describes our checks back + * to whoever is probing them. */ function classifyJoseFailure(cause: unknown): 'expired' | 'wrong_audience' | 'invalid_signature' { const code = (cause as { code?: unknown })?.code; @@ -153,11 +144,8 @@ function classifyJoseFailure(cause: unknown): 'expired' | 'wrong_audience' | 'in } /** - * `exp` and `iat` are required, not merely checked when present. - * - * jose validates both only if the claim is there, so a mandate omitting `exp` - * verifies and then never expires. An authorisation to spend money that is - * valid forever is not something to accept because a field was absent. + * Required, not merely checked when present: jose validates a time claim only + * if it is there, so a mandate omitting `exp` would verify and never expire */ function requireFreshness( payload: Record, @@ -170,19 +158,12 @@ function requireFreshness( throw ap2Rejected('invalid_claims', context); } const nowSeconds = Math.floor(deps.clock.now().getTime() / 1000); - // An issuance timestamp in the future is either a broken signer or a mandate - // minted to outlive the window its own `exp` describes. + // A future `iat` is a broken signer, or a mandate minted to outlive its own + // expiry window if (iat > nowSeconds + deps.clockSkewSeconds) throw ap2Rejected('expired', context); } -/** - * Exactly `mandate.checkout.1`, compared against the literal. - * - * A `startsWith` test would accept `mandate.checkout.1x`. Accepting the open - * variant would be worse: it carries `allowed_merchants` and `line_items` - * constraints this release does not evaluate, so a buyer would read their - * spending limits as enforced when nothing had looked at them. - */ +// Exactly `mandate.checkout.1`; see AP2_CHECKOUT_MANDATE_VCT for why function requireClosedCheckoutMandate( claims: Record, context: Ap2ErrorContext, diff --git a/src/authorization/ap2/trust.ts b/src/authorization/ap2/trust.ts index 97e3c3b..2193c00 100644 --- a/src/authorization/ap2/trust.ts +++ b/src/authorization/ap2/trust.ts @@ -1,27 +1,20 @@ /** * Static key resolution. * - * Every key this verifier will ever use was written into `config.yaml` by an - * operator. There is no JWKS fetch, no `jku`, no `x5u`, no discovery from an - * issuer-controlled URL. A mandate chooses *which* trusted key verifies it, - * through `iss` and `kid`, and nothing more: an unrecognised pair is refused - * rather than resolved. That is what keeps a mandate from nominating its own - * signer, and it is why the config loader refuses a JWK member naming a URL. + * Every key was written into `config.yaml` by an operator. No JWKS fetch, no + * `jku`, no `x5u`. A mandate's `iss` and `kid` only choose *which* trusted key + * verifies it; an unrecognised pair is refused, so a mandate can never + * nominate its own signer. * - * The lookup is deliberately not a "try every key" loop. Trying keys until - * one verifies would make `kid` advisory and would quietly accept a mandate - * that named a key it was not signed with. + * Not a "try every key" loop: that would make `kid` advisory and accept a + * mandate that named a key it was not signed with. */ import { importJWK, type JWK } from 'jose'; import { AP2_SIGNING_ALGORITHM } from './constants.js'; import { type Ap2ErrorContext, ap2Rejected, ap2Unavailable } from './errors.js'; import type { Ap2TrustedIssuer } from './types.js'; -/** - * Whatever `importJWK` hands back on this runtime, named off the function - * itself rather than spelled out. `CryptoKey` is a DOM type and server code - * here does not load the DOM lib on purpose. - */ +/** Named off `importJWK` because `CryptoKey` is a DOM type we do not load */ export type VerificationKey = Awaited>; export interface ResolvedKey { @@ -30,19 +23,15 @@ export interface ResolvedKey { } /** - * Resolves `(iss, kid)` against one configured issuer list. - * - * Imported keys are cached because `importJWK` does real work and the same - * handful of keys verifies every mandate. The cache holds the promise rather - * than the resolved key, so two concurrent requests for a cold key do one - * import between them. + * Resolves `(iss, kid)` against one configured issuer list. Caches the import + * promise, not the key, so concurrent requests for a cold key share one import. */ export function createTrustStore(issuers: readonly Ap2TrustedIssuer[]) { const byIssuer = new Map(issuers.map((entry) => [entry.issuer, entry])); const imported = new Map>(); return { - /** Every trusted issuer id, for diagnostics. Never the keys themselves. */ + /** Every trusted issuer id, for diagnostics. Never the keys themselves */ issuerIds(): readonly string[] { return [...byIssuer.keys()]; }, @@ -54,18 +43,16 @@ export function createTrustStore(issuers: readonly Ap2TrustedIssuer[]) { const issuer = byIssuer.get(iss); if (issuer === undefined) throw ap2Rejected('untrusted_issuer', context); - // A missing kid is refused rather than defaulted to the issuer's only - // key. An issuer mid-rotation has two, and a presentation that declines - // to say which one it used should not have the gateway guess. + // Not defaulted to the issuer's only key: mid-rotation there are two, + // and the gateway should not guess which one signed this if (typeof kid !== 'string' || kid.length === 0) { throw ap2Rejected('unknown_key', context); } const trusted = issuer.keys.find((candidate) => candidate.kid === kid); if (trusted === undefined) throw ap2Rejected('unknown_key', context); - // JSON rather than a joined string: an issuer id and a kid are both - // operator-chosen, and any separator picked out of the air is one they - // could contain. + // JSON, not a joined string: both halves are operator-chosen and could + // contain whatever separator we picked const cacheKey = JSON.stringify([iss, kid]); let pending = imported.get(cacheKey); if (pending === undefined) { @@ -76,10 +63,9 @@ export function createTrustStore(issuers: readonly Ap2TrustedIssuer[]) { try { return { issuer, key: await pending }; } catch (cause) { - // The config loader already checked this JWK member by member, so - // reaching here means our own configuration is broken rather than the - // buyer's mandate. Drop the cached rejection so a fixed config is not - // still failing against a poisoned cache entry. + // Config already checked this JWK member by member, so reaching here + // means our config is broken, not the mandate. Drop the cached + // rejection so a fixed config is not still failing against it. imported.delete(cacheKey); throw ap2Unavailable('configured verification key could not be imported', { ...context, diff --git a/src/authorization/ap2/types.ts b/src/authorization/ap2/types.ts index 9746859..16147bb 100644 --- a/src/authorization/ap2/types.ts +++ b/src/authorization/ap2/types.ts @@ -1,41 +1,36 @@ /** * AP2 trust configuration and the shape a successful verification produces. * - * The trust types live here rather than in `src/config` so the dependency - * points the same way `X402FacilitatorConfig` does: the subsystem owns the - * shape of its own configuration and the config loader imports it. The - * alternative is config owning a type the verifier has to re-describe, which - * is how two definitions of one thing start. + * Trust types live here, not in `src/config`, so the dependency points the way + * `X402FacilitatorConfig` does: the subsystem owns its own config shape and + * the loader imports it. */ import type { AP2_SPEC_VERSION, Ap2Mode } from './constants.js'; export type { Ap2Mode }; -/** One inline public verification key, trusted because an operator wrote it here. */ +/** One inline public verification key, trusted because an operator wrote it here */ export interface Ap2TrustedKey { readonly kid: string; - /** A public P-256 JWK. Validated member by member at config load. */ + /** A public P-256 JWK. Validated member by member at config load */ readonly jwk: Readonly>; } -/** One trusted issuer and the keys it signs with. */ +/** One trusted issuer and the keys it signs with */ export interface Ap2TrustedIssuer { readonly issuer: string; /** - * The audience this issuer must address. - * - * Per issuer rather than one gateway-wide value: the Checkout Mandate is - * addressed to the merchant while the checkout JWT it binds is addressed to - * the gateway, so a single audience could not be right for both. + * Per issuer, not one gateway-wide value: the mandate is addressed to the + * merchant and the checkout JWT it binds to the gateway */ readonly audience: string; readonly keys: readonly Ap2TrustedKey[]; } /** - * Discriminated on `enabled`, like `AcpProtocolConfig`: an enabled AP2 config + * Discriminated on `enabled`, like `AcpProtocolConfig`: an enabled config * carries everything the verifier needs, so nothing downstream asserts on an - * optional field, and a half-configured trust policy is rejected at load. + * optional field */ export type Ap2AuthorizationConfig = | { readonly enabled: false } @@ -44,40 +39,40 @@ export type Ap2AuthorizationConfig = readonly specVersion: typeof AP2_SPEC_VERSION; readonly mode: Ap2Mode; readonly trust: { - /** Signers of the Checkout Mandate itself. */ + /** Signers of the Checkout Mandate itself */ readonly mandateIssuers: readonly Ap2TrustedIssuer[]; - /** Signers of the merchant checkout JWT the mandate binds. */ + /** Signers of the merchant checkout JWT the mandate binds */ readonly checkoutIssuers: readonly Ap2TrustedIssuer[]; }; readonly clockSkewSeconds: number; - /** Its own SQLite file. An authorization replay is not a payment replay. */ + /** Its own SQLite file. An authorization replay is not a payment replay */ readonly replay: { readonly path: string }; }; -/** The enabled half, which is all the verifier ever runs against. */ +/** The enabled half, which is all the verifier ever runs against */ export type EnabledAp2Config = Extract; /** - * A Checkout Mandate that has passed every cryptographic check. + * A Checkout Mandate that passed every cryptographic check. * - * Cryptographically valid is not the same as authorising *this* purchase. - * Binding the mandate to the resolved resource, input and price is a separate - * step, and nothing here should be read as having done it. + * Valid is not the same as authorising *this* purchase. Binding it to the + * resolved resource, input and price is a separate step (profile.ts). */ export interface VerifiedCheckoutMandate { - /** Issuer of the Checkout Mandate, as verified against its signature. */ + /** + * `sha256:` over the issuer-signed token. Stable across every + * presentation of one mandate, which is what makes it a usable replay key, + * and safe to record in a receipt. + */ + readonly reference: string; + /** Issuer of the Checkout Mandate, as verified against its signature */ readonly mandateIssuer: string; - /** Issuer of the merchant checkout JWT the mandate binds. */ + /** Issuer of the merchant checkout JWT the mandate binds */ readonly checkoutIssuer: string; - /** `jti` of the checkout JWT. Safe to record: it is an opaque identifier. */ + /** `jti` of the checkout JWT. Safe to record: it is an opaque identifier */ readonly checkoutJwtId: string; - /** - * Claims of the merchant checkout JWT, signature verified. - * - * This is where the Agent Commerce checkout profile lives, and what the - * purchase binding reads. - */ + /** Signature-verified checkout JWT claims. Carries the checkout profile */ readonly checkoutClaims: Readonly>; - /** Mandate claims with every presented disclosure resolved into place. */ + /** Mandate claims with every presented disclosure resolved into place */ readonly mandateClaims: Readonly>; } diff --git a/src/authorization/ap2/verifier.ts b/src/authorization/ap2/verifier.ts index 4a7ce03..6704e9d 100644 --- a/src/authorization/ap2/verifier.ts +++ b/src/authorization/ap2/verifier.ts @@ -1,17 +1,11 @@ /** - * The Direct Checkout Mandate verifier. + * The Direct Checkout Mandate verifier: mandate signature first, then the + * merchant checkout JWT it binds. Either stage failing is a refusal. * - * Runs the two stages in the one order they work in: the mandate's own - * signature first, then the merchant checkout JWT it binds. A failure at - * either stage is a refusal, and there is no partial result, because "the - * mandate verified but the checkout document did not" authorises nothing. - * - * What this proves is narrow, and worth stating so nobody reads more into it: - * a trusted issuer signed this mandate, it has not expired, it is addressed to - * us, and it binds a checkout document the merchant really signed. It does NOT - * prove the mandate authorises the purchase in front of us. That comparison - * runs the checkout profile against the resolved resource, input and price, - * and it is a separate step. A caller treating this result as permission to + * What this proves is narrow. A trusted issuer signed this mandate, it has not + * expired, it is addressed to us, and it binds a checkout document the + * merchant signed. It does NOT prove the mandate authorises the purchase in + * front of us: that is profile.ts, and a caller treating this as permission to * settle has skipped it. */ import type { Clock } from '../../core/index.js'; @@ -28,14 +22,22 @@ export interface Ap2VerifierOptions { export interface Ap2MandateVerifier { verify(presentation: string, context?: Ap2ErrorContext): Promise; - /** Trusted issuer ids, for `doctor`. Counts and names only, never keys. */ + /** Trusted issuer ids, for `doctor`. Counts and names only, never keys */ trustedIssuers(): { readonly mandate: readonly string[]; readonly checkout: readonly string[] }; } +/** + * Algorithm-prefixed so a future digest change is visible in stored references + * rather than silently producing unequal values for one mandate + */ +async function mandateReference(signedToken: string): Promise { + const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(signedToken)); + return `sha256:${Buffer.from(digest).toString('base64url')}`; +} + export function createAp2MandateVerifier(options: Ap2VerifierOptions): Ap2MandateVerifier { - // Two stores, not one shared list. A party trusted to sign checkout - // documents is not thereby trusted to issue mandates, and merging the lists - // would silently grant each the other's authority. + // Two stores, not one list: signing the merchant's checkout documents must + // not confer the power to issue mandates const mandateTrust = createTrustStore(options.config.trust.mandateIssuers); const checkoutTrust = createTrustStore(options.config.trust.checkoutIssuers); const deps = { clock: options.clock, clockSkewSeconds: options.config.clockSkewSeconds }; @@ -57,6 +59,7 @@ export function createAp2MandateVerifier(options: Ap2VerifierOptions): Ap2Mandat ); return { + reference: await mandateReference(mandate.signedToken), mandateIssuer: mandate.issuer, checkoutIssuer: checkout.issuer, checkoutJwtId: checkout.jwtId, diff --git a/tests/unit/authorization-ap2/fixtures.ts b/tests/unit/authorization-ap2/fixtures.ts index 3eb6c58..f752cde 100644 --- a/tests/unit/authorization-ap2/fixtures.ts +++ b/tests/unit/authorization-ap2/fixtures.ts @@ -1,20 +1,14 @@ /** * Builds AP2 Direct Checkout Mandate presentations for the verifier tests. * - * PROVENANCE, because it decides what these tests are worth. These are not - * golden vectors from the AP2 repository. They are built here to the v0.2.0 - * closed Checkout Mandate shape (`vct` `mandate.checkout.1`, a `checkout_hash` - * over the exact compact checkout JWT, SD-JWT disclosures under `_sd` with - * `_sd_alg` sha-256), with real ES256 keys and real signatures from `jose`. + * PROVENANCE: these are NOT golden vectors from the AP2 repository. They are + * built here to the v0.2.0 closed Checkout Mandate shape, with real ES256 keys + * and real signatures from `jose`. So they show the verifier enforces the + * rules as this repository reads them; they do not show interoperability with + * a mandate the reference implementation minted. Upstream vectors, with the + * commit recorded, belong here before anyone calls this stable. * - * So they show the verifier enforces the rules as this repository reads them. - * They do not show interoperability with a mandate minted by the reference - * implementation. Vectors generated from upstream, with the commit recorded, - * are what would show that, and they belong here before anyone calls this - * feature stable. - * - * Keys are generated per test run and never written down. Nothing here signs - * anything outside the test process. + * Keys are generated per run and never written down. */ import { exportJWK, generateKeyPair, SignJWT } from 'jose'; import { @@ -28,11 +22,11 @@ export const MANDATE_AUDIENCE = 'merchant.example'; export const CHECKOUT_ISSUER = 'https://merchant.example'; export const CHECKOUT_AUDIENCE = 'agent-commerce'; -/** Fixed instant every fixture is minted against, so nothing races a real clock. */ +/** Fixed instant every fixture is minted against, so nothing races a real clock */ export const NOW = new Date('2026-09-14T12:00:00.000Z'); const NOW_SECONDS = Math.floor(NOW.getTime() / 1000); -/** `CryptoKey` is a DOM type and server code here does not load the DOM lib. */ +// `CryptoKey` is a DOM type and server code here does not load the DOM lib type PrivateKey = Awaited>['privateKey']; export interface SigningIdentity { @@ -76,15 +70,15 @@ async function sha256(input: string): Promise { return base64url(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(input))); } -/** base64url(SHA-256(utf8)), the digest AP2 uses everywhere. */ +/** base64url(SHA-256(utf8)), the digest AP2 uses everywhere */ export const sha256Base64url = sha256; -/** One SD-JWT disclosure for an object property: `[salt, name, value]`. */ +/** One SD-JWT disclosure for an object property: `[salt, name, value]` */ export function disclosure(salt: string, name: string, value: unknown): string { return Buffer.from(JSON.stringify([salt, name, value]), 'utf8').toString('base64url'); } -/** The Agent Commerce checkout profile payload, as the plan specifies it. */ +/** The Agent Commerce checkout profile payload, as the plan specifies it */ export function checkoutPayload(overrides: Record = {}): Record { return { iss: CHECKOUT_ISSUER, @@ -115,26 +109,69 @@ export async function signCheckoutJwt( } export interface MandateOptions { - /** Replaces the compact checkout JWT after `checkout_hash` has been computed. */ + /** Replaces the compact checkout JWT after `checkout_hash` has been computed */ readonly checkoutJwtOverride?: string; readonly payloadOverrides?: Record; readonly header?: Record; - /** Extra disclosure strings appended to the presentation. */ + /** Extra disclosure strings appended to the presentation */ readonly extraDisclosures?: readonly string[]; - /** Omit the disclosure that carries the checkout JWT. */ + /** Extra claims committed to in `_sd`, disclosable independently */ + readonly disclosable?: Readonly>; + /** Which of {@link MandateOptions.disclosable} to actually present */ + readonly present?: readonly string[]; + /** Omit the disclosure that carries the checkout JWT */ readonly withholdCheckoutDisclosure?: boolean; } +/** + * A signed mandate and its disclosures, kept apart. ES256 uses a fresh nonce + * per signature, so a test needing ONE mandate presented two ways must mint + * once and vary the disclosures afterwards. + */ +export interface MandateParts { + readonly signedToken: string; + readonly checkoutDisclosure: string; + /** Encoded disclosure per optional claim name */ + readonly optional: Readonly>; +} + +/** Joins a signed token and a chosen set of disclosures into a presentation */ +export function assemblePresentation(signedToken: string, disclosures: readonly string[]): string { + return `${signedToken}~${disclosures.map((d) => `${d}~`).join('')}`; +} + /** * Mints a closed Checkout Mandate presentation carrying `checkout_jwt` as a - * selectively disclosed claim, which is the shape a Direct presentation takes. + * selectively disclosed claim, which is the shape a Direct presentation takes */ export async function mintMandate( mandateSigner: SigningIdentity, checkoutJwt: string, options: MandateOptions = {}, ): Promise { + const parts = await mintMandateParts(mandateSigner, checkoutJwt, options); + const presented = [ + ...(options.withholdCheckoutDisclosure ? [] : [parts.checkoutDisclosure]), + ...Object.entries(parts.optional) + .filter(([name]) => options.present === undefined || options.present.includes(name)) + .map(([, encoded]) => encoded), + ...(options.extraDisclosures ?? []), + ]; + return assemblePresentation(parts.signedToken, presented); +} + +/** The same mandate, handed back unassembled */ +export async function mintMandateParts( + mandateSigner: SigningIdentity, + checkoutJwt: string, + options: MandateOptions = {}, +): Promise { const checkoutDisclosure = disclosure('salt-checkout', 'checkout_jwt', checkoutJwt); + const optional = Object.entries(options.disclosable ?? {}).map(([name, value]) => ({ + name, + encoded: disclosure(`salt-${name}`, name, value), + })); + const optionalDigests = await Promise.all(optional.map((entry) => sha256(entry.encoded))); const payload: Record = { vct: AP2_CHECKOUT_MANDATE_VCT, iss: MANDATE_ISSUER, @@ -143,7 +180,7 @@ export async function mintMandate( exp: NOW_SECONDS + 300, checkout_hash: await sha256(checkoutJwt), _sd_alg: 'sha-256', - _sd: [await sha256(checkoutDisclosure)], + _sd: [await sha256(checkoutDisclosure), ...optionalDigests], ...options.payloadOverrides, }; @@ -151,17 +188,14 @@ export async function mintMandate( .setProtectedHeader({ alg: 'ES256', kid: mandateSigner.kid, ...options.header }) .sign(mandateSigner.privateKey); - const presented = [ - ...(options.withholdCheckoutDisclosure - ? [] - : [ - options.checkoutJwtOverride !== undefined - ? disclosure('salt-checkout', 'checkout_jwt', options.checkoutJwtOverride) - : checkoutDisclosure, - ]), - ...(options.extraDisclosures ?? []), - ]; - return `${jws}~${presented.map((d) => `${d}~`).join('')}`; + return { + signedToken: jws, + checkoutDisclosure: + options.checkoutJwtOverride !== undefined + ? disclosure('salt-checkout', 'checkout_jwt', options.checkoutJwtOverride) + : checkoutDisclosure, + optional: Object.fromEntries(optional.map((entry) => [entry.name, entry.encoded])), + }; } export interface Party { @@ -172,7 +206,7 @@ export interface Party { readonly checkoutIssuers: readonly Ap2TrustedIssuer[]; } -/** Key generation is the slow part, so a suite builds this once. */ +/** Key generation is the slow part, so a suite builds this once */ export async function createParties(): Promise { const [mandateSigner, checkoutSigner, stranger] = await Promise.all([ identity('mandate-key-2026-01'), @@ -188,7 +222,7 @@ export async function createParties(): Promise { }; } -/** A `Clock` pinned to {@link NOW}, or to an offset from it. */ +/** A `Clock` pinned to {@link NOW}, or to an offset from it */ export function fixedClock(at: Date = NOW) { return { now: () => at, diff --git a/tests/unit/authorization-ap2/purchase-binding.test.ts b/tests/unit/authorization-ap2/purchase-binding.test.ts new file mode 100644 index 0000000..da1ef3c --- /dev/null +++ b/tests/unit/authorization-ap2/purchase-binding.test.ts @@ -0,0 +1,369 @@ +/** + * Binding a verified mandate to the purchase in front of us, and spending it + * exactly once. + * + * The verifier proves a mandate is genuine, which on its own authorises + * nothing: a genuine mandate for a $0.01 report would unlock a $500 one. These + * own the comparison that stops that, and the reservation that stops one + * approval paying twice. + */ +import { beforeAll, describe, expect, it } from 'vitest'; +import { AP2_CHECKOUT_PROFILE } from '../../../src/authorization/ap2/constants.js'; +import { bindMandateToPurchase, computeInputHash } from '../../../src/authorization/ap2/profile.js'; +import { createAp2ReplayStore } from '../../../src/authorization/ap2/replay-store.js'; +import { + type Ap2MandateVerifier, + createAp2MandateVerifier, +} from '../../../src/authorization/ap2/verifier.js'; +import type { + AuthorizationVerificationContext, + CommerceError, + PaymentRequirement, +} from '../../../src/core/index.js'; +import { isCommerceError } from '../../../src/core/index.js'; +import { + assemblePresentation, + checkoutPayload, + createParties, + fixedClock, + mintMandate, + mintMandateParts, + type Party, + signCheckoutJwt, +} from './fixtures.js'; + +const RESOURCE_ID = 'market_report'; +const INPUT = { city: 'Berlin', detail: { depth: 2, tags: ['a', 'b'] } }; + +let parties: Party; +let verifier: Ap2MandateVerifier; +let inputHash: string; + +function requirement(overrides: Partial = {}): PaymentRequirement { + return { + id: 'pr-1', + requestId: 'req-1', + resourceId: RESOURCE_ID, + provider: 'x402', + amount: '0.01', + currency: 'USDC', + destination: '0x70997970C51812dc3A010C7d01b50e0d17dc79C8', + network: 'eip155:84532', + asset: '0x1111111111111111111111111111111111111111', + challenge: { provider: 'x402', version: '2', accepts: [] }, + ...overrides, + }; +} + +// A requirement for a rail with no chain coordinates at all +function requirementWithoutCoordinates(): PaymentRequirement { + const { network: _n, asset: _a, ...rest } = requirement(); + return rest; +} + +function context( + overrides: Partial = {}, +): AuthorizationVerificationContext { + return { + requestId: 'req-1', + resourceId: RESOURCE_ID, + input: INPUT, + submission: { method: 'ap2', payload: 'unused-here' }, + requirement: requirement(), + ...overrides, + }; +} + +// The checkout profile a correctly minted mandate carries for this purchase +function profileClaims(overrides: Record = {}): Record { + return { + profile: AP2_CHECKOUT_PROFILE, + resource_id: RESOURCE_ID, + input_hash: inputHash, + amount: '0.01', + currency: 'USDC', + payment_method: 'x402', + destination: '0x70997970C51812dc3A010C7d01b50e0d17dc79C8', + network: 'eip155:84532', + asset: '0x1111111111111111111111111111111111111111', + ...overrides, + }; +} + +// Mints a mandate whose checkout JWT carries `agent_commerce` +async function mandateFor(profile: Record): Promise { + const jwt = await signCheckoutJwt( + parties.checkoutSigner, + checkoutPayload({ agent_commerce: profile }), + ); + return mintMandate(parties.mandateSigner, jwt); +} + +async function bindRejection( + presentation: string, + ctx: AuthorizationVerificationContext = context(), +): Promise { + const verified = await verifier.verify(presentation); + try { + await bindMandateToPurchase(verified, ctx, {}); + } catch (error) { + expect(isCommerceError(error)).toBe(true); + return error as CommerceError; + } + return expect.unreachable('expected the mandate to be refused') as never; +} + +beforeAll(async () => { + parties = await createParties(); + inputHash = await computeInputHash(INPUT); + verifier = createAp2MandateVerifier({ + config: { + enabled: true, + specVersion: '0.2.0', + mode: 'direct', + trust: { mandateIssuers: parties.mandateIssuers, checkoutIssuers: parties.checkoutIssuers }, + clockSkewSeconds: 60, + replay: { path: ':memory:' }, + }, + clock: fixedClock(), + }); +}); + +describe('the RFC 8785 input hash', () => { + it('does not depend on the order keys were written in', async () => { + // Their signer hashed the buyer's request, we hash what arrived. Same + // content in a different key order is the same request. + const a = await computeInputHash({ city: 'Berlin', depth: 2 }); + const b = await computeInputHash({ depth: 2, city: 'Berlin' }); + expect(a).toBe(b); + }); + + it('is stable through nesting', async () => { + const a = await computeInputHash({ outer: { x: 1, y: { p: 'a', q: 'b' } } }); + const b = await computeInputHash({ outer: { y: { q: 'b', p: 'a' }, x: 1 } }); + expect(a).toBe(b); + }); + + it('does depend on array order, because a reordered list is a different request', async () => { + const a = await computeInputHash({ tags: ['a', 'b'] }); + const b = await computeInputHash({ tags: ['b', 'a'] }); + expect(a).not.toBe(b); + }); + + it.each([ + ['a changed value', { city: 'Paris' }], + ['an added field', { city: 'Berlin', extra: 1 }], + ['a number where a string was', { city: 1 }], + ['an empty object', {}], + ])('changes for %s', async (_label, input) => { + expect(await computeInputHash(input)).not.toBe(await computeInputHash({ city: 'Berlin' })); + }); + + it('treats absent input as the empty object rather than failing', async () => { + expect(await computeInputHash(undefined)).toBe(await computeInputHash({})); + }); +}); + +describe('binding a mandate to the resolved purchase', () => { + it('accepts a mandate that authorises exactly this purchase', async () => { + const verified = await verifier.verify(await mandateFor(profileClaims())); + await expect(bindMandateToPurchase(verified, context(), {})).resolves.toEqual({ + resourceId: RESOURCE_ID, + amount: '0.01', + currency: 'USDC', + paymentMethod: 'x402', + }); + }); + + it.each([ + ['a different profile', { profile: 'agent-commerce/ap2/checkout/v2' }], + ['a different resource', { resource_id: 'weather_basic' }], + ['a different input', { input_hash: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' }], + ['a different amount', { amount: '500.00' }], + ['the same amount written differently', { amount: '0.010' }], + ['a different currency', { currency: 'EUR' }], + ['a different payment method', { payment_method: 'card' }], + ['a different destination', { destination: '0x000000000000000000000000000000000000dEaD' }], + ['a different network', { network: 'eip155:8453' }], + ['a different asset', { asset: '0x2222222222222222222222222222222222222222' }], + ])('refuses one carrying %s', async (_label, override) => { + const error = await bindRejection(await mandateFor(profileClaims(override))); + expect(error.code).toBe('AUTHORIZATION_INVALID'); + expect(error.details?.['reason']).toBe('purchase_mismatch'); + }); + + it.each(['profile', 'resource_id', 'input_hash', 'amount', 'currency', 'payment_method'])( + 'refuses one that omits %s rather than skipping the check', + async (claim) => { + const claims = profileClaims(); + delete claims[claim]; + await bindRejection(await mandateFor(claims)); + }, + ); + + it('refuses a mandate with no checkout profile at all', async () => { + const jwt = await signCheckoutJwt(parties.checkoutSigner, checkoutPayload()); + const presentation = await mintMandate(parties.mandateSigner, jwt); + // The default fixture profile has a placeholder input hash, so this is + // also the "mandate for some other request" case + await bindRejection(presentation); + }); + + it('refuses a profile that is not an object', async () => { + const jwt = await signCheckoutJwt( + parties.checkoutSigner, + checkoutPayload({ agent_commerce: 'agent-commerce/ap2/checkout/v1' }), + ); + await bindRejection(await mintMandate(parties.mandateSigner, jwt)); + }); + + it('refuses a mandate silent about the chain when the requirement names one', async () => { + // A mandate that does not say which chain it authorises must not unlock a + // mainnet settlement + const claims = profileClaims(); + delete claims['network']; + await bindRejection(await mandateFor(claims)); + }); + + it('refuses a mandate naming a chain when the requirement has none', async () => { + const ctx = context({ requirement: requirementWithoutCoordinates() }); + await bindRejection(await mandateFor(profileClaims()), ctx); + }); + + it('accepts when neither side names settlement coordinates', async () => { + const claims = profileClaims(); + for (const key of ['network', 'asset']) delete claims[key]; + const ctx = context({ requirement: requirementWithoutCoordinates() }); + const verified = await verifier.verify(await mandateFor(claims)); + await expect(bindMandateToPurchase(verified, ctx, {})).resolves.toBeDefined(); + }); + + it('does not report which field disagreed', async () => { + // Asking one field at a time reads a mandate out by elimination + const error = await bindRejection(await mandateFor(profileClaims({ amount: '500.00' }))); + const onTheWire = JSON.stringify(error.toInfo()); + expect(onTheWire).not.toContain('500.00'); + expect(onTheWire).not.toContain('amount'); + }); +}); + +describe('the replay identity of a mandate', () => { + it('is the same however the mandate is presented', async () => { + // The defect this design exists to avoid: keying replay on the + // presentation string gives each disclosed subset its own identity, so one + // approval could be spent once per subset + const jwt = await signCheckoutJwt( + parties.checkoutSigner, + checkoutPayload({ agent_commerce: profileClaims() }), + ); + // Minted ONCE, presented two ways. Signing twice gives two different + // tokens (fresh ES256 nonce) and would prove nothing. + const parts = await mintMandateParts(parties.mandateSigner, jwt, { + disclosable: { buyer_note: 'hello' }, + }); + const withNote = assemblePresentation(parts.signedToken, [ + parts.checkoutDisclosure, + parts.optional['buyer_note'] as string, + ]); + const withoutNote = assemblePresentation(parts.signedToken, [parts.checkoutDisclosure]); + + expect(withNote).not.toBe(withoutNote); + const a = await verifier.verify(withNote); + const b = await verifier.verify(withoutNote); + expect(a.reference).toBe(b.reference); + // And the presentations really did differ in what they disclosed + expect(a.mandateClaims['buyer_note']).toBe('hello'); + expect(b.mandateClaims['buyer_note']).toBeUndefined(); + }); + + it('is a digest, carrying nothing readable from the mandate', async () => { + const verified = await verifier.verify(await mandateFor(profileClaims())); + expect(verified.reference).toMatch(/^sha256:[A-Za-z0-9_-]{43}$/); + }); + + it('differs between two mandates', async () => { + const first = await verifier.verify(await mandateFor(profileClaims())); + const second = await verifier.verify( + await mandateFor(profileClaims({ input_hash: await computeInputHash({ city: 'Paris' }) })), + ); + expect(first.reference).not.toBe(second.reference); + }); +}); + +describe('verify, bind and reserve together', () => { + it('spends a mandate once and refuses every later presentation of it', async () => { + const store = createAp2ReplayStore({ path: ':memory:' }); + const presentation = await mandateFor(profileClaims()); + + const verified = await verifier.verify(presentation); + await bindMandateToPurchase(verified, context(), {}); + const first = store.reserve({ + reference: verified.reference, + checkoutJti: verified.checkoutJwtId, + mandateIssuer: verified.mandateIssuer, + checkoutIssuer: verified.checkoutIssuer, + resourceId: RESOURCE_ID, + requestId: 'req-1', + }); + expect(first).toEqual({ kind: 'reserved' }); + store.consume(verified.reference); + + // Same mandate, second request: still valid, still bound, still refused + const again = await verifier.verify(presentation); + expect(again.reference).toBe(verified.reference); + await expect(bindMandateToPurchase(again, context(), {})).resolves.toBeDefined(); + expect( + store.reserve({ + reference: again.reference, + checkoutJti: again.checkoutJwtId, + mandateIssuer: again.mandateIssuer, + checkoutIssuer: again.checkoutIssuer, + resourceId: RESOURCE_ID, + requestId: 'req-2', + }), + ).toEqual({ kind: 'replayed', state: 'consumed' }); + + store.close(); + }); + + it('refuses a re-presented mandate even when disclosed differently', async () => { + const store = createAp2ReplayStore({ path: ':memory:' }); + const jwt = await signCheckoutJwt( + parties.checkoutSigner, + checkoutPayload({ agent_commerce: profileClaims() }), + ); + const parts = await mintMandateParts(parties.mandateSigner, jwt, { + disclosable: { buyer_note: 'hello' }, + }); + const full = assemblePresentation(parts.signedToken, [ + parts.checkoutDisclosure, + parts.optional['buyer_note'] as string, + ]); + const trimmed = assemblePresentation(parts.signedToken, [parts.checkoutDisclosure]); + + const a = await verifier.verify(full); + store.reserve({ + reference: a.reference, + checkoutJti: a.checkoutJwtId, + mandateIssuer: a.mandateIssuer, + checkoutIssuer: a.checkoutIssuer, + resourceId: RESOURCE_ID, + requestId: 'req-1', + }); + store.consume(a.reference); + + const b = await verifier.verify(trimmed); + expect( + store.reserve({ + reference: b.reference, + checkoutJti: b.checkoutJwtId, + mandateIssuer: b.mandateIssuer, + checkoutIssuer: b.checkoutIssuer, + resourceId: RESOURCE_ID, + requestId: 'req-2', + }), + ).toEqual({ kind: 'replayed', state: 'consumed' }); + + store.close(); + }); +}); diff --git a/tests/unit/authorization-ap2/replay-store.test.ts b/tests/unit/authorization-ap2/replay-store.test.ts new file mode 100644 index 0000000..6c7fc5a --- /dev/null +++ b/tests/unit/authorization-ap2/replay-store.test.ts @@ -0,0 +1,217 @@ +/** + * One mandate authorises one settlement. These are the ways a second could be + * got out of the same approval, and the state machine that refuses them. + */ +import { existsSync, mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import Database from 'better-sqlite3'; +import { afterAll, beforeEach, describe, expect, it } from 'vitest'; +import { + type Ap2ReplayStore, + type Ap2ReservationRequest, + createAp2ReplayStore, +} from '../../../src/authorization/ap2/replay-store.js'; + +const scratch = mkdtempSync(join(tmpdir(), 'ap2-replay-')); +afterAll(() => rmSync(scratch, { recursive: true, force: true })); + +function request(overrides: Partial = {}): Ap2ReservationRequest { + return { + reference: 'sha256:AAAA', + checkoutJti: 'checkout_01', + mandateIssuer: 'https://trusted-surface.example', + checkoutIssuer: 'https://merchant.example', + resourceId: 'market_report', + requestId: 'req-1', + ...overrides, + }; +} + +let store: Ap2ReplayStore; +beforeEach(() => { + store = createAp2ReplayStore({ path: ':memory:' }); +}); + +describe('reserving', () => { + it('accepts a mandate never seen before', () => { + expect(store.reserve(request())).toEqual({ kind: 'reserved' }); + expect(store.stateOf('sha256:AAAA')).toBe('reserved'); + }); + + it('refuses the same mandate while a first reservation is still open', () => { + store.reserve(request()); + expect(store.reserve(request({ requestId: 'req-2' }))).toEqual({ + kind: 'replayed', + state: 'reserved', + }); + }); + + it('refuses a mandate that has already been spent', () => { + store.reserve(request()); + store.consume('sha256:AAAA'); + expect(store.reserve(request({ requestId: 'req-2' }))).toEqual({ + kind: 'replayed', + state: 'consumed', + }); + }); + + it('refuses a mandate whose settlement outcome was never learned', () => { + // The buyer may well have paid. Handing it back risks a second payment + // for one approval, which is worth refusing a legitimate retry to avoid. + store.reserve(request()); + store.markUncertain('sha256:AAAA'); + expect(store.reserve(request({ requestId: 'req-2' }))).toEqual({ + kind: 'replayed', + state: 'uncertain', + }); + }); + + it('lets a released mandate be presented again, which is the retry path', () => { + store.reserve(request()); + store.release('sha256:AAAA'); + expect(store.reserve(request({ requestId: 'req-2' }))).toEqual({ kind: 'reserved' }); + expect(store.stateOf('sha256:AAAA')).toBe('reserved'); + }); + + it('refuses a different mandate that binds a checkout already in flight', () => { + // Two mandates, one checkout document: different references, so the + // reference alone would let the second through + store.reserve(request()); + expect(store.reserve(request({ reference: 'sha256:BBBB', requestId: 'req-2' }))).toEqual({ + kind: 'replayed', + state: 'reserved', + }); + }); + + it('refuses a different mandate binding a checkout that was already spent', () => { + store.reserve(request()); + store.consume('sha256:AAAA'); + expect(store.reserve(request({ reference: 'sha256:BBBB', requestId: 'req-2' }))).toEqual({ + kind: 'replayed', + state: 'consumed', + }); + }); + + it('allows a different mandate for a checkout whose reservation was released', () => { + store.reserve(request()); + store.release('sha256:AAAA'); + expect(store.reserve(request({ reference: 'sha256:BBBB', requestId: 'req-2' }))).toEqual({ + kind: 'reserved', + }); + }); + + it('keeps unrelated mandates independent', () => { + expect(store.reserve(request())).toEqual({ kind: 'reserved' }); + expect( + store.reserve( + request({ reference: 'sha256:CCCC', checkoutJti: 'checkout_02', requestId: 'req-2' }), + ), + ).toEqual({ kind: 'reserved' }); + }); +}); + +describe('finalising', () => { + it('never lets a consumed mandate be released back into circulation', () => { + // Stops a backend failure after settlement handing back a spent mandate + store.reserve(request()); + store.consume('sha256:AAAA'); + store.release('sha256:AAAA'); + expect(store.stateOf('sha256:AAAA')).toBe('consumed'); + }); + + it('never lets an uncertain mandate be released', () => { + store.reserve(request()); + store.markUncertain('sha256:AAAA'); + store.release('sha256:AAAA'); + expect(store.stateOf('sha256:AAAA')).toBe('uncertain'); + }); + + it('never lets a released mandate be consumed without a fresh reservation', () => { + store.reserve(request()); + store.release('sha256:AAAA'); + store.consume('sha256:AAAA'); + expect(store.stateOf('sha256:AAAA')).toBe('released'); + }); + + it('ignores a finalise for a mandate nobody reserved', () => { + store.consume('sha256:NEVER'); + expect(store.stateOf('sha256:NEVER')).toBeUndefined(); + }); +}); + +describe('durability', () => { + it('still refuses a consumed mandate after the database is reopened', () => { + const path = join(scratch, 'reopen.sqlite'); + const first = createAp2ReplayStore({ path }); + first.reserve(request()); + first.consume('sha256:AAAA'); + first.close(); + + const second = createAp2ReplayStore({ path }); + expect(second.stateOf('sha256:AAAA')).toBe('consumed'); + expect(second.reserve(request({ requestId: 'req-2' }))).toEqual({ + kind: 'replayed', + state: 'consumed', + }); + second.close(); + expect(existsSync(path)).toBe(true); + }); + + it('reopens an existing file without re-running the migration', () => { + const path = join(scratch, 'migrate-once.sqlite'); + const first = createAp2ReplayStore({ path }); + first.reserve(request()); + first.close(); + const second = createAp2ReplayStore({ path }); + expect(second.stateOf('sha256:AAAA')).toBe('reserved'); + second.close(); + }); +}); + +describe('what is written down', () => { + it('stores identifiers and a digest, never the mandate itself', () => { + // A leaked database must not hand anyone a mandate, its disclosures, the + // checkout JWT, or anything about the buyer + const path = join(scratch, 'contents.sqlite'); + const s = createAp2ReplayStore({ path }); + s.reserve(request()); + s.close(); + + const db = new Database(path); + const columns = ( + db.prepare('SELECT name FROM pragma_table_info(?)').all('ap2_authorizations') as { + name: string; + }[] + ).map((c) => c.name); + db.close(); + + expect(columns.sort()).toEqual([ + 'checkout_issuer', + 'checkout_jti', + 'created_at', + 'mandate_issuer', + 'reference', + 'request_id', + 'resource_id', + 'state', + 'updated_at', + ]); + for (const forbidden of ['mandate', 'presentation', 'checkout_jwt', 'disclosure', 'payload']) { + expect(columns).not.toContain(forbidden); + } + }); + + it('rejects a state the schema does not know', () => { + const path = join(scratch, 'check.sqlite'); + const s = createAp2ReplayStore({ path }); + s.reserve(request()); + s.close(); + + const db = new Database(path); + expect(() => + db.prepare("UPDATE ap2_authorizations SET state = 'spent-ish'").run(), + ).toThrowError(/CHECK/i); + db.close(); + }); +}); diff --git a/tests/unit/authorization-ap2/verifier.test.ts b/tests/unit/authorization-ap2/verifier.test.ts index c95e6f5..9c0c07c 100644 --- a/tests/unit/authorization-ap2/verifier.test.ts +++ b/tests/unit/authorization-ap2/verifier.test.ts @@ -1,14 +1,10 @@ /** - * The Direct Checkout Mandate verifier, exercised with real ES256 signatures. + * The Direct Checkout Mandate verifier, with real ES256 signatures. See + * fixtures.ts for what these vectors are and are not. * - * See fixtures.ts for what these vectors are and, more importantly, what they - * are not: mandates built to the v0.2.0 shape by this repository, not golden - * vectors from the reference implementation. - * - * Every negative case asserts the error CODE as well as the rejection, because - * the one thing this feature must never do is report a bad mandate as a - * payment problem. A 402 tells an auto-paying client to spend money on a - * request that was never going to be delivered. + * Every negative case asserts the error CODE too: reporting a bad mandate as a + * payment problem would tell an auto-paying client to spend money on a request + * that was never going to be delivered. */ import { beforeAll, describe, expect, it } from 'vitest'; import type { EnabledAp2Config } from '../../../src/authorization/ap2/types.js'; @@ -55,7 +51,7 @@ function verifierFor(config: EnabledAp2Config, at: Date = NOW): Ap2MandateVerifi return createAp2MandateVerifier({ config, clock: fixedClock(at) }); } -/** Returns the CommerceError a rejected verification produced. */ +// Returns the CommerceError a rejected verification produced async function rejection(run: Promise): Promise { try { await run; @@ -66,7 +62,7 @@ async function rejection(run: Promise): Promise { return expect.unreachable('expected the mandate to be refused') as never; } -/** Every refusal here must be an authorization failure, never a payment one. */ +// Every refusal here must be an authorization failure, never a payment one async function expectRefused(run: Promise, reason?: string): Promise { const error = await rejection(run); expect(error.code).toBe('AUTHORIZATION_INVALID'); @@ -139,7 +135,7 @@ describe('malformed presentations', () => { it('refuses a disclosure appended that no digest in the payload references', async () => { // The forged-claim attack: append `[salt, "amount", "0.01"]` and hope the - // verifier merges it in without checking it was ever committed to. + // verifier merges it in without checking it was ever committed to const forged = disclosure('salt-forged', 'amount', '0.01'); const presentation = await mintMandate(parties.mandateSigner, checkoutJwt, { extraDisclosures: [forged], @@ -197,9 +193,8 @@ describe('signature and trust', () => { }); it('refuses a mandate signed by a key that is trusted for checkout documents only', async () => { - // Key confusion across the two trust lists. Being allowed to sign the - // merchant's own checkout documents must not confer the power to issue - // mandates authorising purchases from them. + // Signing the merchant's checkout documents must not confer the power to + // issue mandates authorising purchases from them const presentation = await mintMandate(parties.checkoutSigner, checkoutJwt, { header: { kid: parties.mandateSigner.kid }, }); @@ -231,10 +226,9 @@ describe('signature and trust', () => { }); it('refuses HS256 forged against the public key', async () => { - // The classic confusion: take the public EC key, treat it as an HMAC - // secret, and sign. Refused twice over - by the algorithm allowlist and by - // the key being an EC key that cannot do HMAC - and this asserts the - // outcome rather than which of the two got there first. + // Take the public EC key, treat it as an HMAC secret, sign. Refused twice + // over (allowlist, and an EC key cannot do HMAC); this asserts the + // outcome, not which one got there first. const header = Buffer.from( JSON.stringify({ alg: 'HS256', kid: parties.mandateSigner.kid }), ).toString('base64url'); @@ -324,8 +318,8 @@ describe('the merchant checkout JWT', () => { }); it('refuses a swapped checkout JWT, caught by checkout_hash', async () => { - // A genuine, correctly signed merchant document - for a different - // purchase. The signature verifies; the hash the buyer approved does not. + // A genuine merchant document, for a different purchase: the signature + // verifies, the hash the buyer approved does not const other = await signCheckoutJwt( parties.checkoutSigner, checkoutPayload({ jti: 'checkout_OTHER' }), @@ -398,7 +392,7 @@ describe('the merchant checkout JWT', () => { }); it('refuses a mandate and a checkout JWT that are each valid but unrelated', async () => { - // Both documents genuine, neither binding the other. + // Both documents genuine, neither binding the other const unrelated = await signCheckoutJwt( parties.checkoutSigner, checkoutPayload({ jti: 'checkout_UNRELATED' }), @@ -430,9 +424,8 @@ describe('error reporting', () => { }); it('reports a broken configured key as our fault, not the buyer', async () => { - // The config loader would refuse this key, so reaching the verifier with - // one means our deployment is broken. Blaming the payer would burn a - // mandate that is very likely fine. + // Config would refuse this key, so reaching the verifier means our + // deployment is broken. Blaming the payer burns a mandate that is fine. const broken = configFor(parties, { trust: { mandateIssuers: [ @@ -457,7 +450,7 @@ describe('trust list separation', () => { const config = configFor(parties, { trust: { mandateIssuers: parties.mandateIssuers, - // Only the mandate issuer is trusted for checkout documents now. + // Only the mandate issuer is trusted for checkout documents now checkoutIssuers: [trustedIssuer(MANDATE_ISSUER, CHECKOUT_AUDIENCE, parties.mandateSigner)], }, }); From 2401b76157e90bb7ca0c59568880c9497aa89774 Mon Sep 17 00:00:00 2001 From: Revinand Date: Mon, 14 Sep 2026 20:51:45 +0200 Subject: [PATCH 05/11] feat(pipeline): enforce authorization before payment settlement --- docs/contract-surface.txt | 17 +- docs/contracts.md | 1 + src/core/domain/authorization.ts | 44 +- src/core/domain/event.ts | 4 + src/core/domain/receipt.ts | 6 + src/core/execution/pipeline.ts | 200 +++++- src/core/public-types.ts | 1 + src/storage/receipts/rows.ts | 12 + src/storage/receipts/schema.ts | 8 + src/storage/receipts/store.ts | 4 +- tests/unit/core/execution/helpers.ts | 71 ++ .../execution/pipeline-authorization.test.ts | 622 ++++++++++++++++++ tests/unit/storage-receipts/helpers.ts | 1 + .../unit/storage-receipts/no-secrets.test.ts | 17 + .../storage-receipts/receipts-events.test.ts | 18 +- 15 files changed, 1002 insertions(+), 24 deletions(-) create mode 100644 tests/unit/core/execution/pipeline-authorization.test.ts diff --git a/docs/contract-surface.txt b/docs/contract-surface.txt index bd46b77..1d56cf1 100644 --- a/docs/contract-surface.txt +++ b/docs/contract-surface.txt @@ -1,6 +1,6 @@ # Semantic surface of src/core/public-types.ts # Generated by scripts/contract-surface.mjs — do not edit by hand. -# 85 exported symbols. +# 86 exported symbols. interface AdapterDescriptor { readonly capabilities: ReadonlyArray; @@ -33,12 +33,20 @@ interface AuthorizationFinalizeContext { interface AuthorizationProvider { consume: (reservationId: string, context: AuthorizationFinalizeContext) => Promise; health: () => Promise; + markUncertain: (reservationId: string, context: AuthorizationFinalizeContext) => Promise; readonly descriptor: AdapterDescriptor; readonly name: "ap2"; + readonly requirement: AuthorizationRequirement; release: (reservationId: string, context: AuthorizationFinalizeContext) => Promise; verifyAndReserve: (context: AuthorizationVerificationContext) => Promise; } +interface AuthorizationRecord { + readonly metadata?: Readonly>; + readonly method: "ap2"; + readonly reference: string; + } + interface AuthorizationRequirement { readonly method: "ap2"; readonly profile?: string; @@ -137,10 +145,11 @@ interface CommerceEvent { readonly requestId: string; readonly resourceId?: string; readonly status?: "ok" | "error"; - readonly type: "resource.discovered" | "resource.requested" | "payment.required" | "payment.rejected" | "payment.verified" | "payment.settled" | "backend.called" | "backend.failed" | "resource.delivered"; + readonly type: "resource.discovered" | "resource.requested" | "payment.required" | "payment.rejected" | "payment.verified" | "payment.settled" | "authorization.verified" | "authorization.rejected" | "backend.called" | "backend.failed" | "resource.delivered"; } interface CommerceReceipt { + readonly authorization?: AuthorizationRecord; readonly backendStatus: number; readonly deliveredAt: string; readonly durationMs?: number; @@ -408,7 +417,7 @@ type BackendMethod = BackendMethod type CommerceErrorCode = "CONFIG_INVALID" | "RESOURCE_NOT_FOUND" | "INPUT_INVALID" | "PAYMENT_REQUIRED" | "PAYMENT_INVALID" | "PAYMENT_REPLAYED" | "PAYMENT_PROVIDER_UNAVAILABLE" | "PAYMENT_SETTLEMENT_FAILED" | "AUTHORIZATION_REQUIRED" | "AUTHORIZATION_INVALID" | "AUTHORIZATION_REPLAYED" | "AUTHORIZATION_PROVIDER_UNAVAILABLE" | "BACKEND_TIMEOUT" | "BACKEND_ERROR" | "PROTOCOL_UNSUPPORTED" | "GATEWAY_BUSY" | "STORAGE_ERROR" | "INTERNAL_ERROR" -type CommerceEventType = "resource.discovered" | "resource.requested" | "payment.required" | "payment.rejected" | "payment.verified" | "payment.settled" | "backend.called" | "backend.failed" | "resource.delivered" +type CommerceEventType = "resource.discovered" | "resource.requested" | "payment.required" | "payment.rejected" | "payment.verified" | "payment.settled" | "authorization.verified" | "authorization.rejected" | "backend.called" | "backend.failed" | "resource.delivered" type DecimalAmount = string @@ -432,7 +441,7 @@ value COMMERCE_ERROR_CODES: readonly ["CONFIG_INVALID", "RESOURCE_NOT_FOUND", "I value COMMERCE_ERROR_HTTP_STATUS: Readonly> -value COMMERCE_EVENT_TYPES: readonly ["resource.discovered", "resource.requested", "payment.required", "payment.rejected", "payment.verified", "payment.settled", "backend.called", "backend.failed", "resource.delivered"] +value COMMERCE_EVENT_TYPES: readonly ["resource.discovered", "resource.requested", "payment.required", "payment.rejected", "payment.verified", "payment.settled", "authorization.verified", "authorization.rejected", "backend.called", "backend.failed", "resource.delivered"] value CommerceError: typeof CommerceError diff --git a/docs/contracts.md b/docs/contracts.md index 5a301be..eda7cee 100644 --- a/docs/contracts.md +++ b/docs/contracts.md @@ -88,6 +88,7 @@ the generated file is right and this table is stale. - **Additive:** `ProtocolName` gains `'acp'` (experimental); config gains `protocols.acp` (disabled by default, mount `/acp`) and accepts `expose: [acp]`. *Use case:* the ACP checkout adapter. *Shape:* unlike `mcp`/`a2a`, the normalised `protocols.acp` is discriminated on `enabled` - an enabled block carries `auth`, `idempotency` and all five `checkout.operations` mappings, so the adapter needs no optional-field assertions and a half-configured checkout lifecycle is refused at load rather than advertised through ACP discovery. *Compatibility:* additive union member; a config with no `protocols.acp` block parses unchanged. - **Additive (main entry):** `createAcpAdapter`, `AcpAdapterOptions`, `ACP_SPEC_VERSION`, `ACP_API_VERSION`, `ACP_WELL_KNOWN_PATH`. *Use case:* a consumer running `createGateway` needs the adapter to mount. *Why the main entry and not a subpath:* a subpath is a peer-dependency boundary, not a category - the ACP adapter needs no peer, only `ajv`/`ajv-formats` (real dependencies) and its own vendored schema. *Cost:* the pinned schema is inlined into `dist/index.js` (+~124 kB; package 396 kB -> 479 kB). The CLI bundle is unaffected - `doctor` reads only the ACP constants and descriptor, never the validator. - **Additive:** the generic authorization contract - `AuthorizationMethodName` (`'ap2'`), `AuthorizationSubmission`, `AuthorizationRequirement`, `AuthorizationVerification`, `AuthorizationProvider` and its two contexts; optional `CanonicalRequest.authorization`, optional `CommerceResource.authorization`, optional `PaymentRequiredOutcome.authorization` and the matching `PaymentRequiredEnvelope.authorization`; `AdapterDescriptor.kind` gains `'authorization'`; four `AUTHORIZATION_*` error codes (403 / 403 / 409 / 503, the last retryable); and the wire carriers `AUTHORIZATION_INPUT_FIELD` (`_authorization`), `AUTHORIZATION_HEADER` (`agent-authorization`), `MAX_AUTHORIZATION_HEADER_BYTES` and `RESERVED_INPUT_FIELDS`. *Use case:* AP2 mandate verification - proving the human behind an agent approved this exact purchase, a separate question from whether the payment verified. *Why generic:* AP2 is the first implementation, not the abstraction. Core states that a resource requires authorization and when the pipeline checks it, and knows nothing about SD-JWTs. An authorization method is deliberately neither a `ProtocolName` nor a `PaymentMethodName`, because it is not a transport and must never be selectable as a payment rail. *Compatibility:* every field is optional and every consumer that sets none behaves exactly as before; a resource with no `authorization` policy is unchanged end to end. `extractReservedInputFields` replaces the two hand-written `_payment` extractors in the MCP and A2A adapters with one path in core, so the reserved-field list cannot drift between surfaces. `_payment` handling is byte-identical, including dropping a proof for a resource with no configured rail. +- **Additive:** `AuthorizationRecord`; optional `CommerceReceipt.authorization`; `AuthorizationProvider` gains `requirement` and `markUncertain`; `AuthorizationVerification` now extends `AuthorizationRecord`; `CommerceEventType` gains `authorization.verified` and `authorization.rejected`. *Use case:* the execution pipeline enforcing authorization, in the order payment verify -> authorize/reserve -> payment replay reserve -> settle -> consume/release/mark-uncertain. *Why `requirement` on the provider:* the 402 challenge has to name what the retry must also carry, and only the provider knows its own spec version and payload profile. *Why `markUncertain` rather than leaving a reservation alone:* a settlement that was broadcast but never confirmed must not hand the proof back, and "we did nothing" is indistinguishable from a path that forgot to finalize. *Why the receipt stores a record and not the verification:* `reservationId` is a live handle, not an audit fact, and a stored proof would be a spendable secret at rest. *Compatibility:* `CommerceReceipt.authorization` is optional and absent for every resource that requires no authorization; the receipt store adds schema version 2 (`ALTER TABLE receipts ADD COLUMN authorization_json`), so an existing database keeps its rows. `AuthorizationProvider` is not yet implemented by anything shipped, so the two new members break no consumer. --- # Integration contract - exact factory signatures diff --git a/src/core/domain/authorization.ts b/src/core/domain/authorization.ts index bc97360..4c2d020 100644 --- a/src/core/domain/authorization.ts +++ b/src/core/domain/authorization.ts @@ -47,21 +47,27 @@ export interface AuthorizationRequirement { } /** - * A verified, reserved authorization. - * - * `reference` is a safe stable identity (a digest, never the proof itself) fit - * for a receipt. `reservationId` is the handle the pipeline later consumes or - * releases depending on how settlement went. + * Safe audit identity of an authorization, fit for a receipt. `reference` is a + * digest: a receipt outlives its request, and a stored proof would be a + * spendable secret at rest. */ -export interface AuthorizationVerification { - readonly status: 'verified'; +export interface AuthorizationRecord { readonly method: AuthorizationMethodName; readonly reference: string; - readonly reservationId: string; - /** Safe audit summary only. Never the proof, its disclosures, or PII. */ + /** Safe audit summary only. Never the proof, its disclosures, or PII */ readonly metadata?: Readonly>; } +/** + * A verified, reserved authorization. `reservationId` is the live handle the + * pipeline consumes, releases or marks uncertain once settlement resolves; it + * stays out of the record above because a handle is not an audit fact. + */ +export interface AuthorizationVerification extends AuthorizationRecord { + readonly status: 'verified'; + readonly reservationId: string; +} + /** Input to {@link AuthorizationProvider.verifyAndReserve}. */ export interface AuthorizationVerificationContext { readonly requestId: string; @@ -87,16 +93,18 @@ export interface AuthorizationFinalizeContext { /** * Contract every authorization method implements. * - * The lifecycle is verify -> reserve -> settlement outcome -> consume/release. - * The two halves straddle settlement because a proof has to be reserved - * *before* funds move, so a replay cannot race a settlement, and its fate is - * only known *after*. Releasing is for failures that provably moved no money. - * Anything ambiguous is consumed rather than handed back: an authorization - * handed back after an uncertain settlement can be spent a second time. + * The lifecycle straddles settlement: a proof must be reserved *before* funds + * move so a replay cannot race one, and its fate is only known *after*. + * + * Release only for a failure that provably moved no money. Anything ambiguous + * is marked uncertain: a proof handed back after a settlement that may have + * landed can be spent twice. */ export interface AuthorizationProvider { readonly name: AuthorizationMethodName; readonly descriptor: AdapterDescriptor; + /** What a buyer must present, advertised beside the payment challenge */ + readonly requirement: AuthorizationRequirement; /** * Verify a submission against the resolved purchase and atomically reserve @@ -114,5 +122,11 @@ export interface AuthorizationProvider { /** Return a reservation to unused. Only for failures that moved no funds. */ release(reservationId: string, context: AuthorizationFinalizeContext): Promise; + /** + * Settlement broadcast but never confirmed: neither spend the reservation + * nor hand it back, and flag it for an operator + */ + markUncertain(reservationId: string, context: AuthorizationFinalizeContext): Promise; + health(): Promise; } diff --git a/src/core/domain/event.ts b/src/core/domain/event.ts index db4c6b2..5632ab8 100644 --- a/src/core/domain/event.ts +++ b/src/core/domain/event.ts @@ -13,6 +13,10 @@ export const COMMERCE_EVENT_TYPES = [ 'payment.rejected', 'payment.verified', 'payment.settled', + // The same two types whatever the method, so an audit-trail reader never + // has to know what AP2 is + 'authorization.verified', + 'authorization.rejected', 'backend.called', 'backend.failed', 'resource.delivered', diff --git a/src/core/domain/receipt.ts b/src/core/domain/receipt.ts index ca681e5..ae5bccf 100644 --- a/src/core/domain/receipt.ts +++ b/src/core/domain/receipt.ts @@ -3,6 +3,7 @@ * * FROZEN CONTRACT. */ +import type { AuthorizationRecord } from './authorization.js'; import type { IsoTimestamp } from './common.js'; import type { PaymentResult } from './payment.js'; @@ -12,6 +13,11 @@ export interface CommerceReceipt { readonly resourceId: string; /** Absent for free resources. */ readonly payment?: PaymentResult; + /** + * Present only when the resource required one. A method and a digest, so the + * receipt records that consent existed without storing the proof of it. + */ + readonly authorization?: AuthorizationRecord; readonly deliveredAt: IsoTimestamp; readonly backendStatus: number; readonly durationMs?: number; diff --git a/src/core/execution/pipeline.ts b/src/core/execution/pipeline.ts index ecf1b33..ea18523 100644 --- a/src/core/execution/pipeline.ts +++ b/src/core/execution/pipeline.ts @@ -7,12 +7,18 @@ * 3. resolve price * 4. free -> straight to backend * 5. paid -> pick provider -> createRequirement -> (challenge | verify -> - * reserve replay key -> settle), fail closed at every step + * authorize/reserve -> reserve replay key -> settle -> consume/release the + * authorization), fail closed at every step * 6. call backend BACKEND_TIMEOUT / BACKEND_ERROR * 7. persist receipt, emit events, return outcome */ -import type { PaymentMethodName } from '../domain/common.js'; +import type { + AuthorizationProvider, + AuthorizationRecord, + AuthorizationVerification, +} from '../domain/authorization.js'; +import type { AuthorizationMethodName, PaymentMethodName } from '../domain/common.js'; import type { CommerceEvent, EventSink } from '../domain/event.js'; import type { PaymentProvider, PaymentRequirement, PaymentResult } from '../domain/payment.js'; import type { CommerceReceipt } from '../domain/receipt.js'; @@ -30,6 +36,8 @@ import { compileJsonSchema, type Validator } from './validation.js'; export interface CreateExecutionPipelineOptions { readonly resources: ResourceRegistry; readonly paymentProviders: readonly PaymentProvider[]; + /** Empty by default, so a deployment configuring none is unchanged */ + readonly authorizationProviders?: readonly AuthorizationProvider[]; readonly store: ReceiptStore; readonly backend: BackendExecutor; readonly events: EventSink; @@ -38,6 +46,15 @@ export interface CreateExecutionPipelineOptions { readonly ids: IdGenerator; } +/** + * A reservation held across settlement, plus the summary the receipt keeps. + * `finalize` is the only way it ends, so no path can leave one open. + */ +interface AuthorizationHold { + readonly record: AuthorizationRecord; + finalize(action: 'consume' | 'release' | 'markUncertain'): Promise; +} + export function createExecutionPipeline( options: CreateExecutionPipelineOptions, ): ExecutionPipeline { @@ -81,6 +98,117 @@ export function createExecutionPipeline( return { id: options.ids.next('evt'), at: options.clock.nowIso(), ...partial }; } + /** + * Verify and reserve what the resource requires, or undefined if it requires + * none. Runs after payment verification, which has no side effect: a bad + * payment proof must not burn a reservation. + */ + async function authorizeAndReserve( + request: CanonicalRequest, + resource: CommerceResource, + providers: readonly AuthorizationProvider[], + requirement: PaymentRequirement, + input: unknown, + ): Promise { + if (providers.length === 0) return undefined; + + const submission = request.authorization; + const provider = providers.find((candidate) => candidate.name === submission?.method); + // A request carries one proof, so a resource requiring two methods is + // refused here rather than half-checked + const unmet = providers.filter((candidate) => candidate !== provider).map((c) => c.name); + + if (submission === undefined || provider === undefined || unmet.length > 0) { + await safeEmit( + buildEvent({ + type: 'authorization.rejected', + requestId: request.requestId, + resourceId: resource.id, + adapter: request.protocol, + status: 'error', + data: { reason: 'AUTHORIZATION_REQUIRED', methods: unmet }, + }), + ); + throw new CommerceError( + 'AUTHORIZATION_REQUIRED', + `Resource "${resource.id}" requires authorization: ${unmet.join(', ')}`, + { + requestId: request.requestId, + resourceId: resource.id, + details: { required: providers.map((candidate) => candidate.name), missing: unmet }, + }, + ); + } + + let verification: AuthorizationVerification; + try { + verification = await provider.verifyAndReserve({ + requestId: request.requestId, + resourceId: resource.id, + input, + submission, + requirement, + }); + } catch (error) { + // An untyped throw says nothing about whose fault it was. Unavailable is + // honest and retryable; invalid would blame the buyer for our outage. + const mapped = isCommerceError(error) + ? error + : new CommerceError( + 'AUTHORIZATION_PROVIDER_UNAVAILABLE', + 'Authorization provider is unavailable', + { + requestId: request.requestId, + resourceId: resource.id, + cause: error, + }, + ); + await safeEmit( + buildEvent({ + type: 'authorization.rejected', + requestId: request.requestId, + resourceId: resource.id, + adapter: request.protocol, + status: 'error', + data: { reason: mapped.code, method: provider.name }, + }), + ); + throw mapped; + } + + await safeEmit( + buildEvent({ + type: 'authorization.verified', + requestId: request.requestId, + resourceId: resource.id, + adapter: request.protocol, + status: 'ok', + data: { method: verification.method, reference: verification.reference }, + }), + ); + + const context = { requestId: request.requestId, resourceId: resource.id }; + return { + record: { + method: verification.method, + reference: verification.reference, + ...(verification.metadata !== undefined ? { metadata: verification.metadata } : {}), + }, + async finalize(action) { + try { + await provider[action](verification.reservationId, context); + } catch (error) { + // Any failure leaves the row reserved, which is still unspendable. + // Not worth replacing the outcome the caller is about to see. + options.logger.error( + { err: describeError(error), requestId: request.requestId, action }, + 'Authorization finalization failed; the reservation stays reserved', + ); + } + }, + }; + } + async function execute(request: CanonicalRequest): Promise { const pipelineStart = options.clock.monotonicMs(); @@ -152,7 +280,22 @@ export function createExecutionPipeline( ); } + // Authorization gates settlement, so requiring one on a free resource + // means nothing would ever read the proof. Config refuses it at load; the + // execution path must not be the one that serves it unchecked. + if (resource.pricing.type !== 'fixed' && (resource.authorization?.required.length ?? 0) > 0) { + throw new CommerceError( + 'CONFIG_INVALID', + `Resource "${resource.id}" requires authorization but is not a paid resource`, + { + requestId: request.requestId, + resourceId: resource.id, + }, + ); + } + let paymentResult: PaymentResult | undefined; + let authorization: AuthorizationRecord | undefined; if (resource.pricing.type === 'fixed') { const pricing = resource.pricing; @@ -169,6 +312,14 @@ export function createExecutionPipeline( ); } + // Resolved before the challenge so the 402 can name what the retry must + // also carry, and so an uncheckable method fails before payment starts + const authProviders = resolveAuthorizationProviders( + options.authorizationProviders ?? [], + resource, + request.requestId, + ); + let requirement: PaymentRequirement; try { requirement = await provider.createRequirement({ @@ -203,6 +354,9 @@ export function createExecutionPipeline( requestId: request.requestId, resourceId: resource.id, requirement, + ...(authProviders.length > 0 + ? { authorization: authProviders.map((p) => p.requirement) } + : {}), }; } @@ -285,6 +439,15 @@ export function createExecutionPipeline( } const replayKey = verification.replayKey; + const hold = await authorizeAndReserve( + request, + resource, + authProviders, + requirement, + validInput, + ); + authorization = hold?.record; + try { await options.store.reservePaymentAttempt({ requestId: request.requestId, @@ -309,6 +472,7 @@ export function createExecutionPipeline( 'STORAGE_ERROR', 'Payment attempt could not be recorded', ); + await hold?.finalize('release'); await safeEmit( buildEvent({ type: 'payment.rejected', @@ -354,6 +518,9 @@ export function createExecutionPipeline( // is still not delivered either way: only what gets *recorded* // changes, never the fail-closed outcome. const uncertainTxHash = uncertainSettlementTxHash(error); + // An unconfirmed broadcast may still have moved funds, so the proof is + // not handed back. Only a settlement that provably failed is releasable. + await hold?.finalize(uncertainTxHash !== undefined ? 'markUncertain' : 'release'); await safePersist( () => options.store.updatePaymentAttempt( @@ -409,6 +576,7 @@ export function createExecutionPipeline( } if (settlement.status !== 'settled') { + await hold?.finalize('release'); await safePersist( () => options.store.updatePaymentAttempt({ @@ -442,6 +610,7 @@ export function createExecutionPipeline( ); } + await hold?.finalize('consume'); await safePersist( () => options.store.updatePaymentAttempt({ @@ -510,6 +679,7 @@ export function createExecutionPipeline( backendStatus: backendErrorStatus(commerceError), protocol: request.protocol, payment: paymentResult, + ...(authorization !== undefined ? { authorization } : {}), metadata: { delivered: false, backendErrorCode: commerceError.code }, }; await safePersist( @@ -566,6 +736,7 @@ export function createExecutionPipeline( durationMs: backendResponse.durationMs, protocol: request.protocol, ...(paymentResult !== undefined ? { payment: paymentResult } : {}), + ...(authorization !== undefined ? { authorization } : {}), }; await safePersist(() => options.store.saveReceipt(receipt), 'saveReceipt', request.requestId); @@ -614,6 +785,31 @@ function uncertainSettlementTxHash(error: unknown): string | undefined { return typeof hash === 'string' ? hash : undefined; } +/** + * The provider for each method a resource requires, in the resource's order. + * + * Config refuses an unconfigured method at load. Missing it here would mean + * serving the resource with no authorization at all, so it is checked again. + */ +function resolveAuthorizationProviders( + providers: readonly AuthorizationProvider[], + resource: CommerceResource, + requestId: string, +): readonly AuthorizationProvider[] { + const required = resource.authorization?.required ?? []; + return required.map((method: AuthorizationMethodName) => { + const found = providers.find((provider) => provider.name === method); + if (!found) { + throw new CommerceError( + 'CONFIG_INVALID', + `Resource "${resource.id}" requires authorization method "${method}", which is not enabled`, + { requestId, resourceId: resource.id }, + ); + } + return found; + }); +} + function pickProvider( providers: readonly PaymentProvider[], methods: readonly PaymentMethodName[], diff --git a/src/core/public-types.ts b/src/core/public-types.ts index 4317de3..3a6ae78 100644 --- a/src/core/public-types.ts +++ b/src/core/public-types.ts @@ -17,6 +17,7 @@ export type { AuthorizationFinalizeContext, AuthorizationProvider, + AuthorizationRecord, AuthorizationRequirement, AuthorizationSubmission, AuthorizationVerification, diff --git a/src/storage/receipts/rows.ts b/src/storage/receipts/rows.ts index 5c669f5..84f2651 100644 --- a/src/storage/receipts/rows.ts +++ b/src/storage/receipts/rows.ts @@ -5,6 +5,7 @@ * present (see docs/contracts.md, assumption 8). */ import type { + AuthorizationRecord, CommerceEvent, CommerceEventType, CommerceReceipt, @@ -23,6 +24,7 @@ export interface ReceiptRow { readonly duration_ms: number | null; readonly protocol: string | null; readonly metadata_json: string | null; + readonly authorization_json: string | null; } export interface EventRow { @@ -65,6 +67,7 @@ export function receiptToRow(receipt: CommerceReceipt): { duration_ms: number | null; protocol: string | null; metadata_json: string | null; + authorization_json: string | null; } { return { id: receipt.id, @@ -76,6 +79,8 @@ export function receiptToRow(receipt: CommerceReceipt): { duration_ms: receipt.durationMs !== undefined ? receipt.durationMs : null, protocol: receipt.protocol !== undefined ? receipt.protocol : null, metadata_json: receipt.metadata !== undefined ? JSON.stringify(redact(receipt.metadata)) : null, + authorization_json: + receipt.authorization !== undefined ? JSON.stringify(redact(receipt.authorization)) : null, }; } @@ -86,6 +91,12 @@ export function rowToReceipt(row: ReceiptRow): CommerceReceipt { row.metadata_json !== null ? (JSON.parse(row.metadata_json) as Record) : undefined; + // Null for every receipt written before this column existed, and for every + // resource that requires no authorization + const authorization = + row.authorization_json !== null + ? (JSON.parse(row.authorization_json) as AuthorizationRecord) + : undefined; return { id: row.id, requestId: row.request_id, @@ -96,6 +107,7 @@ export function rowToReceipt(row: ReceiptRow): CommerceReceipt { ...(row.duration_ms !== null ? { durationMs: row.duration_ms } : {}), ...(row.protocol !== null ? { protocol: row.protocol } : {}), ...(metadata !== undefined ? { metadata } : {}), + ...(authorization !== undefined ? { authorization } : {}), }; } diff --git a/src/storage/receipts/schema.ts b/src/storage/receipts/schema.ts index dd86dc2..1d204c9 100644 --- a/src/storage/receipts/schema.ts +++ b/src/storage/receipts/schema.ts @@ -71,6 +71,14 @@ const MIGRATIONS: readonly Migration[] = [ `); }, }, + { + version: 2, + up(db) { + // Not folded into v1: an existing database must keep its rows, and a + // receipt written before authorization truthfully has none + db.exec(`ALTER TABLE receipts ADD COLUMN authorization_json TEXT;`); + }, + }, ]; /** Current target schema version — the version of the last migration. */ diff --git a/src/storage/receipts/store.ts b/src/storage/receipts/store.ts index 25d22be..4a1c5d7 100644 --- a/src/storage/receipts/store.ts +++ b/src/storage/receipts/store.ts @@ -92,8 +92,8 @@ export function createSqliteReceiptStore(options: SqliteReceiptStoreOptions): Re migrate(db); const insertReceiptStmt = db.prepare( - `INSERT INTO receipts (id, request_id, resource_id, payment_json, delivered_at, backend_status, duration_ms, protocol, metadata_json) - VALUES (@id, @request_id, @resource_id, @payment_json, @delivered_at, @backend_status, @duration_ms, @protocol, @metadata_json)`, + `INSERT INTO receipts (id, request_id, resource_id, payment_json, delivered_at, backend_status, duration_ms, protocol, metadata_json, authorization_json) + VALUES (@id, @request_id, @resource_id, @payment_json, @delivered_at, @backend_status, @duration_ms, @protocol, @metadata_json, @authorization_json)`, ); const getReceiptStmt = db.prepare<[string], ReceiptRow>('SELECT * FROM receipts WHERE id = ?'); const listReceiptsStmt = db.prepare<[number], ReceiptRow>( diff --git a/tests/unit/core/execution/helpers.ts b/tests/unit/core/execution/helpers.ts index 063869e..1014ba1 100644 --- a/tests/unit/core/execution/helpers.ts +++ b/tests/unit/core/execution/helpers.ts @@ -5,6 +5,11 @@ */ import type { AdapterDescriptor, + AuthorizationMethodName, + AuthorizationProvider, + AuthorizationRequirement, + AuthorizationVerification, + AuthorizationVerificationContext, BackendExecutor, BackendHandler, BackendRequest, @@ -272,3 +277,69 @@ export function makeResource(overrides: Partial = {}): Commerc ...overrides, }; } + +export interface FakeAuthorizationProviderOptions { + readonly name?: AuthorizationMethodName; + readonly requirement?: AuthorizationRequirement; + readonly verifyAndReserve?: ( + ctx: AuthorizationVerificationContext, + ) => Promise; + readonly consume?: (reservationId: string) => Promise; + readonly release?: (reservationId: string) => Promise; + readonly markUncertain?: (reservationId: string) => Promise; + /** Called with each lifecycle action, for cross-fake ordering assertions */ + readonly onCall?: (action: string) => void; +} + +export interface FakeAuthorizationProvider extends AuthorizationProvider { + /** Lifecycle calls in order, so a test can assert counts and transitions */ + readonly calls: string[]; + /** What verifyAndReserve was last asked to bind against */ + readonly contexts: AuthorizationVerificationContext[]; +} + +export function createFakeAuthorizationProvider( + options: FakeAuthorizationProviderOptions = {}, +): FakeAuthorizationProvider { + const name = options.name ?? 'ap2'; + const calls: string[] = []; + const contexts: AuthorizationVerificationContext[] = []; + + const record = (action: string): void => { + calls.push(action); + options.onCall?.(action); + }; + + return { + name, + descriptor: { ...fakeDescriptor, kind: 'authorization', name }, + requirement: options.requirement ?? { method: name, version: '0.2.0', profile: 'test/v1' }, + calls, + contexts, + async verifyAndReserve(ctx) { + record('verifyAndReserve'); + contexts.push(ctx); + if (options.verifyAndReserve) return options.verifyAndReserve(ctx); + return { + status: 'verified', + method: name, + reference: 'sha256:REFERENCE', + reservationId: 'reservation-1', + metadata: { checkoutId: 'checkout-1' }, + }; + }, + async consume(reservationId) { + record('consume'); + await options.consume?.(reservationId); + }, + async release(reservationId) { + record('release'); + await options.release?.(reservationId); + }, + async markUncertain(reservationId) { + record('markUncertain'); + await options.markUncertain?.(reservationId); + }, + health: async () => ({ status: 'pass', checkedAt: '2026-01-01T00:00:00.000Z' }), + }; +} diff --git a/tests/unit/core/execution/pipeline-authorization.test.ts b/tests/unit/core/execution/pipeline-authorization.test.ts new file mode 100644 index 0000000..989fa99 --- /dev/null +++ b/tests/unit/core/execution/pipeline-authorization.test.ts @@ -0,0 +1,622 @@ +/** + * Authorization is a gate on settlement, so these tests assert call counts and + * ordering rather than return values: "the resource was refused" is worth + * little if the payment settled on the way to refusing it. + */ +import { describe, expect, it } from 'vitest'; +import type { AuthorizationMethodName } from '../../../../src/core/domain/common.js'; +import type { CommerceEvent } from '../../../../src/core/domain/event.js'; +import type { + CanonicalRequest, + DeliveredOutcome, + PaymentRequiredOutcome, +} from '../../../../src/core/domain/request.js'; +import { CommerceError, isCommerceError } from '../../../../src/core/errors/index.js'; +import { + type CreateExecutionPipelineOptions, + createExecutionPipeline, +} from '../../../../src/core/execution/pipeline.js'; +import { createResourceRegistry } from '../../../../src/core/execution/registry.js'; +import { + createCapturingLogger, + createFakeAuthorizationProvider, + createFakeBackendExecutor, + createFakeClock, + createFakeIdGenerator, + createFakePaymentProvider, + createFakeStore, + type FakeStore, + makeResource, +} from './helpers.js'; + +const PAID = { + pricing: { type: 'fixed', amount: '0.01', currency: 'USDC' }, + paymentMethods: ['x402'], + authorization: { required: ['ap2'] }, +} as const; + +// Omits a key outright: `exactOptionalPropertyTypes` refuses an explicit undefined +function without( + request: CanonicalRequest, + ...keys: readonly ('payment' | 'authorization')[] +): CanonicalRequest { + const copy = { ...request }; + for (const key of keys) delete copy[key]; + return copy; +} + +function makeRequest(overrides: Partial = {}): CanonicalRequest { + return { + requestId: 'req-1', + resourceId: 'res-1', + input: { city: 'berlin' }, + protocol: 'http', + receivedAt: '2026-01-01T00:00:00.000Z', + payment: { method: 'x402', payload: 'proof' }, + authorization: { method: 'ap2', payload: 'mandate~disclosure~' }, + ...overrides, + }; +} + +function buildPipeline(overrides: Partial & { store?: FakeStore }) { + const store = overrides.store ?? createFakeStore(); + return { + store, + pipeline: createExecutionPipeline({ + resources: createResourceRegistry([makeResource({ ...PAID })]), + paymentProviders: [createFakePaymentProvider()], + backend: createFakeBackendExecutor(), + logger: createCapturingLogger(), + clock: createFakeClock(), + ids: createFakeIdGenerator(), + ...overrides, + store, + events: store, + }), + }; +} + +function codeOf(error: unknown): string { + return isCommerceError(error) ? error.code : `not-a-commerce-error:${String(error)}`; +} + +function types(store: FakeStore): string[] { + return store.events.map((e: CommerceEvent) => e.type); +} + +describe('execution pipeline authorization', () => { + it('advertises the authorization requirement with the payment challenge', async () => { + const auth = createFakeAuthorizationProvider({ + requirement: { method: 'ap2', version: '0.2.0', profile: 'agent-commerce/ap2/checkout/v1' }, + }); + const { pipeline } = buildPipeline({ authorizationProviders: [auth] }); + + const outcome = (await pipeline.execute( + without(makeRequest(), 'payment', 'authorization'), + )) as PaymentRequiredOutcome; + + expect(outcome.kind).toBe('payment-required'); + expect(outcome.authorization).toEqual([ + { method: 'ap2', version: '0.2.0', profile: 'agent-commerce/ap2/checkout/v1' }, + ]); + expect(auth.calls).toEqual([]); + }); + + it('leaves the challenge untouched for a paid resource that requires no authorization', async () => { + const auth = createFakeAuthorizationProvider(); + const { pipeline } = buildPipeline({ + resources: createResourceRegistry([ + makeResource({ pricing: PAID.pricing, paymentMethods: ['x402'] }), + ]), + authorizationProviders: [auth], + }); + + const outcome = (await pipeline.execute( + without(makeRequest(), 'payment', 'authorization'), + )) as PaymentRequiredOutcome; + + expect(outcome.authorization).toBeUndefined(); + }); + + it('settles, consumes and delivers in that order on the happy path', async () => { + const order: string[] = []; + const auth = createFakeAuthorizationProvider({ + onCall: (action) => order.push(`auth.${action}`), + }); + const store = createFakeStore({ + reservePaymentAttempt: async (reservation) => { + order.push('store.reservePaymentAttempt'); + return { + id: 'attempt-1', + requestId: reservation.requestId, + resourceId: reservation.resourceId, + provider: reservation.provider, + replayKey: reservation.replayKey, + status: 'reserved', + amount: reservation.amount, + currency: reservation.currency, + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + }; + }, + }); + const payment = createFakePaymentProvider({ + verify: async () => { + order.push('payment.verify'); + return { + status: 'verified', + provider: 'x402', + amount: '0.01', + currency: 'USDC', + replayKey: 'replay-key-1', + }; + }, + settle: async () => { + order.push('payment.settle'); + return { status: 'settled', provider: 'x402', amount: '0.01', currency: 'USDC' }; + }, + }); + const { pipeline } = buildPipeline({ + store, + paymentProviders: [payment], + authorizationProviders: [auth], + backend: createFakeBackendExecutor(async () => { + order.push('backend.call'); + return { status: 200, headers: {}, body: { ok: true }, durationMs: 1 }; + }), + }); + + const outcome = (await pipeline.execute(makeRequest())) as DeliveredOutcome; + + expect(outcome.kind).toBe('delivered'); + expect(order).toEqual([ + 'payment.verify', + 'auth.verifyAndReserve', + 'store.reservePaymentAttempt', + 'payment.settle', + 'auth.consume', + 'backend.call', + ]); + }); + + it('binds the proof to the validated input and the resolved price', async () => { + const auth = createFakeAuthorizationProvider(); + const { pipeline } = buildPipeline({ authorizationProviders: [auth] }); + + await pipeline.execute(makeRequest({ input: { city: 'berlin', _payment: 'proof' } })); + + const context = auth.contexts[0]; + expect(context?.resourceId).toBe('res-1'); + // Reserved wire fields stripped: the provider hashes this, and the backend + // is called with the same bytes + expect(context?.input).toEqual({ city: 'berlin' }); + expect(context?.requirement.amount).toBe('0.01'); + expect(context?.requirement.currency).toBe('USDC'); + expect(context?.submission).toEqual({ method: 'ap2', payload: 'mandate~disclosure~' }); + }); + + it('records a digest on the receipt and never the reservation handle', async () => { + const auth = createFakeAuthorizationProvider(); + const { pipeline, store } = buildPipeline({ authorizationProviders: [auth] }); + + const outcome = (await pipeline.execute(makeRequest())) as DeliveredOutcome; + + expect(outcome.receipt.authorization).toEqual({ + method: 'ap2', + reference: 'sha256:REFERENCE', + metadata: { checkoutId: 'checkout-1' }, + }); + expect(JSON.stringify(store.receipts[0])).not.toContain('reservation-1'); + expect(JSON.stringify(store.receipts[0])).not.toContain('mandate~disclosure~'); + }); + + it('emits authorization.verified between payment verification and settlement', async () => { + const auth = createFakeAuthorizationProvider(); + const { pipeline, store } = buildPipeline({ authorizationProviders: [auth] }); + + await pipeline.execute(makeRequest()); + + expect(types(store)).toEqual([ + 'resource.requested', + 'authorization.verified', + 'payment.verified', + 'payment.settled', + 'backend.called', + 'resource.delivered', + ]); + const verified = store.events.find((e) => e.type === 'authorization.verified'); + expect(verified?.data).toEqual({ method: 'ap2', reference: 'sha256:REFERENCE' }); + }); + + describe('refusals before any money moves', () => { + it('refuses a request with no authorization at all', async () => { + const auth = createFakeAuthorizationProvider(); + let settled = 0; + let backendCalls = 0; + const { pipeline, store } = buildPipeline({ + authorizationProviders: [auth], + paymentProviders: [ + createFakePaymentProvider({ + settle: async () => { + settled += 1; + return { status: 'settled', provider: 'x402', amount: '0.01', currency: 'USDC' }; + }, + }), + ], + backend: createFakeBackendExecutor(async () => { + backendCalls += 1; + return { status: 200, headers: {}, body: {}, durationMs: 1 }; + }), + }); + + await expect(pipeline.execute(without(makeRequest(), 'authorization'))).rejects.toSatisfy( + (error: unknown) => codeOf(error) === 'AUTHORIZATION_REQUIRED', + ); + expect(settled).toBe(0); + expect(backendCalls).toBe(0); + expect(auth.calls).toEqual([]); + expect(store.attempts.size).toBe(0); + expect(types(store)).toContain('authorization.rejected'); + }); + + it('refuses a proof presented under a method the resource does not require', async () => { + const auth = createFakeAuthorizationProvider(); + const { pipeline } = buildPipeline({ authorizationProviders: [auth] }); + + await expect( + pipeline.execute( + makeRequest({ + authorization: { method: 'other' as 'ap2', payload: 'x' }, + }), + ), + ).rejects.toSatisfy((error: unknown) => codeOf(error) === 'AUTHORIZATION_REQUIRED'); + expect(auth.calls).toEqual([]); + }); + + it('refuses a resource requiring two methods, since one request carries one proof', async () => { + // The union has one member today; the cast stands in for a second method + // and pins that it is refused rather than quietly skipped + const second = 'mock' as AuthorizationMethodName; + const ap2 = createFakeAuthorizationProvider(); + const other = createFakeAuthorizationProvider({ name: second }); + const { pipeline } = buildPipeline({ + resources: createResourceRegistry([ + makeResource({ + pricing: PAID.pricing, + paymentMethods: ['x402'], + authorization: { required: ['ap2', second] }, + }), + ]), + authorizationProviders: [ap2, other], + }); + + await expect(pipeline.execute(makeRequest())).rejects.toSatisfy( + (error: unknown) => codeOf(error) === 'AUTHORIZATION_REQUIRED', + ); + expect(ap2.calls).toEqual([]); + expect(other.calls).toEqual([]); + }); + + it('names the unsatisfied methods, not the satisfied one', async () => { + const second = 'mock' as AuthorizationMethodName; + const { pipeline } = buildPipeline({ + resources: createResourceRegistry([ + makeResource({ + pricing: PAID.pricing, + paymentMethods: ['x402'], + authorization: { required: ['ap2', second] }, + }), + ]), + authorizationProviders: [ + createFakeAuthorizationProvider(), + createFakeAuthorizationProvider({ name: second }), + ], + }); + + await expect(pipeline.execute(makeRequest())).rejects.toSatisfy( + (error: unknown) => + isCommerceError(error) && + JSON.stringify(error.details?.['missing']) === JSON.stringify(['mock']), + ); + }); + + it('propagates an invalid proof as AUTHORIZATION_INVALID, with no settlement', async () => { + let settled = 0; + const auth = createFakeAuthorizationProvider({ + verifyAndReserve: async () => { + throw new CommerceError('AUTHORIZATION_INVALID', 'mandate rejected'); + }, + }); + const { pipeline, store } = buildPipeline({ + authorizationProviders: [auth], + paymentProviders: [ + createFakePaymentProvider({ + settle: async () => { + settled += 1; + return { status: 'settled', provider: 'x402', amount: '0.01', currency: 'USDC' }; + }, + }), + ], + }); + + await expect(pipeline.execute(makeRequest())).rejects.toSatisfy( + (error: unknown) => codeOf(error) === 'AUTHORIZATION_INVALID', + ); + expect(settled).toBe(0); + expect(store.attempts.size).toBe(0); + expect(auth.calls).toEqual(['verifyAndReserve']); + const rejected = store.events.find((e) => e.type === 'authorization.rejected'); + expect(rejected?.data).toEqual({ reason: 'AUTHORIZATION_INVALID', method: 'ap2' }); + }); + + it('propagates a replayed proof as AUTHORIZATION_REPLAYED', async () => { + const auth = createFakeAuthorizationProvider({ + verifyAndReserve: async () => { + throw new CommerceError('AUTHORIZATION_REPLAYED', 'already spent'); + }, + }); + const { pipeline } = buildPipeline({ authorizationProviders: [auth] }); + + await expect(pipeline.execute(makeRequest())).rejects.toSatisfy( + (error: unknown) => codeOf(error) === 'AUTHORIZATION_REPLAYED', + ); + }); + + it('reports an untyped provider failure as unavailable, not as a bad proof', async () => { + const auth = createFakeAuthorizationProvider({ + verifyAndReserve: async () => { + throw new Error('sqlite: database is locked'); + }, + }); + const { pipeline } = buildPipeline({ authorizationProviders: [auth] }); + + await expect(pipeline.execute(makeRequest())).rejects.toSatisfy( + (error: unknown) => codeOf(error) === 'AUTHORIZATION_PROVIDER_UNAVAILABLE', + ); + }); + + it('never reserves a proof for a payment that failed verification', async () => { + const auth = createFakeAuthorizationProvider(); + const { pipeline } = buildPipeline({ + authorizationProviders: [auth], + paymentProviders: [ + createFakePaymentProvider({ + verify: async () => ({ + status: 'rejected', + provider: 'x402', + amount: '0.01', + currency: 'USDC', + rejectionReason: 'wrong amount', + }), + }), + ], + }); + + await expect(pipeline.execute(makeRequest())).rejects.toSatisfy( + (error: unknown) => codeOf(error) === 'PAYMENT_INVALID', + ); + expect(auth.calls).toEqual([]); + }); + + it('refuses a resource requiring a method no provider implements', async () => { + let createRequirementCalls = 0; + const { pipeline } = buildPipeline({ + authorizationProviders: [], + paymentProviders: [ + createFakePaymentProvider({ + createRequirement: async (ctx) => { + createRequirementCalls += 1; + return { + id: 'r', + requestId: ctx.requestId, + resourceId: ctx.resource.id, + provider: 'x402', + amount: ctx.amount, + currency: ctx.currency, + destination: '0xM', + challenge: { provider: 'x402', version: '1', accepts: [] }, + }; + }, + }), + ], + }); + + await expect(pipeline.execute(makeRequest())).rejects.toSatisfy( + (error: unknown) => codeOf(error) === 'CONFIG_INVALID', + ); + expect(createRequirementCalls).toBe(0); + }); + + it('refuses a free resource that requires authorization rather than serving it unchecked', async () => { + const auth = createFakeAuthorizationProvider(); + let backendCalls = 0; + const { pipeline } = buildPipeline({ + resources: createResourceRegistry([ + makeResource({ pricing: { type: 'free' }, authorization: { required: ['ap2'] } }), + ]), + authorizationProviders: [auth], + backend: createFakeBackendExecutor(async () => { + backendCalls += 1; + return { status: 200, headers: {}, body: {}, durationMs: 1 }; + }), + }); + + await expect(pipeline.execute(makeRequest())).rejects.toSatisfy( + (error: unknown) => codeOf(error) === 'CONFIG_INVALID', + ); + expect(backendCalls).toBe(0); + expect(auth.calls).toEqual([]); + }); + }); + + describe('finalization', () => { + it('releases the reservation when the payment replay key is already spent', async () => { + let settled = 0; + const auth = createFakeAuthorizationProvider(); + const { pipeline } = buildPipeline({ + authorizationProviders: [auth], + store: createFakeStore({ + reservePaymentAttempt: async () => { + throw new CommerceError('PAYMENT_REPLAYED', 'already reserved'); + }, + }), + paymentProviders: [ + createFakePaymentProvider({ + settle: async () => { + settled += 1; + return { status: 'settled', provider: 'x402', amount: '0.01', currency: 'USDC' }; + }, + }), + ], + }); + + await expect(pipeline.execute(makeRequest())).rejects.toSatisfy( + (error: unknown) => codeOf(error) === 'PAYMENT_REPLAYED', + ); + expect(auth.calls).toEqual(['verifyAndReserve', 'release']); + expect(settled).toBe(0); + }); + + it('releases the reservation when settlement is definitively rejected', async () => { + const auth = createFakeAuthorizationProvider(); + const { pipeline } = buildPipeline({ + authorizationProviders: [auth], + paymentProviders: [ + createFakePaymentProvider({ + settle: async () => ({ + status: 'rejected', + provider: 'x402', + amount: '0.01', + currency: 'USDC', + rejectionReason: 'insufficient balance', + }), + }), + ], + }); + + await expect(pipeline.execute(makeRequest())).rejects.toSatisfy( + (error: unknown) => codeOf(error) === 'PAYMENT_SETTLEMENT_FAILED', + ); + expect(auth.calls).toEqual(['verifyAndReserve', 'release']); + }); + + it('releases the reservation when settlement throws without moving funds', async () => { + const auth = createFakeAuthorizationProvider(); + const { pipeline } = buildPipeline({ + authorizationProviders: [auth], + paymentProviders: [ + createFakePaymentProvider({ + settle: async () => { + throw new Error('facilitator refused the request'); + }, + }), + ], + }); + + await expect(pipeline.execute(makeRequest())).rejects.toSatisfy( + (error: unknown) => codeOf(error) === 'PAYMENT_SETTLEMENT_FAILED', + ); + expect(auth.calls).toEqual(['verifyAndReserve', 'release']); + }); + + it('marks the reservation uncertain when a broadcast settlement was never confirmed', async () => { + const auth = createFakeAuthorizationProvider(); + const { pipeline } = buildPipeline({ + authorizationProviders: [auth], + paymentProviders: [ + createFakePaymentProvider({ + settle: async () => { + throw new CommerceError('PAYMENT_PROVIDER_UNAVAILABLE', 'confirmation timed out', { + details: { transactionHash: '0xabc' }, + }); + }, + }), + ], + }); + + await expect(pipeline.execute(makeRequest())).rejects.toSatisfy( + (error: unknown) => codeOf(error) === 'PAYMENT_SETTLEMENT_FAILED', + ); + // Not released: the buyer's funds may already have moved, and a released + // mandate is spendable again + expect(auth.calls).toEqual(['verifyAndReserve', 'markUncertain']); + }); + + it('keeps the reservation consumed when the backend fails after settlement', async () => { + const auth = createFakeAuthorizationProvider(); + const { pipeline, store } = buildPipeline({ + authorizationProviders: [auth], + backend: createFakeBackendExecutor(async () => { + throw new CommerceError('BACKEND_ERROR', 'merchant returned 500', { + details: { status: 500 }, + }); + }), + }); + + await expect(pipeline.execute(makeRequest())).rejects.toSatisfy( + (error: unknown) => codeOf(error) === 'BACKEND_ERROR', + ); + expect(auth.calls).toEqual(['verifyAndReserve', 'consume']); + expect(store.receipts[0]?.authorization?.reference).toBe('sha256:REFERENCE'); + }); + + it('delivers even when consuming the reservation fails', async () => { + const logger = createCapturingLogger(); + const auth = createFakeAuthorizationProvider({ + consume: async () => { + throw new Error('sqlite: disk I/O error'); + }, + }); + const { pipeline } = buildPipeline({ authorizationProviders: [auth], logger }); + + const outcome = (await pipeline.execute(makeRequest())) as DeliveredOutcome; + + expect(outcome.kind).toBe('delivered'); + expect(logger.errors.some((e) => e.obj['action'] === 'consume')).toBe(true); + }); + + it('does not mask the original failure when releasing the reservation fails', async () => { + const auth = createFakeAuthorizationProvider({ + release: async () => { + throw new Error('sqlite: disk I/O error'); + }, + }); + const { pipeline } = buildPipeline({ + authorizationProviders: [auth], + paymentProviders: [ + createFakePaymentProvider({ + settle: async () => ({ + status: 'rejected', + provider: 'x402', + amount: '0.01', + currency: 'USDC', + }), + }), + ], + }); + + await expect(pipeline.execute(makeRequest())).rejects.toSatisfy( + (error: unknown) => codeOf(error) === 'PAYMENT_SETTLEMENT_FAILED', + ); + }); + }); + + it('leaves a paid resource without authorization exactly as it was', async () => { + const auth = createFakeAuthorizationProvider(); + const { pipeline, store } = buildPipeline({ + resources: createResourceRegistry([ + makeResource({ pricing: PAID.pricing, paymentMethods: ['x402'] }), + ]), + authorizationProviders: [auth], + }); + + const outcome = (await pipeline.execute( + without(makeRequest(), 'authorization'), + )) as DeliveredOutcome; + + expect(outcome.kind).toBe('delivered'); + expect(auth.calls).toEqual([]); + expect(outcome.receipt.authorization).toBeUndefined(); + expect(types(store)).not.toContain('authorization.verified'); + }); +}); diff --git a/tests/unit/storage-receipts/helpers.ts b/tests/unit/storage-receipts/helpers.ts index 52b450d..3926151 100644 --- a/tests/unit/storage-receipts/helpers.ts +++ b/tests/unit/storage-receipts/helpers.ts @@ -48,6 +48,7 @@ export function makeReceipt(overrides: Partial = {}): CommerceR ...(overrides.durationMs !== undefined ? { durationMs: overrides.durationMs } : {}), ...(overrides.protocol !== undefined ? { protocol: overrides.protocol } : {}), ...(overrides.metadata !== undefined ? { metadata: overrides.metadata } : {}), + ...(overrides.authorization !== undefined ? { authorization: overrides.authorization } : {}), }; } diff --git a/tests/unit/storage-receipts/no-secrets.test.ts b/tests/unit/storage-receipts/no-secrets.test.ts index 481d717..030dc90 100644 --- a/tests/unit/storage-receipts/no-secrets.test.ts +++ b/tests/unit/storage-receipts/no-secrets.test.ts @@ -80,6 +80,23 @@ describe('no-secrets guarantee', () => { expect(JSON.stringify(fetched)).not.toContain('0xSHOULD_NOT_PERSIST'); }); + it('strips a secret-shaped field from receipt.authorization.metadata', async () => { + const receipt = makeReceipt({ + id: 'r_auth', + authorization: { + method: 'ap2', + reference: 'sha256:abc', + metadata: { mandateToken: 'eyJ...', checkoutId: 'checkout-1' }, + }, + }); + await store.saveReceipt(receipt); + + const fetched = await store.getReceipt('r_auth'); + expect(fetched?.authorization?.metadata?.['mandateToken']).toBe('[REDACTED]'); + expect(fetched?.authorization?.metadata?.['checkoutId']).toBe('checkout-1'); + expect(fetched?.authorization?.reference).toBe('sha256:abc'); + }); + it('strips a raw payment proof / Authorization header from receipt.payment.metadata', async () => { const receipt = makeReceipt({ id: 'r_secret_payment', diff --git a/tests/unit/storage-receipts/receipts-events.test.ts b/tests/unit/storage-receipts/receipts-events.test.ts index 14bc28d..2fc3ab1 100644 --- a/tests/unit/storage-receipts/receipts-events.test.ts +++ b/tests/unit/storage-receipts/receipts-events.test.ts @@ -121,11 +121,18 @@ describe('receipts', () => { }); }); - it('round-trips optional fields (payment, protocol, metadata) exactly', async () => { + it('round-trips optional fields (payment, protocol, metadata, authorization) exactly', async () => { const receipt = makeReceipt({ id: 'r_full', protocol: 'http', metadata: { note: 'ok' }, + authorization: { + method: 'ap2', + reference: 'sha256:abc', + // Not `checkoutJwtId`: the redactor strips any key containing "jwt", + // so a provider naming its audit fields carelessly persists nothing + metadata: { checkoutId: 'checkout-1' }, + }, payment: { status: 'settled', provider: 'x402', @@ -149,6 +156,15 @@ describe('receipts', () => { expect('durationMs' in (fetched ?? {})).toBe(false); expect('protocol' in (fetched ?? {})).toBe(false); expect('metadata' in (fetched ?? {})).toBe(false); + expect('authorization' in (fetched ?? {})).toBe(false); + }); + + it('reads a receipt written before the authorization column existed', async () => { + // Migration 2 added the column, so a v1 row has NULL there. That must read + // back as "required none", not as a receipt the mapper refuses. + await store.saveReceipt(makeReceipt({ id: 'r_legacy' })); + const fetched = await store.getReceipt('r_legacy'); + expect(fetched?.authorization).toBeUndefined(); }); }); From cda67837155bc8de930760549fe48252904c7a1a Mon Sep 17 00:00:00 2001 From: Revinand Date: Tue, 15 Sep 2026 11:56:10 +0200 Subject: [PATCH 06/11] feat(runtime): wire ap2 authorization provider --- docs/contracts.md | 3 + docs/security.md | 9 + package.json | 4 + src/ap2.ts | 37 ++ src/authorization/ap2/descriptor.ts | 67 ++++ src/authorization/ap2/errors.ts | 19 +- src/authorization/ap2/index.ts | 41 +++ src/authorization/ap2/provider.ts | 163 +++++++++ src/cli/commands/doctor.ts | 142 +++++++- src/gateway/main.ts | 15 + src/gateway/readiness.ts | 101 ++++-- src/gateway/routes.ts | 3 + src/gateway/server.ts | 7 + tests/integration/ap2-runtime.test.ts | 332 +++++++++++++++++ tests/unit/authorization-ap2/provider.test.ts | 335 ++++++++++++++++++ tests/unit/cli/doctor.test.ts | 161 +++++++++ tests/unit/cli/packaging.test.ts | 16 +- tests/unit/gateway/readiness.test.ts | 28 +- tsup.config.ts | 6 +- 19 files changed, 1441 insertions(+), 48 deletions(-) create mode 100644 src/ap2.ts create mode 100644 src/authorization/ap2/descriptor.ts create mode 100644 src/authorization/ap2/index.ts create mode 100644 src/authorization/ap2/provider.ts create mode 100644 tests/integration/ap2-runtime.test.ts create mode 100644 tests/unit/authorization-ap2/provider.test.ts diff --git a/docs/contracts.md b/docs/contracts.md index eda7cee..bcdb9be 100644 --- a/docs/contracts.md +++ b/docs/contracts.md @@ -89,6 +89,7 @@ the generated file is right and this table is stale. - **Additive (main entry):** `createAcpAdapter`, `AcpAdapterOptions`, `ACP_SPEC_VERSION`, `ACP_API_VERSION`, `ACP_WELL_KNOWN_PATH`. *Use case:* a consumer running `createGateway` needs the adapter to mount. *Why the main entry and not a subpath:* a subpath is a peer-dependency boundary, not a category - the ACP adapter needs no peer, only `ajv`/`ajv-formats` (real dependencies) and its own vendored schema. *Cost:* the pinned schema is inlined into `dist/index.js` (+~124 kB; package 396 kB -> 479 kB). The CLI bundle is unaffected - `doctor` reads only the ACP constants and descriptor, never the validator. - **Additive:** the generic authorization contract - `AuthorizationMethodName` (`'ap2'`), `AuthorizationSubmission`, `AuthorizationRequirement`, `AuthorizationVerification`, `AuthorizationProvider` and its two contexts; optional `CanonicalRequest.authorization`, optional `CommerceResource.authorization`, optional `PaymentRequiredOutcome.authorization` and the matching `PaymentRequiredEnvelope.authorization`; `AdapterDescriptor.kind` gains `'authorization'`; four `AUTHORIZATION_*` error codes (403 / 403 / 409 / 503, the last retryable); and the wire carriers `AUTHORIZATION_INPUT_FIELD` (`_authorization`), `AUTHORIZATION_HEADER` (`agent-authorization`), `MAX_AUTHORIZATION_HEADER_BYTES` and `RESERVED_INPUT_FIELDS`. *Use case:* AP2 mandate verification - proving the human behind an agent approved this exact purchase, a separate question from whether the payment verified. *Why generic:* AP2 is the first implementation, not the abstraction. Core states that a resource requires authorization and when the pipeline checks it, and knows nothing about SD-JWTs. An authorization method is deliberately neither a `ProtocolName` nor a `PaymentMethodName`, because it is not a transport and must never be selectable as a payment rail. *Compatibility:* every field is optional and every consumer that sets none behaves exactly as before; a resource with no `authorization` policy is unchanged end to end. `extractReservedInputFields` replaces the two hand-written `_payment` extractors in the MCP and A2A adapters with one path in core, so the reserved-field list cannot drift between surfaces. `_payment` handling is byte-identical, including dropping a proof for a resource with no configured rail. - **Additive:** `AuthorizationRecord`; optional `CommerceReceipt.authorization`; `AuthorizationProvider` gains `requirement` and `markUncertain`; `AuthorizationVerification` now extends `AuthorizationRecord`; `CommerceEventType` gains `authorization.verified` and `authorization.rejected`. *Use case:* the execution pipeline enforcing authorization, in the order payment verify -> authorize/reserve -> payment replay reserve -> settle -> consume/release/mark-uncertain. *Why `requirement` on the provider:* the 402 challenge has to name what the retry must also carry, and only the provider knows its own spec version and payload profile. *Why `markUncertain` rather than leaving a reservation alone:* a settlement that was broadcast but never confirmed must not hand the proof back, and "we did nothing" is indistinguishable from a path that forgot to finalize. *Why the receipt stores a record and not the verification:* `reservationId` is a live handle, not an audit fact, and a stored proof would be a spendable secret at rest. *Compatibility:* `CommerceReceipt.authorization` is optional and absent for every resource that requires no authorization; the receipt store adds schema version 2 (`ALTER TABLE receipts ADD COLUMN authorization_json`), so an existing database keeps its rows. `AuthorizationProvider` is not yet implemented by anything shipped, so the two new members break no consumer. +- **Additive (non-frozen surfaces):** `GatewayOptions.authorizationProviders` (optional) and `ReadinessResult.authorizationProviders`; a new `./ap2` subpath exporting `createAp2AuthorizationProvider` / `ap2`, with `jose`, `@sd-jwt/core` and `canonicalize` as optional peers. *Use case:* running AP2 as a wired subsystem. *Why a subpath:* one entry per distinct peer set, named for the peer - a gateway serving no gated resource should install neither a JOSE stack nor an SD-JWT parser, and the main entry and the CLI import the narrow AP2 modules (`constants.ts`, `types.ts`, `descriptor.ts`) so neither pulls a peer. *Readiness:* an authorization provider reporting `fail` blocks `/ready` on the same threshold as a payment provider - a resource that requires a mandate cannot be served without one, and serving its challenge anyway promises what cannot be honoured. Only the fixed vocabulary token `authorization-provider-unreachable` reaches the client. *Compatibility:* both fields are additive and a deployment configuring no authorization behaves exactly as before. --- # Integration contract - exact factory signatures @@ -184,6 +185,8 @@ export interface GatewayOptions { readonly config: GatewayConfig; // from src/config readonly store: ReceiptStore; readonly paymentProviders: readonly PaymentProvider[]; + // Absent means no resource requires authorization, which is the default + readonly authorizationProviders?: readonly AuthorizationProvider[]; readonly protocolAdapters: readonly ProtocolAdapter[]; readonly logger?: Logger; readonly clock?: Clock; diff --git a/docs/security.md b/docs/security.md index caac010..7336d28 100644 --- a/docs/security.md +++ b/docs/security.md @@ -215,6 +215,15 @@ list. What exists: into one upstream RPC call per request - `X-Request-Id` accepted only as `[A-Za-z0-9._:-]{1,64}`, so a caller cannot write an unbounded string into every audit row +- an **8192-byte cap on the `Agent-Authorization` header** + (`MAX_AUTHORIZATION_HEADER_BYTES`), checked on the raw header before it is + base64url-decoded or parsed as JSON. Over the limit is + `AUTHORIZATION_INVALID`, and nothing from the caller's value is echoed back. + The cap is on the encoded header rather than on the decoded proof because + base64url expands by 4/3, so a decode-first check would have to allocate the + oversized string first. A real Direct Checkout Mandate presentation is well + inside it; MCP and A2A carry the same proof in the request body instead and + are bounded by the body-size cap What does **not** exist: rate limiting, per-agent quotas, adaptive backpressure. Put the gateway behind your own edge if you expose it publicly. diff --git a/package.json b/package.json index 580141a..b9af4f7 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,10 @@ "types": "./dist/index.d.ts", "import": "./dist/index.js" }, + "./ap2": { + "types": "./dist/ap2.d.ts", + "import": "./dist/ap2.js" + }, "./mcp": { "types": "./dist/mcp.d.ts", "import": "./dist/mcp.js" diff --git a/src/ap2.ts b/src/ap2.ts new file mode 100644 index 0000000..a64c1e5 --- /dev/null +++ b/src/ap2.ts @@ -0,0 +1,37 @@ +/** + * `@devlab.group/agent-commerce/ap2` - AP2 Direct Checkout Mandate verification. + * + * A separate entry point because mandate verification brings a JOSE stack and + * an SD-JWT parser, and a gateway serving no authorization-gated resource + * should not install either. + * + * npm install @devlab.group/agent-commerce jose @sd-jwt/core canonicalize + * import { ap2 } from '@devlab.group/agent-commerce/ap2'; + * + * Authorization is not a transport and not a payment rail. It gates settlement + * on a resource that also takes a real payment proof, and never unlocks one on + * its own. + */ + +export { + AP2_CAPABILITIES, + AP2_CHECKOUT_MANDATE_VCT, + AP2_CHECKOUT_PROFILE, + AP2_DEFAULT_CLOCK_SKEW_SECONDS, + AP2_MAX_CLOCK_SKEW_SECONDS, + AP2_REJECTION_REASONS, + AP2_SIGNING_ALGORITHM, + AP2_SPEC_VERSION, + AP2_UNSUPPORTED, + type Ap2AuthorizationConfig, + type Ap2AuthorizationProvider, + type Ap2AuthorizationProviderOptions, + type Ap2Mode, + type Ap2RejectionReason, + type Ap2TrustedIssuer, + type Ap2TrustedKey, + createAp2AuthorizationProvider, + // `ap2` reads well at a call site; the full name reads better in a trace. + createAp2AuthorizationProvider as ap2, + type EnabledAp2Config, +} from './authorization/ap2/index.js'; diff --git a/src/authorization/ap2/descriptor.ts b/src/authorization/ap2/descriptor.ts new file mode 100644 index 0000000..f785f32 --- /dev/null +++ b/src/authorization/ap2/descriptor.ts @@ -0,0 +1,67 @@ +/** + * Adapter self-description. + * + * Imports nothing but core types and the pins, so `doctor` can report AP2 + * without pulling `jose` or `@sd-jwt/core` into the CLI bundle. + */ +import type { AdapterDescriptor } from '../../core/index.js'; +import { + AP2_CHECKOUT_MANDATE_VCT, + AP2_CHECKOUT_PROFILE, + AP2_DIGEST_ALGORITHM, + AP2_SIGNING_ALGORITHM, + AP2_SPEC_VERSION, +} from './constants.js'; + +/** What this provider actually verifies */ +export const AP2_CAPABILITIES: readonly string[] = [ + 'direct-mode', + AP2_CHECKOUT_MANDATE_VCT, + 'sd-jwt-presentation', + AP2_SIGNING_ALGORITHM, + AP2_DIGEST_ALGORITHM, + 'static-inline-trust', + 'merchant-checkout-jwt-binding', + AP2_CHECKOUT_PROFILE, + 'purchase-binding', + 'replay-defence', +]; + +/** + * Everything an AP2 client may reasonably expect and will not get here. + * Complete on purpose: a short list reads as "mostly compatible", which is the + * blanket claim alpha honesty forbids. + */ +export const AP2_UNSUPPORTED: readonly string[] = [ + // Mandate kinds. Open mandates carry spending constraints nothing here + // evaluates, so accepting one would tell a buyer their limits were checked. + 'autonomous mode', + 'open checkout mandates (mandate.checkout.open.1)', + 'intent mandates', + 'cart mandates', + 'spending constraint evaluation', + 'cnf-bound agent keys', + // Key handling. Every key is written into config by an operator + 'JWKS and any key discovery by URL (jku, x5u)', + 'issuer metadata fetching', + 'key rotation without a config change', + // Algorithms + 'signature algorithms other than ES256', + 'digest algorithms other than sha-256', + // Roles this gateway does not play + 'mandate issuance', + 'merchant checkout JWT issuance', + 'AP2 over the ACP checkout adapter', +]; + +export function buildAp2Descriptor(implementationVersion: string): AdapterDescriptor { + return { + name: 'ap2', + kind: 'authorization', + implementationVersion, + supportedSpec: `ap2/v${AP2_SPEC_VERSION} mode=direct`, + capabilities: AP2_CAPABILITIES, + status: 'experimental', + unsupported: AP2_UNSUPPORTED, + }; +} diff --git a/src/authorization/ap2/errors.ts b/src/authorization/ap2/errors.ts index e45d013..d2ff7f1 100644 --- a/src/authorization/ap2/errors.ts +++ b/src/authorization/ap2/errors.ts @@ -6,7 +6,8 @@ * the list below. The machine-readable code goes in `details.reason` and the * original exception on `cause`, which is never serialised. * - * Two codes: AUTHORIZATION_INVALID when the buyer's mandate is bad, + * Three codes: AUTHORIZATION_INVALID when the buyer's mandate is bad, + * AUTHORIZATION_REPLAYED when it is good but spent, and * AUTHORIZATION_PROVIDER_UNAVAILABLE when our verifier never reached a * verdict. Blaming the buyer for our outage refuses a good mandate. */ @@ -65,6 +66,22 @@ export function ap2Rejected( }); } +/** + * The mandate verified but has already been presented. A separate code from + * a bad mandate: nothing is wrong with this proof except that it is spent + */ +export function ap2Replayed(state: string, context: Ap2ErrorContext = {}): CommerceError { + return new CommerceError( + 'AUTHORIZATION_REPLAYED', + 'This mandate has already been presented and cannot authorize another purchase.', + { + details: { method: 'ap2', reason: 'replayed', state }, + ...(context.requestId !== undefined ? { requestId: context.requestId } : {}), + ...(context.resourceId !== undefined ? { resourceId: context.resourceId } : {}), + }, + ); +} + /** * The check never ran (a configured key that will not import, say). Retryable, * and never recorded against the payer: the mandate may be perfectly good. diff --git a/src/authorization/ap2/index.ts b/src/authorization/ap2/index.ts new file mode 100644 index 0000000..fedbee6 --- /dev/null +++ b/src/authorization/ap2/index.ts @@ -0,0 +1,41 @@ +/** + * src/authorization/ap2 + * + * AP2 Direct Checkout Mandate verification. Everything reachable from here + * pulls the optional peers (`jose`, `@sd-jwt/core`, `canonicalize`), so the + * main entry and the CLI import the narrow modules instead of this barrel. + */ +export { + AP2_CHECKOUT_MANDATE_VCT, + AP2_CHECKOUT_PROFILE, + AP2_DEFAULT_CLOCK_SKEW_SECONDS, + AP2_DIGEST_ALGORITHM, + AP2_MAX_CLOCK_SKEW_SECONDS, + AP2_MODES, + AP2_SIGNING_ALGORITHM, + AP2_SPEC_VERSION, + type Ap2Mode, +} from './constants.js'; +export { + AP2_CAPABILITIES, + AP2_UNSUPPORTED, + buildAp2Descriptor, +} from './descriptor.js'; +export { AP2_REJECTION_REASONS, type Ap2RejectionReason } from './errors.js'; +export { + type Ap2AuthorizationProvider, + type Ap2AuthorizationProviderOptions, + createAp2AuthorizationProvider, +} from './provider.js'; +export { + type Ap2AuthorizationState, + type Ap2ReplayStore, + createAp2ReplayStore, +} from './replay-store.js'; +export type { + Ap2AuthorizationConfig, + Ap2TrustedIssuer, + Ap2TrustedKey, + EnabledAp2Config, + VerifiedCheckoutMandate, +} from './types.js'; diff --git a/src/authorization/ap2/provider.ts b/src/authorization/ap2/provider.ts new file mode 100644 index 0000000..86c3ba8 --- /dev/null +++ b/src/authorization/ap2/provider.ts @@ -0,0 +1,163 @@ +/** + * The AP2 authorization provider: the seam between core's generic contract and + * the mandate machinery. + * + * Owns the replay database, so the gateway owns this object's lifetime and + * closes it on shutdown. + */ +import type { + AdapterHealth, + AuthorizationFinalizeContext, + AuthorizationProvider, + AuthorizationRequirement, + AuthorizationVerification, + AuthorizationVerificationContext, + Clock, + Logger, +} from '../../core/index.js'; +import { NOOP_LOGGER, systemClock } from '../../core/index.js'; +import { PACKAGE_VERSION } from '../../version.js'; +import { AP2_CHECKOUT_PROFILE, AP2_SPEC_VERSION } from './constants.js'; +import { buildAp2Descriptor } from './descriptor.js'; +import { type Ap2ErrorContext, ap2Replayed, ap2Unavailable } from './errors.js'; +import { bindMandateToPurchase } from './profile.js'; +import { type Ap2ReplayStore, createAp2ReplayStore } from './replay-store.js'; +import type { EnabledAp2Config } from './types.js'; +import { createAp2MandateVerifier } from './verifier.js'; + +export interface Ap2AuthorizationProviderOptions { + readonly config: EnabledAp2Config; + readonly clock?: Clock; + readonly logger?: Logger; + /** Injectable so tests need not touch the filesystem */ + readonly replayStore?: Ap2ReplayStore; +} + +export interface Ap2AuthorizationProvider extends AuthorizationProvider { + /** Closes the replay database */ + close(): void; +} + +/** + * A reservation is keyed by the mandate reference, so the handle and the + * receipt's identity are the same digest. Kept as one name rather than two + * fields that must never disagree. + */ +export function createAp2AuthorizationProvider( + options: Ap2AuthorizationProviderOptions, +): Ap2AuthorizationProvider { + const clock = options.clock ?? systemClock; + const logger = options.logger ?? NOOP_LOGGER; + + const verifier = createAp2MandateVerifier({ config: options.config, clock }); + const replay = + options.replayStore ?? createAp2ReplayStore({ path: options.config.replay.path, logger }); + + const requirement: AuthorizationRequirement = { + method: 'ap2', + version: AP2_SPEC_VERSION, + profile: AP2_CHECKOUT_PROFILE, + }; + + async function verifyAndReserve( + context: AuthorizationVerificationContext, + ): Promise { + const errorContext: Ap2ErrorContext = { + requestId: context.requestId, + resourceId: context.resourceId, + }; + + const mandate = await verifier.verify(context.submission.payload, errorContext); + // Before reserving, not after: a mandate that does not authorize this + // purchase must stay spendable on the purchase it does authorize + await bindMandateToPurchase(mandate, context, errorContext); + + let reservation: ReturnType; + try { + reservation = replay.reserve({ + reference: mandate.reference, + checkoutJti: mandate.checkoutJwtId, + mandateIssuer: mandate.mandateIssuer, + checkoutIssuer: mandate.checkoutIssuer, + resourceId: context.resourceId, + requestId: context.requestId, + }); + } catch (cause) { + throw ap2Unavailable('replay store could not be written', { ...errorContext, cause }); + } + + if (reservation.kind === 'replayed') { + throw ap2Replayed(reservation.state, errorContext); + } + + return { + status: 'verified', + method: 'ap2', + reference: mandate.reference, + reservationId: mandate.reference, + // Opaque identifiers an operator can reconcile with. Never a claim from + // the mandate: those carry the buyer's purchase and their personal data. + // `checkoutId`, not `checkoutJwtId` - the receipt store redacts any key + // that reads as secret-shaped, and "jwt" is on that list. + metadata: { + mandateIssuer: mandate.mandateIssuer, + checkoutIssuer: mandate.checkoutIssuer, + checkoutId: mandate.checkoutJwtId, + }, + }; + } + + const finalize = ( + action: 'consume' | 'release' | 'markUncertain', + reservationId: string, + context: AuthorizationFinalizeContext, + ): void => { + try { + replay[action](reservationId); + } catch (cause) { + throw ap2Unavailable(`replay store could not record ${action}`, { ...context, cause }); + } + }; + + return { + name: 'ap2', + descriptor: buildAp2Descriptor(PACKAGE_VERSION), + requirement, + verifyAndReserve, + + async consume(reservationId, context) { + finalize('consume', reservationId, context); + }, + async release(reservationId, context) { + finalize('release', reservationId, context); + }, + async markUncertain(reservationId, context) { + finalize('markUncertain', reservationId, context); + }, + + async health(): Promise { + const startedAt = clock.monotonicMs(); + const trusted = verifier.trustedIssuers(); + const checkedAt = clock.nowIso(); + const durationMs = Math.round(clock.monotonicMs() - startedAt); + try { + // A read against the real table, so a database that opened but cannot + // be queried is caught here rather than on the first purchase + replay.stateOf('health-probe'); + } catch { + // A fixed token, never a sentence built from the caught error + return { status: 'fail', checkedAt, durationMs, detail: 'replay-store-unavailable' }; + } + return { + status: 'pass', + checkedAt, + durationMs, + detail: `mandate-issuers=${trusted.mandate.length} checkout-issuers=${trusted.checkout.length}`, + }; + }, + + close() { + replay.close(); + }, + }; +} diff --git a/src/cli/commands/doctor.ts b/src/cli/commands/doctor.ts index 5ae9737..6d30b65 100644 --- a/src/cli/commands/doctor.ts +++ b/src/cli/commands/doctor.ts @@ -1,6 +1,15 @@ import { accessSync, existsSync, constants as fsConstants } from 'node:fs'; import { dirname } from 'node:path'; import picocolors from 'picocolors'; +// Constants and a descriptor only. Importing the AP2 provider here would pull +// jose and @sd-jwt/core into the CLI bundle and make two optional peers +// mandatory for anyone running `agent-commerce`. +import { + AP2_CHECKOUT_MANDATE_VCT, + AP2_CHECKOUT_PROFILE, + AP2_SPEC_VERSION, +} from '../../authorization/ap2/constants.js'; +import { AP2_UNSUPPORTED } from '../../authorization/ap2/descriptor.js'; import { extractPathParameterNames } from '../../core/execution/index.js'; import { type CommerceResource, isCommerceError, type ReceiptStore } from '../../core/index.js'; import { @@ -175,6 +184,73 @@ function findX402Mismatch(configured: LiveX402, live: LiveX402): string | undefi return `${diffs.join('; ')} — the gateway may be running against an older deployment; restart it or re-run chain:deploy`; } +// Issuer ids and how many keys each carries. Never a key +function describeIssuers( + label: string, + issuers: readonly { readonly issuer: string; readonly keys: readonly unknown[] }[], +): string { + const described = issuers + .map( + (entry) => `${entry.issuer} (${entry.keys.length} key${entry.keys.length === 1 ? '' : 's'})`, + ) + .join(', '); + return `${label} issuers: ${described}`; +} + +/** + * Whether an existing file can be written, without creating one. Shared by the + * two store checks below: each loses its guarantee on an unwritable file, and + * a second copy of the probe is how one of them reports PASS on a path the + * gateway cannot actually use. + */ +function isWritableFile(path: string): boolean { + try { + accessSync(path, fsConstants.W_OK); + return true; + } catch { + return false; + } +} + +/** + * The AP2 replay database, diagnosed without creating it. A read-only command + * that produced the file would report a healthy empty store and shadow the + * real one, exactly as the receipt-store check describes. + */ +function ap2ReplayCheck(path: string): DoctorCheck { + const name = 'AP2 replay store'; + if (path === ':memory:') { + return { + name, + status: 'WARN', + detail: + 'in-memory store - every spent mandate is forgotten on restart, so one could authorize a second purchase; use a file path in production', + }; + } + if (existsSync(path)) { + return isWritableFile(path) + ? { name, status: 'PASS', detail: `writable at "${path}"` } + : { + name, + status: 'FAIL', + detail: `"${path}" exists but is not writable by this user - no mandate could be recorded as spent`, + }; + } + const directory = dirname(path); + if (directory !== '' && directory !== '.' && !existsSync(directory)) { + return { + name, + status: 'WARN', + detail: `no store yet at "${path}" and its directory does not exist - it is created when the gateway starts, provided that path is writable`, + }; + } + return { + name, + status: 'WARN', + detail: `no store yet at "${path}" - it is created the first time the gateway starts`, + }; +} + /** * The idempotency database, diagnosed without creating it: `doctor` is * read-only, and opening a mistyped path would leave a fresh empty database @@ -203,16 +279,13 @@ function acpIdempotencyCheck(idempotency: { } const directory = dirname(idempotency.path); if (existsSync(idempotency.path)) { - try { - accessSync(idempotency.path, fsConstants.W_OK); - return { name: 'ACP idempotency', status: 'PASS', detail: `writable, ${retention}` }; - } catch { - return { - name: 'ACP idempotency', - status: 'FAIL', - detail: `"${idempotency.path}" exists but is not writable by this user — the adapter cannot claim idempotency keys`, - }; - } + return isWritableFile(idempotency.path) + ? { name: 'ACP idempotency', status: 'PASS', detail: `writable, ${retention}` } + : { + name: 'ACP idempotency', + status: 'FAIL', + detail: `"${idempotency.path}" exists but is not writable by this user — the adapter cannot claim idempotency keys`, + }; } if (directory !== '' && directory !== '.' && !existsSync(directory)) { return { @@ -442,6 +515,55 @@ export async function runDoctor( }); } + // 5d. AP2 authorization. Read from config and the pins only. Verification + // keys are public, but they are still trust policy an operator did not ask + // this report to print, so only issuer ids and key counts appear. + const ap2 = config?.authorization?.ap2; + if (config === undefined) { + checks.push({ name: 'AP2', status: 'WARN', detail: 'skipped — config invalid' }); + } else if (ap2 === undefined || !ap2.enabled) { + checks.push({ name: 'AP2', status: 'INFO', detail: 'disabled' }); + } else { + checks.push({ + name: 'AP2', + status: 'PASS', + detail: `experimental · spec ${AP2_SPEC_VERSION} · mode ${ap2.mode} · ${AP2_CHECKOUT_MANDATE_VCT} · profile ${AP2_CHECKOUT_PROFILE} · clock skew ${ap2.clockSkewSeconds}s`, + }); + + // Two lists, reported separately: signing the merchant's checkout + // documents must not read as the power to issue mandates. + checks.push({ + name: 'AP2 trust', + status: 'PASS', + detail: `${describeIssuers('mandate', ap2.trust.mandateIssuers)} · ${describeIssuers('checkout', ap2.trust.checkoutIssuers)}`, + }); + + checks.push(ap2ReplayCheck(ap2.replay.path)); + + // Resource ids, so an operator can see exactly which purchases now need a + // mandate. An empty list means AP2 is configured and gating nothing. + const gated = config.resources.filter((resource) => + resource.authorization?.required.includes('ap2'), + ); + checks.push({ + name: 'AP2 resources', + status: gated.length > 0 ? 'PASS' : 'WARN', + detail: + gated.length > 0 + ? gated.map((resource) => resource.id).join(', ') + : 'AP2 is enabled but no resource requires it — every purchase settles without a mandate', + }); + + // Listed in full, never summarised as a count: "14 unsupported" tells an + // operator nothing about whether the one thing their client sends is + // among them. + checks.push({ + name: 'AP2 unsupported', + status: 'INFO', + detail: AP2_UNSUPPORTED.join(', '), + }); + } + // 6. Payments const x402 = config?.payments.x402; if (x402 === undefined || !x402.enabled) { diff --git a/src/gateway/main.ts b/src/gateway/main.ts index 0290ddb..c552278 100644 --- a/src/gateway/main.ts +++ b/src/gateway/main.ts @@ -12,11 +12,15 @@ * store that will not open is fatal, not degraded; * 3. build payment providers — a paid resource with no working provider must * fail closed at request time, not be quietly downgraded to free; + * 3b. build authorization providers — same reasoning, and the replay database + * opens here, so a mandate store that will not open stops startup; * 4. build protocol adapters — these are isolated: one failing to start is * reported unhealthy and does not stop the others; * 5. listen, then print the effective settlement destination so an operator * or presenter can see where money actually goes. */ +import type { Ap2AuthorizationProvider } from '../authorization/ap2/index.js'; +import { createAp2AuthorizationProvider } from '../authorization/ap2/index.js'; import { loadConfig } from '../config/index.js'; import type { PaymentProvider, ProtocolAdapter, ReceiptStore } from '../core/index.js'; import { CommerceError, isCommerceError } from '../core/index.js'; @@ -72,6 +76,14 @@ async function main(): Promise { ); } + // Built only when enabled: the replay database is opened by the constructor, + // so a disabled AP2 block creates no file and holds no handle. + const authorizationProviders: Ap2AuthorizationProvider[] = []; + const ap2 = config.authorization?.ap2; + if (ap2?.enabled) { + authorizationProviders.push(createAp2AuthorizationProvider({ config: ap2, logger })); + } + // A paid resource with no provider is a configuration error we can catch now // rather than discovering it on the first purchase attempt. Config validation // already rejects this, so reaching here means the two drifted apart. @@ -123,6 +135,7 @@ async function main(): Promise { config, store, paymentProviders, + authorizationProviders, protocolAdapters, logger, }); @@ -136,6 +149,7 @@ async function main(): Promise { resources: config.resources.length, protocols: protocolAdapters.map((adapter) => adapter.name), payments: paymentProviders.map((provider) => provider.name), + authorization: authorizationProviders.map((provider) => provider.name), }, 'gateway listening', ); @@ -167,6 +181,7 @@ async function main(): Promise { void (async () => { try { await gateway.close(); + for (const provider of authorizationProviders) provider.close(); await store.close(); process.exit(0); } catch (error) { diff --git a/src/gateway/readiness.ts b/src/gateway/readiness.ts index 9fddf2d..bd2815b 100644 --- a/src/gateway/readiness.ts +++ b/src/gateway/readiness.ts @@ -1,8 +1,9 @@ /** * `GET /ready` readiness computation: 503 unless the store, every configured - * protocol adapter, AND every payment provider are healthy. `status: 'warn'` - * is treated as still-serving (degraded); only `status: 'fail'` blocks - * readiness — applied uniformly to all three kinds of dependency. + * protocol adapter, every payment provider AND every authorization provider + * are healthy. `status: 'warn'` is treated as still-serving (degraded); only + * `status: 'fail'` blocks readiness — applied uniformly to all four kinds of + * dependency. * * Payment providers are consulted here alongside the store and protocol * adapters: without that, a gateway whose x402 RPC is unreachable reports @@ -26,7 +27,14 @@ * READINESS_TTL_MS and collapses concurrent callers onto one in-flight * evaluation, so a burst of N requests produces at most one real check. */ -import type { AdapterHealth, Clock, Logger, PaymentProvider, ReceiptStore } from '../core/index.js'; +import type { + AdapterHealth, + AuthorizationProvider, + Clock, + Logger, + PaymentProvider, + ReceiptStore, +} from '../core/index.js'; import { type AdapterRuntime, getAdapterHealth } from './adapters.js'; export interface ReadinessCheck { @@ -40,12 +48,14 @@ export interface ReadinessResult { readonly store: ReadinessCheck; readonly adapters: readonly ReadinessCheck[]; readonly paymentProviders: readonly ReadinessCheck[]; + readonly authorizationProviders: readonly ReadinessCheck[]; } export interface CheckReadinessOptions { readonly store: ReceiptStore; readonly adapterRuntimes: readonly AdapterRuntime[]; readonly paymentProviders: readonly PaymentProvider[]; + readonly authorizationProviders: readonly AuthorizationProvider[]; readonly clock: Clock; readonly logger: Logger; } @@ -68,6 +78,46 @@ const PAYMENT_PROVIDER_DETAIL: Readonly> = + { + pass: undefined, + warn: 'authorization-provider-degraded', + fail: 'authorization-provider-unreachable', + }; + +/** + * One probe for both provider kinds. A provider whose `health()` throws told us + * nothing, so it counts as failing rather than as absent, and only the fixed + * vocabulary above reaches the client. + */ +async function probeProvider( + provider: { readonly name: string; health(): Promise }, + kind: string, + details: Readonly>, + options: Pick, +): Promise { + let health: AdapterHealth; + try { + health = await provider.health(); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + options.logger.error({ err: message, provider: provider.name }, `${kind} health() threw`); + health = { status: 'fail', checkedAt: options.clock.nowIso() }; + } + if (health.detail !== undefined) { + options.logger.debug( + { provider: provider.name, detail: health.detail }, + `${kind} health detail (not sent to the client)`, + ); + } + const detail = details[health.status]; + return { + name: provider.name, + status: health.status, + ...(detail !== undefined ? { detail } : {}), + }; +} + export async function checkReadiness(options: CheckReadinessOptions): Promise { let storeHealth: AdapterHealth; let storeThrew = false; @@ -105,40 +155,30 @@ export async function checkReadiness(options: CheckReadinessOptions): Promise => { - let health: AdapterHealth; - try { - health = await provider.health(); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - options.logger.error( - { err: message, provider: provider.name }, - 'payment provider health() threw', - ); - health = { status: 'fail', checkedAt: options.clock.nowIso() }; - } - if (health.detail !== undefined) { - options.logger.debug( - { provider: provider.name, detail: health.detail }, - 'payment provider health detail (not sent to the client)', - ); - } - const detail = PAYMENT_PROVIDER_DETAIL[health.status]; - return { - name: provider.name, - status: health.status, - ...(detail !== undefined ? { detail } : {}), - }; - }), + options.paymentProviders.map((provider) => + probeProvider(provider, 'payment provider', PAYMENT_PROVIDER_DETAIL, options), + ), + ); + + // An unusable authorization provider blocks readiness on the same threshold + // as a payment one: a resource that requires a mandate cannot be served + // without it, and serving the challenge anyway promises what we cannot honour + const authorizationProviderChecks = await Promise.all( + options.authorizationProviders.map((provider) => + probeProvider(provider, 'authorization provider', AUTHORIZATION_PROVIDER_DETAIL, options), + ), ); const storeReady = storeHealth.status !== 'fail'; const adaptersReady = adapterChecks.every((check) => check.status !== 'fail'); const paymentProvidersReady = paymentProviderChecks.every((check) => check.status !== 'fail'); + const authorizationProvidersReady = authorizationProviderChecks.every( + (check) => check.status !== 'fail', + ); const storeDetail = storeThrew ? 'store-unreachable' : STORE_DETAIL[storeHealth.status]; return { - ready: storeReady && adaptersReady && paymentProvidersReady, + ready: storeReady && adaptersReady && paymentProvidersReady && authorizationProvidersReady, store: { name: 'store', status: storeHealth.status, @@ -146,6 +186,7 @@ export async function checkReadiness(options: CheckReadinessOptions): Promise { + parties = await createParties(); +}); + +afterEach(async () => { + await gateway?.close().catch(() => {}); + provider?.close(); + gateway = undefined; + provider = undefined; +}); + +const backend: BackendExecutor = { + async call() { + return { status: 200, body: { forecast: 'sunny' }, headers: {}, durationMs: 1 }; + }, +}; + +function ap2Config(): EnabledAp2Config { + return { + enabled: true, + specVersion: '0.2.0', + mode: 'direct', + trust: { mandateIssuers: parties.mandateIssuers, checkoutIssuers: parties.checkoutIssuers }, + clockSkewSeconds: 60, + replay: { path: ':memory:' }, + }; +} + +/** + * Three resources: one gated by AP2, one paid but ungated, one free. The + * second and third are what proves enabling AP2 is not a gateway-wide switch. + */ +function config(): GatewayConfig { + return { + version: 1, + merchant: { id: 'demo-store', name: 'Demo Store', publicBaseUrl: 'http://localhost:8080' }, + server: { port: 0, host: '127.0.0.1', allowedOrigins: [] }, + storage: { receipts: { driver: 'sqlite', path: ':memory:' } }, + protocols: { + http: { enabled: true }, + mcp: { enabled: false, mountPath: '/mcp' }, + a2a: { enabled: false, mountPath: '/a2a' }, + acp: { enabled: false, mountPath: '/acp' }, + }, + resources: [ + { + id: 'gated_report', + name: 'Gated Report', + handler: { type: 'http', method: 'GET', url: 'http://backend.local/report' }, + pricing: { type: 'fixed', amount: '0.01', currency: 'USDC' }, + exposedVia: ['http'], + paymentMethods: ['x402'], + authorization: { required: ['ap2'] }, + }, + { + id: 'paid_report', + name: 'Paid Report', + handler: { type: 'http', method: 'GET', url: 'http://backend.local/report' }, + pricing: { type: 'fixed', amount: '0.01', currency: 'USDC' }, + exposedVia: ['http'], + paymentMethods: ['x402'], + }, + { + id: 'free_report', + name: 'Free Report', + handler: { type: 'http', method: 'GET', url: 'http://backend.local/report' }, + pricing: { type: 'free' }, + exposedVia: ['http'], + paymentMethods: [], + }, + ], + payments: {}, + }; +} + +async function startGateway( + authorizationProviders: readonly AuthorizationProvider[], +): Promise { + gateway = await createGateway({ + config: config(), + store: createFakeStore(), + paymentProviders: [createFakePaymentProvider()], + authorizationProviders, + protocolAdapters: [], + backend, + }); + return gateway; +} + +function startAp2(replayStore?: Ap2ReplayStore): AuthorizationProvider { + const created = createAp2AuthorizationProvider({ + config: ap2Config(), + clock: fixedClock(), + ...(replayStore !== undefined ? { replayStore } : {}), + }); + provider = created; + return created; +} + +async function invoke( + gw: GatewayInstance, + resourceId: string, + headers: Record = {}, +): Promise<{ statusCode: number; body: Record }> { + const res = await gw.server.inject({ + method: 'POST', + url: `/api/resources/${resourceId}/invoke`, + headers: { 'content-type': 'application/json', ...headers }, + payload: {}, + }); + return { statusCode: res.statusCode, body: res.json>() }; +} + +function encodeCarrier(payload: string): string { + return Buffer.from(JSON.stringify({ method: 'ap2', payload }), 'utf8').toString('base64url'); +} + +/** + * A mandate that authorizes exactly what `createFakePaymentProvider` requires + * of `gated_report`, so a refusal can only come from the wiring under test. + */ +async function validCarrier(): Promise { + const jwt = await signCheckoutJwt( + parties.checkoutSigner, + checkoutPayload({ + agent_commerce: { + profile: AP2_CHECKOUT_PROFILE, + resource_id: 'gated_report', + input_hash: await computeInputHash({}), + amount: '0.01', + currency: 'USDC', + payment_method: 'x402', + destination: '0xMERCHANT', + }, + }), + ); + return encodeCarrier(await mintMandate(parties.mandateSigner, jwt)); +} + +describe('AP2 wired into the gateway', () => { + it('advertises the mandate a gated resource needs alongside its 402 challenge', async () => { + const gw = await startGateway([startAp2()]); + + const { statusCode, body } = await invoke(gw, 'gated_report'); + + expect(statusCode).toBe(402); + expect(body['authorization']).toEqual({ + required: [{ method: 'ap2', version: '0.2.0', profile: 'agent-commerce/ap2/checkout/v1' }], + }); + }); + + it('leaves the challenge for an ungated paid resource untouched', async () => { + const gw = await startGateway([startAp2()]); + + const { statusCode, body } = await invoke(gw, 'paid_report'); + + expect(statusCode).toBe(402); + expect(body['authorization']).toBeUndefined(); + }); + + it('serves a free resource with AP2 enabled exactly as before', async () => { + const gw = await startGateway([startAp2()]); + + const { statusCode } = await invoke(gw, 'free_report'); + + expect(statusCode).toBe(200); + }); + + it('refuses a gated purchase whose proof is missing, with a payment proof present', async () => { + const gw = await startGateway([startAp2()]); + + const { statusCode, body } = await invoke(gw, 'gated_report', { + 'payment-signature': 'x402-proof', + }); + + expect(statusCode).toBe(403); + expect(body['code']).toBe('AUTHORIZATION_REQUIRED'); + }); + + it('delivers a gated purchase and records the mandate digest on the receipt', async () => { + const store = createFakeStore(); + gateway = await createGateway({ + config: config(), + store, + paymentProviders: [createFakePaymentProvider()], + authorizationProviders: [startAp2()], + protocolAdapters: [], + backend, + }); + + const { statusCode } = await invoke(gateway, 'gated_report', { + 'payment-signature': 'x402-proof', + [AUTHORIZATION_HEADER]: await validCarrier(), + }); + + expect(statusCode).toBe(200); + const receipt = store.receipts[0]; + expect(receipt?.authorization?.method).toBe('ap2'); + expect(receipt?.authorization?.reference).toMatch(/^sha256:[\w-]+$/); + // The proof itself never reaches the record + expect(JSON.stringify(receipt)).not.toContain('eyJ'); + }); + + it('refuses a proof that is not a mandate as invalid, never as a payment failure', async () => { + const gw = await startGateway([startAp2()]); + + const { statusCode, body } = await invoke(gw, 'gated_report', { + 'payment-signature': 'x402-proof', + [AUTHORIZATION_HEADER]: encodeCarrier('not-a-mandate'), + }); + + expect(statusCode).toBe(403); + expect(body['code']).toBe('AUTHORIZATION_INVALID'); + }); + + describe('a verifier whose store is broken', () => { + const brokenStore = (): Ap2ReplayStore => { + const boom = (): never => { + throw new Error('sqlite: disk I/O error'); + }; + return { + reserve: boom, + consume: boom, + release: boom, + markUncertain: boom, + stateOf: boom, + close: () => {}, + }; + }; + + it('reports a good mandate it cannot record as unavailable, not as a bad mandate', async () => { + const gw = await startGateway([startAp2(brokenStore())]); + + const { statusCode, body } = await invoke(gw, 'gated_report', { + 'payment-signature': 'x402-proof', + [AUTHORIZATION_HEADER]: await validCarrier(), + }); + + // 503 and retryable: the mandate verified, and our store is what failed + expect(statusCode).toBe(503); + expect(body['code']).toBe('AUTHORIZATION_PROVIDER_UNAVAILABLE'); + expect(body['retryable']).toBe(true); + }); + + it('keeps serving every resource that does not require a mandate', async () => { + const gw = await startGateway([startAp2(brokenStore())]); + + expect((await invoke(gw, 'free_report')).statusCode).toBe(200); + expect((await invoke(gw, 'paid_report')).statusCode).toBe(402); + }); + + it('blocks readiness, since a gated purchase cannot be honoured', async () => { + const gw = await startGateway([startAp2(brokenStore())]); + + const res = await gw.server.inject({ method: 'GET', url: '/ready' }); + const body = res.json<{ + ready: boolean; + authorizationProviders: { name: string; status: string; detail?: string }[]; + }>(); + + expect(res.statusCode).toBe(503); + expect(body.ready).toBe(false); + expect(body.authorizationProviders).toEqual([ + { name: 'ap2', status: 'fail', detail: 'authorization-provider-unreachable' }, + ]); + // A fixed vocabulary token: `/ready` is unauthenticated + expect(JSON.stringify(body)).not.toContain('disk I/O'); + }); + }); + + it('reports a healthy provider on /ready without naming a key', async () => { + const gw = await startGateway([startAp2()]); + + const res = await gw.server.inject({ method: 'GET', url: '/ready' }); + const body = res.json<{ ready: boolean; authorizationProviders: { status: string }[] }>(); + + expect(res.statusCode).toBe(200); + expect(body.ready).toBe(true); + expect(body.authorizationProviders).toEqual([{ name: 'ap2', status: 'pass' }]); + expect(JSON.stringify(body)).not.toContain(parties.mandateSigner.publicJwk['x']); + }); + + it('runs with no authorization provider at all, which is the default', async () => { + const gw = await startGateway([]); + + const res = await gw.server.inject({ method: 'GET', url: '/ready' }); + const body = res.json<{ ready: boolean; authorizationProviders: unknown[] }>(); + + expect(body.ready).toBe(true); + expect(body.authorizationProviders).toEqual([]); + // The resource still declares `authorization.required`, and with no + // provider to check it the pipeline refuses rather than serving it + // A misconfigured gateway is our fault, not the caller's, so 500 + const { statusCode, body: invoked } = await invoke(gw, 'gated_report'); + expect(statusCode).toBe(500); + expect(invoked['code']).toBe('CONFIG_INVALID'); + }); +}); diff --git a/tests/unit/authorization-ap2/provider.test.ts b/tests/unit/authorization-ap2/provider.test.ts new file mode 100644 index 0000000..d649e44 --- /dev/null +++ b/tests/unit/authorization-ap2/provider.test.ts @@ -0,0 +1,335 @@ +/** + * The provider is the seam between core's generic contract and the mandate + * machinery. These own what that seam is responsible for: the right error code + * for each kind of failure, the reservation lifecycle, and a health probe that + * tells the truth about a store it cannot read. + */ +import { beforeAll, describe, expect, it } from 'vitest'; +import { + AP2_CHECKOUT_PROFILE, + AP2_SPEC_VERSION, +} from '../../../src/authorization/ap2/constants.js'; +import { AP2_UNSUPPORTED } from '../../../src/authorization/ap2/descriptor.js'; +import { computeInputHash } from '../../../src/authorization/ap2/profile.js'; +import { + type Ap2AuthorizationProvider, + createAp2AuthorizationProvider, +} from '../../../src/authorization/ap2/provider.js'; +import { + type Ap2ReplayStore, + createAp2ReplayStore, +} from '../../../src/authorization/ap2/replay-store.js'; +import type { EnabledAp2Config } from '../../../src/authorization/ap2/types.js'; +import type { + AuthorizationVerificationContext, + PaymentRequirement, +} from '../../../src/core/index.js'; +import { isCommerceError } from '../../../src/core/index.js'; +import { + checkoutPayload, + createParties, + fixedClock, + mintMandate, + type Party, + signCheckoutJwt, +} from './fixtures.js'; + +const RESOURCE_ID = 'market_report'; +const INPUT = { city: 'Berlin' }; + +let parties: Party; +let inputHash: string; + +function config(overrides: Partial = {}): EnabledAp2Config { + return { + enabled: true, + specVersion: '0.2.0', + mode: 'direct', + trust: { mandateIssuers: parties.mandateIssuers, checkoutIssuers: parties.checkoutIssuers }, + clockSkewSeconds: 60, + replay: { path: ':memory:' }, + ...overrides, + }; +} + +function requirement(): PaymentRequirement { + return { + id: 'pr-1', + requestId: 'req-1', + resourceId: RESOURCE_ID, + provider: 'x402', + amount: '0.01', + currency: 'USDC', + destination: '0xMERCHANT', + challenge: { provider: 'x402', version: '2', accepts: [] }, + }; +} + +function context(payload: string, requestId = 'req-1'): AuthorizationVerificationContext { + return { + requestId, + resourceId: RESOURCE_ID, + input: INPUT, + submission: { method: 'ap2', payload }, + requirement: requirement(), + }; +} + +// A mandate that authorizes exactly the purchase `requirement()` describes +async function validMandate(overrides: Record = {}): Promise { + const jwt = await signCheckoutJwt( + parties.checkoutSigner, + checkoutPayload({ + agent_commerce: { + profile: AP2_CHECKOUT_PROFILE, + resource_id: RESOURCE_ID, + input_hash: inputHash, + amount: '0.01', + currency: 'USDC', + payment_method: 'x402', + // Required whenever the requirement names one, which x402 always does + destination: '0xMERCHANT', + ...overrides, + }, + }), + ); + return mintMandate(parties.mandateSigner, jwt); +} + +function makeProvider(replayStore?: Ap2ReplayStore): Ap2AuthorizationProvider { + return createAp2AuthorizationProvider({ + config: config(), + clock: fixedClock(), + ...(replayStore !== undefined ? { replayStore } : {}), + }); +} + +async function codeOf(run: () => Promise): Promise { + try { + await run(); + } catch (error) { + return isCommerceError(error) ? error.code : `untyped:${String(error)}`; + } + return 'no-error'; +} + +beforeAll(async () => { + parties = await createParties(); + inputHash = await computeInputHash(INPUT); +}); + +describe('createAp2AuthorizationProvider', () => { + it('describes itself as an experimental authorization adapter', () => { + const provider = makeProvider(); + expect(provider.name).toBe('ap2'); + expect(provider.descriptor.kind).toBe('authorization'); + expect(provider.descriptor.status).toBe('experimental'); + expect(provider.descriptor.supportedSpec).toContain(AP2_SPEC_VERSION); + expect(provider.descriptor.capabilities).toContain('direct-mode'); + provider.close(); + }); + + it('names what it does not do, rather than summarising it as a count', () => { + const provider = makeProvider(); + expect(provider.descriptor.unsupported).toEqual(AP2_UNSUPPORTED); + // The two an operator is most likely to assume they have + expect(AP2_UNSUPPORTED).toContain('autonomous mode'); + expect(AP2_UNSUPPORTED).toContain('open checkout mandates (mandate.checkout.open.1)'); + provider.close(); + }); + + it('advertises the spec version and the profile a retry must carry', () => { + const provider = makeProvider(); + expect(provider.requirement).toEqual({ + method: 'ap2', + version: AP2_SPEC_VERSION, + profile: AP2_CHECKOUT_PROFILE, + }); + provider.close(); + }); + + it('verifies a mandate, reserves it, and returns a digest rather than the proof', async () => { + const provider = makeProvider(); + const presentation = await validMandate(); + + const verification = await provider.verifyAndReserve(context(presentation)); + + expect(verification.status).toBe('verified'); + expect(verification.method).toBe('ap2'); + expect(verification.reference).toMatch(/^sha256:[\w-]+$/); + // One digest, used as both the audit identity and the reservation handle, + // so the two can never name different mandates + expect(verification.reservationId).toBe(verification.reference); + expect(JSON.stringify(verification)).not.toContain(presentation); + provider.close(); + }); + + it('records only opaque identifiers on the verification, never mandate claims', async () => { + const provider = makeProvider(); + + const verification = await provider.verifyAndReserve(context(await validMandate())); + + expect(verification.metadata).toEqual({ + mandateIssuer: 'https://trusted-surface.example', + checkoutIssuer: 'https://merchant.example', + checkoutId: 'checkout_01KTEST', + }); + // The receipt store redacts any key that reads as secret-shaped, so a + // field named for the JWT would persist as [REDACTED] + expect(Object.keys(verification.metadata ?? {})).not.toContain('checkoutJwtId'); + provider.close(); + }); + + it('refuses a second presentation of one mandate as replayed, not as invalid', async () => { + const provider = makeProvider(); + const presentation = await validMandate(); + + await provider.verifyAndReserve(context(presentation)); + const code = await codeOf(() => provider.verifyAndReserve(context(presentation, 'req-2'))); + + expect(code).toBe('AUTHORIZATION_REPLAYED'); + provider.close(); + }); + + it('refuses a mandate for a different purchase as invalid', async () => { + const provider = makeProvider(); + const presentation = await validMandate({ amount: '500.00' }); + + const code = await codeOf(() => provider.verifyAndReserve(context(presentation))); + + expect(code).toBe('AUTHORIZATION_INVALID'); + provider.close(); + }); + + it('refuses a mandate from an untrusted issuer as invalid', async () => { + const provider = makeProvider(); + const jwt = await signCheckoutJwt(parties.checkoutSigner, checkoutPayload()); + const presentation = await mintMandate(parties.stranger, jwt); + + const code = await codeOf(() => provider.verifyAndReserve(context(presentation))); + + expect(code).toBe('AUTHORIZATION_INVALID'); + provider.close(); + }); + + it('does not reserve a mandate that fails to bind to the purchase', async () => { + const reserved: string[] = []; + const provider = makeProvider(recordingStore(reserved)); + const presentation = await validMandate({ amount: '9.99' }); + + await codeOf(() => provider.verifyAndReserve(context(presentation))); + + // Otherwise the buyer's own mandate is spent by the purchase it does not + // authorize, and unusable for the one it does + expect(reserved).toEqual([]); + provider.close(); + }); + + it('reports a replay-store failure as unavailable, not as a bad mandate', async () => { + const provider = makeProvider(throwingStore()); + const presentation = await validMandate(); + + const code = await codeOf(() => provider.verifyAndReserve(context(presentation))); + + expect(code).toBe('AUTHORIZATION_PROVIDER_UNAVAILABLE'); + provider.close(); + }); + + describe('finalization', () => { + it('moves a reservation to consumed, released or uncertain', async () => { + for (const [action, expected] of [ + ['consume', 'consumed'], + ['release', 'released'], + ['markUncertain', 'uncertain'], + ] as const) { + const store = createAp2ReplayStore({ path: ':memory:' }); + const provider = makeProvider(store); + const verification = await provider.verifyAndReserve(context(await validMandate())); + + await provider[action](verification.reservationId, { + requestId: 'req-1', + resourceId: RESOURCE_ID, + }); + + expect(store.stateOf(verification.reference)).toBe(expected); + provider.close(); + } + }); + + it('lets a released mandate authorize a corrected retry', async () => { + const store = createAp2ReplayStore({ path: ':memory:' }); + const provider = makeProvider(store); + const presentation = await validMandate(); + const first = await provider.verifyAndReserve(context(presentation)); + await provider.release(first.reservationId, { + requestId: 'req-1', + resourceId: RESOURCE_ID, + }); + + const second = await provider.verifyAndReserve(context(presentation, 'req-2')); + + expect(second.reference).toBe(first.reference); + provider.close(); + }); + + it('reports a store failure during finalization as unavailable', async () => { + const provider = makeProvider(throwingStore()); + + const code = await codeOf(() => + provider.consume('sha256:whatever', { requestId: 'req-1', resourceId: RESOURCE_ID }), + ); + + expect(code).toBe('AUTHORIZATION_PROVIDER_UNAVAILABLE'); + provider.close(); + }); + }); + + describe('health', () => { + it('passes and reports how many issuers are trusted, never a key', async () => { + const provider = makeProvider(); + + const health = await provider.health(); + + expect(health.status).toBe('pass'); + expect(health.detail).toBe('mandate-issuers=1 checkout-issuers=1'); + expect(JSON.stringify(health)).not.toContain(parties.mandateSigner.publicJwk['x']); + provider.close(); + }); + + it('fails with a fixed token when the replay store cannot be read', async () => { + const provider = makeProvider(throwingStore()); + + const health = await provider.health(); + + expect(health.status).toBe('fail'); + // A fixed vocabulary token, never a sentence built from the caught error + expect(health.detail).toBe('replay-store-unavailable'); + provider.close(); + }); + }); +}); + +function throwingStore(): Ap2ReplayStore { + const boom = (): never => { + throw new Error('sqlite: disk I/O error'); + }; + return { + reserve: boom, + consume: boom, + release: boom, + markUncertain: boom, + stateOf: boom, + close: () => {}, + }; +} + +function recordingStore(reserved: string[]): Ap2ReplayStore { + const inner = createAp2ReplayStore({ path: ':memory:' }); + return { + ...inner, + reserve(request) { + reserved.push(request.reference); + return inner.reserve(request); + }, + }; +} diff --git a/tests/unit/cli/doctor.test.ts b/tests/unit/cli/doctor.test.ts index d8425a3..2d8dd18 100644 --- a/tests/unit/cli/doctor.test.ts +++ b/tests/unit/cli/doctor.test.ts @@ -1203,3 +1203,164 @@ describe('runDoctor - ACP', () => { } }); }); + +describe('runDoctor - AP2', () => { + const KEY = { + kty: 'EC', + crv: 'P-256', + // RFC 7515 A.3.1's public P-256 key. A published test vector, not a key + // anything here can sign with. + x: 'f83OJ3D2xF1Bg8vub9tLe1gHMzV76e8Tus9uPHvRVEU', + y: 'x_FEzRu9m36HLN_tue659LNpXW6pCyStikYjKIWI5a0', + }; + + function enabledAp2(overrides: Record = {}): Record { + return { + enabled: true, + specVersion: '0.2.0', + mode: 'direct', + trust: { + mandateIssuers: [ + { + issuer: 'https://surface.example', + audience: 'merchant.example', + keys: [{ kid: 'mandate-2026-01', jwk: KEY }], + }, + ], + checkoutIssuers: [ + { + issuer: 'https://merchant.example', + audience: 'agent-commerce', + keys: [{ kid: 'checkout-2026-01', jwk: KEY }], + }, + ], + }, + clockSkewSeconds: 60, + replay: { path: ':memory:' }, + ...overrides, + }; + } + + function gatedResource(): CommerceResource { + return { + id: 'market_report', + name: 'Market Report', + handler: { type: 'http', method: 'GET', url: 'http://backend.local/report' }, + pricing: { type: 'fixed', amount: '0.01', currency: 'USDC' }, + exposedVia: ['http'], + paymentMethods: ['x402'], + authorization: { required: ['ap2'] }, + }; + } + + async function ap2Report( + ap2: unknown, + resources: readonly CommerceResource[] = [gatedResource()], + ): Promise>> { + const base = makeGatewayConfig(); + return runDoctor( + { gatewayUrl: GATEWAY }, + { + fetchImpl: healthyFetch(), + loadConfig: async () => ({ + ...base, + resources, + ...(ap2 === undefined + ? {} + : { + authorization: { ap2: ap2 as NonNullable['ap2'] }, + }), + }), + createStore: () => makeFakeReceiptStore(), + }, + ); + } + + it('reports AP2 as disabled without any further AP2 checks', async () => { + const report = await ap2Report({ enabled: false }, []); + + expect(report.checks.find((c) => c.name === 'AP2')?.detail).toBe('disabled'); + for (const name of ['AP2 trust', 'AP2 replay store', 'AP2 resources', 'AP2 unsupported']) { + expect(report.checks.find((c) => c.name === name)).toBeUndefined(); + } + }); + + it('reports AP2 as disabled when no authorization block is configured at all', async () => { + const report = await ap2Report(undefined, []); + + expect(report.checks.find((c) => c.name === 'AP2')?.detail).toBe('disabled'); + }); + + it('reports the pinned spec, the mode, the mandate type and the profile', async () => { + const report = await ap2Report(enabledAp2()); + + const ap2 = report.checks.find((c) => c.name === 'AP2'); + expect(ap2?.status).toBe('PASS'); + expect(ap2?.detail).toContain('experimental'); + expect(ap2?.detail).toContain('spec 0.2.0'); + expect(ap2?.detail).toContain('mode direct'); + expect(ap2?.detail).toContain('mandate.checkout.1'); + expect(ap2?.detail).toContain('agent-commerce/ap2/checkout/v1'); + expect(ap2?.detail).toContain('clock skew 60s'); + }); + + it('names the trusted issuers and how many keys each has, never a key', async () => { + const report = await ap2Report(enabledAp2()); + + const trust = report.checks.find((c) => c.name === 'AP2 trust'); + expect(trust?.detail).toContain('mandate issuers: https://surface.example (1 key)'); + expect(trust?.detail).toContain('checkout issuers: https://merchant.example (1 key)'); + // Key material is public, but it is trust policy nobody asked this report + // to print, and a report is pasted into issues + expect(JSON.stringify(report)).not.toContain(KEY.x); + expect(JSON.stringify(report)).not.toContain(KEY.y); + }); + + it('warns that an in-memory replay store forgets every spent mandate', async () => { + const report = await ap2Report(enabledAp2()); + + const replay = report.checks.find((c) => c.name === 'AP2 replay store'); + expect(replay?.status).toBe('WARN'); + expect(replay?.detail).toContain('in-memory'); + }); + + it('warns when a store file does not exist yet, without creating one', async () => { + const path = join(tmpdir(), `ap2-doctor-${Date.now()}.db`); + const report = await ap2Report(enabledAp2({ replay: { path } })); + + const replay = report.checks.find((c) => c.name === 'AP2 replay store'); + expect(replay?.status).toBe('WARN'); + expect(existsSync(path)).toBe(false); + }); + + it('names the resources a mandate now gates', async () => { + const report = await ap2Report(enabledAp2()); + + const resources = report.checks.find((c) => c.name === 'AP2 resources'); + expect(resources?.status).toBe('PASS'); + expect(resources?.detail).toBe('market_report'); + }); + + it('warns when AP2 is enabled but gates nothing', async () => { + const report = await ap2Report(enabledAp2(), []); + + const resources = report.checks.find((c) => c.name === 'AP2 resources'); + expect(resources?.status).toBe('WARN'); + expect(resources?.detail).toContain('no resource requires it'); + }); + + it('lists what AP2 does not do in full, rather than as a count', async () => { + const report = await ap2Report(enabledAp2()); + + const unsupported = report.checks.find((c) => c.name === 'AP2 unsupported'); + for (const capability of [ + 'autonomous mode', + 'open checkout mandates (mandate.checkout.open.1)', + 'spending constraint evaluation', + 'JWKS and any key discovery by URL (jku, x5u)', + 'AP2 over the ACP checkout adapter', + ]) { + expect(unsupported?.detail).toContain(capability); + } + }); +}); diff --git a/tests/unit/cli/packaging.test.ts b/tests/unit/cli/packaging.test.ts index b503868..297ec75 100644 --- a/tests/unit/cli/packaging.test.ts +++ b/tests/unit/cli/packaging.test.ts @@ -189,14 +189,17 @@ describe('published package metadata', () => { // a consumer serving a free HTTP resource. None of these is needed by the // main entry or the CLI, so they are optional peers reached by subpath. const exportsField = manifest.exports as Record | undefined; - for (const subpath of ['./mcp', './x402']) { + for (const subpath of ['./ap2', './mcp', './x402']) { expect(exportsField?.[subpath]).toBeDefined(); } for (const peer of [ '@coinbase/x402', '@modelcontextprotocol/sdk', + '@sd-jwt/core', '@x402/core', '@x402/evm', + 'canonicalize', + 'jose', 'viem', ]) { expect(manifest.peerDependencies?.[peer]).toBeDefined(); @@ -370,6 +373,7 @@ describe.skipIf(!existsSync(libEntry))('built library entry', () => { }); describe.skipIf(!existsSync(libEntry))('optional-peer subpaths', () => { + const ap2Entry = join(pkgRoot, 'dist', 'ap2.js'); const mcpEntry = join(pkgRoot, 'dist', 'mcp.js'); const x402Entry = join(pkgRoot, 'dist', 'x402.js'); @@ -392,6 +396,15 @@ describe.skipIf(!existsSync(libEntry))('optional-peer subpaths', () => { it('imports only its own peer, in each subpath', () => { expect(bareImportsOf(mcpEntry)).toEqual(['@modelcontextprotocol/sdk']); expect(bareImportsOf(x402Entry).sort()).toEqual(['@x402/core', '@x402/evm', 'viem']); + // `better-sqlite3` rides along through the shared storage chunk: the AP2 + // replay store is a SQLite file. It is a real dependency, not a peer, so + // it is always installed anyway. + expect(bareImportsOf(ap2Entry).sort()).toEqual([ + '@sd-jwt/core', + 'better-sqlite3', + 'canonicalize', + 'jose', + ]); }); it('exports its factory under both the short and the full name', () => { @@ -406,6 +419,7 @@ describe.skipIf(!existsSync(libEntry))('optional-peer subpaths', () => { ); expect(probe(mcpEntry, ['mcp', 'createMcpAdapter'])).toBe(''); expect(probe(x402Entry, ['x402', 'createX402PaymentProvider', 'createPaymentProof'])).toBe(''); + expect(probe(ap2Entry, ['ap2', 'createAp2AuthorizationProvider'])).toBe(''); }); it('shares one CommerceError class with the main entry', () => { diff --git a/tests/unit/gateway/readiness.test.ts b/tests/unit/gateway/readiness.test.ts index 90b1f4c..45726f3 100644 --- a/tests/unit/gateway/readiness.test.ts +++ b/tests/unit/gateway/readiness.test.ts @@ -41,6 +41,7 @@ describe('createReadinessProbe', () => { store, adapterRuntimes: [], paymentProviders: [provider], + authorizationProviders: [], clock, logger: NOOP_LOGGER, }); @@ -59,7 +60,14 @@ describe('createReadinessProbe', () => { }; const clock = createFakeClock(); const probe = createReadinessProbe( - { store, adapterRuntimes: [], paymentProviders: [], clock, logger: NOOP_LOGGER }, + { + store, + adapterRuntimes: [], + paymentProviders: [], + authorizationProviders: [], + clock, + logger: NOOP_LOGGER, + }, 2000, ); @@ -78,7 +86,14 @@ describe('createReadinessProbe', () => { }; const clock = createFakeClock(); const probe = createReadinessProbe( - { store, adapterRuntimes: [], paymentProviders: [], clock, logger: NOOP_LOGGER }, + { + store, + adapterRuntimes: [], + paymentProviders: [], + authorizationProviders: [], + clock, + logger: NOOP_LOGGER, + }, 2000, ); @@ -93,7 +108,14 @@ describe('createReadinessProbe', () => { store.healthStatus = 'pass'; const clock = createFakeClock(); const probe = createReadinessProbe( - { store, adapterRuntimes: [], paymentProviders: [], clock, logger: NOOP_LOGGER }, + { + store, + adapterRuntimes: [], + paymentProviders: [], + authorizationProviders: [], + clock, + logger: NOOP_LOGGER, + }, 100, ); diff --git a/tsup.config.ts b/tsup.config.ts index f3cc846..52f2448 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -12,8 +12,8 @@ const pkg = require('./package.json') as { /** * Two builds, four entry points. * - * **Library** — `dist/index.js` plus the two optional-peer subpaths, - * `dist/mcp.js` and `dist/x402.js`. These are built together with + * **Library** — `dist/index.js` plus the three optional-peer subpaths, + * `dist/ap2.js`, `dist/mcp.js` and `dist/x402.js`. These are built together with * `splitting: true` so everything they share — `src/core`, `CommerceError`, * the canonical types — lands in one shared chunk that all three import. * That is not a size optimisation, it is a correctness requirement: built as @@ -74,7 +74,7 @@ const shared = { export default defineConfig([ { ...shared, - entry: { index: 'src/index.ts', mcp: 'src/mcp.ts', x402: 'src/x402.ts' }, + entry: { index: 'src/index.ts', ap2: 'src/ap2.ts', mcp: 'src/mcp.ts', x402: 'src/x402.ts' }, clean: true, splitting: true, }, From 80f327755503c2e04c9faf9e2a35060b7329f35f Mon Sep 17 00:00:00 2001 From: Revinand Date: Tue, 15 Sep 2026 13:18:16 +0200 Subject: [PATCH 07/11] test(ap2): add x402 authorization conformance coverage --- docs/security.md | 16 + src/authorization/ap2/sd-jwt.ts | 5 + tests/e2e/authorization/ap2-x402.e2e.test.ts | 334 ++++++++++ .../integration/ap2-x402-conformance.test.ts | 626 ++++++++++++++++++ tests/unit/authorization-ap2/fixtures.ts | 8 +- 5 files changed, 986 insertions(+), 3 deletions(-) create mode 100644 tests/e2e/authorization/ap2-x402.e2e.test.ts create mode 100644 tests/integration/ap2-x402-conformance.test.ts diff --git a/docs/security.md b/docs/security.md index 7336d28..38cb5be 100644 --- a/docs/security.md +++ b/docs/security.md @@ -333,6 +333,22 @@ rejection outcomes assert that balances did not move. | **a merchant leaking a connection string or stack in an error body** | never relayed; the ACP error carries type, code and message only | same | | **an ACP `Request-Id` carrying a header-injection payload** | dropped, never echoed | `tests/unit/protocols-acp/adapter.test.ts` | | **an ACP checkout resource configured as paid** | refused at config load; `payment-required` at runtime is a 500 | `tests/unit/config/schema.test.ts` | +| **an AP2 mandate with a tampered signature** | `AUTHORIZATION_INVALID`; nothing settles | `tests/integration/ap2-x402-conformance.test.ts` | +| **an expired AP2 mandate** | `AUTHORIZATION_INVALID`; nothing settles | same | +| **a mandate from an issuer that is not configured** | refused at the trust allowlist, before any signature check | same, `tests/unit/authorization-ap2` | +| **a mandate claiming a trusted `kid` but signed with another key** | refused at the signature; `kid` selects the key, never labels it | same | +| **a mandate naming a `kid` the issuer does not have** | refused; no "try every key" fallback | same | +| **a mandate whose `checkout_hash` does not match its checkout JWT** | `checkout_binding_failed`; nothing settles | same | +| **a mandate approved for another resource, input, amount, currency, payment method, network or asset** | `purchase_mismatch`, one coarse reason; nothing settles | same | +| **a mandate silent about the chain the requirement names** | refused - fail closed both ways | same | +| **a mandate presented twice** | `AUTHORIZATION_REPLAYED`; the second purchase moves no funds | same, `tests/e2e/authorization` | +| **the same mandate re-presented with a fresh, valid payment proof** | still refused; balances unchanged on a real chain | `tests/e2e/authorization` | +| **a mandate replayed under selective disclosure** (one mandate, many presentation strings) | refused - the replay key is the issuer-signed token, not the presentation | `tests/unit/authorization-ap2` | +| **the AP2 replay store unreachable** | `AUTHORIZATION_PROVIDER_UNAVAILABLE`, retryable, never the buyer's fault | `tests/integration/ap2-runtime.test.ts` | +| **a payment rejected after a mandate verified** | the reservation is released; a corrected proof reuses the mandate | `tests/integration/ap2-x402-conformance.test.ts` | +| **a settlement broadcast but never confirmed** | the mandate is *not* handed back; marked uncertain for an operator | same | +| **a free resource configured to require a mandate** | refused at config load, and again on the execution path | `tests/unit/config/ap2.test.ts`, `tests/unit/core/execution` | +| **an oversized `Agent-Authorization` header** | `AUTHORIZATION_INVALID` before any decode; nothing echoed back | `tests/integration/authorization-carrier.test.ts` | Two of those exist because writing them found a bug. The SDK's `exact`/EVM scheme reports an unreachable node as `invalid_exact_evm_signature`, and its diff --git a/src/authorization/ap2/sd-jwt.ts b/src/authorization/ap2/sd-jwt.ts index 0dac7a5..f4b27a3 100644 --- a/src/authorization/ap2/sd-jwt.ts +++ b/src/authorization/ap2/sd-jwt.ts @@ -92,6 +92,11 @@ export async function verifyMandate( // a forged HMAC) and kept for the day this resolves a key set instead of // one key, where the header would get to pick algorithms: [AP2_SIGNING_ALGORITHM], + // Also redundant, and for the same reason: the key was resolved *from* + // `iss` against the configured allowlist, so nothing reaching here can + // carry a different one. It backstops a future resolver that matches on + // something else. `audience` below is not redundant - `aud` is the + // presenter's claim, checked against what the operator configured. issuer: issuer.issuer, audience: issuer.audience, clockTolerance: deps.clockSkewSeconds, diff --git a/tests/e2e/authorization/ap2-x402.e2e.test.ts b/tests/e2e/authorization/ap2-x402.e2e.test.ts new file mode 100644 index 0000000..48292e4 --- /dev/null +++ b/tests/e2e/authorization/ap2-x402.e2e.test.ts @@ -0,0 +1,334 @@ +/** + * An AP2-gated purchase settling on chain, through the whole gateway. + * + * The refusal matrix and the call counts are in + * `tests/integration/ap2-x402-conformance.test.ts`. Here is what only a chain + * shows: the coordinates a mandate commits to - destination, network, asset - + * are the ones the x402 provider builds its challenge from, and a replayed + * mandate moves no money. Balance deltas read back off the chain, never a log + * line claiming success. + * + * The chain is an ephemeral Anvil this file spawns; the merchant backend is + * stubbed, because what is under test is everything in front of it. + */ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { AP2_CHECKOUT_PROFILE } from '../../../src/authorization/ap2/constants.js'; +import { + type Ap2AuthorizationProvider, + createAp2AuthorizationProvider, +} from '../../../src/authorization/ap2/index.js'; +import { computeInputHash } from '../../../src/authorization/ap2/profile.js'; +import { type GatewayConfig, parseConfig } from '../../../src/config/index.js'; +import type { BackendExecutor, ReceiptStore } from '../../../src/core/index.js'; +import { AUTHORIZATION_HEADER, PAYMENT_HEADER } from '../../../src/core/index.js'; +import { createGateway, type GatewayInstance } from '../../../src/gateway/index.js'; +import { createPaymentProof, createX402PaymentProvider } from '../../../src/payments/x402/index.js'; +import { + type AnvilHandle, + deployLocalChain, + startAnvil, +} from '../../../src/payments/x402/testing.js'; +import { createSqliteReceiptStore } from '../../../src/storage/receipts/index.js'; +import { expectRealSettlement, readBalances } from '../../fixtures/x402/settlement.js'; +import { + checkoutPayload, + createParties, + fixedClock, + mintMandate, + type Party, + signCheckoutJwt, +} from '../../unit/authorization-ap2/fixtures.js'; + +const PORT = 18791; +const RESOURCE_ID = 'market_report'; +const INPUT = { city: 'Berlin' }; +const AMOUNT = '1.00'; +const CURRENCY = 'USD'; +const NETWORK = 'eip155:84532'; + +let anvil: AnvilHandle; +let deployment: Awaited>; +let parties: Party; +let gateway: GatewayInstance; +let store: ReceiptStore; +let authorization: Ap2AuthorizationProvider; +let backendCalls = 0; + +const backend: BackendExecutor = { + async call() { + backendCalls += 1; + return { status: 200, body: { report: 'ok' }, headers: {}, durationMs: 1 }; + }, +}; + +function rawConfig(): Record { + const issuers = (entries: Party['mandateIssuers']) => + entries.map((entry) => ({ + issuer: entry.issuer, + audience: entry.audience, + keys: entry.keys.map((key) => ({ kid: key.kid, jwk: key.jwk })), + })); + return { + version: 1, + merchant: { id: 'ap2-e2e', name: 'AP2 E2E', publicBaseUrl: 'http://127.0.0.1:8080' }, + server: { port: 8080, host: '127.0.0.1', allowedOrigins: [] }, + storage: { receipts: { driver: 'sqlite', path: ':memory:' } }, + protocols: { http: { enabled: true }, mcp: { enabled: false, mountPath: '/mcp' } }, + resources: { + [RESOURCE_ID]: { + name: 'Market report', + input: { + type: 'object', + properties: { city: { type: 'string' } }, + required: ['city'], + additionalProperties: false, + }, + backend: { type: 'http', method: 'GET', url: 'http://merchant.invalid/api/report' }, + pricing: { type: 'fixed', amount: AMOUNT, currency: CURRENCY }, + expose: ['http'], + payments: ['x402'], + authorization: { required: ['ap2'] }, + }, + }, + payments: { + x402: { + enabled: true, + network: NETWORK, + rpcUrl: anvil.rpcUrl, + asset: deployment.asset, + assetName: deployment.assetName, + assetVersion: deployment.assetVersion, + assetDecimals: deployment.assetDecimals, + payTo: deployment.merchant.address, + maxTimeoutSeconds: 120, + facilitator: { mode: 'local', signerPrivateKey: deployment.facilitator.privateKey }, + }, + }, + authorization: { + ap2: { + enabled: true, + specVersion: '0.2.0', + mode: 'direct', + trust: { + mandateIssuers: issuers(parties.mandateIssuers), + checkoutIssuers: issuers(parties.checkoutIssuers), + }, + clockSkewSeconds: 60, + replay: { path: ':memory:' }, + }, + }, + }; +} + +async function balances() { + return readBalances({ + rpcUrl: anvil.rpcUrl, + asset: deployment.asset, + buyer: deployment.buyer.address, + merchant: deployment.merchant.address, + }); +} + +// A mandate approving exactly what the gateway's own x402 challenge asks for +async function mandateForChallenge(): Promise { + const jwt = await signCheckoutJwt( + parties.checkoutSigner, + checkoutPayload({ + agent_commerce: { + profile: AP2_CHECKOUT_PROFILE, + resource_id: RESOURCE_ID, + input_hash: await computeInputHash(INPUT), + amount: AMOUNT, + currency: CURRENCY, + payment_method: 'x402', + destination: deployment.merchant.address, + network: NETWORK, + asset: deployment.asset, + }, + }), + ); + return mintMandate(parties.mandateSigner, jwt); +} + +function carrier(presentation: string): string { + return Buffer.from(JSON.stringify({ method: 'ap2', payload: presentation }), 'utf8').toString( + 'base64url', + ); +} + +interface Invocation { + readonly statusCode: number; + readonly body: Record; +} + +async function invoke(headers: Record = {}): Promise { + const res = await gateway.server.inject({ + method: 'POST', + url: `/api/resources/${RESOURCE_ID}/invoke`, + headers: { 'content-type': 'application/json', ...headers }, + payload: INPUT, + }); + return { statusCode: res.statusCode, body: res.json>() }; +} + +// Asks for the resource unpaid, then signs a proof against the challenge it returns +async function freshProof(): Promise { + const challenge = await invoke(); + expect(challenge.statusCode).toBe(402); + const payment = challenge.body['payment'] as { accepts: Record[] }; + return createPaymentProof({ + buyerPrivateKey: deployment.buyer.privateKey, + rpcUrl: anvil.rpcUrl, + accepts: payment.accepts[0] as Record, + }); +} + +beforeAll(async () => { + anvil = await startAnvil({ port: PORT, silent: true }); + deployment = await deployLocalChain({ rpcUrl: anvil.rpcUrl, buyerInitialBalance: '100.00' }); + parties = await createParties(); + + const config: GatewayConfig = parseConfig(rawConfig(), process.env); + const ap2 = config.authorization?.ap2; + if (ap2 === undefined || !ap2.enabled) throw new Error('the fixture config must enable AP2'); + + store = createSqliteReceiptStore({ path: ':memory:' }); + await store.init(); + authorization = createAp2AuthorizationProvider({ config: ap2, clock: fixedClock() }); + gateway = await createGateway({ + config, + store, + paymentProviders: [ + createX402PaymentProvider({ + network: NETWORK, + rpcUrl: anvil.rpcUrl, + asset: deployment.asset, + assetName: deployment.assetName, + assetVersion: deployment.assetVersion, + assetDecimals: deployment.assetDecimals, + payTo: deployment.merchant.address, + facilitator: { mode: 'local', signerPrivateKey: deployment.facilitator.privateKey }, + }), + ], + authorizationProviders: [authorization], + protocolAdapters: [], + backend, + }); +}, 180_000); + +afterAll(async () => { + await gateway?.close().catch(() => {}); + authorization?.close(); + await store?.close().catch(() => {}); + await anvil?.stop(); +}); + +describe('AP2-gated purchase over x402 - real local chain', () => { + it('1. the 402 carries both the payment challenge and the mandate requirement', async () => { + const challenge = await invoke(); + + expect(challenge.statusCode).toBe(402); + expect(challenge.body['authorization']).toEqual({ + required: [{ method: 'ap2', version: '0.2.0', profile: AP2_CHECKOUT_PROFILE }], + }); + const payment = challenge.body['payment'] as Record; + // The three coordinates the mandate has to commit to are the ones the + // challenge publishes, not values this test invented + expect(payment['destination']).toBe(deployment.merchant.address); + expect(payment['network']).toBe(NETWORK); + expect(payment['asset']).toBe(deployment.asset); + }); + + it('2. a mandate matching that challenge settles on chain and delivers once', async () => { + const proof = await freshProof(); + const presentation = await mandateForChallenge(); + const before = await balances(); + const callsBefore = backendCalls; + + const delivered = await invoke({ + [PAYMENT_HEADER]: proof, + [AUTHORIZATION_HEADER]: carrier(presentation), + }); + + expect(delivered.statusCode).toBe(200); + expect(backendCalls).toBe(callsBefore + 1); + + const receipts = await store.listReceipts({ limit: 5 }); + const receipt = receipts[0]; + expect(receipt?.authorization?.reference).toMatch(/^sha256:[\w-]+$/); + const txHash = receipt?.payment?.externalReference; + expect(txHash).toBeDefined(); + + await expectRealSettlement({ + rpcUrl: anvil.rpcUrl, + asset: deployment.asset, + buyer: deployment.buyer.address, + merchant: deployment.merchant.address, + before, + after: await balances(), + amountBaseUnits: 1_000_000n, // 1.00 at 6 decimals + txHash: txHash as string, + }); + }); + + it('3. the same mandate with a fresh payment proof moves no second payment', async () => { + const proof = await freshProof(); + const presentation = await mandateForChallenge(); + await invoke({ + [PAYMENT_HEADER]: proof, + [AUTHORIZATION_HEADER]: carrier(presentation), + }); + + // A brand-new, perfectly good payment authorisation. Only the mandate is + // reused, so nothing but the mandate can be what refuses this. + const replayProof = await freshProof(); + const before = await balances(); + const callsBefore = backendCalls; + + const replayed = await invoke({ + [PAYMENT_HEADER]: replayProof, + [AUTHORIZATION_HEADER]: carrier(presentation), + }); + + expect(replayed.statusCode).toBe(409); + expect(replayed.body['code']).toBe('AUTHORIZATION_REPLAYED'); + expect(backendCalls).toBe(callsBefore); + const after = await balances(); + expect(after.buyer).toBe(before.buyer); + expect(after.merchant).toBe(before.merchant); + }); + + it('4. a mandate approved for a different amount settles nothing', async () => { + const proof = await freshProof(); + const jwt = await signCheckoutJwt( + parties.checkoutSigner, + checkoutPayload({ + agent_commerce: { + profile: AP2_CHECKOUT_PROFILE, + resource_id: RESOURCE_ID, + input_hash: await computeInputHash(INPUT), + amount: '0.01', + currency: CURRENCY, + payment_method: 'x402', + destination: deployment.merchant.address, + network: NETWORK, + asset: deployment.asset, + }, + }), + ); + const before = await balances(); + const callsBefore = backendCalls; + + const refused = await invoke({ + [PAYMENT_HEADER]: proof, + [AUTHORIZATION_HEADER]: carrier(await mintMandate(parties.mandateSigner, jwt)), + }); + + expect(refused.statusCode).toBe(403); + expect(refused.body['code']).toBe('AUTHORIZATION_INVALID'); + expect(backendCalls).toBe(callsBefore); + const after = await balances(); + expect(after.buyer).toBe(before.buyer); + expect(after.merchant).toBe(before.merchant); + }); +}); diff --git a/tests/integration/ap2-x402-conformance.test.ts b/tests/integration/ap2-x402-conformance.test.ts new file mode 100644 index 0000000..746207b --- /dev/null +++ b/tests/integration/ap2-x402-conformance.test.ts @@ -0,0 +1,626 @@ +/** + * Every AP2 refusal the gateway has to make, driven end to end. + * + * Only the payment rail is doubled, and it counts calls: what every case has + * to prove is that settlement was never reached. The config, the provider and + * the signing are the shipped ones. Settlement behind a mandate is + * `tests/e2e/authorization`. + * + * FIXTURE PROVENANCE: mandates are minted to the AP2 v0.2.0 shape (tagged + * 2026-04-28, commit b4587ac), not upstream golden vectors. See fixtures.ts. + */ +import { afterEach, beforeAll, describe, expect, it } from 'vitest'; +import { AP2_CHECKOUT_PROFILE } from '../../src/authorization/ap2/constants.js'; +import { createAp2AuthorizationProvider } from '../../src/authorization/ap2/index.js'; +import { computeInputHash } from '../../src/authorization/ap2/profile.js'; +import { type GatewayConfig, parseConfig } from '../../src/config/index.js'; +import type { + AdapterDescriptor, + AuthorizationProvider, + BackendExecutor, + PaymentProvider, + PaymentRequirement, + PaymentResult, + ReceiptStore, +} from '../../src/core/index.js'; +import { AUTHORIZATION_HEADER, CommerceError, PAYMENT_HEADER } from '../../src/core/index.js'; +import { createGateway, type GatewayInstance } from '../../src/gateway/index.js'; +import { createSqliteReceiptStore } from '../../src/storage/receipts/index.js'; +import { + checkoutPayload, + createParties, + fixedClock, + type MandateOptions, + mintMandate, + NOW_SECONDS, + type Party, + sha256Base64url, + signCheckoutJwt, +} from '../unit/authorization-ap2/fixtures.js'; + +process.env['NODE_ENV'] = 'test'; + +const RESOURCE_ID = 'market_report'; +const INPUT = { city: 'Berlin' }; +const MERCHANT = '0x70997970C51812dc3A010C7d01b50e0d17dc79C8'; +const ASSET = '0x1111111111111111111111111111111111111111'; +const NETWORK = 'eip155:84532'; +const PROOF = 'x402-proof-1'; + +let parties: Party; +let config: GatewayConfig; +let inputHash: string; + +let gateway: GatewayInstance | undefined; +let store: ReceiptStore | undefined; +let authorization: (AuthorizationProvider & { close(): void }) | undefined; + +interface Counters { + verify: number; + settle: number; + backend: number; +} +let counts: Counters; + +// --- the rail --------------------------------------------------------------- + +const descriptor: AdapterDescriptor = { + name: 'x402', + kind: 'payment', + implementationVersion: '0.0.0-test', + supportedSpec: 'x402/v2 scheme=exact family=eip155', + capabilities: [], + status: 'stable', +}; + +interface RailOptions { + readonly verify?: () => Promise; + readonly settle?: () => Promise; +} + +// Counts what it was asked to do. Its requirement carries the chain +// coordinates a real x402 challenge does, which the mandate must agree with +function countingRail(options: RailOptions = {}): PaymentProvider { + const settled: PaymentResult = { + status: 'settled', + provider: 'x402', + amount: '0.01', + currency: 'USDC', + payer: '0xBUYER', + payee: MERCHANT, + network: NETWORK, + externalReference: '0xtx', + }; + return { + name: 'x402', + descriptor, + async createRequirement(ctx): Promise { + return { + id: 'requirement-1', + requestId: ctx.requestId, + resourceId: ctx.resource.id, + provider: 'x402', + amount: ctx.amount, + currency: ctx.currency, + destination: MERCHANT, + network: NETWORK, + asset: ASSET, + challenge: { provider: 'x402', version: '2', accepts: [{ scheme: 'exact' }] }, + }; + }, + async verify() { + counts.verify += 1; + if (options.verify) return options.verify(); + return { + status: 'verified', + provider: 'x402', + amount: '0.01', + currency: 'USDC', + payer: '0xBUYER', + payee: MERCHANT, + replayKey: `replay-${counts.verify}`, + }; + }, + async settle() { + counts.settle += 1; + if (options.settle) return options.settle(); + return settled; + }, + async health() { + return { status: 'pass', checkedAt: '2026-01-01T00:00:00.000Z' }; + }, + }; +} + +const backend: BackendExecutor = { + async call() { + counts.backend += 1; + return { status: 200, body: { report: 'ok' }, headers: {}, durationMs: 1 }; + }, +}; + +// --- config ----------------------------------------------------------------- + +function rawConfig(): Record { + const issuer = (entry: { issuer: string; audience: string; kid: string; jwk: unknown }) => ({ + issuer: entry.issuer, + audience: entry.audience, + keys: [{ kid: entry.kid, jwk: entry.jwk }], + }); + return { + version: 1, + merchant: { id: 'conformance', name: 'Conformance', publicBaseUrl: 'http://127.0.0.1:8080' }, + server: { port: 8080, host: '127.0.0.1', allowedOrigins: [] }, + storage: { receipts: { driver: 'sqlite', path: ':memory:' } }, + protocols: { + http: { enabled: true }, + mcp: { enabled: false, mountPath: '/mcp' }, + }, + resources: { + [RESOURCE_ID]: { + name: 'Market report', + input: { + type: 'object', + properties: { city: { type: 'string' } }, + required: ['city'], + // Closed: a reserved field that survived extraction would fail here + additionalProperties: false, + }, + backend: { type: 'http', method: 'GET', url: 'http://merchant.invalid/api/report' }, + pricing: { type: 'fixed', amount: '0.01', currency: 'USDC' }, + expose: ['http'], + payments: ['x402'], + authorization: { required: ['ap2'] }, + }, + }, + payments: { + x402: { + enabled: true, + network: NETWORK, + rpcUrl: 'http://127.0.0.1:8545', + asset: ASSET, + assetName: 'MockUSDC', + assetVersion: '2', + assetDecimals: 6, + payTo: MERCHANT, + maxTimeoutSeconds: 120, + // Never used: the rail below is a counting double, and no chain is + // reached. It is here so the config is the one a real deployment writes + facilitator: { mode: 'local', signerPrivateKey: '0xKEY' }, + }, + }, + authorization: { + ap2: { + enabled: true, + specVersion: '0.2.0', + mode: 'direct', + trust: { + mandateIssuers: parties.mandateIssuers.map((entry) => + issuer({ + issuer: entry.issuer, + audience: entry.audience, + kid: entry.keys[0]?.kid ?? '', + jwk: entry.keys[0]?.jwk, + }), + ), + checkoutIssuers: parties.checkoutIssuers.map((entry) => + issuer({ + issuer: entry.issuer, + audience: entry.audience, + kid: entry.keys[0]?.kid ?? '', + jwk: entry.keys[0]?.jwk, + }), + ), + }, + clockSkewSeconds: 60, + replay: { path: ':memory:' }, + }, + }, + }; +} + +// --- mandates --------------------------------------------------------------- + +// The profile a correctly minted mandate carries for this exact purchase +function profile(overrides: Record = {}): Record { + return { + profile: AP2_CHECKOUT_PROFILE, + resource_id: RESOURCE_ID, + input_hash: inputHash, + amount: '0.01', + currency: 'USDC', + payment_method: 'x402', + destination: MERCHANT, + network: NETWORK, + asset: ASSET, + ...overrides, + }; +} + +interface MandateSpec { + readonly profile?: Record; + readonly checkout?: Record; + readonly mandate?: MandateOptions; + // Signs the mandate with a key nobody trusts + readonly stranger?: boolean; +} + +async function mandate(spec: MandateSpec = {}): Promise { + const jwt = await signCheckoutJwt( + parties.checkoutSigner, + checkoutPayload({ agent_commerce: profile(spec.profile), ...spec.checkout }), + ); + return mintMandate( + spec.stranger === true ? parties.stranger : parties.mandateSigner, + jwt, + spec.mandate ?? {}, + ); +} + +function carrier(presentation: string): string { + return Buffer.from(JSON.stringify({ method: 'ap2', payload: presentation }), 'utf8').toString( + 'base64url', + ); +} + +// --- harness ---------------------------------------------------------------- + +type Ap2Provider = AuthorizationProvider & { close(): void }; + +async function startGateway( + rail: PaymentProvider = countingRail(), + // Wraps the real provider, for the cases that need one of its calls to fail + wrap: (real: Ap2Provider) => AuthorizationProvider = (real) => real, +): Promise { + counts = { verify: 0, settle: 0, backend: 0 }; + store = createSqliteReceiptStore({ path: ':memory:' }); + await store.init(); + const ap2Config = config.authorization?.ap2; + if (ap2Config === undefined || !ap2Config.enabled) { + throw new Error('the fixture config must enable AP2'); + } + authorization = createAp2AuthorizationProvider({ config: ap2Config, clock: fixedClock() }); + gateway = await createGateway({ + config, + store, + paymentProviders: [rail], + authorizationProviders: [wrap(authorization)], + protocolAdapters: [], + backend, + }); + return gateway; +} + +interface Invocation { + readonly statusCode: number; + readonly body: Record; +} + +async function invoke( + gw: GatewayInstance, + options: { proof?: string; presentation?: string; input?: Record } = {}, +): Promise { + const res = await gw.server.inject({ + method: 'POST', + url: `/api/resources/${RESOURCE_ID}/invoke`, + headers: { + 'content-type': 'application/json', + ...(options.proof !== undefined ? { [PAYMENT_HEADER]: options.proof } : {}), + ...(options.presentation !== undefined + ? { [AUTHORIZATION_HEADER]: carrier(options.presentation) } + : {}), + }, + payload: options.input ?? INPUT, + }); + return { statusCode: res.statusCode, body: res.json>() }; +} + +// A purchase with a real payment proof and whatever mandate the case supplies +async function purchase(presentation: string, gw = gateway): Promise { + return invoke(gw as GatewayInstance, { proof: PROOF, presentation }); +} + +beforeAll(async () => { + parties = await createParties(); + config = parseConfig(rawConfig(), process.env); + inputHash = await computeInputHash(INPUT); +}); + +afterEach(async () => { + await gateway?.close().catch(() => {}); + authorization?.close(); + await store?.close().catch(() => {}); + gateway = undefined; + authorization = undefined; + store = undefined; +}); + +describe('AP2 over x402: the purchase that works', () => { + it('challenges, verifies, settles once, consumes, delivers once', async () => { + const gw = await startGateway(); + + const challenge = await invoke(gw); + expect(challenge.statusCode).toBe(402); + expect(challenge.body['authorization']).toEqual({ + required: [{ method: 'ap2', version: '0.2.0', profile: AP2_CHECKOUT_PROFILE }], + }); + expect(counts.settle).toBe(0); + + const delivered = await purchase(await mandate()); + + expect(delivered.statusCode).toBe(200); + expect(counts).toEqual({ verify: 1, settle: 1, backend: 1 }); + }); + + it('records the mandate as a digest and stores no part of the presentation', async () => { + await startGateway(); + const presentation = await mandate(); + + await purchase(presentation); + + const receipts = await (store as ReceiptStore).listReceipts({ limit: 10 }); + const receipt = receipts[0]; + expect(receipt?.authorization?.method).toBe('ap2'); + expect(receipt?.authorization?.reference).toMatch(/^sha256:[\w-]+$/); + + // Every segment of the presentation, not just the whole string: a stored + // disclosure alone would still leak the buyer's purchase + const persisted = JSON.stringify(receipts); + for (const segment of presentation.split('~').filter((part) => part.length > 0)) { + expect(persisted).not.toContain(segment); + } + }); + + it('emits the authorization in the audit trail alongside the payment', async () => { + await startGateway(); + + await purchase(await mandate()); + + const events = await (store as ReceiptStore).listEvents({ limit: 20 }); + const types = events.map((event) => event.type); + expect(types).toContain('authorization.verified'); + expect(types).toContain('payment.settled'); + expect(types).toContain('resource.delivered'); + }); +}); + +describe('AP2 over x402: mandates that must not settle', () => { + // Every case here asserts the same thing: no money moved, nothing delivered + async function refuse( + presentation: string, + expected: { status: number; code: string }, + ): Promise { + const gw = await startGateway(); + + const result = await purchase(presentation, gw); + + expect(result.statusCode).toBe(expected.status); + expect(result.body['code']).toBe(expected.code); + expect(counts.settle).toBe(0); + expect(counts.backend).toBe(0); + } + + const invalid = { status: 403, code: 'AUTHORIZATION_INVALID' } as const; + + it('refuses an altered mandate', async () => { + const original = await mandate(); + const [token, ...rest] = original.split('~'); + const [header, payload, signature] = (token as string).split('.'); + // A flipped bit in the signature's first byte, not the last base64url + // character: that one has four meaningful bits in an 86-character ES256 + // signature, so A/B/C/D all decode alike and nothing would change. + const bytes = Buffer.from(signature as string, 'base64url'); + bytes[0] = (bytes[0] as number) ^ 0x01; + const forged = `${header}.${payload}.${bytes.toString('base64url')}`; + await refuse([forged, ...rest].join('~'), invalid); + }); + + it('refuses an expired mandate', async () => { + await refuse( + await mandate({ + mandate: { payloadOverrides: { iat: NOW_SECONDS - 7200, exp: NOW_SECONDS - 3600 } }, + }), + invalid, + ); + }); + + it('refuses a mandate from an untrusted issuer', async () => { + await refuse( + await mandate({ mandate: { payloadOverrides: { iss: 'https://evil.example' } } }), + invalid, + ); + }); + + it('refuses a mandate that claims a trusted kid but was signed with another key', async () => { + // The attack `kid` exists to stop: a trusted issuer, a trusted key id, and + // a real signature from a key nobody trusts. Refused at the signature, so + // `kid` selects the verifying key rather than labelling it. + await refuse( + await mandate({ stranger: true, mandate: { header: { kid: parties.mandateSigner.kid } } }), + invalid, + ); + }); + + it('refuses a mandate naming a kid the issuer does not have', async () => { + await refuse(await mandate({ mandate: { header: { kid: 'rotated-out-2025' } } }), invalid); + }); + + it('refuses a mandate whose checkout_hash does not match the disclosed checkout', async () => { + await refuse( + await mandate({ + mandate: { payloadOverrides: { checkout_hash: await sha256Base64url('another-document') } }, + }), + invalid, + ); + }); + + it('refuses a mandate approved for a different resource', async () => { + await refuse(await mandate({ profile: { resource_id: 'other_report' } }), invalid); + }); + + it('refuses a mandate approved for different input', async () => { + await refuse( + await mandate({ profile: { input_hash: await computeInputHash({ city: 'Paris' }) } }), + invalid, + ); + }); + + it('refuses a mandate approved for a different amount', async () => { + await refuse(await mandate({ profile: { amount: '500.00' } }), invalid); + }); + + it('refuses a mandate approved in a different currency', async () => { + await refuse(await mandate({ profile: { currency: 'EURC' } }), invalid); + }); + + it('refuses a mandate approved for a different payment method', async () => { + await refuse(await mandate({ profile: { payment_method: 'acp' } }), invalid); + }); + + it('refuses a mandate approved for a different network', async () => { + await refuse(await mandate({ profile: { network: 'eip155:8453' } }), invalid); + }); + + it('refuses a mandate approved for a different asset', async () => { + await refuse( + await mandate({ profile: { asset: '0x2222222222222222222222222222222222222222' } }), + invalid, + ); + }); + + it('refuses a mandate silent about the chain the requirement names', async () => { + // Fail closed both ways: a mandate that never mentioned a chain must not + // unlock a settlement on one. `undefined` is dropped when the JWT is + // serialised, so these two claims are genuinely absent. + await refuse(await mandate({ profile: { network: undefined, asset: undefined } }), invalid); + }); + + it('refuses a replayed mandate as replayed, not as invalid', async () => { + const gw = await startGateway(); + const presentation = await mandate(); + + const first = await purchase(presentation, gw); + const second = await purchase(presentation, gw); + + expect(first.statusCode).toBe(200); + expect(second.statusCode).toBe(409); + expect(second.body['code']).toBe('AUTHORIZATION_REPLAYED'); + // The first purchase settled; the replay did not + expect(counts.settle).toBe(1); + expect(counts.backend).toBe(1); + }); +}); + +describe('AP2 over x402: when settlement goes wrong', () => { + it('reports a verifier outage as unavailable and retryable, never as a payment failure', async () => { + const gw = await startGateway(countingRail(), (real) => ({ + ...real, + // Our store is what broke, not the buyer's mandate + async verifyAndReserve() { + throw new CommerceError('AUTHORIZATION_PROVIDER_UNAVAILABLE', 'store down'); + }, + })); + + const result = await purchase(await mandate(), gw); + + expect(result.statusCode).toBe(503); + expect(result.body['code']).toBe('AUTHORIZATION_PROVIDER_UNAVAILABLE'); + expect(result.body['retryable']).toBe(true); + expect(counts.settle).toBe(0); + }); + + it('lets a corrected payment proof retry with the same still-valid mandate', async () => { + let attempt = 0; + const gw = await startGateway( + countingRail({ + // First proof is rejected, the second verifies + verify: async () => { + attempt += 1; + return attempt === 1 + ? { + status: 'rejected', + provider: 'x402', + amount: '0.01', + currency: 'USDC', + rejectionReason: 'signature does not match payer', + } + : { + status: 'verified', + provider: 'x402', + amount: '0.01', + currency: 'USDC', + replayKey: 'replay-2', + }; + }, + }), + ); + const presentation = await mandate(); + + const rejected = await purchase(presentation, gw); + expect(rejected.statusCode).toBe(402); + expect(rejected.body['code']).toBe('PAYMENT_INVALID'); + + const delivered = await purchase(presentation, gw); + expect(delivered.statusCode).toBe(200); + expect(counts.settle).toBe(1); + }); + + it('hands the mandate back when settlement is definitively refused', async () => { + let attempt = 0; + const gw = await startGateway( + countingRail({ + settle: async () => { + attempt += 1; + return attempt === 1 + ? { + status: 'rejected', + provider: 'x402', + amount: '0.01', + currency: 'USDC', + rejectionReason: 'insufficient balance', + } + : { + status: 'settled', + provider: 'x402', + amount: '0.01', + currency: 'USDC', + externalReference: '0xtx', + }; + }, + }), + ); + const presentation = await mandate(); + + const failed = await purchase(presentation, gw); + expect(failed.statusCode).toBe(502); + expect(failed.body['code']).toBe('PAYMENT_SETTLEMENT_FAILED'); + + // The reservation was released, so the buyer's own mandate is still theirs + const delivered = await purchase(presentation, gw); + expect(delivered.statusCode).toBe(200); + expect(counts.backend).toBe(1); + }); + + it('does not hand the mandate back when a broadcast settlement was never confirmed', async () => { + const gw = await startGateway( + countingRail({ + settle: async () => { + throw new CommerceError('PAYMENT_PROVIDER_UNAVAILABLE', 'confirmation timed out', { + details: { transactionHash: '0xabc' }, + }); + }, + }), + ); + const presentation = await mandate(); + + const uncertain = await purchase(presentation, gw); + expect(uncertain.statusCode).toBe(502); + expect(uncertain.body['code']).toBe('PAYMENT_SETTLEMENT_FAILED'); + + // The buyer's funds may already have moved, so the mandate is not reusable + const retry = await purchase(presentation, gw); + expect(retry.statusCode).toBe(409); + expect(retry.body['code']).toBe('AUTHORIZATION_REPLAYED'); + expect(counts.backend).toBe(0); + }); +}); diff --git a/tests/unit/authorization-ap2/fixtures.ts b/tests/unit/authorization-ap2/fixtures.ts index f752cde..c5bf966 100644 --- a/tests/unit/authorization-ap2/fixtures.ts +++ b/tests/unit/authorization-ap2/fixtures.ts @@ -2,8 +2,9 @@ * Builds AP2 Direct Checkout Mandate presentations for the verifier tests. * * PROVENANCE: these are NOT golden vectors from the AP2 repository. They are - * built here to the v0.2.0 closed Checkout Mandate shape, with real ES256 keys - * and real signatures from `jose`. So they show the verifier enforces the + * built here to the closed Checkout Mandate shape of AP2 v0.2.0, the release + * tagged 2026-04-28 at commit b4587ac, with real ES256 keys and real + * signatures from `jose`. So they show the verifier enforces the * rules as this repository reads them; they do not show interoperability with * a mandate the reference implementation minted. Upstream vectors, with the * commit recorded, belong here before anyone calls this stable. @@ -24,7 +25,8 @@ export const CHECKOUT_AUDIENCE = 'agent-commerce'; /** Fixed instant every fixture is minted against, so nothing races a real clock */ export const NOW = new Date('2026-09-14T12:00:00.000Z'); -const NOW_SECONDS = Math.floor(NOW.getTime() / 1000); +/** {@link NOW} as the epoch seconds every `iat`/`exp` here is built from */ +export const NOW_SECONDS = Math.floor(NOW.getTime() / 1000); // `CryptoKey` is a DOM type and server code here does not load the DOM lib type PrivateKey = Awaited>['privateKey']; From 315c13b76aa701ea49b7636834ee26333ae69b85 Mon Sep 17 00:00:00 2001 From: Revinand Date: Tue, 15 Sep 2026 15:00:32 +0200 Subject: [PATCH 08/11] docs(ap2): document mandate verification and trust model --- README.md | 35 +++-- config.example.yaml | 64 +++++++- docs/ap2.md | 340 ++++++++++++++++++++++++++++++++++++++++++ docs/architecture.md | 44 ++++-- docs/configuration.md | 56 +++++++ docs/contracts.md | 62 ++++++++ docs/payment-flow.md | 5 + docs/protocols.md | 8 +- docs/security.md | 28 ++++ 9 files changed, 611 insertions(+), 31 deletions(-) create mode 100644 docs/ap2.md diff --git a/README.md b/README.md index a45b352..aca3bb0 100644 --- a/README.md +++ b/README.md @@ -90,16 +90,17 @@ const { url } = await gateway.listen(); ### Optional peers - install only the rails you use -The MCP adapter and the x402 provider live on their own subpaths, because each -needs a dependency the rest of the package does not - the x402 rail brings the -whole EVM signing and RPC stack, which a gateway serving a free HTTP resource -has no business installing. +The MCP adapter, the x402 provider and AP2 verification live on their own +subpaths, because each needs a dependency the rest of the package does not - +the x402 rail brings the whole EVM signing and RPC stack, which a gateway +serving a free HTTP resource has no business installing. | You want | Install | Import | | --------------------------------- | ------------------------------ | ------------------------------------------ | | gateway, config, receipts, CLI | `@devlab.group/agent-commerce` | `from '@devlab.group/agent-commerce'` | | expose resources as MCP tools | `+ @modelcontextprotocol/sdk` | `from '@devlab.group/agent-commerce/mcp'` | | accept x402 payments | `+ @x402/core @x402/evm viem` | `from '@devlab.group/agent-commerce/x402'` | +| verify AP2 mandates | `+ jose @sd-jwt/core canonicalize` | `from '@devlab.group/agent-commerce/ap2'` | | authenticate to a CDP facilitator | `+ @coinbase/x402` | (no import - loaded on demand) | ```bash @@ -109,8 +110,14 @@ npm install @devlab.group/agent-commerce @modelcontextprotocol/sdk @x402/core @x ```ts import { mcp } from '@devlab.group/agent-commerce/mcp'; import { x402 } from '@devlab.group/agent-commerce/x402'; +import { ap2 } from '@devlab.group/agent-commerce/ap2'; ``` +The AP2 three are small - about 1.3 MB installed between them, against roughly +63 MB for the x402 stack - but they stay optional on the same principle: a +deployment that gates nothing on a mandate should not carry a JOSE stack and an +SD-JWT parser to serve a resource. + Peers are pinned exactly: x402's schemas and EIP-712 domains cross this boundary, so a version skew is a correctness problem rather than a convenience one. Import a subpath without its @@ -233,7 +240,14 @@ See [docs/configuration.md](docs/configuration.md). | **HTTP** | Supported | native routes | | **A2A** | Experimental | A2A v1.0.0, binding `JSONRPC`, method `SendMessage` | | **ACP** | Experimental | ACP `2026-04-17`, REST checkout + discovery | -| UCP · MPP · AP2 | Planned | - | +| **AP2** | Experimental | AP2 `v0.2.0`, Direct Checkout Mandate verification | +| UCP · MPP | Planned | - | + +AP2 is in that table because people look there, but it is not a transport: it +is an **authorization** method that gates settlement on a resource that still +takes a real payment. It is merchant-side mandate verification, not a full AP2 +Merchant implementation - the gateway holds no signing key and issues no +Checkout Receipt. Detail: [docs/ap2.md](docs/ap2.md). "Planned" means **no code ships for it**. "Experimental" means the code ships, is tested against the protocol's own official artifacts, and serves a narrow @@ -425,11 +439,13 @@ See [CONTRIBUTING.md](CONTRIBUTING.md). **Now** - MCP, x402 v2, settlement on the local chain, Base Sepolia and Base mainnet, receipts, doctor, deterministic demo, experimental A2A v1.0.0 and ACP -`2026-04-17` checkout adapters, and experimental OpenAPI import. +`2026-04-17` checkout adapters, experimental AP2 v0.2.0 mandate verification, +and experimental OpenAPI import. -**Next** - a `doctor` GitHub Action · UCP · MPP · AP2 · more of ACP (carts, -feed, delegated payment) · Shopify and WooCommerce examples · PostgreSQL · -richer observability · multi-file and remote OpenAPI sources. +**Next** - a `doctor` GitHub Action · UCP · MPP · autonomous-mode AP2 (open +mandates, agent key binding, constraint evaluation) · more of ACP (carts, feed, +delegated payment) · Shopify and WooCommerce examples · PostgreSQL · richer +observability · multi-file and remote OpenAPI sources. New protocols land only after the adapter model survives real use. Scope discipline is a release requirement, not a mood. @@ -441,6 +457,7 @@ discipline is a release requirement, not a mood. | [Architecture](docs/architecture.md) | how the pieces fit | | [Payment flow](docs/payment-flow.md) | the paid round trip, and every way it fails | | [Protocols](docs/protocols.md) | exactly what is and is not supported | +| [AP2](docs/ap2.md) | mandate verification and the trust model | | [Configuration](docs/configuration.md) | `config.yaml` reference | | [OpenAPI import](docs/openapi-import.md) | generate resources from an existing API | | [Security model](docs/security.md) | trust boundaries, and what we do not defend | diff --git a/config.example.yaml b/config.example.yaml index f76e317..03d8ca0 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -1,5 +1,5 @@ # --------------------------------------------------------------------------- -# Agent Commerce Gateway — example configuration +# Agent Commerce Gateway - example configuration # # Copy to `config.yaml` and edit, or generate one with: # npm run agent-commerce -- init @@ -44,7 +44,7 @@ protocols: mcp: enabled: true mountPath: /mcp - # A2A (Agent2Agent) v1.0.0 — experimental, off by default. + # A2A (Agent2Agent) v1.0.0 - experimental, off by default. # Enabling it serves JSON-RPC `SendMessage` at mountPath and the # specification-fixed Agent Card at /.well-known/agent-card.json. Clients must # send `A2A-Version: 1.0`. Streaming, task persistence and push notifications @@ -52,7 +52,7 @@ protocols: a2a: enabled: false mountPath: /a2a - # ACP (Agentic Commerce Protocol), stable snapshot 2026-04-17 — experimental, + # ACP (Agentic Commerce Protocol), stable snapshot 2026-04-17 - experimental, # off by default. Enabling it serves the five checkout routes under mountPath # and the specification-fixed discovery document at /.well-known/acp.json. # Clients must send `Authorization: Bearer …`, `API-Version: 2026-04-17`, and @@ -118,14 +118,14 @@ payments: assetVersion: ${X402_ASSET_VERSION} assetDecimals: ${X402_ASSET_DECIMALS} # Merchant-controlled settlement destination. - # This is NEVER a gateway-owned wallet — see docs/security.md. + # This is NEVER a gateway-owned wallet - see docs/security.md. payTo: ${MERCHANT_WALLET} # Seconds a payment challenge stays valid. maxTimeoutSeconds: 120 # Who verifies the authorisation and broadcasts the transfer. # # mode: local the facilitator runs in this process and signs with - # signerPrivateKey. Deterministic dev chain only — the key + # signerPrivateKey. Deterministic dev chain only - the key # must be an Anvil well-known one, and startup refuses it # against anything that is not a local/private RPC. # @@ -136,7 +136,7 @@ payments: mode: local signerPrivateKey: ${X402_FACILITATOR_PRIVATE_KEY} # - # A public testnet instead — no key anywhere in this file: + # A public testnet instead - no key anywhere in this file: # # network: eip155:84532 # facilitator: @@ -156,3 +156,55 @@ payments: # auth: # type: bearer # token: ${X402_FACILITATOR_TOKEN} + +# --------------------------------------------------------------------------- +# AP2 mandate verification - experimental, off by default. +# +# Makes a paid resource require proof that the human behind the agent approved +# THIS purchase: a signed AP2 v0.2.0 Direct Checkout Mandate, verified before +# the payment is allowed to settle. A mandate never unlocks a resource on its +# own and never moves money; the resource still takes a real x402 payment. +# +# Public keys only, written here by you. Nothing is fetched at runtime: no +# JWKS, no `jku`, no `x5u`, no issuer discovery. Rotate by listing the new key +# beside the old one, moving the signer to the new `kid`, then removing the old. +# +# Needs the optional peers: npm install jose @sd-jwt/core canonicalize +# Full reference: docs/ap2.md +# --------------------------------------------------------------------------- +# authorization: +# ap2: +# enabled: true +# specVersion: "0.2.0" # the only supported value +# mode: direct # the only supported mode +# clockSkewSeconds: 60 # default; 300 is the ceiling +# replay: +# # Its own file. An authorization replay is not a payment replay, and a +# # spent mandate must stay spent for as long as you can be asked what +# # you delivered. +# path: ./data/ap2-authorizations.sqlite +# trust: +# # Who may issue a Checkout Mandate. Usually the buyer's shopping agent +# # or credential provider. +# mandateIssuers: +# - issuer: https://surface.example +# audience: ${MERCHANT_ID} # required: who the mandate is for +# keys: +# - kid: mandate-2026-01 +# jwk: { kty: EC, crv: P-256, x: "...", y: "..." } +# # Who signs YOUR checkout documents. A separate list on purpose: +# # signing checkouts must not confer the power to issue mandates. +# checkoutIssuers: +# - issuer: https://merchant.example +# audience: agent-commerce +# keys: +# - kid: checkout-2026-01 +# jwk: { kty: EC, crv: P-256, x: "...", y: "..." } +# +# Then require it on a paid resource, under `resources:`: +# +# market_report: +# pricing: { type: fixed, amount: "0.01", currency: USDC } +# payments: [x402] +# authorization: +# required: [ap2] diff --git a/docs/ap2.md b/docs/ap2.md new file mode 100644 index 0000000..cc5e9c4 --- /dev/null +++ b/docs/ap2.md @@ -0,0 +1,340 @@ +# AP2 mandate verification + +**Experimental.** The gateway verifies an [AP2](https://github.com/google-agentic-commerce/AP2) +**v0.2.0 Direct Checkout Mandate** before it lets a payment settle, so a paid +resource can require proof that the human behind an agent approved *this exact +purchase*. + +Off by default: a deployment that configures nothing here behaves exactly as it +did before AP2 existed. + +## Call it what it is + +This is **AP2 merchant-side mandate verification**, not a full AP2 Merchant +implementation. AP2 v0.2's Merchant role also covers Checkout Receipts, and the +gateway holds no signing key and issues none. It is the verifying half. + +| | | +| --- | --- | +| Spec | AP2 **v0.2.0**, tagged 2026-04-28, commit `b4587ac` | +| Mode | Direct (Human-Present) | +| Mandate type | closed Checkout Mandate, `vct` exactly `mandate.checkout.1` | +| Signatures | ES256 over P-256, and nothing else | +| Trust | static public keys in `config.yaml`, no discovery of any kind | + +## What a verified mandate proves + +1. A configured issuer signed it, with a key that issuer declared. +2. It has not expired, and was not issued in the future. +3. It is addressed to this merchant. +4. It binds a checkout document the merchant signed. +5. That document authorises the resource, input, price and rail in front of us. +6. It has not been spent before. + +Nothing else. A mandate never unlocks a resource on its own and never moves +money: a gated resource still needs a real payment proof. + +## Where it sits + +```text +CanonicalRequest + -> resolve resource, validate input, resolve price + -> no payment proof? 402 challenge + the AP2 requirement + -> verify payment proof no funds move + -> VERIFY AND RESERVE THE MANDATE AUTHORIZATION_*, fail closed + -> reserve the payment replay key + -> settle funds move here, and only here + -> consume | release | mark uncertain the reservation + -> merchant backend + -> receipt, carrying the mandate's digest +``` + +The order is the control. Payment verification runs first because it has no +side effect, so a bad proof cannot burn a reservation; the mandate is reserved +before settlement, so two presentations cannot race one payment; and its fate +is decided afterwards, because until settlement returns nobody knows it. + +## The Agent Commerce checkout profile + +AP2 leaves the checkout payload outside its scope, so the claims a paid +invocation needs are specified here instead, under the identifier + +```text +agent-commerce/ap2/checkout/v1 +``` + +A bare name, like the gateway's other wire identifiers: a profile id is a +namespace, never dereferenced, so a URL would only tie the format to a domain. +**Frozen** once released, because merchants sign it into every checkout JWT. + +### The mandate + +A closed Checkout Mandate, presented as an SD-JWT with its disclosures: + +| Claim | Required | Notes | +| --- | --- | --- | +| `vct` | yes | exactly `mandate.checkout.1` | +| `iss` | yes | must be a configured mandate issuer | +| `aud` | yes | must equal that issuer's configured `audience` | +| `iat` | yes | rejected if further ahead than the configured skew | +| `exp` | yes | required, not only checked when present | +| `checkout_hash` | yes | `base64url(SHA-256(compact checkout JWT))` | +| `checkout_jwt` | yes | the compact merchant checkout JWT, read after disclosures resolve | +| `_sd_alg` | when present | `sha-256` only | + +A key-bound presentation (`cnf`, a KB-JWT) is refused: Direct mode issues none, +so one arriving belongs to a flow this release does not verify. + +### The merchant checkout JWT + +| Claim | Required | Notes | +| --- | --- | --- | +| `iss` | yes | must be a configured **checkout** issuer | +| `aud` | yes | must equal that issuer's configured `audience` | +| `iat` | yes | rejected if further ahead than the configured skew | +| `exp` | yes | required | +| `jti` | yes | an opaque id; recorded in the receipt and used for replay defence | +| `agent_commerce` | yes | the profile object below | + +### The profile object + +Every field is a string, and absent is a mismatch rather than a skipped check: +a mandate that will not say which resource or how much authorises nothing in +particular. + +| Field | Compared against | +| --- | --- | +| `profile` | the literal `agent-commerce/ap2/checkout/v1` | +| `resource_id` | the resolved resource | +| `input_hash` | the digest of the validated input, below | +| `amount` | the resolved price, **as a string** | +| `currency` | the resolved currency | +| `payment_method` | the payment provider that built the requirement | +| `destination` | the requirement's settlement destination | +| `network` | the requirement's CAIP-2 network | +| `asset` | the requirement's asset | + +The last three are checked whenever **either** side names one, so under x402, +which names all three, all three are required. A mandate silent about the chain +must not unlock a settlement on one, and a mandate naming a chain the +requirement lacks was approved for another rail. + +Amounts are compared as decimal strings, never numerically: `0.10` and `0.1` +are different strings, and a mandate says what it says. + +Everything is compared against the **already resolved** request. Nothing is +taken from the mandate and used to shape the purchase, which would invert the +control. + +### The input hash + +```text +input_hash = base64url(SHA-256(RFC 8785 JCS(validated input))) +``` + +[RFC 8785](https://www.rfc-editor.org/rfc/rfc8785) (JCS), not a sorted-key +`JSON.stringify`. The merchant's signer computes this digest too, probably in +another language, and the two agree only if both follow JCS number formatting +and UTF-16 key ordering. + +What gets hashed is exactly what the backend will receive: validated, reserved +fields stripped, no request id, no transport metadata. A buyer could not have +known any of that when they approved. + +Without it, one mandate for `translate` would authorise any translation. + +## Trust + +**Static public keys only.** Every verification key is written into +`config.yaml` by an operator. + +- No JWKS, no issuer metadata, no fetching of any kind at runtime. +- `jku` and `x5u` are not followed. A JWK carrying either is refused at load, + by an allowlist of members (`kty`, `crv`, `x`, `y`, `kid`, `alg`, `use`) + rather than a denylist that has to remember them. +- Private material (`d`) is refused at load, naming the key to rotate. + +A mandate's `iss` and `kid` only choose *which* configured key verifies it. An +unrecognised pair is refused, so a mandate can never nominate its own signer, +and there is no "try every key" fallback that would make `kid` advisory. + +**Two separate lists.** `trust.mandateIssuers` signs mandates; +`trust.checkoutIssuers` signs the merchant's checkout documents. Being trusted +for one confers nothing for the other. + +Each issuer carries its own `audience`, required and never defaulted. Without +it a mandate minted for another merchant would verify here, and there is no +value worth guessing for something that decides that. + +### Rotating a key + +List the new public key beside the old one under the same issuer and deploy; +move the signer to the new `kid`; once nothing old is in flight, remove the old +key and deploy again. Both are live during the overlap, and `kid` picks which +one verifies a given mandate. + +There is no revocation API: removing a key from the config and restarting is +the revocation. + +## Time + +`clockSkewSeconds` (default 60, ceiling 300) applies to `exp`, `nbf` and `iat` +on both the mandate and the checkout JWT. The ceiling exists because a skew +wide enough to cover a mandate's whole validity window stops `exp` rejecting +anything; an operator needing more than five minutes has a clock to fix. + +`iat` further ahead than the skew is refused. That is a broken signer, or a +mandate minted to outlive its own expiry window. + +## Replay + +A mandate is spendable exactly once, recorded in its own SQLite database +(`authorization.ap2.replay.path`) that no other store shares. + +The replay key is a digest of the **issuer-signed token**, not of the +presentation. Selective disclosure gives one mandate many valid presentation +strings, so keying on the presentation would let it be spent once per disclosed +subset. The checkout `jti` is guarded as well, so two mandates binding one +checkout document cannot both settle. + +| State | Meaning | +| --- | --- | +| `reserved` | claimed, outcome not yet known. Not reusable | +| `consumed` | settled. Never reusable | +| `released` | nothing happened. Presentable again | +| `uncertain` | settlement broadcast, outcome never learned. Not reusable | + +Only a failure that provably moved no money releases a reservation. A +settlement broadcast but never confirmed is marked `uncertain` instead: the +buyer's funds may already have moved, and a mandate handed back after that can +be spent twice. + +Nothing is swept: deleting a consumed row makes that mandate spendable again, +and it must stay consumed for as long as the merchant can be asked what they +delivered. If the table needs bounding, archive `released` rows only. + +Settlement and the local commit are not one transaction. If the process dies +between them the row stays `reserved` and that mandate is refused from then on: +a refused retry costs a round trip, the other direction costs a second payment. + +## What is recorded, and what is not + +A receipt keeps a method and a digest: + +```json +{ + "authorization": { + "method": "ap2", + "reference": "sha256:BASE64URL", + "metadata": { + "mandateIssuer": "https://surface.example", + "checkoutIssuer": "https://merchant.example", + "checkoutId": "checkout_01K..." + } + } +} +``` + +The presentation, its disclosures, the checkout JWT and the +`Agent-Authorization` header are **never** stored and never logged. A receipt +outlives the request that produced it, and a stored mandate would be a +spendable secret at rest. Failures are logged as reason codes. + +**Evidence retention is not solved here.** A digest proves a mandate with that +identity was accepted; it does not reconstruct what the buyer saw or agreed to. +Dispute-grade evidence stays with the merchant or the system that minted the +mandate, unless an encrypted evidence store is added later. + +## Errors + +| Code | HTTP | When | +| --- | --- | --- | +| `AUTHORIZATION_REQUIRED` | 403 | the resource requires a mandate and none was presented | +| `AUTHORIZATION_INVALID` | 403 | signature, trust, binding, time or purchase mismatch | +| `AUTHORIZATION_REPLAYED` | 409 | the mandate is good, and already spent | +| `AUTHORIZATION_PROVIDER_UNAVAILABLE` | 503 | our verifier or store failed. Retryable | + +403 rather than 402: the buyer's money is not the problem. A 402 tells a client +"pay and retry", which cannot fix a rejected mandate, and a client that auto-pays +on 402 would be charged for a request that was never going to be delivered. + +Rejection reasons are coarse by design (`untrusted_issuer`, `invalid_signature`, +`expired`, `purchase_mismatch`, and a handful more). A caller learns roughly +where its mandate was refused, not which field disagreed: finer detail lets +someone read a mandate's contents out of the gateway by elimination. + +An outage is never recorded against the payer. Their mandate may be perfectly +good. + +## Carrying a mandate + +One envelope, three transports: + +```json +{ "method": "ap2", "payload": "" } +``` + +| Surface | Carrier | +| --- | --- | +| HTTP | `Agent-Authorization` header, base64url of that JSON | +| MCP | the reserved `_authorization` tool argument | +| A2A | the reserved `_authorization` input field | + +HTTP uses a header because the payment proof already travels out of band there, +and an authorization inside the body would have to survive every backend +input-binding mode intact. The header is capped at 8192 bytes, checked before +any decode; see [security.md](security.md#denial-of-service). + +The payload is preserved byte for byte from the wire. Reserved fields are +stripped before validation, so `_authorization` never reaches the merchant +backend and never enters the input hash. + +## Configuration + +The YAML block and every rule the loader enforces are in +[configuration.md](configuration.md#authorizationap2). One rule surprises +people: `required: [ap2]` on a **free** resource is refused, at load and again +on the execution path. Authorization gates settlement, so where there is no +settlement nothing would ever read the mandate. + +The provider lives on the `./ap2` subpath and its peers are optional: + +```bash +npm install @devlab.group/agent-commerce jose @sd-jwt/core canonicalize +``` + +`agent-commerce doctor` reports the pins, the trusted issuer ids with key +counts, the replay store's writability, and which resources a mandate gates. + +## Not implemented + +Refused rather than half-served. The adapter descriptor and `agent-commerce +doctor` print the machine-readable half of this at runtime; this page adds the +AP2 roles and artefacts the gateway does not play or produce. If the two ever +disagree about something they both name, the runtime list is the truth and this +page is a bug. + +- autonomous mode, and open Checkout Mandates (`mandate.checkout.open.1`) +- intent mandates, cart mandates, Payment Mandate verification +- spending-constraint evaluation (`allowed_merchants`, `line_items`) +- `cnf`-bound agent keys and delegation chains +- JWKS, `jku`, `x5u`, issuer metadata fetching, remote revocation +- key rotation without a config change +- algorithms other than ES256, digests other than sha-256 +- mandate issuance, merchant checkout JWT issuance, signed Checkout Receipts +- an AP2 transport adapter, `/.well-known/ap2`, AP2 as a payment rail +- AP2 over the ACP checkout adapter + +Open mandates are the one worth naming twice: they carry spending constraints +this release does not evaluate, so accepting one would tell a buyer their +limits were checked when nothing read them. + +## Where to look + +| | | +| --- | --- | +| `src/authorization/ap2/` | verifier, trust store, purchase binding, replay store | +| `src/core/domain/authorization.ts` | the generic contract core enforces | +| `src/core/execution/pipeline.ts` | the ordering above | +| `tests/integration/ap2-x402-conformance.test.ts` | every refusal, end to end | +| `tests/e2e/authorization/` | a gated purchase settling on a real chain | diff --git a/docs/architecture.md b/docs/architecture.md index cc522e0..f08600f 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -29,9 +29,9 @@ not scale, and handing the money to a proprietary middleman defeats the point. │ (x402) (bounded HTTP) (SQLite) │ └──────┬────────────────┬──────────────────────────────────┘ │ │ - payment protocol ┌──────▼──────────────┐ + payment protocol ┌──────▼───────────────┐ buyer → merchant │ Merchant Backend API │ (unchanged) - └──────────────────────┘ + └──────────────────────┘ ``` Three properties are load-bearing: @@ -103,8 +103,10 @@ CanonicalRequest ├─ no proof ──► PaymentRequiredOutcome (402) ─────┤ fail closed ├─ verify ──► rejected ► PAYMENT_INVALID ──────┤ ├─ replayKey missing ────► PAYMENT_INVALID ───────┤ + ├─ authorize + reserve ──► AUTHORIZATION_* ───────┤ ├─ reserve replayKey ────► PAYMENT_REPLAYED ──────┤ - └─ settle ─────────────► PAYMENT_SETTLEMENT_FAILED + ├─ settle ─────────────► PAYMENT_SETTLEMENT_FAILED + └─ consume | release | mark the authorization │ ┌───────────────────────────────────────────────────────┘ ├─ call merchant backend ────────────────► BACKEND_TIMEOUT / BACKEND_ERROR @@ -116,6 +118,12 @@ CanonicalRequest deliberately **between** them: a duplicate authorisation is rejected before any funds move. +The authorization step is opt-in per resource and absent from almost every +deployment. Where a resource does require one, it sits between payment +verification and settlement for the same reason the replay reservation does, +and only a failure that provably moved no money hands it back. See +[ap2.md](ap2.md#where-it-sits). + ## Correlation Every flow has one `requestId`, generated by the protocol adapter and carried @@ -129,6 +137,11 @@ resource.requested → payment.required → payment.verified → payment.settled → backend.called → resource.delivered ``` +A resource requiring authorization adds `authorization.verified` (or +`authorization.rejected`) between the request and the payment events. The event +types are the same whatever the method, so reading the audit trail never +requires knowing what AP2 is. + ## Adapter isolation An optional adapter that fails to start is marked unhealthy and reported by @@ -151,15 +164,16 @@ demo buyer agent is a deterministic program, and that is the path CI runs. ## Where to look in the code -| Concern | Path | -| ------------------------------------------ | ------------------------------ | -| canonical model, errors, pipeline | `src/core` | -| config schema, loader, env substitution | `src/config` | -| Fastify server, routes, adapter mounting | `src/gateway` | -| MCP adapter | `src/protocols/mcp` | -| x402 provider + local/remote facilitator | `src/payments/x402` | -| SQLite receipts/events/attempts | `src/storage/receipts` | -| OpenAPI import (config ingress only) | `src/openapi` | -| CLI (`init`, `import`, `validate`, `doctor`, `demo`) | `src/cli` | -| demo merchant API / buyer / dashboard | `demo/*` | -| MockUSDC + local chain scripts | `contracts/`, `scripts/chain/` | +| Concern | Path | +| ---------------------------------------------------- | ------------------------------ | +| canonical model, errors, pipeline | `src/core` | +| config schema, loader, env substitution | `src/config` | +| Fastify server, routes, adapter mounting | `src/gateway` | +| MCP adapter | `src/protocols/mcp` | +| x402 provider + local/remote facilitator | `src/payments/x402` | +| AP2 mandate verification | `src/authorization/ap2` | +| SQLite receipts/events/attempts | `src/storage/receipts` | +| OpenAPI import (config ingress only) | `src/openapi` | +| CLI (`init`, `import`, `validate`, `doctor`, `demo`) | `src/cli` | +| demo merchant API / buyer / dashboard | `demo/*` | +| MockUSDC + local chain scripts | `contracts/`, `scripts/chain/` | diff --git a/docs/configuration.md b/docs/configuration.md index 08edb8d..cb82737 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -34,6 +34,7 @@ npm run agent-commerce -- validate | `protocols` | yes | which surfaces are enabled | | `resources` | yes | the capabilities you expose | | `payments` | when a paid resource exists | rail configuration | +| `authorization` | no | AP2 mandate verification | ## Resources @@ -209,6 +210,61 @@ the x402 replay defence. See [protocols.md](protocols.md#acp) for the wire contract. +## `authorization.ap2` + +Experimental, off by default, and absent from a config that predates it. Full +reference, including the checkout profile and the trust model: +[ap2.md](ap2.md). + +```yaml +authorization: + ap2: + enabled: true + specVersion: "0.2.0" # the only supported value + mode: direct # the only supported mode + clockSkewSeconds: 60 # default; 300 is the ceiling + replay: + path: ./data/ap2-authorizations.sqlite # its own file, never shared + trust: + mandateIssuers: # who may issue a Checkout Mandate + - issuer: https://surface.example + audience: merchant.example + keys: + - kid: mandate-2026-01 + jwk: { kty: EC, crv: P-256, x: "...", y: "..." } + checkoutIssuers: # who may sign the merchant checkout JWT + - issuer: https://merchant.example + audience: agent-commerce + keys: + - kid: checkout-2026-01 + jwk: { kty: EC, crv: P-256, x: "...", y: "..." } +``` + +Then require it on a paid resource: + +```yaml +resources: + market_report: + pricing: { type: fixed, amount: "0.01", currency: USDC } + payments: [x402] + authorization: + required: [ap2] +``` + +Public keys only, written here by an operator. Nothing is fetched: no JWKS, no +`jku`, no `x5u`, no issuer discovery. A JWK carrying private material is +refused at load and names the key to rotate. + +The two issuer lists are separate on purpose - signing the merchant's checkout +documents must not confer the power to issue mandates - and `audience` is +required per issuer rather than defaulted, because without it a mandate minted +for another merchant would verify here. + +Refused at load: requiring `ap2` while the block is absent or disabled; +requiring it on a **free** resource, since authorization gates settlement and +there would be none; a `replay.path` shared with the receipt store or the ACP +idempotency store; and a `clockSkewSeconds` above the ceiling. + ## Payments ```yaml diff --git a/docs/contracts.md b/docs/contracts.md index bcdb9be..4605c0f 100644 --- a/docs/contracts.md +++ b/docs/contracts.md @@ -164,6 +164,68 @@ export type DeploymentMode = 'local' | 'testnet' | 'mainnet'; export const SUPPORTED_NETWORK_IDS: readonly string[]; // ['eip155:84532', 'eip155:8453'] ``` +## `src/authorization/ap2` +> **Published as** `@devlab.group/agent-commerce/ap2`, gated behind the optional +> peers `jose`, `@sd-jwt/core` and `canonicalize`. The main entry and the CLI +> import only the narrow modules (`constants.ts`, `types.ts`, `descriptor.ts`), +> which pull no peer, so `doctor` can report AP2 without installing a JOSE +> stack. Trust and config types live here rather than in `src/config`, the way +> `X402FacilitatorConfig` does: the subsystem owns its own config shape and the +> loader imports it. + +```ts +export interface Ap2AuthorizationProviderOptions { + /** The enabled half of the parsed `authorization.ap2` block. */ + readonly config: EnabledAp2Config; + readonly clock?: Clock; + readonly logger?: Logger; + /** Injectable so tests need not touch the filesystem. */ + readonly replayStore?: Ap2ReplayStore; +} + +/** The gateway owns the lifetime: `close()` releases the replay database. */ +export interface Ap2AuthorizationProvider extends AuthorizationProvider { + close(): void; +} +export function createAp2AuthorizationProvider( + options: Ap2AuthorizationProviderOptions, +): Ap2AuthorizationProvider; + +export type Ap2AuthorizationConfig = + | { readonly enabled: false } + | { + readonly enabled: true; + readonly specVersion: '0.2.0'; + readonly mode: 'direct'; + readonly trust: { + /** Signers of the Checkout Mandate itself. */ + readonly mandateIssuers: readonly Ap2TrustedIssuer[]; + /** Signers of the merchant checkout JWT the mandate binds. */ + readonly checkoutIssuers: readonly Ap2TrustedIssuer[]; + }; + readonly clockSkewSeconds: number; + /** Its own SQLite file. An authorization replay is not a payment replay. */ + readonly replay: { readonly path: string }; + }; + +export interface Ap2TrustedIssuer { + readonly issuer: string; + /** Per issuer, not gateway-wide: the mandate is addressed to the merchant. */ + readonly audience: string; + readonly keys: readonly Ap2TrustedKey[]; +} +export interface Ap2TrustedKey { + readonly kid: string; + /** A public P-256 JWK, validated member by member at config load. */ + readonly jwk: Readonly>; +} + +export const AP2_SPEC_VERSION = '0.2.0'; +export const AP2_CHECKOUT_PROFILE = 'agent-commerce/ap2/checkout/v1'; +export const AP2_CAPABILITIES: readonly string[]; +export const AP2_UNSUPPORTED: readonly string[]; +``` + ## `src/protocols/mcp` > **Published as** `@devlab.group/agent-commerce/mcp`, gated behind the optional peer > `@modelcontextprotocol/sdk`. In-repo consumers keep importing it by relative diff --git a/docs/payment-flow.md b/docs/payment-flow.md index 048ec6a..70e8bb9 100644 --- a/docs/payment-flow.md +++ b/docs/payment-flow.md @@ -106,6 +106,11 @@ Deriving the key from the authorisation rather than the request is what makes it work: the same authorisation replayed against a *different* request still collides. +A resource that also requires an AP2 mandate gets a third, independent +reservation, in its own database and on its own key. It is claimed before the +payment replay key and released only by a failure that provably moved no money. +See [ap2.md](ap2.md#replay). + ## Amounts Canonical amounts are decimal strings in display units — `"0.01"` — never diff --git a/docs/protocols.md b/docs/protocols.md index c2cf49d..ee89b9c 100644 --- a/docs/protocols.md +++ b/docs/protocols.md @@ -12,9 +12,9 @@ implemented, exactly what is not, and pins the revisions. | **HTTP** | Supported | — | native resource routes with `PAYMENT-SIGNATURE` | | **A2A** | Experimental | A2A **v1.0.0**, negotiation version `1.0`, binding `JSONRPC` | Agent Card discovery, `SendMessage`, terminal tasks, paid flow | | **ACP** | Experimental | ACP stable snapshot **2026-04-17**, REST binding | discovery, the five checkout operations, bearer auth, idempotency | +| **AP2** | Experimental | AP2 **v0.2.0**, tagged 2026-04-28, commit `b4587ac`, Direct mode | closed Checkout Mandate verification before settlement | | UCP | Planned | — | planned, no code ships | | MPP | Planned | — | planned, no code ships | -| AP2 | Planned | — | planned, no code ships | "Planned" means **no code ships for it**. There is no partial adapter, no endpoint and no diagnostic pretending otherwise. @@ -24,6 +24,12 @@ ships, it is tested against the protocol's own official artifacts - the A2A SDK, the ACP schema and examples - and the supported subset is narrow and named below. Both are off by default. +AP2 is listed here because this is where people look, but it is not a +transport and has no adapter, no mount path and no discovery document. It is an +authorization method: it decides whether a payment is allowed to settle, and a +resource that requires one still needs a real payment proof. It has its own +page, [ap2.md](ap2.md), and `doctor` reports it separately from the protocols. + Every adapter reports itself at runtime through `GET /.well-known/agent-commerce` and in `agent-commerce doctor`, with `supportedSpec`, `capabilities`, `unsupported` and `status`. If this page and diff --git a/docs/security.md b/docs/security.md index 38cb5be..e90b79e 100644 --- a/docs/security.md +++ b/docs/security.md @@ -29,6 +29,8 @@ Never logged, never persisted, never returned: - private keys, seed phrases, mnemonics - `Authorization` headers and backend API secrets - the `PAYMENT-SIGNATURE` header and raw payment authorisation payloads +- the `Agent-Authorization` header, AP2 presentations, their disclosures, and + the merchant checkout JWT they bind - `signature`, `secret`, `apiKey`, `signerPrivateKey`, `adminToken` and `token` fields, **at the top level and one level deep** (see below) @@ -45,6 +47,32 @@ redaction (`src/storage/receipts/redact.ts`) has no such limit: it is a recursive key-pattern strip at every depth. Both have tests. Resolved `${VAR}` values are never printed, even in configuration error messages - errors name the *variable*, not the value. +## Authorization trust (AP2) + +A separate trust anchor from payment, and a deliberately small one. The key +policy, the two issuer lists and the rotation procedure are in +[ap2.md](ap2.md#trust). + +Every AP2 verification key is a **public** key an operator wrote into +`config.yaml`. The gateway performs no key discovery of any kind: no JWKS +endpoint, no `jku`, no `x5u`, no issuer metadata fetch, no revocation call. +A JWK is validated member by member at load against an allowlist, so private +material and anything naming a URL is refused without the check having to name +it. That closes an SSRF surface before it exists: no code path lets a presented +mandate cause an outbound request, and removing a key from the config is the +revocation. + +The algorithm comes from local policy, never from the JWT header: ES256 over +P-256, one entry, so `alg: none` and the HMAC family are excluded by +construction rather than by a check that has to remember them. `iss` and `kid` +select which configured key verifies a mandate, and an unrecognised pair is +refused - there is no "try every key" fallback that would make `kid` advisory. + +Digests are taken over the bytes that arrived - the compact checkout JWT as +presented, and the issuer-signed token - never over a re-serialised object. A +normalised payload has a different digest, and hashing it would check a +document other than the one being verified. + ## SSRF The gateway makes outbound HTTP calls to URLs it was configured with: From 724e1152533cadfb149f6bf7c90515c1c8f63775 Mon Sep 17 00:00:00 2001 From: Revinand Date: Tue, 15 Sep 2026 15:53:32 +0200 Subject: [PATCH 09/11] feat(ap2): add merchant checkout jwt signer --- README.md | 2 +- docs/ap2.md | 45 +++- docs/contracts.md | 24 ++ src/ap2.ts | 4 + src/authorization/ap2/checkout-signer.ts | 170 +++++++++++++ src/authorization/ap2/index.ts | 6 + .../authorization-ap2/checkout-signer.test.ts | 232 ++++++++++++++++++ tests/unit/cli/packaging.test.ts | 4 +- 8 files changed, 484 insertions(+), 3 deletions(-) create mode 100644 src/authorization/ap2/checkout-signer.ts create mode 100644 tests/unit/authorization-ap2/checkout-signer.test.ts diff --git a/README.md b/README.md index aca3bb0..a27c734 100644 --- a/README.md +++ b/README.md @@ -100,7 +100,7 @@ serving a free HTTP resource has no business installing. | gateway, config, receipts, CLI | `@devlab.group/agent-commerce` | `from '@devlab.group/agent-commerce'` | | expose resources as MCP tools | `+ @modelcontextprotocol/sdk` | `from '@devlab.group/agent-commerce/mcp'` | | accept x402 payments | `+ @x402/core @x402/evm viem` | `from '@devlab.group/agent-commerce/x402'` | -| verify AP2 mandates | `+ jose @sd-jwt/core canonicalize` | `from '@devlab.group/agent-commerce/ap2'` | +| verify AP2 mandates, sign checkout JWTs | `+ jose @sd-jwt/core canonicalize` | `from '@devlab.group/agent-commerce/ap2'` | | authenticate to a CDP facilitator | `+ @coinbase/x402` | (no import - loaded on demand) | ```bash diff --git a/docs/ap2.md b/docs/ap2.md index cc5e9c4..11d5779 100644 --- a/docs/ap2.md +++ b/docs/ap2.md @@ -143,6 +143,48 @@ known any of that when they approved. Without it, one mandate for `translate` would authorise any translation. +## Minting the checkout JWT + +The gateway only verifies. Someone has to sign, and for the checkout JWT that +someone is you, in your own process, with a key whose public half you listed +under `checkoutIssuers`. + +```ts +import { createCheckoutJwt } from '@devlab.group/agent-commerce/ap2'; + +const jwt = await createCheckoutJwt({ + privateKey, // a private JWK, or a PKCS#8 PEM + kid: 'checkout-2026-01', // must match a configured key + issuer: 'https://merchant.example', + audience: 'agent-commerce', + resourceId: 'market_report', + input: { city: 'Berlin' }, // it computes the RFC 8785 digest + amount: '0.01', // a string, from your own catalogue + currency: 'USDC', + paymentMethod: 'x402', + destination, network, asset, // as the 402 published them +}); +``` + +It exists mainly for `input_hash`. A signer that reaches for a sorted-key +`JSON.stringify` agrees with this gateway on most inputs and parts company on +the ones carrying floats or non-ASCII keys, and the resulting mandate is +refused with a reason that does not say which field disagreed. + +It also refuses, before signing, what would otherwise become that same opaque +refusal: a numeric `amount`, the public half of the key pair, a key that is not +P-256, and a missing required field. + +What it cannot check is agreement with the gateway's own resolved requirement, +which it never sees. Take `amount` and `currency` from your catalogue and the +settlement coordinates from the 402, rather than echoing what the agent asked +for. A lie from the agent fails closed at verification either way, but a +mismatch you introduce fails just as closed and is yours to debug. + +The mandate that wraps this JWT is signed elsewhere, by the buyer's agent or +credential provider, using a key listed under `mandateIssuers`. Nothing in this +package mints one: the gateway is the merchant, not the buyer. + ## Trust **Static public keys only.** Every verification key is written into @@ -321,7 +363,8 @@ page is a bug. - JWKS, `jku`, `x5u`, issuer metadata fetching, remote revocation - key rotation without a config change - algorithms other than ES256, digests other than sha-256 -- mandate issuance, merchant checkout JWT issuance, signed Checkout Receipts +- mandate issuance and signed Checkout Receipts (the checkout JWT you can sign + with `createCheckoutJwt`, above; the mandate itself is the buyer's side) - an AP2 transport adapter, `/.well-known/ap2`, AP2 as a payment rail - AP2 over the ACP checkout adapter diff --git a/docs/contracts.md b/docs/contracts.md index 4605c0f..13d5516 100644 --- a/docs/contracts.md +++ b/docs/contracts.md @@ -90,6 +90,7 @@ the generated file is right and this table is stale. - **Additive:** the generic authorization contract - `AuthorizationMethodName` (`'ap2'`), `AuthorizationSubmission`, `AuthorizationRequirement`, `AuthorizationVerification`, `AuthorizationProvider` and its two contexts; optional `CanonicalRequest.authorization`, optional `CommerceResource.authorization`, optional `PaymentRequiredOutcome.authorization` and the matching `PaymentRequiredEnvelope.authorization`; `AdapterDescriptor.kind` gains `'authorization'`; four `AUTHORIZATION_*` error codes (403 / 403 / 409 / 503, the last retryable); and the wire carriers `AUTHORIZATION_INPUT_FIELD` (`_authorization`), `AUTHORIZATION_HEADER` (`agent-authorization`), `MAX_AUTHORIZATION_HEADER_BYTES` and `RESERVED_INPUT_FIELDS`. *Use case:* AP2 mandate verification - proving the human behind an agent approved this exact purchase, a separate question from whether the payment verified. *Why generic:* AP2 is the first implementation, not the abstraction. Core states that a resource requires authorization and when the pipeline checks it, and knows nothing about SD-JWTs. An authorization method is deliberately neither a `ProtocolName` nor a `PaymentMethodName`, because it is not a transport and must never be selectable as a payment rail. *Compatibility:* every field is optional and every consumer that sets none behaves exactly as before; a resource with no `authorization` policy is unchanged end to end. `extractReservedInputFields` replaces the two hand-written `_payment` extractors in the MCP and A2A adapters with one path in core, so the reserved-field list cannot drift between surfaces. `_payment` handling is byte-identical, including dropping a proof for a resource with no configured rail. - **Additive:** `AuthorizationRecord`; optional `CommerceReceipt.authorization`; `AuthorizationProvider` gains `requirement` and `markUncertain`; `AuthorizationVerification` now extends `AuthorizationRecord`; `CommerceEventType` gains `authorization.verified` and `authorization.rejected`. *Use case:* the execution pipeline enforcing authorization, in the order payment verify -> authorize/reserve -> payment replay reserve -> settle -> consume/release/mark-uncertain. *Why `requirement` on the provider:* the 402 challenge has to name what the retry must also carry, and only the provider knows its own spec version and payload profile. *Why `markUncertain` rather than leaving a reservation alone:* a settlement that was broadcast but never confirmed must not hand the proof back, and "we did nothing" is indistinguishable from a path that forgot to finalize. *Why the receipt stores a record and not the verification:* `reservationId` is a live handle, not an audit fact, and a stored proof would be a spendable secret at rest. *Compatibility:* `CommerceReceipt.authorization` is optional and absent for every resource that requires no authorization; the receipt store adds schema version 2 (`ALTER TABLE receipts ADD COLUMN authorization_json`), so an existing database keeps its rows. `AuthorizationProvider` is not yet implemented by anything shipped, so the two new members break no consumer. - **Additive (non-frozen surfaces):** `GatewayOptions.authorizationProviders` (optional) and `ReadinessResult.authorizationProviders`; a new `./ap2` subpath exporting `createAp2AuthorizationProvider` / `ap2`, with `jose`, `@sd-jwt/core` and `canonicalize` as optional peers. *Use case:* running AP2 as a wired subsystem. *Why a subpath:* one entry per distinct peer set, named for the peer - a gateway serving no gated resource should install neither a JOSE stack nor an SD-JWT parser, and the main entry and the CLI import the narrow AP2 modules (`constants.ts`, `types.ts`, `descriptor.ts`) so neither pulls a peer. *Readiness:* an authorization provider reporting `fail` blocks `/ready` on the same threshold as a payment provider - a resource that requires a mandate cannot be served without one, and serving its challenge anyway promises what cannot be honoured. Only the fixed vocabulary token `authorization-provider-unreachable` reaches the client. *Compatibility:* both fields are additive and a deployment configuring no authorization behaves exactly as before. +- **Additive (`./ap2` subpath):** `createCheckoutJwt` and `CreateCheckoutJwtOptions`. *Use case:* a merchant has to sign the checkout JWT a Checkout Mandate binds, and the gateway only verifies. *Why it ships:* `input_hash` is an RFC 8785 digest, and a hand-rolled signer reaching for a sorted-key `JSON.stringify` agrees on most inputs and disagrees on floats and non-ASCII keys - producing a mandate refused with a deliberately coarse reason. The helper also refuses a numeric `amount`, the public half of a key pair, a non-P-256 key and a missing field before signing, rather than letting each become that same opaque refusal. *Scope:* signing only. It runs in the merchant's process, never calls the gateway and is never called by it - the mirror of `createPaymentProof`. The Checkout Mandate itself is the buyer's side and nothing here mints one. --- # Integration contract - exact factory signatures @@ -220,6 +221,29 @@ export interface Ap2TrustedKey { readonly jwk: Readonly>; } +/** Merchant-side. Signs the checkout JWT a Checkout Mandate binds. */ +export interface CreateCheckoutJwtOptions { + /** A private ES256 JWK, or a PKCS#8 PEM. Never leaves the caller's process. */ + readonly privateKey: Ap2SigningKey; + readonly kid: string; + readonly issuer: string; + readonly audience: string; + readonly resourceId: string; + /** Hashed with RFC 8785 (JCS), the same way the gateway hashes it. */ + readonly input: unknown; + /** A decimal string; compared as a string, never numerically. */ + readonly amount: string; + readonly currency: string; + readonly paymentMethod: string; + readonly destination?: string; + readonly network?: string; + readonly asset?: string; + readonly jwtId?: string; + readonly expiresInSeconds?: number; + readonly now?: Date; +} +export function createCheckoutJwt(options: CreateCheckoutJwtOptions): Promise; + export const AP2_SPEC_VERSION = '0.2.0'; export const AP2_CHECKOUT_PROFILE = 'agent-commerce/ap2/checkout/v1'; export const AP2_CAPABILITIES: readonly string[]; diff --git a/src/ap2.ts b/src/ap2.ts index a64c1e5..d5a41e2 100644 --- a/src/ap2.ts +++ b/src/ap2.ts @@ -28,10 +28,14 @@ export { type Ap2AuthorizationProviderOptions, type Ap2Mode, type Ap2RejectionReason, + type Ap2SigningKey, type Ap2TrustedIssuer, type Ap2TrustedKey, + type CreateCheckoutJwtOptions, createAp2AuthorizationProvider, // `ap2` reads well at a call site; the full name reads better in a trace. createAp2AuthorizationProvider as ap2, + // Merchant-side, and the only export here that signs rather than verifies. + createCheckoutJwt, type EnabledAp2Config, } from './authorization/ap2/index.js'; diff --git a/src/authorization/ap2/checkout-signer.ts b/src/authorization/ap2/checkout-signer.ts new file mode 100644 index 0000000..0b25e36 --- /dev/null +++ b/src/authorization/ap2/checkout-signer.ts @@ -0,0 +1,170 @@ +/** + * Merchant-side helper: mint the checkout JWT a Checkout Mandate binds. + * + * The gateway only verifies. This is what a merchant runs in their own + * process, with their own key, to produce the document the buyer approves. It + * never calls the gateway and the gateway never calls it - the mirror of + * `createPaymentProof`, which a buyer runs to produce a payment proof. + * + * It exists for one claim in particular. `input_hash` is an RFC 8785 digest, + * and a hand-rolled signer that reaches for a sorted-key `JSON.stringify` + * agrees with this gateway on most inputs and disagrees on the ones that carry + * floats or non-ASCII keys. The mandate then fails verification with a + * deliberately coarse reason that does not say which field disagreed. + */ +import { importPKCS8, type JWK, SignJWT } from 'jose'; +import { + AP2_CHECKOUT_PROFILE, + AP2_JWK_CURVE, + AP2_KEY_TYPE, + AP2_SIGNING_ALGORITHM, +} from './constants.js'; +import { computeInputHash } from './profile.js'; + +/** + * A private ES256 key: either a private JWK (the pair of the public one in the + * gateway's `checkoutIssuers`) or a PKCS#8 PEM, as `openssl` emits it. + */ +export type Ap2SigningKey = Readonly> | string; + +export interface CreateCheckoutJwtOptions { + readonly privateKey: Ap2SigningKey; + /** Must match a `kid` configured under the gateway's `checkoutIssuers` */ + readonly kid: string; + /** Must match that issuer's configured `issuer` */ + readonly issuer: string; + /** Must match that issuer's configured `audience` */ + readonly audience: string; + + readonly resourceId: string; + /** + * The resource input this purchase is for, exactly as the buyer will send + * it: no reserved fields, no request id, no transport metadata. + */ + readonly input: unknown; + + /** + * Decimal string, never a number, and compared as a string: `0.10` and `0.1` + * are different mandates. Take it from your own catalogue rather than from + * whatever the agent asked for. + */ + readonly amount: string; + readonly currency: string; + /** The rail that will settle, e.g. `x402` */ + readonly paymentMethod: string; + + /** + * Settlement coordinates. Required whenever the gateway's requirement names + * them, which under x402 is always. A mandate silent about the chain will + * not unlock a settlement on one. + */ + readonly destination?: string; + readonly network?: string; + readonly asset?: string; + + /** Defaults to a random UUID. Recorded on the receipt and used for replay defence */ + readonly jwtId?: string; + /** + * Defaults to 900 (15 minutes). A human approval sits inside this window, so + * it has to outlast someone reading a checkout screen. + */ + readonly expiresInSeconds?: number; + /** Injectable so a test need not move the wall clock */ + readonly now?: Date; +} + +const DEFAULT_EXPIRES_IN_SECONDS = 900; + +function requireText(value: unknown, field: string): string { + if (typeof value !== 'string' || value.length === 0) { + throw new TypeError( + `createCheckoutJwt: ${field} must be a non-empty string, received ${describe(value)}`, + ); + } + return value; +} + +function describe(value: unknown): string { + return value === null ? 'null' : typeof value; +} + +/** + * Refuses the two copy-paste mistakes that would otherwise surface as an + * opaque verification failure: signing with the public half, and signing with + * a key of the wrong type. + */ +async function resolveKey( + key: Ap2SigningKey, +): Promise>> { + if (typeof key === 'string') { + if (!key.includes('BEGIN PRIVATE KEY')) { + throw new TypeError( + 'createCheckoutJwt: a string privateKey must be a PKCS#8 PEM beginning "-----BEGIN PRIVATE KEY-----"', + ); + } + return importPKCS8(key, AP2_SIGNING_ALGORITHM); + } + if (typeof key !== 'object' || key === null) { + throw new TypeError('createCheckoutJwt: privateKey must be a private JWK or a PKCS#8 PEM'); + } + if (key['d'] === undefined) { + throw new TypeError( + 'createCheckoutJwt: privateKey is a public JWK (no "d"). Use the private half of the pair whose public key is configured under checkoutIssuers', + ); + } + if (key['kty'] !== AP2_KEY_TYPE || key['crv'] !== AP2_JWK_CURVE) { + throw new TypeError( + `createCheckoutJwt: privateKey must be ${AP2_KEY_TYPE}/${AP2_JWK_CURVE}, the pair ${AP2_SIGNING_ALGORITHM} implies`, + ); + } + return key as JWK; +} + +/** + * Returns the compact JWT to hand to the agent, which wraps it in the Checkout + * Mandate the buyer signs. + */ +export async function createCheckoutJwt(options: CreateCheckoutJwtOptions): Promise { + const kid = requireText(options.kid, 'kid'); + const issuer = requireText(options.issuer, 'issuer'); + const audience = requireText(options.audience, 'audience'); + const resourceId = requireText(options.resourceId, 'resourceId'); + const currency = requireText(options.currency, 'currency'); + const paymentMethod = requireText(options.paymentMethod, 'paymentMethod'); + + if (typeof options.amount === 'number') { + throw new TypeError( + 'createCheckoutJwt: amount must be a decimal string, not a number. It is compared as a string, so 0.1 and "0.10" are different mandates', + ); + } + const amount = requireText(options.amount, 'amount'); + + const key = await resolveKey(options.privateKey); + const issuedAt = Math.floor((options.now ?? new Date()).getTime() / 1000); + const expiresIn = options.expiresInSeconds ?? DEFAULT_EXPIRES_IN_SECONDS; + if (!Number.isInteger(expiresIn) || expiresIn <= 0) { + throw new TypeError('createCheckoutJwt: expiresInSeconds must be a positive whole number'); + } + + const agentCommerce: Record = { + profile: AP2_CHECKOUT_PROFILE, + resource_id: resourceId, + // The reason this helper exists + input_hash: await computeInputHash(options.input), + amount, + currency, + payment_method: paymentMethod, + ...(options.destination !== undefined ? { destination: options.destination } : {}), + ...(options.network !== undefined ? { network: options.network } : {}), + ...(options.asset !== undefined ? { asset: options.asset } : {}), + }; + + return new SignJWT({ agent_commerce: agentCommerce }) + .setProtectedHeader({ alg: AP2_SIGNING_ALGORITHM, kid, typ: 'JWT' }) + .setIssuer(issuer) + .setAudience(audience) + .setIssuedAt(issuedAt) + .setExpirationTime(issuedAt + expiresIn) + .setJti(options.jwtId ?? crypto.randomUUID()) + .sign(key); +} diff --git a/src/authorization/ap2/index.ts b/src/authorization/ap2/index.ts index fedbee6..6ed9f3e 100644 --- a/src/authorization/ap2/index.ts +++ b/src/authorization/ap2/index.ts @@ -5,6 +5,12 @@ * pulls the optional peers (`jose`, `@sd-jwt/core`, `canonicalize`), so the * main entry and the CLI import the narrow modules instead of this barrel. */ + +export { + type Ap2SigningKey, + type CreateCheckoutJwtOptions, + createCheckoutJwt, +} from './checkout-signer.js'; export { AP2_CHECKOUT_MANDATE_VCT, AP2_CHECKOUT_PROFILE, diff --git a/tests/unit/authorization-ap2/checkout-signer.test.ts b/tests/unit/authorization-ap2/checkout-signer.test.ts new file mode 100644 index 0000000..f40ea53 --- /dev/null +++ b/tests/unit/authorization-ap2/checkout-signer.test.ts @@ -0,0 +1,232 @@ +/** + * The merchant-side signer, checked against the verifier that will judge it. + * + * The round trip is the test that matters: a JWT this helper produced has to + * pass `verifyCheckoutJwt` and then bind to the purchase. Asserting the claim + * names on their own would pass while the digest silently disagreed, which is + * the failure the helper exists to prevent. + */ +import { exportJWK, exportPKCS8, generateKeyPair } from 'jose'; +import { beforeAll, describe, expect, it } from 'vitest'; +import { AP2_CHECKOUT_PROFILE } from '../../../src/authorization/ap2/constants.js'; +import { createCheckoutJwt } from '../../../src/authorization/ap2/index.js'; +import { bindMandateToPurchase, computeInputHash } from '../../../src/authorization/ap2/profile.js'; +import { + type Ap2MandateVerifier, + createAp2MandateVerifier, +} from '../../../src/authorization/ap2/verifier.js'; +import type { + AuthorizationVerificationContext, + PaymentRequirement, +} from '../../../src/core/index.js'; +import { isCommerceError } from '../../../src/core/index.js'; +import { createParties, fixedClock, mintMandate, NOW, type Party } from './fixtures.js'; + +const RESOURCE_ID = 'market_report'; +// Floats and a non-ASCII key: the inputs where a sorted-key JSON.stringify and +// RFC 8785 part company, and a hand-rolled signer starts producing mandates +// this gateway refuses. +const INPUT = { city: 'Zürich', precision: 1.5e30, tags: ['b', 'a'] }; + +let parties: Party; +let verifier: Ap2MandateVerifier; +let privateJwk: Record; + +beforeAll(async () => { + parties = await createParties(); + verifier = createAp2MandateVerifier({ + config: { + enabled: true, + specVersion: '0.2.0', + mode: 'direct', + trust: { mandateIssuers: parties.mandateIssuers, checkoutIssuers: parties.checkoutIssuers }, + clockSkewSeconds: 60, + replay: { path: ':memory:' }, + }, + clock: fixedClock(), + }); + privateJwk = (await exportJWK(parties.checkoutSigner.privateKey)) as Record; +}); + +function signOptions(overrides: Record = {}) { + return { + privateKey: privateJwk, + kid: parties.checkoutSigner.kid, + issuer: 'https://merchant.example', + audience: 'agent-commerce', + resourceId: RESOURCE_ID, + input: INPUT, + amount: '0.01', + currency: 'USDC', + paymentMethod: 'x402', + destination: '0xMERCHANT', + network: 'eip155:84532', + asset: '0xASSET', + now: NOW, + ...overrides, + } as Parameters[0]; +} + +function requirement(): PaymentRequirement { + return { + id: 'pr-1', + requestId: 'req-1', + resourceId: RESOURCE_ID, + provider: 'x402', + amount: '0.01', + currency: 'USDC', + destination: '0xMERCHANT', + network: 'eip155:84532', + asset: '0xASSET', + challenge: { provider: 'x402', version: '2', accepts: [] }, + }; +} + +function context(): AuthorizationVerificationContext { + return { + requestId: 'req-1', + resourceId: RESOURCE_ID, + input: INPUT, + submission: { method: 'ap2', payload: 'unused-here' }, + requirement: requirement(), + }; +} + +async function failureOf(run: () => Promise): Promise { + try { + await run(); + } catch (error) { + return error instanceof Error ? error.message : String(error); + } + return 'no-error'; +} + +describe('createCheckoutJwt', () => { + it('produces a checkout JWT that verifies and authorises the purchase', async () => { + const jwt = await createCheckoutJwt(signOptions()); + const presentation = await mintMandate(parties.mandateSigner, jwt); + + const mandate = await verifier.verify(presentation); + const bound = await bindMandateToPurchase(mandate, context(), {}); + + expect(bound).toEqual({ + resourceId: RESOURCE_ID, + amount: '0.01', + currency: 'USDC', + paymentMethod: 'x402', + }); + }); + + it('computes the same input hash the gateway computes', async () => { + const jwt = await createCheckoutJwt(signOptions()); + const mandate = await verifier.verify(await mintMandate(parties.mandateSigner, jwt)); + + const profile = mandate.checkoutClaims['agent_commerce'] as Record; + expect(profile['input_hash']).toBe(await computeInputHash(INPUT)); + expect(profile['profile']).toBe(AP2_CHECKOUT_PROFILE); + }); + + it('accepts a PKCS#8 PEM as well as a private JWK', async () => { + const pem = await exportPKCS8(parties.checkoutSigner.privateKey); + const jwt = await createCheckoutJwt(signOptions({ privateKey: pem })); + + const mandate = await verifier.verify(await mintMandate(parties.mandateSigner, jwt)); + expect(mandate.checkoutIssuer).toBe('https://merchant.example'); + }); + + it('omits the chain claims when the purchase has no chain coordinates', async () => { + const jwt = await createCheckoutJwt( + signOptions({ destination: undefined, network: undefined, asset: undefined }), + ); + const mandate = await verifier.verify(await mintMandate(parties.mandateSigner, jwt)); + + const profile = mandate.checkoutClaims['agent_commerce'] as Record; + // Absent, not present-and-undefined: the gateway checks a claim whenever + // either side names one + expect('destination' in profile).toBe(false); + expect('network' in profile).toBe(false); + }); + + it('mints a jti when none is supplied, and honours one that is', async () => { + const generated = await createCheckoutJwt(signOptions()); + const supplied = await createCheckoutJwt(signOptions({ jwtId: 'checkout_01KNOWN' })); + + const first = await verifier.verify(await mintMandate(parties.mandateSigner, generated)); + const second = await verifier.verify(await mintMandate(parties.mandateSigner, supplied)); + + expect(first.checkoutJwtId).toMatch(/^[0-9a-f-]{36}$/); + expect(second.checkoutJwtId).toBe('checkout_01KNOWN'); + }); + + it('expires 15 minutes out by default, and honours an explicit window', async () => { + const now = Math.floor(NOW.getTime() / 1000); + const claimsOf = async (jwt: string): Promise> => + JSON.parse(Buffer.from(jwt.split('.')[1] as string, 'base64url').toString('utf8')) as Record< + string, + number + >; + + expect((await claimsOf(await createCheckoutJwt(signOptions())))['exp']).toBe(now + 900); + expect( + (await claimsOf(await createCheckoutJwt(signOptions({ expiresInSeconds: 60 }))))['exp'], + ).toBe(now + 60); + }); + + describe('refuses what would fail verification with no useful reason', () => { + it('refuses a numeric amount', async () => { + const message = await failureOf(() => createCheckoutJwt(signOptions({ amount: 0.01 }))); + expect(message).toContain('decimal string'); + }); + + it('refuses the public half of the key pair', async () => { + const { d: _d, ...publicHalf } = privateJwk; + const message = await failureOf(() => + createCheckoutJwt(signOptions({ privateKey: publicHalf })), + ); + expect(message).toContain('public JWK'); + }); + + it('refuses a key that is not P-256', async () => { + const { privateKey } = await generateKeyPair('RS256', { extractable: true }); + const rsa = (await exportJWK(privateKey)) as Record; + const message = await failureOf(() => createCheckoutJwt(signOptions({ privateKey: rsa }))); + expect(message).toContain('EC/P-256'); + }); + + it('refuses a string key that is not a PKCS#8 PEM', async () => { + const message = await failureOf(() => + createCheckoutJwt(signOptions({ privateKey: '-----BEGIN EC PRIVATE KEY-----' })), + ); + expect(message).toContain('PKCS#8'); + }); + + it('names the missing field rather than signing an unverifiable JWT', async () => { + expect(await failureOf(() => createCheckoutJwt(signOptions({ kid: '' })))).toContain('kid'); + expect( + await failureOf(() => createCheckoutJwt(signOptions({ resourceId: undefined }))), + ).toContain('resourceId'); + }); + + it('refuses a non-canonicalizable input rather than hashing something else', async () => { + // A BigInt throws inside canonicalize; a function serialises to nothing + // and is caught by computeInputHash. Both must fail, neither may sign. + expect(await failureOf(() => createCheckoutJwt(signOptions({ input: { n: 1n } })))).toContain( + 'BigInt', + ); + expect(await failureOf(() => createCheckoutJwt(signOptions({ input: () => 1 })))).toContain( + 'canonicalizable', + ); + }); + }); + + it('produces a mandate the gateway refuses when the price disagrees', async () => { + // The helper cannot know the gateway's requirement, so a wrong price is + // still caught at verification - fail closed, just without a useful reason + const jwt = await createCheckoutJwt(signOptions({ amount: '500.00' })); + const mandate = await verifier.verify(await mintMandate(parties.mandateSigner, jwt)); + + await expect(bindMandateToPurchase(mandate, context(), {})).rejects.toSatisfy( + (error: unknown) => isCommerceError(error) && error.code === 'AUTHORIZATION_INVALID', + ); + }); +}); diff --git a/tests/unit/cli/packaging.test.ts b/tests/unit/cli/packaging.test.ts index 297ec75..cec0c4e 100644 --- a/tests/unit/cli/packaging.test.ts +++ b/tests/unit/cli/packaging.test.ts @@ -419,7 +419,9 @@ describe.skipIf(!existsSync(libEntry))('optional-peer subpaths', () => { ); expect(probe(mcpEntry, ['mcp', 'createMcpAdapter'])).toBe(''); expect(probe(x402Entry, ['x402', 'createX402PaymentProvider', 'createPaymentProof'])).toBe(''); - expect(probe(ap2Entry, ['ap2', 'createAp2AuthorizationProvider'])).toBe(''); + expect(probe(ap2Entry, ['ap2', 'createAp2AuthorizationProvider', 'createCheckoutJwt'])).toBe( + '', + ); }); it('shares one CommerceError class with the main entry', () => { From 0e891907f4f509cc2e909e8629f0f1769e50d26d Mon Sep 17 00:00:00 2001 From: Revinand Date: Tue, 15 Sep 2026 16:20:03 +0200 Subject: [PATCH 10/11] feat(runtime): advertise authorization providers in discovery --- README.md | 16 +++++++++--- docs/ap2.md | 14 +++++----- docs/contracts.md | 5 ++-- src/authorization/ap2/checkout-signer.ts | 21 ++++++--------- src/gateway/routes.ts | 1 + src/gateway/well-known.ts | 9 +++++++ tests/integration/ap2-runtime.test.ts | 33 ++++++++++++++++++++++++ 7 files changed, 73 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index a27c734..e2f1a3a 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ x402 A2A ACP + AP2

## What it is, in ten seconds @@ -23,13 +24,15 @@ Agent Commerce Gateway sits in front of your existing API, in **your** infrastructure, and does that for you. You describe an endpoint in a YAML file - or generate that description from an OpenAPI document you already have - and agents get an MCP tool and an x402 paywall. Switch on the experimental adapters -and the same resource is also an A2A skill, or an ACP checkout session. The -money goes straight to your wallet - the gateway never holds it, and never holds -your keys. +and the same resource is also an A2A skill, or an ACP checkout session. Switch +on AP2 and a paid resource additionally demands a signed mandate: proof that the +human behind the agent approved that exact purchase, checked before anything +settles. The money goes straight to your wallet - the gateway never holds it, +and never holds your keys. ```text Your existing API → Agent Commerce Gateway → AI Agent - MCP · A2A · ACP · x402 · receipts · doctor + MCP · A2A · ACP · x402 · AP2 · receipts · doctor ``` ## Demo @@ -274,6 +277,10 @@ checkable, not marketing. Detail: [docs/protocols.md](docs/protocols.md). - **Real settlement in CI.** The end-to-end test asserts the buyer's balance falls and the merchant's rises by exactly the price, with a real transaction hash in the receipt. A log line saying "payment successful" would not count. +- **Authorization is separate from payment.** A resource can also require an + AP2 mandate, verified before settlement and spendable exactly once. It never + moves money and never unlocks a resource on its own - the payment still has + to be real. Detail: [docs/ap2.md](docs/ap2.md). Detail: [docs/payment-flow.md](docs/payment-flow.md). @@ -392,6 +399,7 @@ PASS Backend 2/2 backend host(s) reachable PASS Protocols http=on mcp=on (/mcp) a2a=off acp=off INFO A2A disabled INFO ACP disabled +INFO AP2 disabled PASS Payments x402 v2 (scheme=exact) enabled - LOCAL dev chain (eip155:84532, chain id shared with Base Sepolia), destination=0x7099…79C8, facilitator=local INFO Payments (MPP) planned - not implemented in this release PASS Storage sqlite schema v1 writable; receipts=2 diff --git a/docs/ap2.md b/docs/ap2.md index 11d5779..7f2c72f 100644 --- a/docs/ap2.md +++ b/docs/ap2.md @@ -166,14 +166,12 @@ const jwt = await createCheckoutJwt({ }); ``` -It exists mainly for `input_hash`. A signer that reaches for a sorted-key -`JSON.stringify` agrees with this gateway on most inputs and parts company on -the ones carrying floats or non-ASCII keys, and the resulting mandate is -refused with a reason that does not say which field disagreed. +It exists mainly to compute [`input_hash`](#the-input-hash) the way the gateway +does, so nobody has to reimplement JCS and discover the difference on a float. -It also refuses, before signing, what would otherwise become that same opaque -refusal: a numeric `amount`, the public half of the key pair, a key that is not -P-256, and a missing required field. +It also refuses, before signing, what would otherwise surface much later as one +coarse `AUTHORIZATION_INVALID`: a numeric `amount`, the public half of the key +pair, a key that is not P-256, and a missing required field. What it cannot check is agreement with the gateway's own resolved requirement, which it never sees. Take `amount` and `currency` from your catalogue and the @@ -347,6 +345,8 @@ npm install @devlab.group/agent-commerce jose @sd-jwt/core canonicalize `agent-commerce doctor` reports the pins, the trusted issuer ids with key counts, the replay store's writability, and which resources a mandate gates. +`GET /.well-known/agent-commerce` lists the provider's descriptor under +`authorizationProviders`, apart from the payment rails. ## Not implemented diff --git a/docs/contracts.md b/docs/contracts.md index 13d5516..f987a70 100644 --- a/docs/contracts.md +++ b/docs/contracts.md @@ -91,6 +91,7 @@ the generated file is right and this table is stale. - **Additive:** `AuthorizationRecord`; optional `CommerceReceipt.authorization`; `AuthorizationProvider` gains `requirement` and `markUncertain`; `AuthorizationVerification` now extends `AuthorizationRecord`; `CommerceEventType` gains `authorization.verified` and `authorization.rejected`. *Use case:* the execution pipeline enforcing authorization, in the order payment verify -> authorize/reserve -> payment replay reserve -> settle -> consume/release/mark-uncertain. *Why `requirement` on the provider:* the 402 challenge has to name what the retry must also carry, and only the provider knows its own spec version and payload profile. *Why `markUncertain` rather than leaving a reservation alone:* a settlement that was broadcast but never confirmed must not hand the proof back, and "we did nothing" is indistinguishable from a path that forgot to finalize. *Why the receipt stores a record and not the verification:* `reservationId` is a live handle, not an audit fact, and a stored proof would be a spendable secret at rest. *Compatibility:* `CommerceReceipt.authorization` is optional and absent for every resource that requires no authorization; the receipt store adds schema version 2 (`ALTER TABLE receipts ADD COLUMN authorization_json`), so an existing database keeps its rows. `AuthorizationProvider` is not yet implemented by anything shipped, so the two new members break no consumer. - **Additive (non-frozen surfaces):** `GatewayOptions.authorizationProviders` (optional) and `ReadinessResult.authorizationProviders`; a new `./ap2` subpath exporting `createAp2AuthorizationProvider` / `ap2`, with `jose`, `@sd-jwt/core` and `canonicalize` as optional peers. *Use case:* running AP2 as a wired subsystem. *Why a subpath:* one entry per distinct peer set, named for the peer - a gateway serving no gated resource should install neither a JOSE stack nor an SD-JWT parser, and the main entry and the CLI import the narrow AP2 modules (`constants.ts`, `types.ts`, `descriptor.ts`) so neither pulls a peer. *Readiness:* an authorization provider reporting `fail` blocks `/ready` on the same threshold as a payment provider - a resource that requires a mandate cannot be served without one, and serving its challenge anyway promises what cannot be honoured. Only the fixed vocabulary token `authorization-provider-unreachable` reaches the client. *Compatibility:* both fields are additive and a deployment configuring no authorization behaves exactly as before. - **Additive (`./ap2` subpath):** `createCheckoutJwt` and `CreateCheckoutJwtOptions`. *Use case:* a merchant has to sign the checkout JWT a Checkout Mandate binds, and the gateway only verifies. *Why it ships:* `input_hash` is an RFC 8785 digest, and a hand-rolled signer reaching for a sorted-key `JSON.stringify` agrees on most inputs and disagrees on floats and non-ASCII keys - producing a mandate refused with a deliberately coarse reason. The helper also refuses a numeric `amount`, the public half of a key pair, a non-P-256 key and a missing field before signing, rather than letting each become that same opaque refusal. *Scope:* signing only. It runs in the merchant's process, never calls the gateway and is never called by it - the mirror of `createPaymentProof`. The Checkout Mandate itself is the buyer's side and nothing here mints one. +- **Additive (gateway wire surface):** `WellKnownDocument.authorizationProviders`, an `AdapterDescriptor[]` that is empty unless a resource requires authorization. *Use case:* the README promises every adapter's `supportedSpec`, `capabilities` and `unsupported` list is checkable at runtime rather than taken on trust, and AP2 was reportable through `doctor` but absent from the document. *Why a separate field and not `paymentProviders`:* an authorization method is not a payment rail and must never be selectable as one - the same reason `AuthorizationMethodName` is neither a `ProtocolName` nor a `PaymentMethodName`. *Compatibility:* additive; the field is always present, and the dashboard's hand-maintained mirror carries only what it renders, as it already does for `protocols.acp`. --- # Integration contract - exact factory signatures @@ -342,8 +343,8 @@ export interface GatewayConfig { | Route | Purpose | | ---------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `GET /health` | liveness - always 200 when the process is up | -| `GET /ready` | readiness - 200 only when config, store, every required adapter **and every configured payment provider** are healthy (`fail` blocks; `warn` is degraded-but-serving) | -| `GET /.well-known/agent-commerce` | merchant + adapter descriptors, protocol/spec versions | +| `GET /ready` | readiness - 200 only when config, store, every required adapter, **every configured payment provider and every authorization provider** are healthy (`fail` blocks; `warn` is degraded-but-serving) | +| `GET /.well-known/agent-commerce` | merchant, adapter, payment-provider and authorization-provider descriptors, protocol/spec versions | | `GET /api/resources` | canonical resource list (no secrets) | | `POST /api/resources/:id/invoke` | HTTP protocol surface; `PAYMENT-SIGNATURE` header carries the proof; 402 + `PaymentRequiredEnvelope` body and `PAYMENT-REQUIRED` header when unpaid; `PAYMENT-RESPONSE` header on settlement | | `GET /api/receipts?limit=` | recent receipts (dashboard/CLI) | diff --git a/src/authorization/ap2/checkout-signer.ts b/src/authorization/ap2/checkout-signer.ts index 0b25e36..ae6adc6 100644 --- a/src/authorization/ap2/checkout-signer.ts +++ b/src/authorization/ap2/checkout-signer.ts @@ -6,11 +6,10 @@ * never calls the gateway and the gateway never calls it - the mirror of * `createPaymentProof`, which a buyer runs to produce a payment proof. * - * It exists for one claim in particular. `input_hash` is an RFC 8785 digest, - * and a hand-rolled signer that reaches for a sorted-key `JSON.stringify` - * agrees with this gateway on most inputs and disagrees on the ones that carry - * floats or non-ASCII keys. The mandate then fails verification with a - * deliberately coarse reason that does not say which field disagreed. + * It exists for `input_hash`, an RFC 8785 digest. A signer reaching for a + * sorted-key `JSON.stringify` agrees on most inputs and disagrees on floats + * and non-ASCII keys, and the mandate is then refused with a reason that does + * not say which field disagreed. */ import { importPKCS8, type JWK, SignJWT } from 'jose'; import { @@ -64,10 +63,7 @@ export interface CreateCheckoutJwtOptions { /** Defaults to a random UUID. Recorded on the receipt and used for replay defence */ readonly jwtId?: string; - /** - * Defaults to 900 (15 minutes). A human approval sits inside this window, so - * it has to outlast someone reading a checkout screen. - */ + /** Defaults to 900: a human approval sits inside this window */ readonly expiresInSeconds?: number; /** Injectable so a test need not move the wall clock */ readonly now?: Date; @@ -89,9 +85,9 @@ function describe(value: unknown): string { } /** - * Refuses the two copy-paste mistakes that would otherwise surface as an - * opaque verification failure: signing with the public half, and signing with - * a key of the wrong type. + * Every rejection here is a mistake that would otherwise surface as an opaque + * verification failure much later: the public half of the pair, the wrong key + * type, or a PEM that is not PKCS#8. */ async function resolveKey( key: Ap2SigningKey, @@ -149,7 +145,6 @@ export async function createCheckoutJwt(options: CreateCheckoutJwtOptions): Prom const agentCommerce: Record = { profile: AP2_CHECKOUT_PROFILE, resource_id: resourceId, - // The reason this helper exists input_hash: await computeInputHash(options.input), amount, currency, diff --git a/src/gateway/routes.ts b/src/gateway/routes.ts index a85aa5c..6b38d7e 100644 --- a/src/gateway/routes.ts +++ b/src/gateway/routes.ts @@ -76,6 +76,7 @@ export function registerRoutes(options: RegisterRoutesOptions): void { buildWellKnownDocument({ config: options.config, paymentProviders: options.paymentProviders, + authorizationProviders: options.authorizationProviders, store: options.store, adapterRuntimes: options.adapterRuntimes, clock: options.clock, diff --git a/src/gateway/well-known.ts b/src/gateway/well-known.ts index 78b8cc1..4e28975 100644 --- a/src/gateway/well-known.ts +++ b/src/gateway/well-known.ts @@ -33,6 +33,7 @@ import type { GatewayConfig } from '../config/index.js'; import type { AdapterDescriptor, AdapterHealth, + AuthorizationProvider, Clock, PaymentProvider, ReceiptStore, @@ -76,6 +77,12 @@ export interface WellKnownDocument { readonly protocols: WellKnownProtocols; readonly adapters: ReadonlyArray; readonly paymentProviders: readonly AdapterDescriptor[]; + /** + * Empty unless a resource requires authorization. Listed separately from + * `paymentProviders` because an authorization method is not a payment rail + * and must never be selectable as one. + */ + readonly authorizationProviders: readonly AdapterDescriptor[]; readonly store: AdapterDescriptor; readonly payments: { readonly x402?: { @@ -97,6 +104,7 @@ export interface WellKnownDocument { export interface BuildWellKnownOptions { readonly config: GatewayConfig; readonly paymentProviders: readonly PaymentProvider[]; + readonly authorizationProviders: readonly AuthorizationProvider[]; readonly store: ReceiptStore; readonly adapterRuntimes: readonly AdapterRuntime[]; readonly clock: Clock; @@ -149,6 +157,7 @@ export async function buildWellKnownDocument( protocols: publicProtocols(options.config.protocols), adapters, paymentProviders: options.paymentProviders.map((provider) => provider.descriptor), + authorizationProviders: options.authorizationProviders.map((provider) => provider.descriptor), store: options.store.descriptor, payments: { ...(x402 !== undefined diff --git a/tests/integration/ap2-runtime.test.ts b/tests/integration/ap2-runtime.test.ts index 6a299ab..e33cf75 100644 --- a/tests/integration/ap2-runtime.test.ts +++ b/tests/integration/ap2-runtime.test.ts @@ -314,6 +314,39 @@ describe('AP2 wired into the gateway', () => { expect(JSON.stringify(body)).not.toContain(parties.mandateSigner.publicJwk['x']); }); + it('advertises itself in the well-known document, so the claim is checkable', async () => { + const gw = await startGateway([startAp2()]); + + const res = await gw.server.inject({ method: 'GET', url: '/.well-known/agent-commerce' }); + const body = res.json<{ + authorizationProviders: { + name: string; + kind: string; + status: string; + supportedSpec: string; + }[]; + paymentProviders: { name: string }[]; + }>(); + + const [ap2] = body.authorizationProviders; + expect(ap2?.name).toBe('ap2'); + expect(ap2?.kind).toBe('authorization'); + expect(ap2?.status).toBe('experimental'); + expect(ap2?.supportedSpec).toContain('0.2.0'); + // Listed apart from the rails: an authorization method is not a payment + // method and must never be selectable as one + expect(body.paymentProviders.map((p) => p.name)).not.toContain('ap2'); + // Public keys are public, but the document still does not carry trust policy + expect(JSON.stringify(body)).not.toContain(parties.mandateSigner.publicJwk['x']); + }); + + it('reports an empty list when no authorization is configured', async () => { + const gw = await startGateway([]); + + const res = await gw.server.inject({ method: 'GET', url: '/.well-known/agent-commerce' }); + expect(res.json<{ authorizationProviders: unknown[] }>().authorizationProviders).toEqual([]); + }); + it('runs with no authorization provider at all, which is the default', async () => { const gw = await startGateway([]); From 24bace952d29a5e079371ad7becbb6f1df0bab11 Mon Sep 17 00:00:00 2001 From: Revinand Date: Tue, 15 Sep 2026 16:51:44 +0200 Subject: [PATCH 11/11] fix: docs and raw diagrams --- CONTRIBUTING.md | 85 +++++++++----- README.md | 132 +++++++++++----------- SECURITY.md | 182 ++++++++++++++++-------------- docs/architecture.md | 51 +++++---- docs/payment-flow.md | 67 +++++------ docs/security.md | 19 ++-- src/gateway/logger.ts | 22 +++- tests/unit/gateway/logger.test.ts | 28 ++++- 8 files changed, 332 insertions(+), 254 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 50f8e0a..2e20a5b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,14 +1,16 @@ # Contributing -Thanks for looking at Agent Commerce Gateway. This is an early alpha; the -architecture is deliberate and the scope is deliberately narrow. +Thanks for looking at Agent Commerce Gateway. The architecture is deliberate and +the scope is deliberately narrow; both of those are load-bearing, and most of +the rules below exist to keep them that way. ## Ground rules -1. **Scope discipline is a release requirement.** This release is MCP + x402 only. - New protocols and rails land after the adapter model survives real use. - Classify every proposal as `BLOCKER` / `QUALITY` / `NICE-TO-HAVE` / - `POST-ALPHA` — the default answer to a new capability is `POST-ALPHA`. +1. **Scope discipline is a release requirement.** Supported today: MCP, HTTP and + x402. Experimental and off by default: A2A, ACP, AP2 and OpenAPI import. New + protocols and rails land after the adapter model survives real use. Classify + every proposal as `BLOCKER` / `QUALITY` / `NICE-TO-HAVE` / `POST-1.0` - the + default answer to a new capability is `POST-1.0`. 2. **Never make the gateway custodial.** No PR may introduce storage of a merchant or buyer private key, seed phrase or fund custody. See [`SECURITY.md`](SECURITY.md). @@ -32,27 +34,30 @@ npm run demo:agent ``` Requirements: Node >= 22, npm 10, Docker, and [Foundry](https://getfoundry.sh) -(`anvil`, `forge`, `cast`) for the chain work. +(`anvil`, `forge`, `cast`) for the chain work. On Linux, if your user is not +UID/GID 1000, see the note in the [README](README.md#quickstart) before +`docker compose up`. ## The loop ```bash -npm run verify # lint + typecheck + test — run this before opening a PR +npm run verify # contract + lint + typecheck + test - run this before opening a PR npm run test:e2e # deterministic end-to-end (boots its own chain) npm run lint:fix ``` Scope a run to one area with `npx vitest run tests/unit/`. -A change is not mergeable if TypeScript fails, lint fails, required tests fail, -or the deterministic E2E fails. +A change is not mergeable if the contract surface changed unannounced, +TypeScript fails, lint fails, required tests fail, or the deterministic E2E +fails. ## Architecture you need to know before writing code Read, in order: -1. [`docs/architecture.md`](docs/architecture.md) — the shape of the system. -2. [`docs/contracts.md`](docs/contracts.md) — the frozen cross-package contract. +1. [`docs/architecture.md`](docs/architecture.md) - the shape of the system. +2. [`docs/contracts.md`](docs/contracts.md) - the frozen cross-package contract. The one rule that surprises people: **every protocol adapter converges on `ExecutionPipeline.execute`**. An adapter never calls a merchant backend and @@ -79,19 +84,40 @@ Implement `PaymentProvider`. Required before review: - [ ] a `replayKey` derived only from the payment authorisation - [ ] negative tests: no payment, malformed, wrong amount, wrong recipient, wrong network, wrong asset, replay, provider unavailable -- [ ] a deterministic settlement proof — real state change, not a mocked success +- [ ] a deterministic settlement proof - real state change, not a mocked success - [ ] no private key held by the gateway +## Adding an authorization method + +Implement `AuthorizationProvider`. Authorization gates settlement; it never +moves money and never unlocks a resource on its own. Required before review: + +- [ ] verification and reservation are one atomic step, before settlement +- [ ] a replay identity that survives re-presentation of the same proof +- [ ] binding to the resolved resource, input and price, not just to a signature +- [ ] `consume` / `release` / `markUncertain` finalizers, with release reserved + for failures that provably moved no money +- [ ] static trust only: no key discovery, no outbound request from a proof +- [ ] `AUTHORIZATION_*` error codes, never a payment code, and a coarse + rejection reason that is not an oracle for trust policy +- [ ] nothing of the proof itself in a receipt, an event or a log + +AP2 is the worked example: [`docs/ap2.md`](docs/ap2.md). + ## Publishing The repository *is* the package: one `package.json`, published as -**`@devlab.group/agent-commerce`**. It ships the `agent-commerce` binary and three -library paths — `.`, `./mcp`, `./x402` — built from `src/` into `dist/`. The -MCP SDK, x402 and viem are **optional peer dependencies**: neither the main -entry nor the CLI may import them, or a default install breaks. Architectural -boundaries live in directories under `src/`, not in package manifests, so a -reappearing `pnpm-workspace.yaml` or `packages/` directory means the two models -are being run at once; the packaging tests fail on either. +**`@devlab.group/agent-commerce`**. It ships the `agent-commerce` binary and four +library paths - `.`, `./ap2`, `./mcp`, `./x402` - built from `src/` into +`dist/`. + +Two constraints a PR must not break. **The heavy rails are optional peer +dependencies** (`@modelcontextprotocol/sdk`, `@x402/core`, `@x402/evm`, `viem`, +`jose`, `@sd-jwt/core`, `canonicalize`, `@coinbase/x402`), and neither the main +entry nor the CLI may import one, or a default install breaks. **Architectural +boundaries live in directories under `src/`**, not in package manifests, so a +reappearing `pnpm-workspace.yaml` or `packages/` directory means two models are +being run at once. The packaging tests fail on either. ```bash npm run build # bundle -> dist/index.js + dist/cli/index.js @@ -99,8 +125,8 @@ npm run test:cli:dist # run the built binary under plain node npm run pack:dry # inspect what would be published ``` -Only `dist/`, `README.md` and `LICENSE` are published. Everything else — -`src/`, `tests/`, `demo/`, `scripts/`, `docs/` — stays in the repository. Note +Only `dist/`, `README.md` and `LICENSE` are published. Everything else - +`src/`, `tests/`, `demo/`, `scripts/`, `docs/` - stays in the repository. Note that `dist/` ships sourcemaps that embed the TypeScript they were built from; that is intended (public source, real stack traces), not an oversight. @@ -108,12 +134,15 @@ Never run `npm publish` without explicit maintainer approval. ## Commits and PRs -Prefix commits by area: `core:` `protocol:` `payment:` `cli:` `test:` `docs:` -`chore:`. Keep commits small and coherent. +Conventional commits: `type(scope): subject`, lower case, no trailing full stop. +Types in use are `feat`, `fix`, `docs`, `test`, `chore` and `refactor`; the +scope is the area you touched (`core`, `config`, `pipeline`, `gateway`, +`runtime`, `ap2`, `acp`, `openapi`, `cli`, `payment`), and repo-wide changes +drop it. Keep commits small and coherent. ```text -payment: bind x402 verification to a replay key -protocol: expose configured resources as MCP tools +feat(pipeline): enforce authorization before payment settlement +test(acp): add stable checkout conformance coverage ``` A PR should say what changed, why, how you tested it, and what it does **not** @@ -121,9 +150,9 @@ cover. ## Reporting bugs -Include the version/commit, your `agent-commerce doctor --json` output (it +Include the version or commit, your `agent-commerce doctor --json` output (it contains no secrets), what you expected, and what happened. For security issues, -follow [`SECURITY.md`](SECURITY.md) instead — do not open a public issue. +follow [`SECURITY.md`](SECURITY.md) instead - do not open a public issue. ## Code of conduct diff --git a/README.md b/README.md index e2f1a3a..192c04e 100644 --- a/README.md +++ b/README.md @@ -25,10 +25,9 @@ infrastructure, and does that for you. You describe an endpoint in a YAML file - or generate that description from an OpenAPI document you already have - and agents get an MCP tool and an x402 paywall. Switch on the experimental adapters and the same resource is also an A2A skill, or an ACP checkout session. Switch -on AP2 and a paid resource additionally demands a signed mandate: proof that the -human behind the agent approved that exact purchase, checked before anything -settles. The money goes straight to your wallet - the gateway never holds it, -and never holds your keys. +on AP2 and a paid resource can also demand a signed mandate: proof the human +behind the agent approved that exact purchase. The money goes straight to your +wallet - the gateway never holds it, and never holds your keys. ```text Your existing API → Agent Commerce Gateway → AI Agent @@ -57,11 +56,9 @@ Your existing API → Agent Commerce Gateway → AI Agent ``` The dashboard at shows the same request as it happens. -It polls the authenticated events route on a short interval rather than -streaming: a browser `EventSource` cannot send the admin token, and the operator -routes are closed without one - so the SSE endpoint is reachable by a -header-capable client, never by a browser. Polling is the dashboard's intended -path, not a degraded mode. +It polls the authenticated events route rather than streaming, because a browser +cannot send the admin token over `EventSource`; see +[why the stream is polled](SECURITY.md#the-live-event-stream-is-polled-not-streamed). ## Install @@ -98,13 +95,13 @@ subpaths, because each needs a dependency the rest of the package does not - the x402 rail brings the whole EVM signing and RPC stack, which a gateway serving a free HTTP resource has no business installing. -| You want | Install | Import | -| --------------------------------- | ------------------------------ | ------------------------------------------ | -| gateway, config, receipts, CLI | `@devlab.group/agent-commerce` | `from '@devlab.group/agent-commerce'` | -| expose resources as MCP tools | `+ @modelcontextprotocol/sdk` | `from '@devlab.group/agent-commerce/mcp'` | -| accept x402 payments | `+ @x402/core @x402/evm viem` | `from '@devlab.group/agent-commerce/x402'` | -| verify AP2 mandates, sign checkout JWTs | `+ jose @sd-jwt/core canonicalize` | `from '@devlab.group/agent-commerce/ap2'` | -| authenticate to a CDP facilitator | `+ @coinbase/x402` | (no import - loaded on demand) | +| You want | Install | Import | +| --------------------------------------- | ---------------------------------- | ------------------------------------------ | +| gateway, config, receipts, CLI | `@devlab.group/agent-commerce` | `from '@devlab.group/agent-commerce'` | +| expose resources as MCP tools | `+ @modelcontextprotocol/sdk` | `from '@devlab.group/agent-commerce/mcp'` | +| accept x402 payments | `+ @x402/core @x402/evm viem` | `from '@devlab.group/agent-commerce/x402'` | +| verify AP2 mandates, sign checkout JWTs | `+ jose @sd-jwt/core canonicalize` | `from '@devlab.group/agent-commerce/ap2'` | +| authenticate to a CDP facilitator | `+ @coinbase/x402` | (no import - loaded on demand) | ```bash npm install @devlab.group/agent-commerce @modelcontextprotocol/sdk @x402/core @x402/evm viem @@ -167,30 +164,33 @@ To stop and wipe state: `docker compose down -v`. ## How it works ```text - ┌──────────────────────────────────────────────────────┐ - │ AI Agent │ - └──────────────┬───────────────────────────────────────┘ - │ MCP · HTTP + PAYMENT-SIGNATURE - ┌──────────────▼───────────────────────────────────────┐ - │ Agent Commerce Gateway (yours) │ - │ │ - │ protocol adapters → ExecutionPipeline → … │ - │ │ │ - │ ┌─────────────────────┼──────────────┐ │ - │ ▼ ▼ ▼ │ - │ PaymentProvider BackendExecutor ReceiptStore │ - │ (x402) (bounded HTTP) (SQLite) │ - └────────┬─────────────────────┬───────────────────────┘ - │ │ - buyer → merchant ┌──────▼───────────────┐ - (never through us) │ Your backend API │ - └───────────────────────┘ +┌──────────────────────────────────────────────────────────────────────────────┐ +│ AI Agent │ +└───────────────────────────────────────┬──────────────────────────────────────┘ + │ MCP · A2A · ACP · HTTP + │ PAYMENT-SIGNATURE + │ Agent-Authorization +┌───────────────────────────────────────▼──────────────────────────────────────┐ +│ Agent Commerce Gateway (yours) │ +│ │ +│ protocol adapters → ExecutionPipeline │ +│ │ │ +│ ┌────────────────────┬──────────────┴──┬────────────────┐ │ +│ ▼ ▼ ▼ ▼ │ +│ AuthorizationProvider PaymentProvider BackendExecutor ReceiptStore │ +│ (ap2) (x402) (bounded HTTP) (SQLite) │ +└───────────────────────────────────────┬──────────────────────────────────────┘ + │ + ┌──────────▼─────────┐ + │ Your backend API │ + └────────────────────┘ ``` Every protocol adapter converges on **one execution pipeline**. That is what makes payment enforcement a property of the system rather than something each -adapter has to remember. Full detail in -[docs/architecture.md](docs/architecture.md). +adapter has to remember. Money never passes through the box: the buyer pays the +merchant directly on chain, and the gateway holds neither the funds nor a key. +Full detail in [docs/architecture.md](docs/architecture.md). ## Configure a resource @@ -229,40 +229,39 @@ It writes a reviewable `resources:` fragment - path, query and JSON body mapped, schemas converted to what the gateway actually enforces - and deliberately leaves `pricing` and `expose` out, because an OpenAPI document has no opinion on what an operation costs or who may see it. Credentials are never -imported. See [docs/openapi-import.md](docs/openapi-import.md) for the exact -supported subset. +imported. See [OpenAPI import](docs/openapi-import.md) for the exact supported subset. See [docs/configuration.md](docs/configuration.md). ## Protocol support -| Protocol | Status | Pinned revision | -| --------------- | ------------ | -------------------------------------------------------- | -| **MCP** | Supported | `@modelcontextprotocol/sdk@1.30.0` | -| **x402** | Supported | x402 v2 (`@x402/core`, `@x402/evm`), scheme `exact`, EVM | -| **HTTP** | Supported | native routes | -| **A2A** | Experimental | A2A v1.0.0, binding `JSONRPC`, method `SendMessage` | -| **ACP** | Experimental | ACP `2026-04-17`, REST checkout + discovery | -| **AP2** | Experimental | AP2 `v0.2.0`, Direct Checkout Mandate verification | -| UCP · MPP | Planned | - | - -AP2 is in that table because people look there, but it is not a transport: it -is an **authorization** method that gates settlement on a resource that still -takes a real payment. It is merchant-side mandate verification, not a full AP2 -Merchant implementation - the gateway holds no signing key and issues no -Checkout Receipt. Detail: [docs/ap2.md](docs/ap2.md). +| Protocol | Status | Pinned revision | +| --------- | ------------ | -------------------------------------------------------- | +| **MCP** | Supported | `@modelcontextprotocol/sdk@1.30.0` | +| **x402** | Supported | x402 v2 (`@x402/core`, `@x402/evm`), scheme `exact`, EVM | +| **HTTP** | Supported | native routes | +| **A2A** | Experimental | A2A v1.0.0, binding `JSONRPC`, method `SendMessage` | +| **ACP** | Experimental | ACP `2026-04-17`, REST checkout + discovery | +| **AP2** | Experimental | AP2 `v0.2.0`, Direct Checkout Mandate verification | +| UCP · MPP | Planned | - | + +[AP2](docs/ap2.md) is in that table because people look there, but it is an **authorization** +method rather than a transport: it gates settlement on a resource that still +takes a real payment, and it is the verifying half only - the gateway holds no +signing key and issues no Checkout Receipt. "Planned" means **no code ships for it**. "Experimental" means the code ships, is tested against the protocol's own official artifacts, and serves a narrow -named subset - A2A and ACP are both off by default and documented in full at -[docs/protocols.md](docs/protocols.md#a2a) and -[docs/protocols.md](docs/protocols.md#acp). ACP serves the five stable checkout -operations and advertises `services: ["checkout"]` and nothing more; its -`payment_data` stays with the merchant's own checkout and is never turned into -an x402 payment. Each adapter reports its own -`supportedSpec`, `capabilities` and `unsupported` list at runtime via -`GET /.well-known/agent-commerce` and `agent-commerce doctor` - so the claim is -checkable, not marketing. Detail: [docs/protocols.md](docs/protocols.md). +named subset: [A2A](docs/protocols.md#a2a) and [ACP](docs/protocols.md#acp) are +both off by default and documented in full there, as are +[MCP](docs/protocols.md#mcp) and [x402](docs/protocols.md#x402). ACP serves the +five stable checkout operations and advertises `services: ["checkout"]` and +nothing more; its `payment_data` stays with the merchant's own checkout and is +never turned into an x402 payment. + +Each adapter reports its own `supportedSpec`, `capabilities` and `unsupported` +list at runtime through `GET /.well-known/agent-commerce` and +`agent-commerce doctor`, so the claim is checkable rather than marketing. ## Payment model @@ -280,9 +279,7 @@ checkable, not marketing. Detail: [docs/protocols.md](docs/protocols.md). - **Authorization is separate from payment.** A resource can also require an AP2 mandate, verified before settlement and spendable exactly once. It never moves money and never unlocks a resource on its own - the payment still has - to be real. Detail: [docs/ap2.md](docs/ap2.md). - -Detail: [docs/payment-flow.md](docs/payment-flow.md). + to be real. ## Public networks @@ -420,8 +417,9 @@ Exits non-zero if anything fails. `--json` for machines. The demo binds everything to `127.0.0.1`. Before putting the gateway anywhere reachable by anyone else, know the split: -- **Agent routes** (`/api/resources/:id/invoke`, `/mcp`) are unauthenticated by - design - paid resources are protected by payment, not by a password. +- **Agent routes** (`/api/resources/:id/invoke`, `/mcp`, the A2A mount) are + unauthenticated by design - paid resources are protected by payment, not by a + password. ACP is the exception: its checkout routes require a bearer token. - **Operator routes** (`/api/receipts`, `/api/events`, `/api/events/stream`) are the merchant's commerce ledger: payer addresses, amounts, settlement hashes. They require `server.adminToken`, and return **404** if none is configured. diff --git a/SECURITY.md b/SECURITY.md index 2beb993..2e6b22a 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -5,6 +5,31 @@ > path. There is no third-party audit report to point you at, for any release. > Weigh that before putting production funds through it. +## Reporting a vulnerability + +Please report security issues **privately** - do not open a public issue. + +1. Use GitHub's **Report a vulnerability** (Security → Advisories) on this + repository: . + That is the only private reporting channel; this project publishes no + maintainer email address. +2. Include the affected version or commit, a description, reproduction steps, + and the impact you believe it has. +3. You will get an acknowledgement within **5 working days**, and a status + update at least every **10 working days** until it is resolved. +4. Please give us **90 days** before public disclosure, or less by agreement if + a fix ships sooner. + +We credit reporters in the release notes unless you would rather we did not. + +### Out of scope for reports + +- The deliberately public Anvil development keys and the local demo chain. +- The demo merchant API's failure-injection routes (`/api/slow`, `/api/fail`), + which exist only to test the gateway. +- Anything in [what the gateway does not protect against](#what-the-gateway-does-not-protect-against), + which is documented rather than overlooked. + ## Non-custodial by design Agent Commerce Gateway is **not** a payment processor, wallet, exchange or @@ -13,14 +38,13 @@ custodian. - The gateway **never** accepts, stores, derives or requires a merchant or buyer production private key or seed phrase. - The merchant settlement destination is plain configuration - (`payments.x402.payTo: ${MERCHANT_WALLET}`) — an address the merchant + (`payments.x402.payTo: ${MERCHANT_WALLET}`), an address the merchant controls. It is never a gateway-owned wallet. - Funds move **buyer → merchant destination** through the payment protocol itself. With x402 `exact`/EVM this is an EIP-3009 `transferWithAuthorization`: the buyer signs an authorisation that names the merchant as recipient, so a facilitator that broadcasts it cannot redirect the money. -- Rationale and detail: [`docs/payment-flow.md`](docs/payment-flow.md), which - walks the money path end to end. +- The money path end to end: [`docs/payment-flow.md`](docs/payment-flow.md). ## Private-key policy @@ -32,9 +56,9 @@ custodian. - The buyer key used by the demo is **Anvil well-known account #2**, defined in `src/payments/x402/local-chain/accounts.ts` (`LOCAL_BUYER_ACCOUNT`) and written into `.deploy/local.json` by `npm run chain:deploy`; the demo agent - reads it from that manifest. It is never read by the gateway and the gateway - never signs with it. Key locations in this document are stated literally: if - one moves, this bullet is wrong until it is updated. + reads it from that manifest. The gateway never reads it and never signs with + it. Key locations in this document are stated literally: if one moves, this + bullet is wrong until someone updates it. - The local facilitator signer pays gas on the dev chain only. Production deployments point at an external facilitator instead. @@ -51,8 +75,14 @@ custodian. provider derives a `replayKey` from the authorisation (payer, nonce, asset, network) and the pipeline reserves it under a `UNIQUE` constraint *before* settling. A duplicate is `PAYMENT_REPLAYED`. +- **Purchase authorisation, where a resource requires it.** With AP2 enabled, a + paid resource can demand a signed Checkout Mandate proving the human behind + the agent approved that exact purchase. It is verified and reserved before + settlement, spendable once, and never a substitute for payment. Trust is + static public keys in configuration, with no key discovery of any kind. See + [`docs/ap2.md`](docs/ap2.md). - **Bounded backend calls.** Every merchant backend call has an explicit - timeout *and* a 1 MB cap on the response body — a timeout bounds a call by + timeout *and* a 1 MB cap on the response body: a timeout bounds a call by time, not by bytes. There is no unbounded outbound HTTP request. - **Host-header (DNS-rebinding) validation.** Every request, browser or not, is checked against the configured host allow-list before routing. A rebinding @@ -61,24 +91,27 @@ custodian. routes from inside the victim's network. See `src/gateway/access-control.ts`. - **Configuration validated before startup.** Invalid configuration fails the process rather than starting a half-configured gateway. -- **Secret redaction.** The logger redacts `authorization` headers, the - `x-payment` header, and `privateKey`, `signerPrivateKey`, `signature`, - `seed`, `mnemonic`, `secret`, `apiKey`, `adminToken` and `token` fields **at - the top level and one level deep** — pino's redaction wildcards are - single-level, so a secret nested at depth two or more is not covered by the - logger and must not be handed to it (every call site funnels caught errors - through `describeError`, which extracts only `{message, name}`). Receipts - and events persist - no secrets and no raw payment proofs. +- **Secrets kept out of logs and storage.** The request serializer emits only + method, sanitised URL, host and remote address, so request headers never + reach a log line in the first place. Behind that, the logger redacts every + header that carries a credential or a proof - `Authorization`, + `PAYMENT-SIGNATURE` and `Agent-Authorization` - and the + `privateKey`, `signerPrivateKey`, `signature`, `seed`, `mnemonic`, `secret`, + `apiKey`, `adminToken` and `token` fields **at the top level and one level + deep**. Pino's wildcards are single-level, so a secret nested two deep is not + covered by the logger and must not be handed to it; every call site funnels + caught errors through `describeError`, which extracts only `{message, name}`. + Receipts and events persist no secrets, no raw payment proofs and no mandates. - **Input validation** on resource inputs, path parameters, body size, content type, payment metadata and configuration. ## Which routes are authenticated **None of the agent-facing routes, by design.** An agent that can pay is a -customer, not an intruder, so `POST /api/resources/:id/invoke`, `/mcp`, -`GET /api/resources`, `GET /health` and `GET /.well-known/agent-commerce` are -open. Paid resources are protected by payment, not by authentication. +customer, not an intruder, so `POST /api/resources/:id/invoke`, +`GET /api/resources`, `GET /health`, `GET /ready`, +`GET /.well-known/agent-commerce` and the `/mcp` and A2A mounts are open. Paid +resources are protected by payment, not by authentication. **ACP is the exception among agent routes.** When `protocols.acp` is enabled, every checkout route under its mount requires @@ -89,43 +122,43 @@ implemented and a `Signature` header never substitutes for the bearer token, so ACP must be deployed behind TLS. See [docs/security.md](docs/security.md#acp). **The operator routes are different.** `GET /api/receipts`, `GET /api/events` -and `GET /api/events/stream` expose the merchant's commerce ledger — payer and +and `GET /api/events/stream` expose the merchant's commerce ledger: payer and payee addresses, amounts, settlement transaction hashes, resource ids and timings. That is revenue history and customer on-chain identity, not public data. They require `server.adminToken`, and **if no token is configured they return 404 rather than serving openly**. -The dashboard needs this same token to read those routes, via -`VITE_ADMIN_TOKEN` — and because Vite inlines every `VITE_`-prefixed variable -into the JavaScript it serves, that token is **not a server-side secret once it -reaches the dashboard**. It is a public value, readable by anyone who can load -the dashboard's page, not merely anyone who can reach the gateway. The demo -stack accepts this because the dashboard is loopback-only and ships a -non-secret placeholder; a real deployment must not point a real -`server.adminToken` at this variable. The dashboard's port is a different trust -boundary from the gateway's, and there is currently no server-side proxy that -would keep the token off the client (post-alpha). +The dashboard needs that same token to read those routes, via +`VITE_ADMIN_TOKEN`. Because Vite inlines every `VITE_`-prefixed variable into +the JavaScript it serves, that token is **not a server-side secret once it +reaches the dashboard**: it is readable by anyone who can load the dashboard's +page, not merely by anyone who can reach the gateway. The demo stack accepts +this because the dashboard is loopback-only and ships a non-secret placeholder. +A real deployment must not point a real `server.adminToken` at this variable. +The dashboard's port is a different trust boundary from the gateway's, and +there is no server-side proxy yet that would keep the token off the client +(post-alpha). Browser access is governed by `server.allowedOrigins`, an explicit allowlist that defaults to empty. Agent traffic is not browser traffic and receives no CORS headers at all. -### The live event stream is polled, not streamed, when a token is configured +### The live event stream is polled, not streamed A browser `EventSource` cannot send custom headers, so the dashboard's SSE -connection to `/api/events/stream` cannot carry the admin token. It therefore -receives a 401 when a token is configured — and a 404 when one is not, because -the operator routes are closed by default. **There is no posture in which a -browser can read the stream.** The route remains usable by a header-capable -client; the dashboard uses authenticated polling of `GET /api/events`, which is -its intended path rather than a degraded mode. +connection to `/api/events/stream` cannot carry the admin token. It receives a +401 when a token is configured, and a 404 when one is not, because the operator +routes are closed by default. **There is no posture in which a browser can read +the stream.** A header-capable client still can; the dashboard polls +`GET /api/events` instead, which is its intended path rather than a degraded +mode. Accepting the token as a `?adminToken=` query parameter on that one route would -keep the stream working in a browser. **It is deliberately not supported.** The -cost — credentials leaking through `Referer`, browser history and intermediary -logs — buys a convenience nothing needs, because the dashboard polls instead. -Do not add it without a client that genuinely requires it and a reason that -outweighs putting a credential in a URL. +make the stream work in a browser. **It is deliberately not supported.** The +cost - credentials leaking through `Referer`, browser history and intermediary +logs - buys a convenience nothing needs, because the dashboard polls. Do not +add it without a client that genuinely requires it and a reason that outweighs +putting a credential in a URL. ## What the gateway does **not** protect against @@ -133,17 +166,17 @@ Be clear-eyed about this. Running this gateway does not make your agent, your backend or your business secure. - **It does not secure your merchant backend.** Authentication, authorisation, - rate limiting and data protection in your API remain entirely your - responsibility. + rate limiting and data protection in your API remain entirely yours. - **It does not vet the buyer.** Any party able to produce a valid payment gets the resource. There is no KYC, sanctions screening, fraud scoring or dispute - mechanism. + mechanism. An AP2 mandate proves a human approved the purchase; it says + nothing about who that human is. - **It does not make payments reversible.** On-chain settlement is final. There are no refunds, chargebacks or escrow. - **It does not protect against SSRF beyond configuration discipline.** The - gateway calls the backend URLs an administrator configured. Redirects are not - followed. But if you configure an internal URL, the gateway will call it — - agent- or user-controlled backend URLs are forbidden, and there is no + gateway calls the backend URLs an administrator configured, and does not + follow redirects. But if you configure an internal URL, the gateway will call + it. Agent- or user-controlled backend URLs are forbidden, and there is no allowlist enforcement. - **It does not audit the payment protocol or its SDKs.** x402, the MCP SDK and their transitive dependencies are third-party code. @@ -154,11 +187,11 @@ backend or your business secure. successful payment is possible; it is recorded as an event and a payment attempt, and reconciliation is the merchant's responsibility. - **It cannot always tell you whether a payment settled.** If the settlement - transaction is broadcast but its receipt cannot be confirmed — an RPC timeout - or a dropped connection — the outcome is genuinely unknown. The gateway - records the attempt as `settlement-uncertain` with the broadcast transaction - hash, and does **not** deliver the resource. Resolving it is the merchant's - responsibility: check the recorded hash with `getTransactionReceipt`. The + transaction is broadcast but its receipt cannot be confirmed, through an RPC + timeout or a dropped connection, the outcome is genuinely unknown. The + gateway records the attempt as `settlement-uncertain` with the broadcast + transaction hash, and does **not** deliver the resource. Resolving it is the + merchant's job: check the recorded hash with `getTransactionReceipt`. The gateway will not report this as a failure, because it does not know that it was one. - **It prioritises delivery over bookkeeping.** If a resource is delivered but @@ -167,42 +200,19 @@ backend or your business secure. delivered. - **A reserved payment authorisation is never released.** If settlement fails, that authorisation cannot be reused at this gateway even when nothing moved - on-chain. This is deliberate — releasing it would reopen a replay window — - but a buyer hit by a transient error must sign a fresh authorisation. + on-chain. This is deliberate, since releasing it would reopen a replay + window, but a buyer hit by a transient error must sign a fresh authorisation. + An AP2 mandate is handed back in the narrower case where settlement provably + moved no money, and kept otherwise. - **A rejected request can still have been charged for.** A few request-shape errors are only detectable when the backend call is assembled, which happens - after settlement. The gateway hoists the checks it can — empty, `.` and `..` + after settlement. The gateway hoists the checks it can - empty, `.` and `..` path parameters, and input keys colliding with an operator-configured query - parameter, are all rejected **before** any payment is taken. But settlement is - final and there are no refunds, so if you configure a resource whose inputs can - fail late, your buyers can pay for a request that is never delivered. The - attempt is recorded as `settled` with a `backend.failed` event sharing the same - `requestId`, so reconciliation is possible. + parameter are all rejected **before** any payment is taken. But settlement is + final and there are no refunds, so if you configure a resource whose inputs + can fail late, your buyers can pay for a request that is never delivered. The + attempt is recorded as `settled` with a `backend.failed` event sharing the + same `requestId`, so reconciliation is possible. - **It does not rate limit anything.** Free resources are an unauthenticated proxy to your backend at whatever rate a caller chooses. Rate limiting, quotas and abuse controls belong in your API or your edge. - -## Reporting a vulnerability - -Please report security issues **privately** — do not open a public issue. - -1. Use GitHub's **Report a vulnerability** (Security → Advisories) on this - repository: . - That is the only private reporting channel — this project publishes no - maintainer email address, and an earlier revision of this page pointed at a - list in `CONTRIBUTING.md` that does not exist. -2. Include: affected version/commit, a description, reproduction steps, and the - impact you believe it has. -3. You will get an acknowledgement within **5 working days** and a status update - at least every **10 working days** until resolution. -4. Please give us **90 days** before public disclosure, or less by agreement if - a fix ships sooner. - -We will credit reporters in the release notes unless you prefer otherwise. - -## Out of scope for reports - -- The deliberately public Anvil development keys and the local demo chain. -- The demo merchant API's failure-injection routes (`/api/slow`, `/api/fail`), - which exist only to test the gateway. -- Missing hardening we already document as out of scope above. diff --git a/docs/architecture.md b/docs/architecture.md index f08600f..09d88b7 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -10,28 +10,31 @@ not scale, and handing the money to a proprietary middleman defeats the point. ## The shape of the answer ```text - ┌──────────────────────────────────────────────────────────┐ - │ AI Agent │ - └───────────────┬──────────────────────────────────────────┘ - │ MCP (tools/list, tools/call) · HTTP + PAYMENT-SIGNATURE - ┌───────────────▼──────────────────────────────────────────┐ - │ Agent Commerce Gateway │ - │ (runs in MERCHANT infrastructure) │ - │ │ - │ protocol adapters ──┐ │ - │ mcp, http │ │ - │ ▼ │ - │ ExecutionPipeline ── the single path │ - │ │ │ - │ ┌───────────────┼────────────────┐ │ - │ ▼ ▼ ▼ │ - │ PaymentProvider BackendExecutor ReceiptStore │ - │ (x402) (bounded HTTP) (SQLite) │ - └──────┬────────────────┬──────────────────────────────────┘ - │ │ - payment protocol ┌──────▼───────────────┐ - buyer → merchant │ Merchant Backend API │ (unchanged) - └──────────────────────┘ +┌──────────────────────────────────────────────────────────────────────────────┐ +│ AI Agent │ +└───────────────────────────────────────┬──────────────────────────────────────┘ + │ MCP (tools/list, tools/call) · A2A · ACP · HTTP + │ PAYMENT-SIGNATURE · Agent-Authorization +┌───────────────────────────────────────▼──────────────────────────────────────┐ +│ Agent Commerce Gateway │ +│ (runs in MERCHANT infrastructure) │ +│ │ +│ protocol adapters: mcp · http · a2a · acp │ +│ │ │ +│ ▼ │ +│ ExecutionPipeline │ +│ │ │ +│ ┌────────────────────┬───┴─────────────┬────────────────┐ │ +│ ▼ ▼ ▼ ▼ │ +│ AuthorizationProvider PaymentProvider BackendExecutor ReceiptStore │ +│ (ap2) (x402) (bounded HTTP) (SQLite) │ +└───────────────────────────────────────┬──────────────────────────────────────┘ + │ + ┌────────────▼───────────┐ + │ Merchant Backend API │ + └────────────────────────┘ + +payment protocol: buyer → merchant, directly. Never through the gateway. ``` Three properties are load-bearing: @@ -39,7 +42,7 @@ Three properties are load-bearing: - **Self-hosted.** The gateway runs in the merchant's infrastructure. There is no central service operated by this project, and none is planned. - **Non-custodial.** The gateway orchestrates a payment protocol; it never holds - funds or keys. See. + funds or keys. See [security.md](security.md). - **Configuration, not rewriting.** A merchant exposes an existing endpoint by describing it in `config.yaml`. If they already have an OpenAPI description, `agent-commerce import openapi` writes that configuration for them - an @@ -82,7 +85,7 @@ passes them through and never inspects them. Why this matters: adding ACP, AP2, A2A or a second payment rail becomes one new adapter rather than a core rewrite - and semantics from one protocol cannot leak -into another. See. +into another. See [contributing-adapters.md](contributing-adapters.md). ## The execution pipeline diff --git a/docs/payment-flow.md b/docs/payment-flow.md index 70e8bb9..bd54cf7 100644 --- a/docs/payment-flow.md +++ b/docs/payment-flow.md @@ -16,39 +16,40 @@ The gateway is in the middle of the *protocol* and outside the *custody*. ## The round trip ```text - buyer gateway chain / backend - │ │ │ - │ 1. tools/call market_report │ │ - ├────────────────────────────►│ │ - │ │ resolve resource, validate input │ - │ │ price: 0.01 USDC → paid │ - │ │ createRequirement │ - │ 2. isError + envelope │ │ - │◄────────────────────────────┤ PaymentRequiredEnvelope │ - │ payment.accepts[0] │ (x402 PaymentRequirements) │ - │ │ │ - │ 3. sign EIP-3009 │ │ - │ authorisation │ │ - │ (to = merchant payTo) │ │ - │ │ │ - │ 4. tools/call + _payment │ │ - ├────────────────────────────►│ │ - │ │ verify ── signature, recipient,│ - │ │ amount, window, │ - │ │ balance, network, │ - │ │ asset ───────────────┤ read - │ │ replayKey = H(chainId, asset, │ - │ │ payer, nonce) │ - │ │ reservePaymentAttempt(replayKey) │ - │ │ duplicate ⇒ PAYMENT_REPLAYED │ - │ │ settle ─────────────────────────┤ tx - │ │ transferWithAuthorization│ - │ │◄──────────────────────────────────┤ receipt - │ │ call merchant backend ────────────┤ - │ │◄──────────────────────────────────┤ 200 - │ │ saveReceipt(txHash) │ - │ 5. result + receipt │ │ - │◄────────────────────────────┤ │ + buyer gateway chain / backend + │ │ │ + │ 1. tools/call market_report │ │ + ├───────────────────────────────►│ │ + │ │ resolve resource, validate input │ + │ │ price 0.01 USDC → paid │ + │ │ createRequirement │ + │◄───────────────────────────────┤ PaymentRequiredEnvelope │ + │ 2. isError + envelope │ (x402 PaymentRequirements) │ + │ payment.accepts[0] │ │ + │ │ │ + │ 3. sign EIP-3009 authorisation │ │ + │ (to = merchant payTo) │ │ + │ │ │ + │ 4. tools/call + _payment │ │ + ├───────────────────────────────►│ │ + │ │ verify: signature, recipient, │ + │ │ amount, window, │ + │ │ network, asset │ + │ ├──────────────────────────────────►│ chain: balance / allowance + │ │ replayKey = H(chainId, asset, │ + │ │ payer, nonce) │ + │ │ reservePaymentAttempt(replayKey) │ + │ │ duplicate ⇒ PAYMENT_REPLAYED │ + │ │ settle │ + │ ├──────────────────────────────────►│ chain: transferWithAuthorization + │ │◄──────────────────────────────────┤ chain: tx receipt + │ │ call merchant backend │ + │ ├──────────────────────────────────►│ backend: GET /api/report + │ │◄──────────────────────────────────┤ backend: 200 + body + │ │ saveReceipt(txHash) │ + │ │ │ + │◄───────────────────────────────┤ │ + │ 5. result + receipt │ │ ``` Steps 1–2 and 4–5 are the same over plain HTTP; the challenge arrives as a diff --git a/docs/security.md b/docs/security.md index e90b79e..274ec75 100644 --- a/docs/security.md +++ b/docs/security.md @@ -7,15 +7,16 @@ deliberately do not defend. ## Trust boundaries ```text - UNTRUSTED SEMI-TRUSTED TRUSTED - ───────── ──────────── ─────── - agent input ────► gateway process ────► merchant backend - payment proofs (validates all (administrator - protocol traffic of the left, configured, assumed - holds no keys) to be yours) - - configuration ◄──── administrator (trusted) - environment ◄──── operator (trusted) + UNTRUSTED SEMI-TRUSTED TRUSTED + ──────────────────── ────────────────────────── ───────────────────────── + + agent input ────► gateway process ────► merchant backend + payment proofs validates all agent input, administrator configured, + authorization proofs holds no keys assumed to be yours + protocol traffic + + configuration ◄──── administrator + environment ◄──── operator ``` Everything from an agent is untrusted and validated. Configuration is trusted diff --git a/src/gateway/logger.ts b/src/gateway/logger.ts index 2e9eedc..dd3c111 100644 --- a/src/gateway/logger.ts +++ b/src/gateway/logger.ts @@ -1,7 +1,7 @@ /** - * Pino logger factory with redaction. Never log secrets: Authorization - * headers, the payment-signature header, private keys, seeds, mnemonics, - * signatures. + * Pino logger factory with redaction. Never log secrets: the credential and + * proof headers (`authorization`, `payment-signature`, `agent-authorization`), + * private keys, seeds, mnemonics, signatures. * * Two independent things are built here, deliberately not the same pino * instance (Fastify's `loggerInstance` option forces its generic `Logger` @@ -20,7 +20,7 @@ import { createRequire } from 'node:module'; import type { FastifyReply, FastifyRequest } from 'fastify'; import pino, { type LoggerOptions, type Logger as PinoLogger } from 'pino'; -import type { Logger } from '../core/index.js'; +import { AUTHORIZATION_HEADER, type Logger, PAYMENT_HEADER } from '../core/index.js'; /** * Absolute path to pino-pretty, or `undefined` when it is not installed. @@ -75,9 +75,19 @@ const SECRET_FIELD_NAMES = [ * are single-level, so a secret at depth ≥ 2 is still not covered, and the * documentation says so rather than promising "any field". */ +/** + * Request headers carrying a credential or a proof, read from the wire + * constants rather than written out again here. + * + * A hardcoded copy is how this drifted before: `x-payment` became + * `payment-signature` for x402 v2, and a literal list would still be redacting + * a header no client sends. `agent-authorization` carries an AP2 mandate and + * belongs here for the same reason a payment proof does. + */ +const SECRET_HEADERS = ['authorization', PAYMENT_HEADER, AUTHORIZATION_HEADER] as const; + export const REDACT_PATHS: readonly string[] = [ - 'req.headers.authorization', - 'req.headers["payment-signature"]', + ...SECRET_HEADERS.map((name) => `req.headers[${JSON.stringify(name)}]`), ...SECRET_FIELD_NAMES, ...SECRET_FIELD_NAMES.map((name) => `*.${name}`), ]; diff --git a/tests/unit/gateway/logger.test.ts b/tests/unit/gateway/logger.test.ts index ea6e871..1854103 100644 --- a/tests/unit/gateway/logger.test.ts +++ b/tests/unit/gateway/logger.test.ts @@ -4,6 +4,7 @@ import { PassThrough } from 'node:stream'; import Fastify from 'fastify'; import pino from 'pino'; import { describe, expect, it } from 'vitest'; +import { AUTHORIZATION_HEADER, PAYMENT_HEADER } from '../../../src/core/index.js'; import { buildNotFoundHandler, createGatewayLogger, @@ -26,7 +27,11 @@ describe('createGatewayLogger', () => { instance.info( { req: { - headers: { authorization: 'Bearer secret-token', 'payment-signature': 'base64proof' }, + headers: { + authorization: 'Bearer secret-token', + 'payment-signature': 'base64proof', + 'agent-authorization': 'base64mandate', + }, }, }, 'request', @@ -37,9 +42,30 @@ describe('createGatewayLogger', () => { expect(combined).not.toContain('0xSUPER_SECRET'); expect(combined).not.toContain('secret-token'); expect(combined).not.toContain('base64proof'); + expect(combined).not.toContain('base64mandate'); expect(combined).toContain('[REDACTED]'); }); + it('redacts every wire header that carries a credential or a proof', async () => { + // Asserts the redaction, not the spelling of the path: a new header + // constant with no path has to fail here, and rewriting an existing path + // in another notation that still redacts must not + for (const header of ['authorization', PAYMENT_HEADER, AUTHORIZATION_HEADER]) { + const stream = new PassThrough(); + const chunks: string[] = []; + stream.on('data', (chunk: Buffer) => chunks.push(chunk.toString('utf8'))); + const instance = pino( + { level: 'info', redact: { paths: [...REDACT_PATHS], censor: '[REDACTED]' } }, + stream, + ); + + instance.info({ req: { headers: { [header]: 'SENSITIVE-VALUE' } } }, 'request'); + await new Promise((resolve) => setImmediate(resolve)); + + expect(chunks.join(''), header).not.toContain('SENSITIVE-VALUE'); + } + }); + it('exposes a Logger-shaped wrapper whose child() also redacts', async () => { const { core } = createGatewayLogger({ level: 'silent', prettyPrint: false }); const child = core.child({ requestId: 'req-1' });