mppx 0.6.15 → 0.6.16
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/CHANGELOG.md +7 -0
- package/dist/Challenge.d.ts +7 -3
- package/dist/Challenge.d.ts.map +1 -1
- package/dist/Challenge.js +15 -9
- package/dist/Challenge.js.map +1 -1
- package/dist/Credential.d.ts +2 -2
- package/dist/Credential.d.ts.map +1 -1
- package/dist/Credential.js +31 -16
- package/dist/Credential.js.map +1 -1
- package/dist/server/Mppx.d.ts.map +1 -1
- package/dist/server/Mppx.js +19 -9
- package/dist/server/Mppx.js.map +1 -1
- package/dist/stripe/server/internal/html.gen.d.ts +1 -1
- package/dist/stripe/server/internal/html.gen.d.ts.map +1 -1
- package/dist/stripe/server/internal/html.gen.js +1 -1
- package/dist/stripe/server/internal/html.gen.js.map +1 -1
- package/dist/tempo/Proof.d.ts +5 -0
- package/dist/tempo/Proof.d.ts.map +1 -1
- package/dist/tempo/Proof.js +5 -1
- package/dist/tempo/Proof.js.map +1 -1
- package/dist/tempo/internal/proof.d.ts +2 -2
- package/dist/tempo/internal/proof.d.ts.map +1 -1
- package/dist/tempo/internal/proof.js +2 -2
- package/dist/tempo/internal/proof.js.map +1 -1
- package/dist/tempo/server/Charge.d.ts.map +1 -1
- package/dist/tempo/server/Charge.js +57 -17
- package/dist/tempo/server/Charge.js.map +1 -1
- package/dist/tempo/server/internal/html.gen.d.ts +1 -1
- package/dist/tempo/server/internal/html.gen.d.ts.map +1 -1
- package/dist/tempo/server/internal/html.gen.js +1 -1
- package/dist/tempo/server/internal/html.gen.js.map +1 -1
- package/package.json +2 -2
- package/src/Challenge.test.ts +25 -9
- package/src/Challenge.ts +21 -10
- package/src/Credential.test.ts +64 -2
- package/src/Credential.ts +35 -19
- package/src/middlewares/hono.test.ts +2 -2
- package/src/proxy/Proxy.test.ts +2 -2
- package/src/server/Mppx.test.ts +28 -8
- package/src/server/Mppx.ts +23 -9
- package/src/stripe/server/internal/html.gen.ts +1 -1
- package/src/tempo/Proof.test-d.ts +4 -0
- package/src/tempo/Proof.test.ts +9 -0
- package/src/tempo/Proof.ts +6 -1
- package/src/tempo/internal/proof.test.ts +4 -4
- package/src/tempo/internal/proof.ts +2 -2
- package/src/tempo/server/Charge.test.ts +476 -0
- package/src/tempo/server/Charge.ts +61 -17
- package/src/tempo/server/internal/html.gen.ts +1 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"html.gen.js","sourceRoot":"","sources":["../../../../src/tempo/server/internal/html.gen.ts"],"names":[],"mappings":"AAAA,2BAA2B;AAC3B,MAAM,CAAC,MAAM,IAAI,GAAG,
|
|
1
|
+
{"version":3,"file":"html.gen.js","sourceRoot":"","sources":["../../../../src/tempo/server/internal/html.gen.ts"],"names":[],"mappings":"AAAA,2BAA2B;AAC3B,MAAM,CAAC,MAAM,IAAI,GAAG,206tBAA206tB,CAAA"}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mppx",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "0.6.
|
|
4
|
+
"version": "0.6.16",
|
|
5
5
|
"main": "./dist/index.js",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"files": [
|
|
@@ -105,7 +105,7 @@
|
|
|
105
105
|
"@modelcontextprotocol/sdk": ">=1.25.0",
|
|
106
106
|
"elysia": ">=1",
|
|
107
107
|
"express": ">=5",
|
|
108
|
-
"hono": ">=4.12.
|
|
108
|
+
"hono": ">=4.12.16",
|
|
109
109
|
"viem": ">=2.47.5"
|
|
110
110
|
},
|
|
111
111
|
"peerDependenciesMeta": {
|
package/src/Challenge.test.ts
CHANGED
|
@@ -693,7 +693,8 @@ describe('opaque', () => {
|
|
|
693
693
|
meta: { pi: 'pi_3abc123XYZ' },
|
|
694
694
|
})
|
|
695
695
|
|
|
696
|
-
expect(challenge.
|
|
696
|
+
expect(challenge.meta).toEqual({ pi: 'pi_3abc123XYZ' })
|
|
697
|
+
expect(challenge.opaque).toBe('eyJwaSI6InBpXzNhYmMxMjNYWVoifQ')
|
|
697
698
|
expect((challenge.request as Record<string, unknown>).opaque).toBeUndefined()
|
|
698
699
|
})
|
|
699
700
|
|
|
@@ -710,10 +711,11 @@ describe('opaque', () => {
|
|
|
710
711
|
meta: { payment_intent: 'pi_3abc123XYZ' },
|
|
711
712
|
})
|
|
712
713
|
|
|
713
|
-
expect(challenge.
|
|
714
|
+
expect(challenge.meta).toEqual({ payment_intent: 'pi_3abc123XYZ' })
|
|
715
|
+
expect(challenge.opaque).toBe('eyJwYXltZW50X2ludGVudCI6InBpXzNhYmMxMjNYWVoifQ')
|
|
714
716
|
})
|
|
715
717
|
|
|
716
|
-
test('behavior: challenge
|
|
718
|
+
test('behavior: challenge opaque and meta are undefined when no meta', () => {
|
|
717
719
|
const challenge = Challenge.from({
|
|
718
720
|
id: 'abc123',
|
|
719
721
|
realm: 'api.example.com',
|
|
@@ -723,9 +725,10 @@ describe('opaque', () => {
|
|
|
723
725
|
})
|
|
724
726
|
|
|
725
727
|
expect(challenge.opaque).toBeUndefined()
|
|
728
|
+
expect(challenge.meta).toBeUndefined()
|
|
726
729
|
})
|
|
727
730
|
|
|
728
|
-
test('behavior: opaque roundtrips through serialize/deserialize', () => {
|
|
731
|
+
test('behavior: opaque roundtrips through serialize/deserialize as a raw string', () => {
|
|
729
732
|
const original = Challenge.from({
|
|
730
733
|
id: 'abc123',
|
|
731
734
|
realm: 'api.example.com',
|
|
@@ -738,10 +741,21 @@ describe('opaque', () => {
|
|
|
738
741
|
const header = Challenge.serialize(original)
|
|
739
742
|
const deserialized = Challenge.deserialize(header)
|
|
740
743
|
|
|
741
|
-
expect(deserialized.opaque).
|
|
744
|
+
expect(deserialized.opaque).toBe('eyJkZXBvc2l0IjoiZGVwXzQ1NiIsInBpIjoicGlfM2FiYzEyM1hZWiJ9')
|
|
745
|
+
expect(deserialized.meta).toBeUndefined()
|
|
742
746
|
})
|
|
743
747
|
|
|
744
|
-
test('behavior:
|
|
748
|
+
test('behavior: deserialize does not parse non-json opaque', () => {
|
|
749
|
+
const header =
|
|
750
|
+
'Payment id="abc123", realm="api.example.com", method="tempo", intent="charge", request="eyJhbW91bnQiOiIxMDAwMDAwIn0", opaque="eXkgd3Jvbmc"'
|
|
751
|
+
|
|
752
|
+
const challenge = Challenge.deserialize(header)
|
|
753
|
+
|
|
754
|
+
expect(challenge.opaque).toBe('eXkgd3Jvbmc')
|
|
755
|
+
expect(challenge.meta).toBeUndefined()
|
|
756
|
+
})
|
|
757
|
+
|
|
758
|
+
test('behavior: meta with empty object produces opaque string and meta: {}', () => {
|
|
745
759
|
const challenge = Challenge.from({
|
|
746
760
|
id: 'abc123',
|
|
747
761
|
realm: 'api.example.com',
|
|
@@ -751,7 +765,8 @@ describe('opaque', () => {
|
|
|
751
765
|
meta: {},
|
|
752
766
|
})
|
|
753
767
|
|
|
754
|
-
expect(challenge.
|
|
768
|
+
expect(challenge.meta).toEqual({})
|
|
769
|
+
expect(challenge.opaque).toBe('e30')
|
|
755
770
|
})
|
|
756
771
|
|
|
757
772
|
test('hmac: opaque affects challenge ID', () => {
|
|
@@ -844,7 +859,8 @@ describe('opaque', () => {
|
|
|
844
859
|
|
|
845
860
|
const tampered = {
|
|
846
861
|
...challenge,
|
|
847
|
-
|
|
862
|
+
meta: { pi: 'pi_TAMPERED' },
|
|
863
|
+
opaque: 'eyJwaSI6InBpX1RBTVBFUkVEIn0',
|
|
848
864
|
}
|
|
849
865
|
expect(Challenge.verify(tampered, { secretKey: 'my-secret' })).toBe(false)
|
|
850
866
|
})
|
|
@@ -863,7 +879,7 @@ describe('opaque', () => {
|
|
|
863
879
|
},
|
|
864
880
|
})
|
|
865
881
|
|
|
866
|
-
expect(challenge.
|
|
882
|
+
expect(challenge.meta).toEqual({
|
|
867
883
|
payment_intent: 'pi_3abc123XYZ',
|
|
868
884
|
customer: 'cus_xyz',
|
|
869
885
|
session_id: 'sess_abc',
|
package/src/Challenge.ts
CHANGED
|
@@ -29,8 +29,10 @@ export const Schema = z.object({
|
|
|
29
29
|
intent: z.string(),
|
|
30
30
|
/** Payment method (e.g., "tempo", "stripe"). */
|
|
31
31
|
method: z.string(),
|
|
32
|
-
/** Optional server-defined correlation data.
|
|
33
|
-
|
|
32
|
+
/** Optional parsed server-defined correlation data. */
|
|
33
|
+
meta: z.optional(z.record(z.string(), z.string())),
|
|
34
|
+
/** Optional server-defined correlation data. Raw base64url string; clients MUST NOT modify. */
|
|
35
|
+
opaque: z.optional(z.string()),
|
|
34
36
|
/** Server realm (e.g., hostname). */
|
|
35
37
|
realm: z.string(),
|
|
36
38
|
/** Method-specific request data. */
|
|
@@ -128,8 +130,13 @@ export function from<
|
|
|
128
130
|
} = parameters
|
|
129
131
|
|
|
130
132
|
const expires = parameters.expires as string
|
|
133
|
+
const opaque =
|
|
134
|
+
parameters.opaque ?? (meta !== undefined ? PaymentRequest.serialize(meta) : undefined)
|
|
131
135
|
const id = secretKey
|
|
132
|
-
? computeId(
|
|
136
|
+
? computeId(
|
|
137
|
+
{ ...parameters, expires, ...(meta !== undefined && { meta }), opaque },
|
|
138
|
+
{ secretKey },
|
|
139
|
+
)
|
|
133
140
|
: (parameters as { id: string }).id
|
|
134
141
|
|
|
135
142
|
return Schema.parse({
|
|
@@ -141,7 +148,8 @@ export function from<
|
|
|
141
148
|
...(description && { description }),
|
|
142
149
|
...(digest && { digest }),
|
|
143
150
|
...(expires && { expires }),
|
|
144
|
-
...(meta && {
|
|
151
|
+
...(meta !== undefined && { meta }),
|
|
152
|
+
...(opaque !== undefined && { opaque }),
|
|
145
153
|
}) as from.ReturnType<parameters, methods>
|
|
146
154
|
}
|
|
147
155
|
|
|
@@ -170,6 +178,8 @@ export declare namespace from {
|
|
|
170
178
|
intent: string
|
|
171
179
|
/** Optional server-defined correlation data (serialized as `opaque` on the challenge). Flat string-to-string map; clients MUST NOT modify. */
|
|
172
180
|
meta?: Record<string, string> | undefined
|
|
181
|
+
/** Optional raw base64url-encoded server-defined correlation data. Clients MUST NOT modify. */
|
|
182
|
+
opaque?: string | undefined
|
|
173
183
|
/** Payment method (e.g., "tempo", "stripe"). */
|
|
174
184
|
method: string
|
|
175
185
|
/** Server realm (e.g., hostname). */
|
|
@@ -294,8 +304,9 @@ export function serialize(challenge: Challenge): string {
|
|
|
294
304
|
if (challenge.description !== undefined) parts.push(`description="${challenge.description}"`)
|
|
295
305
|
if (challenge.digest !== undefined) parts.push(`digest="${challenge.digest}"`)
|
|
296
306
|
if (challenge.expires !== undefined) parts.push(`expires="${challenge.expires}"`)
|
|
297
|
-
if (challenge.opaque !== undefined)
|
|
298
|
-
|
|
307
|
+
if (challenge.opaque !== undefined) parts.push(`opaque="${challenge.opaque}"`)
|
|
308
|
+
else if (challenge.meta !== undefined)
|
|
309
|
+
parts.push(`opaque="${PaymentRequest.serialize(challenge.meta)}"`)
|
|
299
310
|
|
|
300
311
|
return `Payment ${parts.join(', ')}`
|
|
301
312
|
}
|
|
@@ -335,7 +346,7 @@ export function deserialize<const methods extends readonly Method.Method[] | und
|
|
|
335
346
|
{
|
|
336
347
|
...rest,
|
|
337
348
|
request: PaymentRequest.deserialize(request),
|
|
338
|
-
...(opaque && {
|
|
349
|
+
...(opaque && { opaque }),
|
|
339
350
|
} as from.Parameters,
|
|
340
351
|
options,
|
|
341
352
|
)
|
|
@@ -604,9 +615,9 @@ export declare namespace verify {
|
|
|
604
615
|
}
|
|
605
616
|
}
|
|
606
617
|
|
|
607
|
-
/**
|
|
618
|
+
/** Extracts parsed server-defined correlation data from a challenge when available. */
|
|
608
619
|
export function meta(challenge: Challenge): Record<string, string> | undefined {
|
|
609
|
-
return challenge.
|
|
620
|
+
return challenge.meta
|
|
610
621
|
}
|
|
611
622
|
|
|
612
623
|
/**
|
|
@@ -631,7 +642,7 @@ function idBindingInput(challenge: Omit<Challenge, 'id'>): string {
|
|
|
631
642
|
PaymentRequest.serialize(challenge.request),
|
|
632
643
|
challenge.expires ?? '',
|
|
633
644
|
challenge.digest ?? '',
|
|
634
|
-
challenge.opaque ? PaymentRequest.serialize(challenge.
|
|
645
|
+
challenge.opaque ?? (challenge.meta ? PaymentRequest.serialize(challenge.meta) : ''),
|
|
635
646
|
].join('|')
|
|
636
647
|
}
|
|
637
648
|
|
package/src/Credential.test.ts
CHANGED
|
@@ -111,6 +111,24 @@ describe('serialize', () => {
|
|
|
111
111
|
|
|
112
112
|
expect(parsed.challenge.opaque).toBe('eyJwaSI6InBpXzNhYmMxMjNYWVoifQ')
|
|
113
113
|
})
|
|
114
|
+
|
|
115
|
+
test('behavior: echoes raw opaque unchanged', () => {
|
|
116
|
+
const rawChallenge = Challenge.deserialize(
|
|
117
|
+
'Payment id="opaque123", realm="api.example.com", method="tempo", intent="charge", request="eyJhbW91bnQiOiIxMDAwIn0", opaque="eXkgd3Jvbmc"',
|
|
118
|
+
)
|
|
119
|
+
const credential = Credential.from({
|
|
120
|
+
challenge: rawChallenge,
|
|
121
|
+
payload: { signature: '0x1234' },
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
const header = Credential.serialize(credential)
|
|
125
|
+
const encoded = header.replace(/^Payment\s+/i, '')
|
|
126
|
+
const parsed = JSON.parse(Base64.toString(encoded)) as {
|
|
127
|
+
challenge: { opaque?: unknown }
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
expect(parsed.challenge.opaque).toBe('eXkgd3Jvbmc')
|
|
131
|
+
})
|
|
114
132
|
})
|
|
115
133
|
|
|
116
134
|
describe('deserialize', () => {
|
|
@@ -175,7 +193,31 @@ describe('deserialize', () => {
|
|
|
175
193
|
|
|
176
194
|
const credential = Credential.deserialize(`Payment ${encoded}`)
|
|
177
195
|
|
|
178
|
-
expect(credential.challenge.opaque).
|
|
196
|
+
expect(credential.challenge.opaque).toBe('eyJwaSI6InBpXzNhYmMxMjNYWVoifQ')
|
|
197
|
+
expect(credential.challenge.meta).toBeUndefined()
|
|
198
|
+
expect(credential.challenge.request).toEqual({ amount: '1000' })
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
test('behavior: preserves non-json opaque string credentials', () => {
|
|
202
|
+
const encoded = Base64.fromString(
|
|
203
|
+
JSON.stringify({
|
|
204
|
+
challenge: {
|
|
205
|
+
id: 'opaque123',
|
|
206
|
+
intent: 'charge',
|
|
207
|
+
method: 'tempo',
|
|
208
|
+
opaque: 'eXkgd3Jvbmc',
|
|
209
|
+
realm: 'api.example.com',
|
|
210
|
+
request: 'eyJhbW91bnQiOiIxMDAwIn0',
|
|
211
|
+
},
|
|
212
|
+
payload: { signature: '0x1234' },
|
|
213
|
+
}),
|
|
214
|
+
{ pad: false, url: true },
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
const credential = Credential.deserialize(`Payment ${encoded}`)
|
|
218
|
+
|
|
219
|
+
expect(credential.challenge.opaque).toBe('eXkgd3Jvbmc')
|
|
220
|
+
expect(credential.challenge.meta).toBeUndefined()
|
|
179
221
|
expect(credential.challenge.request).toEqual({ amount: '1000' })
|
|
180
222
|
})
|
|
181
223
|
|
|
@@ -197,7 +239,27 @@ describe('deserialize', () => {
|
|
|
197
239
|
|
|
198
240
|
const credential = Credential.deserialize(`Payment ${encoded}`)
|
|
199
241
|
|
|
200
|
-
expect(credential.challenge.opaque).
|
|
242
|
+
expect(credential.challenge.opaque).toBe('eyJwaSI6InBpXzNhYmMxMjNYWVoifQ')
|
|
243
|
+
expect(credential.challenge.meta).toEqual({ pi: 'pi_3abc123XYZ' })
|
|
244
|
+
})
|
|
245
|
+
|
|
246
|
+
test('error: rejects legacy object-shaped opaque with non-string values', () => {
|
|
247
|
+
const encoded = Base64.fromString(
|
|
248
|
+
JSON.stringify({
|
|
249
|
+
challenge: {
|
|
250
|
+
id: 'opaque123',
|
|
251
|
+
intent: 'charge',
|
|
252
|
+
method: 'tempo',
|
|
253
|
+
opaque: { pi: 123 },
|
|
254
|
+
realm: 'api.example.com',
|
|
255
|
+
request: 'eyJhbW91bnQiOiIxMDAwIn0',
|
|
256
|
+
},
|
|
257
|
+
payload: { signature: '0x1234' },
|
|
258
|
+
}),
|
|
259
|
+
{ pad: false, url: true },
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
expect(() => Credential.deserialize(`Payment ${encoded}`)).toThrow('Invalid base64url or JSON.')
|
|
201
263
|
})
|
|
202
264
|
|
|
203
265
|
test('error: throws for missing Payment scheme', () => {
|
package/src/Credential.ts
CHANGED
|
@@ -63,27 +63,20 @@ export function deserialize<payload = unknown>(value: string): Credential<payloa
|
|
|
63
63
|
try {
|
|
64
64
|
const json = Base64.toString(prefixMatch[1])
|
|
65
65
|
const parsed = JSON.parse(json) as {
|
|
66
|
-
challenge: Omit<Challenge.Challenge, 'opaque' | 'request'> & {
|
|
67
|
-
opaque?:
|
|
66
|
+
challenge: Omit<Challenge.Challenge, 'meta' | 'opaque' | 'request'> & {
|
|
67
|
+
opaque?: unknown
|
|
68
68
|
request: string
|
|
69
69
|
}
|
|
70
70
|
payload: payload
|
|
71
71
|
source?: string
|
|
72
72
|
}
|
|
73
|
+
const { opaque: challengeOpaque, request, ...challengeFields } = parsed.challenge
|
|
74
|
+
const { meta, opaque } = normalizeCredentialOpaque(challengeOpaque)
|
|
73
75
|
const challenge = Challenge.Schema.parse({
|
|
74
|
-
...
|
|
75
|
-
...(
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
// `opaque` as an expanded JSON object in credentials, but the Payment
|
|
79
|
-
// auth spec requires clients to return the original base64url string
|
|
80
|
-
// unchanged in the credential challenge object.
|
|
81
|
-
opaque:
|
|
82
|
-
typeof parsed.challenge.opaque === 'string'
|
|
83
|
-
? (PaymentRequest.deserialize(parsed.challenge.opaque) as Record<string, string>)
|
|
84
|
-
: parsed.challenge.opaque,
|
|
85
|
-
}),
|
|
86
|
-
request: PaymentRequest.deserialize(parsed.challenge.request),
|
|
76
|
+
...challengeFields,
|
|
77
|
+
...(meta !== undefined && { meta }),
|
|
78
|
+
...(opaque !== undefined && { opaque }),
|
|
79
|
+
request: PaymentRequest.deserialize(request),
|
|
87
80
|
})
|
|
88
81
|
return {
|
|
89
82
|
challenge,
|
|
@@ -95,6 +88,28 @@ export function deserialize<payload = unknown>(value: string): Credential<payloa
|
|
|
95
88
|
}
|
|
96
89
|
}
|
|
97
90
|
|
|
91
|
+
function normalizeCredentialOpaque(value: unknown): {
|
|
92
|
+
meta?: Record<string, string> | undefined
|
|
93
|
+
opaque?: string | undefined
|
|
94
|
+
} {
|
|
95
|
+
if (value === undefined) return {}
|
|
96
|
+
if (typeof value === 'string') return { opaque: value }
|
|
97
|
+
if (!isStringRecord(value)) throw new Error('Invalid opaque.')
|
|
98
|
+
|
|
99
|
+
// Older mppx clients emitted credential challenge `opaque` as an expanded
|
|
100
|
+
// object. Keep accepting that legacy shape, but normalize it back to the
|
|
101
|
+
// spec-required base64url string.
|
|
102
|
+
return {
|
|
103
|
+
meta: value,
|
|
104
|
+
opaque: PaymentRequest.serialize(value),
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function isStringRecord(value: unknown): value is Record<string, string> {
|
|
109
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) return false
|
|
110
|
+
return Object.values(value).every((entry) => typeof entry === 'string')
|
|
111
|
+
}
|
|
112
|
+
|
|
98
113
|
/**
|
|
99
114
|
* Creates a credential from the given parameters.
|
|
100
115
|
*
|
|
@@ -156,8 +171,8 @@ export function fromRequest<payload = unknown>(request: Request): Credential<pay
|
|
|
156
171
|
|
|
157
172
|
/**
|
|
158
173
|
* Serializes a credential to the Authorization header format.
|
|
159
|
-
* When present, `challenge.opaque` is
|
|
160
|
-
* by the Payment auth credential format.
|
|
174
|
+
* When present, `challenge.opaque` is emitted unchanged as the base64url string
|
|
175
|
+
* required by the Payment auth credential format.
|
|
161
176
|
*
|
|
162
177
|
* @param credential - The credential to serialize.
|
|
163
178
|
* @returns A string suitable for the Authorization header value.
|
|
@@ -171,11 +186,12 @@ export function fromRequest<payload = unknown>(request: Request): Credential<pay
|
|
|
171
186
|
* ```
|
|
172
187
|
*/
|
|
173
188
|
export function serialize(credential: Credential): string {
|
|
174
|
-
const { opaque, request, ...challenge } = credential.challenge
|
|
189
|
+
const { meta, opaque, request, ...challenge } = credential.challenge
|
|
190
|
+
const wireOpaque = opaque ?? (meta !== undefined ? PaymentRequest.serialize(meta) : undefined)
|
|
175
191
|
const wire = {
|
|
176
192
|
challenge: {
|
|
177
193
|
...challenge,
|
|
178
|
-
...(
|
|
194
|
+
...(wireOpaque !== undefined && { opaque: wireOpaque }),
|
|
179
195
|
request: PaymentRequest.serialize(request),
|
|
180
196
|
},
|
|
181
197
|
payload: credential.payload,
|
|
@@ -180,7 +180,7 @@ describe('scope binding', () => {
|
|
|
180
180
|
expect(challengeResponse.status).toBe(402)
|
|
181
181
|
|
|
182
182
|
const challenge = Challenge.fromResponse(challengeResponse)
|
|
183
|
-
expect(challenge.opaque).
|
|
183
|
+
expect(challenge.opaque).toBe('eyJfbXBweF9zY29wZSI6IkdFVCAvYWxwaGEvOmlkIn0')
|
|
184
184
|
|
|
185
185
|
const credential = Credential.from({ challenge, payload: { token: 'valid' } })
|
|
186
186
|
const replay = await fetch(`${server.url}/beta/1`, {
|
|
@@ -207,7 +207,7 @@ describe('scope binding', () => {
|
|
|
207
207
|
expect(challengeResponse.status).toBe(402)
|
|
208
208
|
|
|
209
209
|
const challenge = Challenge.fromResponse(challengeResponse)
|
|
210
|
-
expect(challenge.opaque).
|
|
210
|
+
expect(challenge.opaque).toBe('eyJfbXBweF9zY29wZSI6InNoYXJlZC1zY29wZSJ9')
|
|
211
211
|
|
|
212
212
|
const credential = Credential.from({ challenge, payload: { token: 'valid' } })
|
|
213
213
|
const replay = await fetch(`${server.url}/beta/2`, {
|
package/src/proxy/Proxy.test.ts
CHANGED
|
@@ -771,7 +771,7 @@ describe('create', () => {
|
|
|
771
771
|
expect(challengeResponse.status).toBe(402)
|
|
772
772
|
|
|
773
773
|
const challenge = Challenge.fromResponse(challengeResponse)
|
|
774
|
-
expect(challenge.opaque).
|
|
774
|
+
expect(challenge.opaque).toBe('eyJfbXBweF9zY29wZSI6IkdFVCAvYXBpL3YxL2FscGhhIn0')
|
|
775
775
|
|
|
776
776
|
const credential = Credential.from({ challenge, payload: { token: 'valid' } })
|
|
777
777
|
const replay = await fetch(`${proxyServer.url}/api/v1/beta`, {
|
|
@@ -813,7 +813,7 @@ describe('create', () => {
|
|
|
813
813
|
expect(challengeResponse.status).toBe(402)
|
|
814
814
|
|
|
815
815
|
const challenge = Challenge.fromResponse(challengeResponse)
|
|
816
|
-
expect(challenge.opaque).
|
|
816
|
+
expect(challenge.opaque).toBe('eyJfbXBweF9zY29wZSI6InNoYXJlZC1zY29wZSJ9')
|
|
817
817
|
|
|
818
818
|
const credential = Credential.from({ challenge, payload: { token: 'valid' } })
|
|
819
819
|
const replay = await fetch(`${proxyServer.url}/api/v1/beta`, {
|
package/src/server/Mppx.test.ts
CHANGED
|
@@ -588,7 +588,7 @@ describe('request handler', () => {
|
|
|
588
588
|
method: rest.method,
|
|
589
589
|
intent: rest.intent,
|
|
590
590
|
request: rest.request,
|
|
591
|
-
...(rest.
|
|
591
|
+
...(rest.meta && { meta: rest.meta }),
|
|
592
592
|
})
|
|
593
593
|
|
|
594
594
|
const credential = Credential.from({
|
|
@@ -1413,8 +1413,8 @@ describe('compose', () => {
|
|
|
1413
1413
|
|
|
1414
1414
|
const challenges = Challenge.fromResponseList(firstResult.challenge)
|
|
1415
1415
|
expect(challenges).toHaveLength(2)
|
|
1416
|
-
expect(challenges[0]?.opaque).
|
|
1417
|
-
expect(challenges[1]?.opaque).
|
|
1416
|
+
expect(challenges[0]?.opaque).toBe('eyJyb3V0ZSI6ImEifQ')
|
|
1417
|
+
expect(challenges[1]?.opaque).toBe('eyJyb3V0ZSI6ImIifQ')
|
|
1418
1418
|
|
|
1419
1419
|
const secondChallenge = challenges[1]!
|
|
1420
1420
|
const credential = Credential.from({
|
|
@@ -2052,8 +2052,8 @@ describe('cross-route credential replay via scope binding flaw', () => {
|
|
|
2052
2052
|
const routeAChallenge = Challenge.fromResponse(routeAChallengeResult.challenge)
|
|
2053
2053
|
const routeBChallenge = Challenge.fromResponse(routeBChallengeResult.challenge)
|
|
2054
2054
|
|
|
2055
|
-
expect(routeAChallenge.opaque).
|
|
2056
|
-
expect(routeBChallenge.opaque).
|
|
2055
|
+
expect(routeAChallenge.opaque).toBe('eyJyb3V0ZSI6ImEifQ')
|
|
2056
|
+
expect(routeBChallenge.opaque).toBe('eyJyb3V0ZSI6ImIifQ')
|
|
2057
2057
|
|
|
2058
2058
|
const credential = Credential.from({
|
|
2059
2059
|
challenge: routeAChallenge,
|
|
@@ -2133,7 +2133,7 @@ describe('cross-route credential replay via scope binding flaw', () => {
|
|
|
2133
2133
|
if (routeAChallengeResult.status !== 402) throw new Error()
|
|
2134
2134
|
|
|
2135
2135
|
const routeAChallenge = Challenge.fromResponse(routeAChallengeResult.challenge)
|
|
2136
|
-
expect(routeAChallenge.opaque).
|
|
2136
|
+
expect(routeAChallenge.opaque).toBe('eyJfbXBweF9zY29wZSI6IkdFVCAvYSJ9')
|
|
2137
2137
|
|
|
2138
2138
|
const credential = Credential.from({
|
|
2139
2139
|
challenge: routeAChallenge,
|
|
@@ -3129,7 +3129,7 @@ describe('challenge', () => {
|
|
|
3129
3129
|
})
|
|
3130
3130
|
|
|
3131
3131
|
expect(challenge.description).toBe('Order #123')
|
|
3132
|
-
expect(challenge.
|
|
3132
|
+
expect(challenge.meta).toEqual({ checkout_id: 'chk_abc' })
|
|
3133
3133
|
})
|
|
3134
3134
|
|
|
3135
3135
|
test('challenge binds scope via reserved opaque metadata', async () => {
|
|
@@ -3144,7 +3144,7 @@ describe('challenge', () => {
|
|
|
3144
3144
|
scope: 'GET /premium',
|
|
3145
3145
|
})
|
|
3146
3146
|
|
|
3147
|
-
expect(challenge.
|
|
3147
|
+
expect(challenge.meta).toEqual({ _mppx_scope: 'GET /premium' })
|
|
3148
3148
|
})
|
|
3149
3149
|
|
|
3150
3150
|
test('scope throws when it conflicts with reserved meta scope', async () => {
|
|
@@ -3420,6 +3420,26 @@ describe('verifyCredential', () => {
|
|
|
3420
3420
|
expect(receipt.method).toBe('alpha')
|
|
3421
3421
|
})
|
|
3422
3422
|
|
|
3423
|
+
test('verifies a parsed raw-opaque credential object when the expected scope matches', async () => {
|
|
3424
|
+
const mppx = Mppx.create({
|
|
3425
|
+
methods: [alphaChargeServer],
|
|
3426
|
+
realm,
|
|
3427
|
+
secretKey,
|
|
3428
|
+
})
|
|
3429
|
+
|
|
3430
|
+
const challenge = await mppx.challenge.alpha.charge({
|
|
3431
|
+
...challengeOpts,
|
|
3432
|
+
scope: 'GET /premium',
|
|
3433
|
+
})
|
|
3434
|
+
const rawChallenge = Challenge.deserialize(Challenge.serialize(challenge))
|
|
3435
|
+
const credential = Credential.from({ challenge: rawChallenge, payload: { token: 'valid' } })
|
|
3436
|
+
|
|
3437
|
+
const receipt = await mppx.verifyCredential(credential, { scope: 'GET /premium' })
|
|
3438
|
+
|
|
3439
|
+
expect(receipt.status).toBe('success')
|
|
3440
|
+
expect(receipt.method).toBe('alpha')
|
|
3441
|
+
})
|
|
3442
|
+
|
|
3423
3443
|
test('rejects a credential when the expected scope mismatches', async () => {
|
|
3424
3444
|
const mppx = Mppx.create({
|
|
3425
3445
|
methods: [alphaChargeServer],
|
package/src/server/Mppx.ts
CHANGED
|
@@ -279,7 +279,9 @@ export function create<
|
|
|
279
279
|
input: string | Credential.Credential,
|
|
280
280
|
options?: VerifyCredentialOptions,
|
|
281
281
|
): Promise<Receipt.Receipt> {
|
|
282
|
-
const credential =
|
|
282
|
+
const credential = hydrateCredentialMeta(
|
|
283
|
+
typeof input === 'string' ? Credential.deserialize(input) : input,
|
|
284
|
+
)
|
|
283
285
|
|
|
284
286
|
// HMAC provenance check (secretKey is guaranteed non-null by the guard at the top of create())
|
|
285
287
|
if (!Challenge.verify(credential.challenge, { secretKey: secretKey! }))
|
|
@@ -307,7 +309,7 @@ export function create<
|
|
|
307
309
|
|
|
308
310
|
const expectedMeta = Scope.merge({ meta: options?.meta, scope: options?.scope })
|
|
309
311
|
|
|
310
|
-
if (options?.scope !== undefined && Scope.read(credential.challenge.
|
|
312
|
+
if (options?.scope !== undefined && Scope.read(credential.challenge.meta) !== options.scope) {
|
|
311
313
|
throw new Errors.InvalidChallengeError({
|
|
312
314
|
id: credential.challenge.id,
|
|
313
315
|
reason: "credential scope does not match this route's requirements",
|
|
@@ -438,10 +440,8 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R
|
|
|
438
440
|
// Extract credential once — getCredential may have side effects (e.g. SSE transports).
|
|
439
441
|
const [credential, credentialError] = (() => {
|
|
440
442
|
try {
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
undefined,
|
|
444
|
-
] as const
|
|
443
|
+
const credential = transport.getCredential(input) as Credential.Credential | null
|
|
444
|
+
return [credential ? hydrateCredentialMeta(credential) : null, undefined] as const
|
|
445
445
|
} catch (e) {
|
|
446
446
|
return [null, e as Error] as const
|
|
447
447
|
}
|
|
@@ -851,7 +851,7 @@ function getPinnedChallengeMismatch(
|
|
|
851
851
|
if (actualChallenge[field] !== expectedChallenge[field]) return field
|
|
852
852
|
}
|
|
853
853
|
|
|
854
|
-
if (!opaqueValuesMatch(expectedChallenge.
|
|
854
|
+
if (!opaqueValuesMatch(expectedChallenge.meta, actualChallenge.meta)) return 'opaque'
|
|
855
855
|
|
|
856
856
|
return getPinnedRequestBindingMismatch(
|
|
857
857
|
expectedChallenge.request as Record<string, unknown>,
|
|
@@ -944,6 +944,20 @@ function opaqueValuesMatch(
|
|
|
944
944
|
return isDeepStrictEqual(expected, actual)
|
|
945
945
|
}
|
|
946
946
|
|
|
947
|
+
function hydrateCredentialMeta<payload>(
|
|
948
|
+
credential: Credential.Credential<payload>,
|
|
949
|
+
): Credential.Credential<payload> {
|
|
950
|
+
const { challenge } = credential
|
|
951
|
+
if (challenge.meta !== undefined || challenge.opaque === undefined) return credential
|
|
952
|
+
return {
|
|
953
|
+
...credential,
|
|
954
|
+
challenge: {
|
|
955
|
+
...challenge,
|
|
956
|
+
meta: PaymentRequest.deserialize(challenge.opaque) as Record<string, string>,
|
|
957
|
+
},
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
|
|
947
961
|
type CoreBinding = {
|
|
948
962
|
[field in CoreBindingField]?: string
|
|
949
963
|
}
|
|
@@ -1112,7 +1126,7 @@ export function compose(
|
|
|
1112
1126
|
// Parse the credential to find method+intent for dispatch.
|
|
1113
1127
|
let credential: Credential.Credential | undefined
|
|
1114
1128
|
try {
|
|
1115
|
-
credential = Credential.deserialize(paymentHeader)
|
|
1129
|
+
credential = hydrateCredentialMeta(Credential.deserialize(paymentHeader))
|
|
1116
1130
|
} catch {}
|
|
1117
1131
|
|
|
1118
1132
|
if (credential) {
|
|
@@ -1132,7 +1146,7 @@ export function compose(
|
|
|
1132
1146
|
if (!canonical) return true
|
|
1133
1147
|
return (
|
|
1134
1148
|
!getPinnedRequestBindingMismatch(canonical, credReq) &&
|
|
1135
|
-
opaqueValuesMatch(internal.meta, credential.challenge.
|
|
1149
|
+
opaqueValuesMatch(internal.meta, credential.challenge.meta)
|
|
1136
1150
|
)
|
|
1137
1151
|
})
|
|
1138
1152
|
|