Skip to content

Commit 0d1e548

Browse files
authored
fix: serialize opaque as a credential string (#388)
* fix: serialize opaque in credentials * chore: fix changeset frontmatter * docs: explain opaque compatibility fallback
1 parent 9536014 commit 0d1e548

5 files changed

Lines changed: 147 additions & 4 deletions

File tree

.changeset/funny-cameras-juggle.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'mppx': patch
3+
---
4+
5+
Fixed credential `opaque` serialization to use the spec-compliant base64url string shape, while keeping deserialization backward-compatible with legacy object-shaped credentials.

AGENTS.md

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -124,14 +124,21 @@ Canonical specs live at [tempoxyz/payment-auth-spec](https://github.com/tempoxyz
124124
- **Receipt**: `Payment-Receipt: <base64url>``{ status, method, timestamp, reference }`
125125
- **Encoding**: All JSON payloads use base64url without padding (RFC 4648)
126126

127+
Follow `paymentauth.org` as the source of truth for wire-format details:
128+
129+
- `request` and `opaque` are base64url-encoded JCS JSON on the wire.
130+
- In the credential `challenge` object, `opaque` is a `string`, not an expanded JSON object.
131+
- Clients MUST return `id` unchanged and MUST return `opaque` unchanged when present.
132+
- Challenge binding includes `opaque` as the final optional slot, using an empty string when absent.
133+
127134
### Challenge ID Binding
128135

129136
The challenge `id` is an HMAC-SHA256 over the challenge parameters, cryptographically binding the ID to its contents. This prevents tampering and ensures the server can verify challenge integrity without storing state.
130137

131138
**HMAC input** (concatenated, pipe-delimited):
132139

133140
```
134-
realm | method | intent | request | expires | digest
141+
realm | method | intent | request | expires | digest | opaque
135142
```
136143

137144
**Generation:**

src/Challenge.test.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,29 @@ describe('from', () => {
109109
},
110110
expectedId: 'm39jbWWCIfmfJZSwCfvKFFtBl0Qwf9X4nOmDb21peLA',
111111
},
112+
{
113+
label: 'with opaque',
114+
params: {
115+
realm: 'api.example.com',
116+
method: 'tempo',
117+
intent: 'charge',
118+
request: { amount: '1000000' },
119+
meta: { pi: 'pi_3abc123XYZ' },
120+
},
121+
expectedId: 'rxzKZ2qjXvinqCH96RORTZEPs1KXsA-0AUjrCAPFOWc',
122+
},
123+
{
124+
label: 'with opaque and expires',
125+
params: {
126+
realm: 'api.example.com',
127+
method: 'tempo',
128+
intent: 'charge',
129+
request: { amount: '1000000' },
130+
expires: '2025-01-06T12:00:00Z',
131+
meta: { pi: 'pi_3abc123XYZ' },
132+
},
133+
expectedId: 'KAfoMrA4fnzS1DPWN_cUv_b3_yHxCizdp6OhH7gluMY',
134+
},
112135
{
113136
label: 'with description (not in HMAC input)',
114137
params: {
@@ -150,6 +173,17 @@ describe('from', () => {
150173
},
151174
expectedId: 'yLN7yChAejW9WNmb54HpJIWpdb1WWXeA3_aCx4dxmkU',
152175
},
176+
{
177+
label: 'with empty opaque',
178+
params: {
179+
realm: 'api.example.com',
180+
method: 'tempo',
181+
intent: 'charge',
182+
request: { amount: '1000000' },
183+
meta: {},
184+
},
185+
expectedId: 'vb4IyH-0LdJ3s7L0QAw8jIzcZkyxksPhIvEfmHmzA9k',
186+
},
153187
{
154188
label: 'different realm',
155189
params: {
@@ -180,6 +214,17 @@ describe('from', () => {
180214
},
181215
expectedId: 'aAY7_IEDzsznNYplhOSE8cERQxvjFcT4Lcn-7FHjLVE',
182216
},
217+
{
218+
label: 'with multi-key opaque',
219+
params: {
220+
realm: 'api.example.com',
221+
method: 'tempo',
222+
intent: 'charge',
223+
request: { amount: '1000000' },
224+
meta: { deposit: 'dep_456', pi: 'pi_3abc123XYZ' },
225+
},
226+
expectedId: 'aKskU8sadR5ZuFbUCsIwhO-ENxuVpTw17FdwHEXsJDk',
227+
},
183228
] as const
184229

185230
test.each(hmacVectors)('hmac: $label', ({ params, expectedId }) => {

src/Credential.test.ts

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { Challenge, Credential } from 'mppx'
2+
import { Base64 } from 'ox'
23
import { describe, expect, test } from 'vp/test'
34

45
const challenge = Challenge.from({
@@ -88,6 +89,28 @@ describe('serialize', () => {
8889
const deserialized = Credential.deserialize(header)
8990
expect(deserialized.challenge.request).toEqual({ amount: '1000' })
9091
})
92+
93+
test('behavior: serializes opaque as a base64url string', () => {
94+
const credential = Credential.from({
95+
challenge: Challenge.from({
96+
id: 'opaque123',
97+
intent: 'charge',
98+
meta: { pi: 'pi_3abc123XYZ' },
99+
method: 'tempo',
100+
realm: 'api.example.com',
101+
request: { amount: '1000' },
102+
}),
103+
payload: { signature: '0x1234' },
104+
})
105+
106+
const header = Credential.serialize(credential)
107+
const encoded = header.replace(/^Payment\s+/i, '')
108+
const parsed = JSON.parse(Base64.toString(encoded)) as {
109+
challenge: { opaque?: unknown }
110+
}
111+
112+
expect(parsed.challenge.opaque).toBe('eyJwaSI6InBpXzNhYmMxMjNYWVoifQ')
113+
})
91114
})
92115

93116
describe('deserialize', () => {
@@ -134,6 +157,49 @@ describe('deserialize', () => {
134157
expect(deserialized.source).toBe(original.source)
135158
})
136159

160+
test('behavior: deserializes spec-compliant opaque string credentials', () => {
161+
const encoded = Base64.fromString(
162+
JSON.stringify({
163+
challenge: {
164+
id: 'opaque123',
165+
intent: 'charge',
166+
method: 'tempo',
167+
opaque: 'eyJwaSI6InBpXzNhYmMxMjNYWVoifQ',
168+
realm: 'api.example.com',
169+
request: 'eyJhbW91bnQiOiIxMDAwIn0',
170+
},
171+
payload: { signature: '0x1234' },
172+
}),
173+
{ pad: false, url: true },
174+
)
175+
176+
const credential = Credential.deserialize(`Payment ${encoded}`)
177+
178+
expect(credential.challenge.opaque).toEqual({ pi: 'pi_3abc123XYZ' })
179+
expect(credential.challenge.request).toEqual({ amount: '1000' })
180+
})
181+
182+
test('behavior: preserves legacy object-shaped opaque credentials', () => {
183+
const encoded = Base64.fromString(
184+
JSON.stringify({
185+
challenge: {
186+
id: 'opaque123',
187+
intent: 'charge',
188+
method: 'tempo',
189+
opaque: { pi: 'pi_3abc123XYZ' },
190+
realm: 'api.example.com',
191+
request: 'eyJhbW91bnQiOiIxMDAwIn0',
192+
},
193+
payload: { signature: '0x1234' },
194+
}),
195+
{ pad: false, url: true },
196+
)
197+
198+
const credential = Credential.deserialize(`Payment ${encoded}`)
199+
200+
expect(credential.challenge.opaque).toEqual({ pi: 'pi_3abc123XYZ' })
201+
})
202+
137203
test('error: throws for missing Payment scheme', () => {
138204
expect(() => Credential.deserialize('Bearer abc123')).toThrow('Missing Payment scheme.')
139205
})

src/Credential.ts

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,8 @@ export class InvalidCredentialEncodingError extends Error {
4444

4545
/**
4646
* Deserializes an Authorization header value to a credential.
47+
* Accepts the spec-compliant base64url `opaque` string shape and the legacy
48+
* object-shaped `opaque` form emitted by older mppx versions.
4749
*
4850
* @param header - The Authorization header value.
4951
* @returns The deserialized credential.
@@ -61,12 +63,26 @@ export function deserialize<payload = unknown>(value: string): Credential<payloa
6163
try {
6264
const json = Base64.toString(prefixMatch[1])
6365
const parsed = JSON.parse(json) as {
64-
challenge: Omit<Challenge.Challenge, 'request'> & { request: string }
66+
challenge: Omit<Challenge.Challenge, 'opaque' | 'request'> & {
67+
opaque?: Record<string, string> | string
68+
request: string
69+
}
6570
payload: payload
6671
source?: string
6772
}
6873
const challenge = Challenge.Schema.parse({
6974
...parsed.challenge,
75+
...(parsed.challenge.opaque !== undefined && {
76+
// TODO: Drop the legacy object-shaped `opaque` fallback after old mppx
77+
// clients are no longer in circulation. Older mppx versions echoed
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+
}),
7086
request: PaymentRequest.deserialize(parsed.challenge.request),
7187
})
7288
return {
@@ -140,6 +156,8 @@ export function fromRequest<payload = unknown>(request: Request): Credential<pay
140156

141157
/**
142158
* Serializes a credential to the Authorization header format.
159+
* When present, `challenge.opaque` is encoded as the base64url string required
160+
* by the Payment auth credential format.
143161
*
144162
* @param credential - The credential to serialize.
145163
* @returns A string suitable for the Authorization header value.
@@ -153,10 +171,12 @@ export function fromRequest<payload = unknown>(request: Request): Credential<pay
153171
* ```
154172
*/
155173
export function serialize(credential: Credential): string {
174+
const { opaque, request, ...challenge } = credential.challenge
156175
const wire = {
157176
challenge: {
158-
...credential.challenge,
159-
request: PaymentRequest.serialize(credential.challenge.request),
177+
...challenge,
178+
...(opaque !== undefined && { opaque: PaymentRequest.serialize(opaque) }),
179+
request: PaymentRequest.serialize(request),
160180
},
161181
payload: credential.payload,
162182
...(credential.source && { source: credential.source }),

0 commit comments

Comments
 (0)