Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/funny-cameras-juggle.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'mppx': patch
---

Fixed credential `opaque` serialization to use the spec-compliant base64url string shape, while keeping deserialization backward-compatible with legacy object-shaped credentials.
9 changes: 8 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,14 +124,21 @@ Canonical specs live at [tempoxyz/payment-auth-spec](https://github.com/tempoxyz
- **Receipt**: `Payment-Receipt: <base64url>` → `{ status, method, timestamp, reference }`
- **Encoding**: All JSON payloads use base64url without padding (RFC 4648)

Follow `paymentauth.org` as the source of truth for wire-format details:

- `request` and `opaque` are base64url-encoded JCS JSON on the wire.
- In the credential `challenge` object, `opaque` is a `string`, not an expanded JSON object.
- Clients MUST return `id` unchanged and MUST return `opaque` unchanged when present.
- Challenge binding includes `opaque` as the final optional slot, using an empty string when absent.

### Challenge ID Binding

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.

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

```
realm | method | intent | request | expires | digest
realm | method | intent | request | expires | digest | opaque
```

**Generation:**
Expand Down
45 changes: 45 additions & 0 deletions src/Challenge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,29 @@ describe('from', () => {
},
expectedId: 'm39jbWWCIfmfJZSwCfvKFFtBl0Qwf9X4nOmDb21peLA',
},
{
label: 'with opaque',
params: {
realm: 'api.example.com',
method: 'tempo',
intent: 'charge',
request: { amount: '1000000' },
meta: { pi: 'pi_3abc123XYZ' },
},
expectedId: 'rxzKZ2qjXvinqCH96RORTZEPs1KXsA-0AUjrCAPFOWc',
},
{
label: 'with opaque and expires',
params: {
realm: 'api.example.com',
method: 'tempo',
intent: 'charge',
request: { amount: '1000000' },
expires: '2025-01-06T12:00:00Z',
meta: { pi: 'pi_3abc123XYZ' },
},
expectedId: 'KAfoMrA4fnzS1DPWN_cUv_b3_yHxCizdp6OhH7gluMY',
},
{
label: 'with description (not in HMAC input)',
params: {
Expand Down Expand Up @@ -150,6 +173,17 @@ describe('from', () => {
},
expectedId: 'yLN7yChAejW9WNmb54HpJIWpdb1WWXeA3_aCx4dxmkU',
},
{
label: 'with empty opaque',
params: {
realm: 'api.example.com',
method: 'tempo',
intent: 'charge',
request: { amount: '1000000' },
meta: {},
},
expectedId: 'vb4IyH-0LdJ3s7L0QAw8jIzcZkyxksPhIvEfmHmzA9k',
},
{
label: 'different realm',
params: {
Expand Down Expand Up @@ -180,6 +214,17 @@ describe('from', () => {
},
expectedId: 'aAY7_IEDzsznNYplhOSE8cERQxvjFcT4Lcn-7FHjLVE',
},
{
label: 'with multi-key opaque',
params: {
realm: 'api.example.com',
method: 'tempo',
intent: 'charge',
request: { amount: '1000000' },
meta: { deposit: 'dep_456', pi: 'pi_3abc123XYZ' },
},
expectedId: 'aKskU8sadR5ZuFbUCsIwhO-ENxuVpTw17FdwHEXsJDk',
},
] as const

test.each(hmacVectors)('hmac: $label', ({ params, expectedId }) => {
Expand Down
66 changes: 66 additions & 0 deletions src/Credential.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Challenge, Credential } from 'mppx'
import { Base64 } from 'ox'
import { describe, expect, test } from 'vp/test'

const challenge = Challenge.from({
Expand Down Expand Up @@ -88,6 +89,28 @@ describe('serialize', () => {
const deserialized = Credential.deserialize(header)
expect(deserialized.challenge.request).toEqual({ amount: '1000' })
})

test('behavior: serializes opaque as a base64url string', () => {
const credential = Credential.from({
challenge: Challenge.from({
id: 'opaque123',
intent: 'charge',
meta: { pi: 'pi_3abc123XYZ' },
method: 'tempo',
realm: 'api.example.com',
request: { amount: '1000' },
}),
payload: { signature: '0x1234' },
})

const header = Credential.serialize(credential)
const encoded = header.replace(/^Payment\s+/i, '')
const parsed = JSON.parse(Base64.toString(encoded)) as {
challenge: { opaque?: unknown }
}

expect(parsed.challenge.opaque).toBe('eyJwaSI6InBpXzNhYmMxMjNYWVoifQ')
})
})

describe('deserialize', () => {
Expand Down Expand Up @@ -134,6 +157,49 @@ describe('deserialize', () => {
expect(deserialized.source).toBe(original.source)
})

test('behavior: deserializes spec-compliant opaque string credentials', () => {
const encoded = Base64.fromString(
JSON.stringify({
challenge: {
id: 'opaque123',
intent: 'charge',
method: 'tempo',
opaque: 'eyJwaSI6InBpXzNhYmMxMjNYWVoifQ',
realm: 'api.example.com',
request: 'eyJhbW91bnQiOiIxMDAwIn0',
},
payload: { signature: '0x1234' },
}),
{ pad: false, url: true },
)

const credential = Credential.deserialize(`Payment ${encoded}`)

expect(credential.challenge.opaque).toEqual({ pi: 'pi_3abc123XYZ' })
expect(credential.challenge.request).toEqual({ amount: '1000' })
})

test('behavior: preserves legacy object-shaped opaque credentials', () => {
const encoded = Base64.fromString(
JSON.stringify({
challenge: {
id: 'opaque123',
intent: 'charge',
method: 'tempo',
opaque: { pi: 'pi_3abc123XYZ' },
realm: 'api.example.com',
request: 'eyJhbW91bnQiOiIxMDAwIn0',
},
payload: { signature: '0x1234' },
}),
{ pad: false, url: true },
)

const credential = Credential.deserialize(`Payment ${encoded}`)

expect(credential.challenge.opaque).toEqual({ pi: 'pi_3abc123XYZ' })
})

test('error: throws for missing Payment scheme', () => {
expect(() => Credential.deserialize('Bearer abc123')).toThrow('Missing Payment scheme.')
})
Expand Down
26 changes: 23 additions & 3 deletions src/Credential.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ export class InvalidCredentialEncodingError extends Error {

/**
* Deserializes an Authorization header value to a credential.
* Accepts the spec-compliant base64url `opaque` string shape and the legacy
* object-shaped `opaque` form emitted by older mppx versions.
*
* @param header - The Authorization header value.
* @returns The deserialized credential.
Expand All @@ -61,12 +63,26 @@ export function deserialize<payload = unknown>(value: string): Credential<payloa
try {
const json = Base64.toString(prefixMatch[1])
const parsed = JSON.parse(json) as {
challenge: Omit<Challenge.Challenge, 'request'> & { request: string }
challenge: Omit<Challenge.Challenge, 'opaque' | 'request'> & {
opaque?: Record<string, string> | string
request: string
}
payload: payload
source?: string
}
const challenge = Challenge.Schema.parse({
...parsed.challenge,
...(parsed.challenge.opaque !== undefined && {
// TODO: Drop the legacy object-shaped `opaque` fallback after old mppx
// clients are no longer in circulation. Older mppx versions echoed
// `opaque` as an expanded JSON object in credentials, but the Payment
// auth spec requires clients to return the original base64url string
// unchanged in the credential challenge object.
opaque:
typeof parsed.challenge.opaque === 'string'
? (PaymentRequest.deserialize(parsed.challenge.opaque) as Record<string, string>)
: parsed.challenge.opaque,
}),
request: PaymentRequest.deserialize(parsed.challenge.request),
})
return {
Expand Down Expand Up @@ -140,6 +156,8 @@ export function fromRequest<payload = unknown>(request: Request): Credential<pay

/**
* Serializes a credential to the Authorization header format.
* When present, `challenge.opaque` is encoded as the base64url string required
* by the Payment auth credential format.
*
* @param credential - The credential to serialize.
* @returns A string suitable for the Authorization header value.
Expand All @@ -153,10 +171,12 @@ export function fromRequest<payload = unknown>(request: Request): Credential<pay
* ```
*/
export function serialize(credential: Credential): string {
const { opaque, request, ...challenge } = credential.challenge
const wire = {
challenge: {
...credential.challenge,
request: PaymentRequest.serialize(credential.challenge.request),
...challenge,
...(opaque !== undefined && { opaque: PaymentRequest.serialize(opaque) }),
request: PaymentRequest.serialize(request),
},
payload: credential.payload,
...(credential.source && { source: credential.source }),
Expand Down
Loading