@devlab.group/agent-commerce 1.2.0 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +94 -66
- package/dist/ap2.d.ts +164 -0
- package/dist/ap2.js +669 -0
- package/dist/ap2.js.map +1 -0
- package/dist/authorization-CPDcpJVa.d.ts +123 -0
- package/dist/chunk-7NKWFGBY.js +68 -0
- package/dist/chunk-7NKWFGBY.js.map +1 -0
- package/dist/{chunk-5TKNTWMC.js → chunk-IUR3Q7DE.js} +3 -3
- package/dist/{chunk-5TKNTWMC.js.map → chunk-IUR3Q7DE.js.map} +1 -1
- package/dist/{chunk-EIACECVM.js → chunk-ZDVJ7EVL.js} +150 -67
- package/dist/chunk-ZDVJ7EVL.js.map +1 -0
- package/dist/cli/index.js +788 -87
- package/dist/cli/index.js.map +1 -1
- package/dist/index.d.ts +256 -7
- package/dist/index.js +5636 -136
- package/dist/index.js.map +1 -1
- package/dist/mcp.d.ts +3 -2
- package/dist/mcp.js +10 -12
- package/dist/mcp.js.map +1 -1
- package/dist/{protocol-adapter-BAKGnuZ4.d.ts → protocol-adapter-CaMZj6Wm.d.ts} +20 -3
- package/dist/{runtime-Bqg9qW3t.d.ts → runtime-CKq3d_3K.d.ts} +25 -5
- package/dist/types-CJtwLu1c.d.ts +99 -0
- package/dist/x402.d.ts +1 -1
- package/dist/x402.js +3 -3
- package/package.json +28 -4
- package/dist/chunk-EIACECVM.js.map +0 -1
package/dist/ap2.js
ADDED
|
@@ -0,0 +1,669 @@
|
|
|
1
|
+
import { AP2_CHECKOUT_MANDATE_VCT, AP2_SIGNING_ALGORITHM, AP2_DIGEST_ALGORITHM, AP2_CHECKOUT_PROFILE, AP2_SPEC_VERSION, AP2_KEY_TYPE, AP2_JWK_CURVE, openSqliteDatabase } from './chunk-7NKWFGBY.js';
|
|
2
|
+
export { AP2_CHECKOUT_MANDATE_VCT, AP2_CHECKOUT_PROFILE, AP2_DEFAULT_CLOCK_SKEW_SECONDS, AP2_MAX_CLOCK_SKEW_SECONDS, AP2_SIGNING_ALGORITHM, AP2_SPEC_VERSION } from './chunk-7NKWFGBY.js';
|
|
3
|
+
import { systemClock, NOOP_LOGGER, CommerceError, PACKAGE_VERSION } from './chunk-ZDVJ7EVL.js';
|
|
4
|
+
import { SignJWT, importPKCS8, importJWK, jwtVerify } from 'jose';
|
|
5
|
+
import canonicalize from 'canonicalize';
|
|
6
|
+
import { decodeSdJwt, splitSdJwt, getClaims } from '@sd-jwt/core';
|
|
7
|
+
|
|
8
|
+
// src/authorization/ap2/errors.ts
|
|
9
|
+
var AP2_REJECTION_REASONS = [
|
|
10
|
+
"malformed_presentation",
|
|
11
|
+
"untrusted_issuer",
|
|
12
|
+
"unknown_key",
|
|
13
|
+
"invalid_signature",
|
|
14
|
+
"unsupported_algorithm",
|
|
15
|
+
"invalid_claims",
|
|
16
|
+
"expired",
|
|
17
|
+
"wrong_audience",
|
|
18
|
+
"unsupported_mandate_type",
|
|
19
|
+
"checkout_binding_failed",
|
|
20
|
+
"purchase_mismatch"
|
|
21
|
+
];
|
|
22
|
+
var MESSAGES = {
|
|
23
|
+
malformed_presentation: "The authorization is not a well-formed SD-JWT presentation.",
|
|
24
|
+
untrusted_issuer: "The mandate was issued by a party this merchant does not trust.",
|
|
25
|
+
unknown_key: "The mandate names a signing key this merchant does not trust.",
|
|
26
|
+
invalid_signature: "The mandate signature did not verify.",
|
|
27
|
+
unsupported_algorithm: "The mandate uses a signature or digest algorithm that is not accepted.",
|
|
28
|
+
invalid_claims: "The mandate is missing required claims or they are malformed.",
|
|
29
|
+
expired: "The mandate is expired or not yet valid.",
|
|
30
|
+
wrong_audience: "The mandate is addressed to a different audience.",
|
|
31
|
+
unsupported_mandate_type: "The mandate is not a closed Direct Checkout Mandate.",
|
|
32
|
+
checkout_binding_failed: "The merchant checkout document bound to the mandate did not verify.",
|
|
33
|
+
purchase_mismatch: "The mandate does not authorize this purchase."
|
|
34
|
+
};
|
|
35
|
+
function ap2Rejected(reason, context = {}) {
|
|
36
|
+
return new CommerceError("AUTHORIZATION_INVALID", MESSAGES[reason], {
|
|
37
|
+
details: { method: "ap2", reason },
|
|
38
|
+
...context.requestId !== void 0 ? { requestId: context.requestId } : {},
|
|
39
|
+
...context.resourceId !== void 0 ? { resourceId: context.resourceId } : {},
|
|
40
|
+
...context.cause !== void 0 ? { cause: context.cause } : {}
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
function ap2Replayed(state, context = {}) {
|
|
44
|
+
return new CommerceError(
|
|
45
|
+
"AUTHORIZATION_REPLAYED",
|
|
46
|
+
"This mandate has already been presented and cannot authorize another purchase.",
|
|
47
|
+
{
|
|
48
|
+
details: { method: "ap2", reason: "replayed", state },
|
|
49
|
+
...context.requestId !== void 0 ? { requestId: context.requestId } : {},
|
|
50
|
+
...context.resourceId !== void 0 ? { resourceId: context.resourceId } : {}
|
|
51
|
+
}
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
function ap2Unavailable(detail, context = {}) {
|
|
55
|
+
return new CommerceError(
|
|
56
|
+
"AUTHORIZATION_PROVIDER_UNAVAILABLE",
|
|
57
|
+
"Authorization could not be verified right now. This is a merchant-side fault, not a problem with the presented mandate.",
|
|
58
|
+
{
|
|
59
|
+
details: { method: "ap2", reason: "verifier_unavailable", detail },
|
|
60
|
+
...context.requestId !== void 0 ? { requestId: context.requestId } : {},
|
|
61
|
+
...context.resourceId !== void 0 ? { resourceId: context.resourceId } : {},
|
|
62
|
+
...context.cause !== void 0 ? { cause: context.cause } : {}
|
|
63
|
+
}
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// src/authorization/ap2/profile.ts
|
|
68
|
+
var REQUIRED_PROFILE_CLAIMS = [
|
|
69
|
+
"profile",
|
|
70
|
+
"resource_id",
|
|
71
|
+
"input_hash",
|
|
72
|
+
"amount",
|
|
73
|
+
"currency",
|
|
74
|
+
"payment_method"
|
|
75
|
+
];
|
|
76
|
+
async function computeInputHash(input) {
|
|
77
|
+
const canonical = canonicalize(input ?? {});
|
|
78
|
+
if (canonical === void 0) {
|
|
79
|
+
throw new TypeError("resource input is not canonicalizable JSON");
|
|
80
|
+
}
|
|
81
|
+
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(canonical));
|
|
82
|
+
return Buffer.from(digest).toString("base64url");
|
|
83
|
+
}
|
|
84
|
+
function asRecord(value) {
|
|
85
|
+
return typeof value === "object" && value !== null && !Array.isArray(value) ? value : void 0;
|
|
86
|
+
}
|
|
87
|
+
async function bindMandateToPurchase(mandate, context, errorContext) {
|
|
88
|
+
const profile = asRecord(mandate.checkoutClaims["agent_commerce"]);
|
|
89
|
+
if (profile === void 0) throw ap2Rejected("purchase_mismatch", errorContext);
|
|
90
|
+
for (const claim of REQUIRED_PROFILE_CLAIMS) {
|
|
91
|
+
const value = profile[claim];
|
|
92
|
+
if (typeof value !== "string" || value.length === 0) {
|
|
93
|
+
throw ap2Rejected("purchase_mismatch", errorContext);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
const requirement = context.requirement;
|
|
97
|
+
const expectedInputHash = await computeInputHash(context.input);
|
|
98
|
+
const mustMatch = [
|
|
99
|
+
["profile", AP2_CHECKOUT_PROFILE],
|
|
100
|
+
["resource_id", context.resourceId],
|
|
101
|
+
["input_hash", expectedInputHash],
|
|
102
|
+
// Decimal strings on both sides. Comparing numerically would make "0.10"
|
|
103
|
+
// and "0.1" equal, and a mandate says what it says.
|
|
104
|
+
["amount", requirement.amount],
|
|
105
|
+
["currency", requirement.currency],
|
|
106
|
+
["payment_method", requirement.provider]
|
|
107
|
+
];
|
|
108
|
+
for (const [claim, expected] of mustMatch) {
|
|
109
|
+
if (profile[claim] !== expected) throw ap2Rejected("purchase_mismatch", errorContext);
|
|
110
|
+
}
|
|
111
|
+
for (const [claim, expected] of [
|
|
112
|
+
["destination", requirement.destination],
|
|
113
|
+
["network", requirement.network],
|
|
114
|
+
["asset", requirement.asset]
|
|
115
|
+
]) {
|
|
116
|
+
const declared = profile[claim];
|
|
117
|
+
if (declared === void 0 && expected === void 0) continue;
|
|
118
|
+
if (declared !== expected) throw ap2Rejected("purchase_mismatch", errorContext);
|
|
119
|
+
}
|
|
120
|
+
return {
|
|
121
|
+
resourceId: profile["resource_id"],
|
|
122
|
+
amount: profile["amount"],
|
|
123
|
+
currency: profile["currency"],
|
|
124
|
+
paymentMethod: profile["payment_method"]
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// src/authorization/ap2/checkout-signer.ts
|
|
129
|
+
var DEFAULT_EXPIRES_IN_SECONDS = 900;
|
|
130
|
+
function requireText(value, field) {
|
|
131
|
+
if (typeof value !== "string" || value.length === 0) {
|
|
132
|
+
throw new TypeError(
|
|
133
|
+
`createCheckoutJwt: ${field} must be a non-empty string, received ${describe(value)}`
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
return value;
|
|
137
|
+
}
|
|
138
|
+
function describe(value) {
|
|
139
|
+
return value === null ? "null" : typeof value;
|
|
140
|
+
}
|
|
141
|
+
async function resolveKey(key) {
|
|
142
|
+
if (typeof key === "string") {
|
|
143
|
+
if (!key.includes("BEGIN PRIVATE KEY")) {
|
|
144
|
+
throw new TypeError(
|
|
145
|
+
'createCheckoutJwt: a string privateKey must be a PKCS#8 PEM beginning "-----BEGIN PRIVATE KEY-----"'
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
return importPKCS8(key, AP2_SIGNING_ALGORITHM);
|
|
149
|
+
}
|
|
150
|
+
if (typeof key !== "object" || key === null) {
|
|
151
|
+
throw new TypeError("createCheckoutJwt: privateKey must be a private JWK or a PKCS#8 PEM");
|
|
152
|
+
}
|
|
153
|
+
if (key["d"] === void 0) {
|
|
154
|
+
throw new TypeError(
|
|
155
|
+
'createCheckoutJwt: privateKey is a public JWK (no "d"). Use the private half of the pair whose public key is configured under checkoutIssuers'
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
if (key["kty"] !== AP2_KEY_TYPE || key["crv"] !== AP2_JWK_CURVE) {
|
|
159
|
+
throw new TypeError(
|
|
160
|
+
`createCheckoutJwt: privateKey must be ${AP2_KEY_TYPE}/${AP2_JWK_CURVE}, the pair ${AP2_SIGNING_ALGORITHM} implies`
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
return key;
|
|
164
|
+
}
|
|
165
|
+
async function createCheckoutJwt(options) {
|
|
166
|
+
const kid = requireText(options.kid, "kid");
|
|
167
|
+
const issuer = requireText(options.issuer, "issuer");
|
|
168
|
+
const audience = requireText(options.audience, "audience");
|
|
169
|
+
const resourceId = requireText(options.resourceId, "resourceId");
|
|
170
|
+
const currency = requireText(options.currency, "currency");
|
|
171
|
+
const paymentMethod = requireText(options.paymentMethod, "paymentMethod");
|
|
172
|
+
if (typeof options.amount === "number") {
|
|
173
|
+
throw new TypeError(
|
|
174
|
+
'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'
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
const amount = requireText(options.amount, "amount");
|
|
178
|
+
const key = await resolveKey(options.privateKey);
|
|
179
|
+
const issuedAt = Math.floor((options.now ?? /* @__PURE__ */ new Date()).getTime() / 1e3);
|
|
180
|
+
const expiresIn = options.expiresInSeconds ?? DEFAULT_EXPIRES_IN_SECONDS;
|
|
181
|
+
if (!Number.isInteger(expiresIn) || expiresIn <= 0) {
|
|
182
|
+
throw new TypeError("createCheckoutJwt: expiresInSeconds must be a positive whole number");
|
|
183
|
+
}
|
|
184
|
+
const agentCommerce = {
|
|
185
|
+
profile: AP2_CHECKOUT_PROFILE,
|
|
186
|
+
resource_id: resourceId,
|
|
187
|
+
input_hash: await computeInputHash(options.input),
|
|
188
|
+
amount,
|
|
189
|
+
currency,
|
|
190
|
+
payment_method: paymentMethod,
|
|
191
|
+
...options.destination !== void 0 ? { destination: options.destination } : {},
|
|
192
|
+
...options.network !== void 0 ? { network: options.network } : {},
|
|
193
|
+
...options.asset !== void 0 ? { asset: options.asset } : {}
|
|
194
|
+
};
|
|
195
|
+
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);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// src/authorization/ap2/descriptor.ts
|
|
199
|
+
var AP2_CAPABILITIES = [
|
|
200
|
+
"direct-mode",
|
|
201
|
+
AP2_CHECKOUT_MANDATE_VCT,
|
|
202
|
+
"sd-jwt-presentation",
|
|
203
|
+
AP2_SIGNING_ALGORITHM,
|
|
204
|
+
AP2_DIGEST_ALGORITHM,
|
|
205
|
+
"static-inline-trust",
|
|
206
|
+
"merchant-checkout-jwt-binding",
|
|
207
|
+
AP2_CHECKOUT_PROFILE,
|
|
208
|
+
"purchase-binding",
|
|
209
|
+
"replay-defence"
|
|
210
|
+
];
|
|
211
|
+
var AP2_UNSUPPORTED = [
|
|
212
|
+
// Mandate kinds. Open mandates carry spending constraints nothing here
|
|
213
|
+
// evaluates, so accepting one would tell a buyer their limits were checked.
|
|
214
|
+
"autonomous mode",
|
|
215
|
+
"open checkout mandates (mandate.checkout.open.1)",
|
|
216
|
+
"intent mandates",
|
|
217
|
+
"cart mandates",
|
|
218
|
+
"spending constraint evaluation",
|
|
219
|
+
"cnf-bound agent keys",
|
|
220
|
+
// Key handling. Every key is written into config by an operator
|
|
221
|
+
"JWKS and any key discovery by URL (jku, x5u)",
|
|
222
|
+
"issuer metadata fetching",
|
|
223
|
+
"key rotation without a config change",
|
|
224
|
+
// Algorithms
|
|
225
|
+
"signature algorithms other than ES256",
|
|
226
|
+
"digest algorithms other than sha-256",
|
|
227
|
+
// Roles this gateway does not play
|
|
228
|
+
"mandate issuance",
|
|
229
|
+
"merchant checkout JWT issuance",
|
|
230
|
+
"AP2 over the ACP checkout adapter"
|
|
231
|
+
];
|
|
232
|
+
function buildAp2Descriptor(implementationVersion) {
|
|
233
|
+
return {
|
|
234
|
+
name: "ap2",
|
|
235
|
+
kind: "authorization",
|
|
236
|
+
implementationVersion,
|
|
237
|
+
supportedSpec: `ap2/v${AP2_SPEC_VERSION} mode=direct`,
|
|
238
|
+
capabilities: AP2_CAPABILITIES,
|
|
239
|
+
status: "experimental",
|
|
240
|
+
unsupported: AP2_UNSUPPORTED
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// src/authorization/ap2/replay-store.ts
|
|
245
|
+
function createAp2ReplayStore(options) {
|
|
246
|
+
const logger = options.logger ?? NOOP_LOGGER;
|
|
247
|
+
const now = options.now ?? (() => Date.now());
|
|
248
|
+
const db = openSqliteDatabase({
|
|
249
|
+
path: options.path,
|
|
250
|
+
label: "AP2 authorization database",
|
|
251
|
+
logger
|
|
252
|
+
});
|
|
253
|
+
migrate(db);
|
|
254
|
+
const selectByReference = db.prepare(
|
|
255
|
+
"SELECT state FROM ap2_authorizations WHERE reference = ?"
|
|
256
|
+
);
|
|
257
|
+
const selectLiveByJti = db.prepare(
|
|
258
|
+
`SELECT state FROM ap2_authorizations
|
|
259
|
+
WHERE checkout_jti = ? AND reference != ? AND state != 'released'`
|
|
260
|
+
);
|
|
261
|
+
const insert = db.prepare(
|
|
262
|
+
`INSERT INTO ap2_authorizations
|
|
263
|
+
(reference, checkout_jti, mandate_issuer, checkout_issuer, resource_id, request_id,
|
|
264
|
+
state, created_at, updated_at)
|
|
265
|
+
VALUES (@reference, @checkout_jti, @mandate_issuer, @checkout_issuer, @resource_id,
|
|
266
|
+
@request_id, 'reserved', @at, @at)`
|
|
267
|
+
);
|
|
268
|
+
const reReserve = db.prepare(
|
|
269
|
+
`UPDATE ap2_authorizations
|
|
270
|
+
SET state = 'reserved', request_id = @request_id, updated_at = @at
|
|
271
|
+
WHERE reference = @reference AND state = 'released'`
|
|
272
|
+
);
|
|
273
|
+
const transition = db.prepare(
|
|
274
|
+
`UPDATE ap2_authorizations SET state = @state, updated_at = @at
|
|
275
|
+
WHERE reference = @reference AND state = 'reserved'`
|
|
276
|
+
);
|
|
277
|
+
const reserveTx = db.transaction((request) => {
|
|
278
|
+
const at = new Date(now()).toISOString();
|
|
279
|
+
const existing = selectByReference.get(request.reference);
|
|
280
|
+
if (existing !== void 0) {
|
|
281
|
+
if (existing.state !== "released") {
|
|
282
|
+
return { kind: "replayed", state: existing.state };
|
|
283
|
+
}
|
|
284
|
+
reReserve.run({ reference: request.reference, request_id: request.requestId, at });
|
|
285
|
+
return { kind: "reserved" };
|
|
286
|
+
}
|
|
287
|
+
const sameCheckout = selectLiveByJti.get(request.checkoutJti, request.reference);
|
|
288
|
+
if (sameCheckout !== void 0) {
|
|
289
|
+
return { kind: "replayed", state: sameCheckout.state };
|
|
290
|
+
}
|
|
291
|
+
insert.run({
|
|
292
|
+
reference: request.reference,
|
|
293
|
+
checkout_jti: request.checkoutJti,
|
|
294
|
+
mandate_issuer: request.mandateIssuer,
|
|
295
|
+
checkout_issuer: request.checkoutIssuer,
|
|
296
|
+
resource_id: request.resourceId,
|
|
297
|
+
request_id: request.requestId,
|
|
298
|
+
at
|
|
299
|
+
});
|
|
300
|
+
return { kind: "reserved" };
|
|
301
|
+
});
|
|
302
|
+
const move = (reference, state) => {
|
|
303
|
+
transition.run({ reference, state, at: new Date(now()).toISOString() });
|
|
304
|
+
};
|
|
305
|
+
let closed = false;
|
|
306
|
+
return {
|
|
307
|
+
reserve(request) {
|
|
308
|
+
return reserveTx(request);
|
|
309
|
+
},
|
|
310
|
+
consume(reference) {
|
|
311
|
+
move(reference, "consumed");
|
|
312
|
+
},
|
|
313
|
+
release(reference) {
|
|
314
|
+
move(reference, "released");
|
|
315
|
+
},
|
|
316
|
+
markUncertain(reference) {
|
|
317
|
+
move(reference, "uncertain");
|
|
318
|
+
},
|
|
319
|
+
stateOf(reference) {
|
|
320
|
+
return selectByReference.get(reference)?.state;
|
|
321
|
+
},
|
|
322
|
+
close() {
|
|
323
|
+
if (closed) return;
|
|
324
|
+
closed = true;
|
|
325
|
+
db.close();
|
|
326
|
+
}
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
function migrate(db) {
|
|
330
|
+
const current = db.pragma("user_version", { simple: true });
|
|
331
|
+
if (current >= 1) return;
|
|
332
|
+
const apply = db.transaction(() => {
|
|
333
|
+
db.exec(`
|
|
334
|
+
CREATE TABLE IF NOT EXISTS ap2_authorizations (
|
|
335
|
+
reference TEXT PRIMARY KEY,
|
|
336
|
+
checkout_jti TEXT NOT NULL,
|
|
337
|
+
mandate_issuer TEXT NOT NULL,
|
|
338
|
+
checkout_issuer TEXT NOT NULL,
|
|
339
|
+
resource_id TEXT NOT NULL,
|
|
340
|
+
request_id TEXT NOT NULL,
|
|
341
|
+
state TEXT NOT NULL
|
|
342
|
+
CHECK (state IN ('reserved', 'consumed', 'released', 'uncertain')),
|
|
343
|
+
created_at TEXT NOT NULL,
|
|
344
|
+
updated_at TEXT NOT NULL
|
|
345
|
+
);
|
|
346
|
+
CREATE INDEX IF NOT EXISTS idx_ap2_authorizations_checkout_jti
|
|
347
|
+
ON ap2_authorizations(checkout_jti);
|
|
348
|
+
`);
|
|
349
|
+
db.pragma("user_version = 1");
|
|
350
|
+
});
|
|
351
|
+
apply();
|
|
352
|
+
}
|
|
353
|
+
function decodeSegment(jwt, index) {
|
|
354
|
+
const segment = jwt.split(".")[index];
|
|
355
|
+
if (segment === void 0) return void 0;
|
|
356
|
+
try {
|
|
357
|
+
const parsed = JSON.parse(Buffer.from(segment, "base64url").toString("utf8"));
|
|
358
|
+
return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) ? parsed : void 0;
|
|
359
|
+
} catch {
|
|
360
|
+
return void 0;
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
async function verifyCheckoutJwt(mandateClaims, deps, context) {
|
|
364
|
+
const compact = mandateClaims["checkout_jwt"];
|
|
365
|
+
const expectedHash = mandateClaims["checkout_hash"];
|
|
366
|
+
if (typeof compact !== "string" || compact.length === 0) {
|
|
367
|
+
throw ap2Rejected("invalid_claims", context);
|
|
368
|
+
}
|
|
369
|
+
if (typeof expectedHash !== "string" || expectedHash.length === 0) {
|
|
370
|
+
throw ap2Rejected("invalid_claims", context);
|
|
371
|
+
}
|
|
372
|
+
await requireMatchingHash(compact, expectedHash, context);
|
|
373
|
+
const header = decodeSegment(compact, 0);
|
|
374
|
+
const unverifiedPayload = decodeSegment(compact, 1);
|
|
375
|
+
if (header === void 0 || unverifiedPayload === void 0) {
|
|
376
|
+
throw ap2Rejected("checkout_binding_failed", context);
|
|
377
|
+
}
|
|
378
|
+
const { issuer, key } = await deps.trust.resolve(
|
|
379
|
+
unverifiedPayload["iss"],
|
|
380
|
+
header["kid"],
|
|
381
|
+
context
|
|
382
|
+
);
|
|
383
|
+
const options = {
|
|
384
|
+
algorithms: [AP2_SIGNING_ALGORITHM],
|
|
385
|
+
issuer: issuer.issuer,
|
|
386
|
+
audience: issuer.audience,
|
|
387
|
+
clockTolerance: deps.clockSkewSeconds,
|
|
388
|
+
currentDate: deps.clock.now()
|
|
389
|
+
};
|
|
390
|
+
let claims;
|
|
391
|
+
try {
|
|
392
|
+
const result = await jwtVerify(compact, key, options);
|
|
393
|
+
claims = result.payload;
|
|
394
|
+
} catch (cause) {
|
|
395
|
+
throw ap2Rejected("checkout_binding_failed", { ...context, cause });
|
|
396
|
+
}
|
|
397
|
+
const exp = claims["exp"];
|
|
398
|
+
const iat = claims["iat"];
|
|
399
|
+
const jti = claims["jti"];
|
|
400
|
+
if (typeof exp !== "number" || typeof iat !== "number") {
|
|
401
|
+
throw ap2Rejected("invalid_claims", context);
|
|
402
|
+
}
|
|
403
|
+
if (typeof jti !== "string" || jti.length === 0) {
|
|
404
|
+
throw ap2Rejected("invalid_claims", context);
|
|
405
|
+
}
|
|
406
|
+
const nowSeconds = Math.floor(deps.clock.now().getTime() / 1e3);
|
|
407
|
+
if (iat > nowSeconds + deps.clockSkewSeconds) throw ap2Rejected("expired", context);
|
|
408
|
+
return { issuer: issuer.issuer, jwtId: jti, claims };
|
|
409
|
+
}
|
|
410
|
+
async function requireMatchingHash(compact, expected, context) {
|
|
411
|
+
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(compact));
|
|
412
|
+
const actual = Buffer.from(digest).toString("base64url");
|
|
413
|
+
if (actual !== expected) throw ap2Rejected("checkout_binding_failed", context);
|
|
414
|
+
}
|
|
415
|
+
async function hasher(data, algorithm) {
|
|
416
|
+
if (algorithm.toLowerCase() !== AP2_DIGEST_ALGORITHM) {
|
|
417
|
+
throw new Error(`unsupported digest algorithm ${algorithm}`);
|
|
418
|
+
}
|
|
419
|
+
const bytes = typeof data === "string" ? new TextEncoder().encode(data) : new Uint8Array(data);
|
|
420
|
+
return new Uint8Array(await crypto.subtle.digest("SHA-256", bytes));
|
|
421
|
+
}
|
|
422
|
+
function asRecord2(value) {
|
|
423
|
+
return typeof value === "object" && value !== null && !Array.isArray(value) ? value : void 0;
|
|
424
|
+
}
|
|
425
|
+
async function verifyMandate(presentation, deps, context) {
|
|
426
|
+
let decoded;
|
|
427
|
+
let encodedJws;
|
|
428
|
+
try {
|
|
429
|
+
decoded = await decodeSdJwt(presentation, hasher);
|
|
430
|
+
encodedJws = splitSdJwt(presentation).jwt;
|
|
431
|
+
} catch (cause) {
|
|
432
|
+
throw ap2Rejected("malformed_presentation", { ...context, cause });
|
|
433
|
+
}
|
|
434
|
+
if (decoded.kbJwt !== void 0) {
|
|
435
|
+
throw ap2Rejected("unsupported_mandate_type", context);
|
|
436
|
+
}
|
|
437
|
+
const rawPayload = asRecord2(decoded.jwt.payload);
|
|
438
|
+
const header = asRecord2(decoded.jwt.header);
|
|
439
|
+
if (rawPayload === void 0 || header === void 0) {
|
|
440
|
+
throw ap2Rejected("malformed_presentation", context);
|
|
441
|
+
}
|
|
442
|
+
const { issuer, key } = await deps.trust.resolve(rawPayload["iss"], header["kid"], context);
|
|
443
|
+
const options = {
|
|
444
|
+
// Redundant today (the key is EC, so jose already refuses `alg: none` and
|
|
445
|
+
// a forged HMAC) and kept for the day this resolves a key set instead of
|
|
446
|
+
// one key, where the header would get to pick
|
|
447
|
+
algorithms: [AP2_SIGNING_ALGORITHM],
|
|
448
|
+
// Also redundant, and for the same reason: the key was resolved *from*
|
|
449
|
+
// `iss` against the configured allowlist, so nothing reaching here can
|
|
450
|
+
// carry a different one. It backstops a future resolver that matches on
|
|
451
|
+
// something else. `audience` below is not redundant - `aud` is the
|
|
452
|
+
// presenter's claim, checked against what the operator configured.
|
|
453
|
+
issuer: issuer.issuer,
|
|
454
|
+
audience: issuer.audience,
|
|
455
|
+
clockTolerance: deps.clockSkewSeconds,
|
|
456
|
+
// Injected clock, not jose's `Date.now()`, so expiry tests are tests
|
|
457
|
+
currentDate: deps.clock.now()
|
|
458
|
+
};
|
|
459
|
+
let verifiedPayload;
|
|
460
|
+
try {
|
|
461
|
+
const result = await jwtVerify(encodedJws, key, options);
|
|
462
|
+
verifiedPayload = result.payload;
|
|
463
|
+
} catch (cause) {
|
|
464
|
+
throw ap2Rejected(classifyJoseFailure(cause), { ...context, cause });
|
|
465
|
+
}
|
|
466
|
+
requireFreshness(verifiedPayload, deps, context);
|
|
467
|
+
let claims;
|
|
468
|
+
try {
|
|
469
|
+
claims = await getClaims(verifiedPayload, decoded.disclosures, hasher);
|
|
470
|
+
} catch (cause) {
|
|
471
|
+
throw ap2Rejected("malformed_presentation", { ...context, cause });
|
|
472
|
+
}
|
|
473
|
+
requireClosedCheckoutMandate(claims, context);
|
|
474
|
+
return { issuer: issuer.issuer, claims, signedToken: encodedJws };
|
|
475
|
+
}
|
|
476
|
+
function classifyJoseFailure(cause) {
|
|
477
|
+
const code = cause?.code;
|
|
478
|
+
if (code === "ERR_JWT_EXPIRED") return "expired";
|
|
479
|
+
if (code === "ERR_JWT_CLAIM_VALIDATION_FAILED") {
|
|
480
|
+
const claim = cause.claim;
|
|
481
|
+
if (claim === "aud") return "wrong_audience";
|
|
482
|
+
if (claim === "nbf" || claim === "exp") return "expired";
|
|
483
|
+
}
|
|
484
|
+
return "invalid_signature";
|
|
485
|
+
}
|
|
486
|
+
function requireFreshness(payload, deps, context) {
|
|
487
|
+
const exp = payload["exp"];
|
|
488
|
+
const iat = payload["iat"];
|
|
489
|
+
if (typeof exp !== "number" || typeof iat !== "number") {
|
|
490
|
+
throw ap2Rejected("invalid_claims", context);
|
|
491
|
+
}
|
|
492
|
+
const nowSeconds = Math.floor(deps.clock.now().getTime() / 1e3);
|
|
493
|
+
if (iat > nowSeconds + deps.clockSkewSeconds) throw ap2Rejected("expired", context);
|
|
494
|
+
}
|
|
495
|
+
function requireClosedCheckoutMandate(claims, context) {
|
|
496
|
+
if (claims["vct"] !== AP2_CHECKOUT_MANDATE_VCT) {
|
|
497
|
+
throw ap2Rejected("unsupported_mandate_type", context);
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
function createTrustStore(issuers) {
|
|
501
|
+
const byIssuer = new Map(issuers.map((entry) => [entry.issuer, entry]));
|
|
502
|
+
const imported = /* @__PURE__ */ new Map();
|
|
503
|
+
return {
|
|
504
|
+
/** Every trusted issuer id, for diagnostics. Never the keys themselves */
|
|
505
|
+
issuerIds() {
|
|
506
|
+
return [...byIssuer.keys()];
|
|
507
|
+
},
|
|
508
|
+
async resolve(iss, kid, context) {
|
|
509
|
+
if (typeof iss !== "string" || iss.length === 0) {
|
|
510
|
+
throw ap2Rejected("invalid_claims", context);
|
|
511
|
+
}
|
|
512
|
+
const issuer = byIssuer.get(iss);
|
|
513
|
+
if (issuer === void 0) throw ap2Rejected("untrusted_issuer", context);
|
|
514
|
+
if (typeof kid !== "string" || kid.length === 0) {
|
|
515
|
+
throw ap2Rejected("unknown_key", context);
|
|
516
|
+
}
|
|
517
|
+
const trusted = issuer.keys.find((candidate) => candidate.kid === kid);
|
|
518
|
+
if (trusted === void 0) throw ap2Rejected("unknown_key", context);
|
|
519
|
+
const cacheKey = JSON.stringify([iss, kid]);
|
|
520
|
+
let pending = imported.get(cacheKey);
|
|
521
|
+
if (pending === void 0) {
|
|
522
|
+
pending = importJWK(trusted.jwk, AP2_SIGNING_ALGORITHM);
|
|
523
|
+
imported.set(cacheKey, pending);
|
|
524
|
+
}
|
|
525
|
+
try {
|
|
526
|
+
return { issuer, key: await pending };
|
|
527
|
+
} catch (cause) {
|
|
528
|
+
imported.delete(cacheKey);
|
|
529
|
+
throw ap2Unavailable("configured verification key could not be imported", {
|
|
530
|
+
...context,
|
|
531
|
+
cause
|
|
532
|
+
});
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
};
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
// src/authorization/ap2/verifier.ts
|
|
539
|
+
async function mandateReference(signedToken) {
|
|
540
|
+
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(signedToken));
|
|
541
|
+
return `sha256:${Buffer.from(digest).toString("base64url")}`;
|
|
542
|
+
}
|
|
543
|
+
function createAp2MandateVerifier(options) {
|
|
544
|
+
const mandateTrust = createTrustStore(options.config.trust.mandateIssuers);
|
|
545
|
+
const checkoutTrust = createTrustStore(options.config.trust.checkoutIssuers);
|
|
546
|
+
const deps = { clock: options.clock, clockSkewSeconds: options.config.clockSkewSeconds };
|
|
547
|
+
return {
|
|
548
|
+
async verify(presentation, context = {}) {
|
|
549
|
+
if (typeof presentation !== "string" || presentation.length === 0) {
|
|
550
|
+
throw ap2Rejected("malformed_presentation", context);
|
|
551
|
+
}
|
|
552
|
+
const mandate = await verifyMandate(presentation, { ...deps, trust: mandateTrust }, context);
|
|
553
|
+
const checkout = await verifyCheckoutJwt(
|
|
554
|
+
mandate.claims,
|
|
555
|
+
{ ...deps, trust: checkoutTrust },
|
|
556
|
+
context
|
|
557
|
+
);
|
|
558
|
+
return {
|
|
559
|
+
reference: await mandateReference(mandate.signedToken),
|
|
560
|
+
mandateIssuer: mandate.issuer,
|
|
561
|
+
checkoutIssuer: checkout.issuer,
|
|
562
|
+
checkoutJwtId: checkout.jwtId,
|
|
563
|
+
checkoutClaims: checkout.claims,
|
|
564
|
+
mandateClaims: mandate.claims
|
|
565
|
+
};
|
|
566
|
+
},
|
|
567
|
+
trustedIssuers() {
|
|
568
|
+
return { mandate: mandateTrust.issuerIds(), checkout: checkoutTrust.issuerIds() };
|
|
569
|
+
}
|
|
570
|
+
};
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
// src/authorization/ap2/provider.ts
|
|
574
|
+
function createAp2AuthorizationProvider(options) {
|
|
575
|
+
const clock = options.clock ?? systemClock;
|
|
576
|
+
const logger = options.logger ?? NOOP_LOGGER;
|
|
577
|
+
const verifier = createAp2MandateVerifier({ config: options.config, clock });
|
|
578
|
+
const replay = options.replayStore ?? createAp2ReplayStore({ path: options.config.replay.path, logger });
|
|
579
|
+
const requirement = {
|
|
580
|
+
method: "ap2",
|
|
581
|
+
version: AP2_SPEC_VERSION,
|
|
582
|
+
profile: AP2_CHECKOUT_PROFILE
|
|
583
|
+
};
|
|
584
|
+
async function verifyAndReserve(context) {
|
|
585
|
+
const errorContext = {
|
|
586
|
+
requestId: context.requestId,
|
|
587
|
+
resourceId: context.resourceId
|
|
588
|
+
};
|
|
589
|
+
const mandate = await verifier.verify(context.submission.payload, errorContext);
|
|
590
|
+
await bindMandateToPurchase(mandate, context, errorContext);
|
|
591
|
+
let reservation;
|
|
592
|
+
try {
|
|
593
|
+
reservation = replay.reserve({
|
|
594
|
+
reference: mandate.reference,
|
|
595
|
+
checkoutJti: mandate.checkoutJwtId,
|
|
596
|
+
mandateIssuer: mandate.mandateIssuer,
|
|
597
|
+
checkoutIssuer: mandate.checkoutIssuer,
|
|
598
|
+
resourceId: context.resourceId,
|
|
599
|
+
requestId: context.requestId
|
|
600
|
+
});
|
|
601
|
+
} catch (cause) {
|
|
602
|
+
throw ap2Unavailable("replay store could not be written", { ...errorContext, cause });
|
|
603
|
+
}
|
|
604
|
+
if (reservation.kind === "replayed") {
|
|
605
|
+
throw ap2Replayed(reservation.state, errorContext);
|
|
606
|
+
}
|
|
607
|
+
return {
|
|
608
|
+
status: "verified",
|
|
609
|
+
method: "ap2",
|
|
610
|
+
reference: mandate.reference,
|
|
611
|
+
reservationId: mandate.reference,
|
|
612
|
+
// Opaque identifiers an operator can reconcile with. Never a claim from
|
|
613
|
+
// the mandate: those carry the buyer's purchase and their personal data.
|
|
614
|
+
// `checkoutId`, not `checkoutJwtId` - the receipt store redacts any key
|
|
615
|
+
// that reads as secret-shaped, and "jwt" is on that list.
|
|
616
|
+
metadata: {
|
|
617
|
+
mandateIssuer: mandate.mandateIssuer,
|
|
618
|
+
checkoutIssuer: mandate.checkoutIssuer,
|
|
619
|
+
checkoutId: mandate.checkoutJwtId
|
|
620
|
+
}
|
|
621
|
+
};
|
|
622
|
+
}
|
|
623
|
+
const finalize = (action, reservationId, context) => {
|
|
624
|
+
try {
|
|
625
|
+
replay[action](reservationId);
|
|
626
|
+
} catch (cause) {
|
|
627
|
+
throw ap2Unavailable(`replay store could not record ${action}`, { ...context, cause });
|
|
628
|
+
}
|
|
629
|
+
};
|
|
630
|
+
return {
|
|
631
|
+
name: "ap2",
|
|
632
|
+
descriptor: buildAp2Descriptor(PACKAGE_VERSION),
|
|
633
|
+
requirement,
|
|
634
|
+
verifyAndReserve,
|
|
635
|
+
async consume(reservationId, context) {
|
|
636
|
+
finalize("consume", reservationId, context);
|
|
637
|
+
},
|
|
638
|
+
async release(reservationId, context) {
|
|
639
|
+
finalize("release", reservationId, context);
|
|
640
|
+
},
|
|
641
|
+
async markUncertain(reservationId, context) {
|
|
642
|
+
finalize("markUncertain", reservationId, context);
|
|
643
|
+
},
|
|
644
|
+
async health() {
|
|
645
|
+
const startedAt = clock.monotonicMs();
|
|
646
|
+
const trusted = verifier.trustedIssuers();
|
|
647
|
+
const checkedAt = clock.nowIso();
|
|
648
|
+
const durationMs = Math.round(clock.monotonicMs() - startedAt);
|
|
649
|
+
try {
|
|
650
|
+
replay.stateOf("health-probe");
|
|
651
|
+
} catch {
|
|
652
|
+
return { status: "fail", checkedAt, durationMs, detail: "replay-store-unavailable" };
|
|
653
|
+
}
|
|
654
|
+
return {
|
|
655
|
+
status: "pass",
|
|
656
|
+
checkedAt,
|
|
657
|
+
durationMs,
|
|
658
|
+
detail: `mandate-issuers=${trusted.mandate.length} checkout-issuers=${trusted.checkout.length}`
|
|
659
|
+
};
|
|
660
|
+
},
|
|
661
|
+
close() {
|
|
662
|
+
replay.close();
|
|
663
|
+
}
|
|
664
|
+
};
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
export { AP2_CAPABILITIES, AP2_REJECTION_REASONS, AP2_UNSUPPORTED, createAp2AuthorizationProvider as ap2, createAp2AuthorizationProvider, createCheckoutJwt };
|
|
668
|
+
//# sourceMappingURL=ap2.js.map
|
|
669
|
+
//# sourceMappingURL=ap2.js.map
|