diff --git a/.agents/friction-log/20260820124001-pnpm-checks-abort/friction.md b/.agents/friction-log/20260820124001-pnpm-checks-abort/friction.md new file mode 100644 index 000000000..c644ba64f --- /dev/null +++ b/.agents/friction-log/20260820124001-pnpm-checks-abort/friction.md @@ -0,0 +1,24 @@ +--- +title: 'pnpm checks abort while attempting non-interactive dependency repair' +severity: 'minor' +--- + +## Expected Behavior + +`pnpm check:types` runs the documented type check, or reports a non-interactive dependency problem with an actionable command. + +## Current Behavior + +The command invokes `pnpm install` and aborts with `ERR_PNPM_ABORTED_REMOVE_MODULES_DIR_NO_TTY`, so validation cannot start. + +## Possible Solution + +Avoid implicit interactive dependency repair for check commands, or provide a CI-safe fallback and clear remediation. + +## Minimal Reproducible Example + +Run `pnpm check:types` in a non-TTY workspace where pnpm decides the modules directory needs repair. + +## Context + +This blocked validation of a source change in this repository. diff --git a/.changeset/payment-authorization-header.md b/.changeset/payment-authorization-header.md new file mode 100644 index 000000000..341df274a --- /dev/null +++ b/.changeset/payment-authorization-header.md @@ -0,0 +1,5 @@ +--- +'mppx': patch +--- + +Added a `requiresAuth` server option that used `Payment-Authorization` for Payment credentials. diff --git a/src/Challenge.test.ts b/src/Challenge.test.ts index bc1809d1b..d6e859f62 100644 --- a/src/Challenge.test.ts +++ b/src/Challenge.test.ts @@ -1,4 +1,4 @@ -import { Challenge } from 'mppx' +import { Challenge, Constants } from 'mppx' import { Methods } from 'mppx/tempo' import { describe, expect, test } from 'vp/test' @@ -66,6 +66,41 @@ describe('from', () => { expect(challenge.expires).toBe('2025-01-06T12:00:00.000Z') }) + test('behavior: preserves an alternate credential header', () => { + const challenge = Challenge.from({ + id: 'abc123', + realm: 'api.example.com', + method: 'tempo', + intent: 'charge', + request: { amount: '1000000' }, + header: Constants.Headers.paymentAuthorization, + }) + + expect(Challenge.serialize(challenge)).toContain( + `header="${Constants.Headers.paymentAuthorization}"`, + ) + expect(Challenge.credentialHeader(Challenge.deserialize(Challenge.serialize(challenge)))).toBe( + Constants.Headers.paymentAuthorization, + ) + }) + + test('behavior: omits the default credential header', () => { + const parameters = { + secretKey: 'test-secret-key-test-secret-key-32', + realm: 'api.example.com', + method: 'tempo', + intent: 'charge', + request: { amount: '1000000' }, + } + const challenge = Challenge.from({ ...parameters, header: Constants.Headers.authorization }) + const implicitChallenge = Challenge.from(parameters) + + expect(challenge.header).toBeUndefined() + expect(challenge.id).toBe(implicitChallenge.id) + expect(Challenge.serialize(challenge)).not.toContain('header=') + expect(Challenge.credentialHeader(challenge)).toBe(Constants.Headers.authorization) + }) + test('error: rejects empty id', () => { expect(() => Challenge.from({ diff --git a/src/Challenge.ts b/src/Challenge.ts index 7e1aeaf80..85bb9cec7 100644 --- a/src/Challenge.ts +++ b/src/Challenge.ts @@ -26,6 +26,10 @@ export const Schema = z.object({ digest: z.optional(z.string().check(z.regex(/^sha-256=/, 'Invalid digest format'))), /** Optional expiration timestamp (ISO 8601). */ expires: z.optional(z.datetime()), + /** Optional HTTP field name to carry the payment credential. When omitted, uses Authorization. */ + header: z.optional( + z.string().check(z.regex(/^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/, 'Invalid HTTP header name')), + ), /** Unique challenge identifier (HMAC-bound). */ id: z.string().check(z.minLength(1)), /** Intent type (e.g., "charge", "session"). */ @@ -126,12 +130,18 @@ export function from< digest, meta, method: methodName, + header: suppliedHeader, intent, realm, request, secretKey, } = parameters + // `Authorization` is the implicit protocol default and is intentionally not + // advertised on the wire. This preserves the legacy challenge binding and + // lets servers opt into an alternate credential field explicitly. + const header = isDefaultCredentialHeader(suppliedHeader) ? undefined : suppliedHeader + const expires = parameters.expires ? z.toDatetimeString(parameters.expires) : undefined const opaque = parameters.opaque ?? (meta !== undefined ? PaymentRequest.serialize(meta) : undefined) @@ -151,6 +161,7 @@ export function from< ...(description && { description }), ...(digest && { digest }), ...(expires && { expires }), + ...(header !== undefined && { header }), ...(meta !== undefined && { meta }), ...(opaque !== undefined && { opaque }), }) as from.ReturnType @@ -177,6 +188,8 @@ export declare namespace from { digest?: string | undefined /** Optional expiration timestamp (ISO 8601). */ expires?: z.DatetimeInput | undefined + /** Optional HTTP field name to carry the payment credential. When omitted, uses Authorization. */ + header?: string | undefined /** Intent type (e.g., "charge", "session"). */ intent: string /** Optional server-defined correlation data (serialized as `opaque` on the challenge). Flat string-to-string map; clients MUST NOT modify. */ @@ -236,7 +249,7 @@ export function fromMethod( parameters: fromMethod.Parameters, ): fromMethod.ReturnType { const { name: methodName, intent } = method - const { description, digest, expires, id, meta, realm, secretKey } = parameters + const { description, digest, expires, header, id, meta, realm, secretKey } = parameters const request = PaymentRequest.fromMethod(method, parameters.request) @@ -249,6 +262,7 @@ export function fromMethod( description, digest, expires, + header, meta, } as from.Parameters) as fromMethod.ReturnType } @@ -270,6 +284,8 @@ export declare namespace fromMethod { digest?: string | undefined /** Optional expiration timestamp (ISO 8601). */ expires?: z.DatetimeInput | undefined + /** Optional HTTP field name to carry the payment credential. When omitted, uses Authorization. */ + header?: string | undefined /** Optional server-defined correlation data (serialized as `opaque` on the challenge). Flat string-to-string map; clients MUST NOT modify. */ meta?: Record | undefined /** Server realm (e.g., hostname). */ @@ -308,6 +324,9 @@ export function serialize(challenge: Challenge): string { parts.push(authParam('description', challenge.description)) if (challenge.digest !== undefined) parts.push(authParam('digest', challenge.digest)) if (challenge.expires !== undefined) parts.push(authParam('expires', challenge.expires)) + const credentialHeader = challenge.header + if (credentialHeader !== undefined && !isDefaultCredentialHeader(credentialHeader)) + parts.push(authParam('header', credentialHeader)) if (challenge.opaque !== undefined) parts.push(authParam('opaque', challenge.opaque)) else if (challenge.meta !== undefined) parts.push(authParam('opaque', PaymentRequest.serialize(challenge.meta))) @@ -654,22 +673,39 @@ export function meta(challenge: Challenge): Record | undefined { * of truth for what the challenge ID binds to — used by both `computeId()` * (challenge creation) and `verify()` (credential verification). * - * Slots: realm | method | intent | request | expires | digest | opaque + * Legacy slots: realm | method | intent | request | expires | digest | opaque. + * Challenges advertising a credential header insert it immediately before the + * final opaque slot. * * Because the HMAC covers ALL fields, the server does not need to separately * pin opaque, digest, or expires during verification — any change to those * fields produces a different HMAC and fails the ID comparison. */ function idBindingInput(challenge: Omit): string { - return [ + const values = [ challenge.realm, challenge.method, challenge.intent, PaymentRequest.serialize(challenge.request), challenge.expires ?? '', challenge.digest ?? '', - challenge.opaque ?? (challenge.meta ? PaymentRequest.serialize(challenge.meta) : ''), - ].join('|') + ] + // Keep opaque in the final optional slot required by the Payment auth scheme. + const credentialHeader = challenge.header + if (credentialHeader !== undefined && !isDefaultCredentialHeader(credentialHeader)) + values.push(credentialHeader) + values.push(challenge.opaque ?? (challenge.meta ? PaymentRequest.serialize(challenge.meta) : '')) + return values.join('|') +} + +/** Returns the HTTP field name a client must use for a payment credential. */ +export function credentialHeader(challenge: Challenge): string { + return challenge.header ?? Constants.Headers.authorization +} + +/** Returns whether a credential header is the implicit HTTP authentication default. */ +function isDefaultCredentialHeader(header: string | undefined): boolean { + return header?.toLowerCase() === Constants.Headers.authorization.toLowerCase() } /** @internal Computes HMAC-SHA256 challenge ID from parameters. */ diff --git a/src/Constants.ts b/src/Constants.ts index e21c881c7..d174e90a2 100644 --- a/src/Constants.ts +++ b/src/Constants.ts @@ -2,6 +2,7 @@ export const Headers = { acceptPayment: 'Accept-Payment', authorization: 'Authorization', + paymentAuthorization: 'Payment-Authorization', paymentReceipt: 'Payment-Receipt', paymentSession: 'Payment-Session', paymentSessionSnapshot: 'Payment-Session-Snapshot', diff --git a/src/Credential.test.ts b/src/Credential.test.ts index 945ef6778..7174b2bb6 100644 --- a/src/Credential.test.ts +++ b/src/Credential.test.ts @@ -1,4 +1,4 @@ -import { Challenge, Credential } from 'mppx' +import { Challenge, Constants, Credential } from 'mppx' import { Base64 } from 'ox' import { describe, expect, test } from 'vp/test' @@ -364,6 +364,21 @@ describe('fromRequest', () => { expect(credential.payload).toEqual({ signature: '0x1234' }) }) + test('behavior: extracts a credential from an alternate header', () => { + const request = new Request('https://api.example.com/resource', { + headers: { + [Constants.Headers.paymentAuthorization]: Credential.serialize( + Credential.from({ challenge, payload: {} }), + ), + }, + }) + + expect( + Credential.fromRequest(request, { header: Constants.Headers.paymentAuthorization }).challenge + .id, + ).toBe(challenge.id) + }) + test('error: throws for missing Authorization header', () => { const request = new Request('https://api.example.com/resource') expect(() => Credential.fromRequest(request)).toThrow('Missing Authorization header.') diff --git a/src/Credential.ts b/src/Credential.ts index 2fddd0925..d8b269fdf 100644 --- a/src/Credential.ts +++ b/src/Credential.ts @@ -22,8 +22,8 @@ export type Credential< export class MissingAuthorizationHeaderError extends Error { override readonly name = 'MissingAuthorizationHeaderError' - constructor() { - super('Missing Authorization header.') + constructor(header: string = Constants.Headers.authorization) { + super(`Missing ${header} header.`) } } @@ -44,7 +44,7 @@ export class InvalidCredentialEncodingError extends Error { } /** - * Deserializes an Authorization header value to a credential. + * Deserializes a Payment credential 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. * @@ -151,7 +151,7 @@ export declare namespace from { } /** - * Extracts the credential from a Request's Authorization header. + * Extracts the credential from a Request's configured credential header. * * @param request - The HTTP request. * @returns The deserialized credential. @@ -163,16 +163,26 @@ export declare namespace from { * const credential = Credential.fromRequest(request) * ``` */ -export function fromRequest(request: Request): Credential { - const header = request.headers.get(Constants.Headers.authorization) - if (!header) throw new MissingAuthorizationHeaderError() +export function fromRequest( + request: Request, + options: fromRequest.Options = {}, +): Credential { + const header = request.headers.get(options.header ?? Constants.Headers.authorization) + if (!header) throw new MissingAuthorizationHeaderError(options.header) const payment = extractPaymentScheme(header) if (!payment) throw new MissingPaymentSchemeError() return deserialize(payment) } +export declare namespace fromRequest { + type Options = { + /** HTTP field containing the Payment credential. @default 'Authorization' */ + header?: string | undefined + } +} + /** - * Serializes a credential to the Authorization header format. + * Serializes a credential to the Payment credential header format. * When present, `challenge.opaque` is emitted unchanged as the base64url string * required by the Payment auth credential format. * diff --git a/src/Html.ts b/src/Html.ts index 05d68361e..ffe0bf758 100644 --- a/src/Html.ts +++ b/src/Html.ts @@ -49,7 +49,7 @@ export function init< document.getElementById(data.rootId)?.after(el) }, root: document.getElementById(data.rootId)!, - submit: submitCredential, + submit: (credential) => submitCredential(credential, data.challenge.header), vars, } } diff --git a/src/cli/cli.ts b/src/cli/cli.ts index 35b7cd397..8da6cd02c 100755 --- a/src/cli/cli.ts +++ b/src/cli/cli.ts @@ -598,7 +598,7 @@ const cli = Cli.create('mppx', { // Send credential and get response const credentialHeaders = { ...normalizeHeaders(init.headers), - Authorization: credential, + [Challenge.credentialHeader(challenge)]: credential, } plugin?.prepareCredentialRequest?.({ challenge, credential, headers: credentialHeaders }) diff --git a/src/client/Transport.test.ts b/src/client/Transport.test.ts index 5fb6c3216..29fdb29c4 100644 --- a/src/client/Transport.test.ts +++ b/src/client/Transport.test.ts @@ -1,4 +1,4 @@ -import { Challenge, Credential, Mcp } from 'mppx' +import { Challenge, Constants, Credential, Mcp } from 'mppx' import { Transport } from 'mppx/client' import { Methods } from 'mppx/tempo' import { Header as x402_Header, Types as x402_Types, type PaymentRequired } from 'mppx/x402' @@ -207,6 +207,13 @@ describe('http', () => { expectedValue: Credential.serialize(credential), name: 'Payment auth credential for Payment auth challenge', }, + { + challenge: { ...challenge, header: Constants.Headers.paymentAuthorization }, + credential: Credential.serialize(credential), + expectedHeader: Constants.Headers.paymentAuthorization, + expectedValue: Credential.serialize(credential), + name: 'Payment auth credential for alternate credential header', + }, { challenge, credential: 'custom-credential', diff --git a/src/client/Transport.ts b/src/client/Transport.ts index f725a94d8..d0e217ed2 100644 --- a/src/client/Transport.ts +++ b/src/client/Transport.ts @@ -124,7 +124,7 @@ export function http(): Transport { const protocol = options?.challenge ? protocolForChallenge.get(options.challenge) : undefined const fallback = protocols[0] if (!protocol && !fallback) throw new Error('No protocol to attach the credential.') - return (protocol ?? fallback)!.setCredential(request, credential) + return (protocol ?? fallback)!.setCredential(request, credential, options) }, }) } diff --git a/src/client/internal/protocols/Mpp.ts b/src/client/internal/protocols/Mpp.ts index 98e1cedd5..390a490e4 100644 --- a/src/client/internal/protocols/Mpp.ts +++ b/src/client/internal/protocols/Mpp.ts @@ -3,7 +3,7 @@ import * as Constants from '../../../Constants.js' import type { Protocol } from './Protocol.js' import { paymentRequiredStatus, setCredentialHeader } from './Shared.js' -/** MPP — the native HTTP scheme: a 402 carrying a `WWW-Authenticate` challenge, paid back in `Authorization`. */ +/** MPP — native HTTP Payment authentication. */ export function mpp(): Protocol { return { getChallenges(response) { @@ -14,8 +14,14 @@ export function mpp(): Protocol { return [] return Challenge.fromResponseList(response) }, - setCredential(request, credential) { - return setCredentialHeader(request, Constants.Headers.authorization, credential) + setCredential(request, credential, options) { + return setCredentialHeader( + request, + options?.challenge + ? Challenge.credentialHeader(options.challenge) + : Constants.Headers.authorization, + credential, + ) }, } } diff --git a/src/client/internal/protocols/Protocol.ts b/src/client/internal/protocols/Protocol.ts index 62c8a1654..85d923f1d 100644 --- a/src/client/internal/protocols/Protocol.ts +++ b/src/client/internal/protocols/Protocol.ts @@ -6,5 +6,9 @@ export type Protocol = { /** This protocol's challenges from a response; `[]` when the response isn't its concern. */ getChallenges: (response: Response, request?: RequestInit) => MaybePromise /** Attaches this protocol's credential to a retry request. */ - setCredential: (request: RequestInit, credential: string) => RequestInit + setCredential: ( + request: RequestInit, + credential: string, + options?: { challenge?: Challenge.Challenge | undefined }, + ) => RequestInit } diff --git a/src/client/internal/protocols/Shared.ts b/src/client/internal/protocols/Shared.ts index 361f38f73..61d7b6a9e 100644 --- a/src/client/internal/protocols/Shared.ts +++ b/src/client/internal/protocols/Shared.ts @@ -19,7 +19,12 @@ export function setCredentialHeader( credential: string, ): RequestInit { const headers = new Headers(request.headers) - for (const stale of credentialHeaders) headers.delete(stale) + for (const stale of credentialHeaders) { + // Never erase ordinary application credentials from Authorization. + if (stale === Constants.Headers.authorization && !headers.get(stale)?.startsWith('Payment ')) + continue + headers.delete(stale) + } headers.set(header, credential) return { ...request, headers } } diff --git a/src/proxy/Proxy.ts b/src/proxy/Proxy.ts index 7e01ddfca..a89b8d88a 100644 --- a/src/proxy/Proxy.ts +++ b/src/proxy/Proxy.ts @@ -425,15 +425,20 @@ type PaymentBinding = { } function getPaymentBinding(request: Request): PaymentBinding | null { - try { - const credential = Credential.fromRequest(request) - return { - intent: credential.challenge.intent, - method: credential.challenge.method, + for (const [, value] of request.headers) { + const payment = Credential.extractPaymentScheme(value) + if (!payment) continue + try { + const credential = Credential.deserialize(payment) + return { + intent: credential.challenge.intent, + method: credential.challenge.method, + } + } catch { + // A malformed payment header is handled by the selected route. } - } catch { - return null } + return null } function matchesPaymentBinding(endpoint: unknown, binding: PaymentBinding | null): boolean { diff --git a/src/server/Mppx.test.ts b/src/server/Mppx.test.ts index 9a86bacef..8efd36345 100644 --- a/src/server/Mppx.test.ts +++ b/src/server/Mppx.test.ts @@ -1,6 +1,6 @@ import * as http from 'node:http' -import { Challenge, Credential, Errors, Method, Receipt, z } from 'mppx' +import { Challenge, Constants, Credential, Errors, Method, Receipt, z } from 'mppx' import { Mppx as Mppx_client, session as tempo_session_client, @@ -34,6 +34,22 @@ describe('create', () => { expect(typeof handler.charge).toBe('function') }) + test('uses Payment-Authorization when application authentication is required', async () => { + const handler = Mppx.create({ methods: [method], realm, requiresAuth: true, secretKey }) + + const result = await handler.charge({ + amount: '1000', + currency: asset, + recipient: accounts[0].address, + })(new Request('https://example.com/resource', { headers: { Authorization: 'Bearer token' } })) + + expect(result.status).toBe(402) + if (result.status !== 402) throw new Error() + expect(result.challenge.headers.get(Constants.Headers.wwwAuthenticate)).toContain( + `header="${Constants.Headers.paymentAuthorization}"`, + ) + }) + test('behavior: with mcp transport', () => { const handler = Mppx.create({ methods: [method], realm, secretKey, transport: Transport.mcp() }) diff --git a/src/server/Mppx.ts b/src/server/Mppx.ts index dfddc4085..d07308f5e 100644 --- a/src/server/Mppx.ts +++ b/src/server/Mppx.ts @@ -502,11 +502,13 @@ export function create< >(config: create.Config): Mppx { const { attestation, + requiresAuth, realm = Env.get('realm'), selectOffers, secretKey = Env.get('secretKey'), - transport = Transport.http() as transport, + transport = Transport.http({ requiresAuth }) as transport, } = config + const credentialHeader = requiresAuth ? Constants.Headers.paymentAuthorization : undefined if (!secretKey) { throw new Error( @@ -544,6 +546,7 @@ export function create< const fn = createMethodFn({ ...(verifyAttestation && { attest: verifyAttestation }), authorize: mi.authorize as never, + credentialHeader, defaults: mi.defaults, method: mi, realm, @@ -607,6 +610,7 @@ export function create< for (const mi of methods) { if (!challengeHandlers[mi.name]) challengeHandlers[mi.name] = {} challengeHandlers[mi.name]![mi.alias ?? mi.intent] = createChallengeFn({ + credentialHeader, defaults: mi.defaults, method: mi, realm, @@ -748,6 +752,7 @@ export function create< credential: parsedCredential, defaults: mi.defaults, expires: credential.challenge.expires, + header: credential.challenge.header, meta: expectedMeta, method: mi, realm: expectedRealm, @@ -956,6 +961,8 @@ export declare namespace create { attestation?: transport extends Transport.Http ? Attestation.VerifierMap | undefined : never /** Array of configured methods. @example [tempo()] */ methods: methods + /** Uses `Payment-Authorization` for Payment credentials so `Authorization` remains available for application authentication. */ + requiresAuth?: transport extends Transport.Http ? boolean | undefined : never /** Server realm (e.g., hostname). Resolution order: explicit value > env vars (`MPP_REALM`, `FLY_APP_NAME`, `VERCEL_URL`, etc.) > request URL hostname > `"MPP Payment"`. */ realm?: string | undefined /** Secret key for HMAC-bound challenge IDs for stateless verification. Must be at least 32 bytes. Auto-detected from `MPP_SECRET_KEY` environment variable. */ @@ -981,6 +988,7 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R const { attest, authorize, + credentialHeader, defaults, events, method, @@ -1129,6 +1137,7 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R defaults, description, expires, + header: credentialHeader, meta: effectiveMeta, method, realm, @@ -1142,6 +1151,7 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R defaults: defaults ?? {}, description, expires, + header: credentialHeader, meta: effectiveMeta, method, realm, @@ -1525,13 +1535,14 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R * but returns a Challenge object directly instead of a request handler. */ function createChallengeFn(parameters: { + credentialHeader?: string | undefined defaults?: Record method: Method.Method realm: string | undefined request?: Method.RequestFn secretKey: string }): (options: Record) => Promise { - const { defaults, method, realm, secretKey } = parameters + const { credentialHeader, defaults, method, realm, secretKey } = parameters return async (options) => { const { description, meta, scope, ...rest } = options as { @@ -1551,6 +1562,7 @@ function createChallengeFn(parameters: { defaults, description, expires, + header: credentialHeader, meta: effectiveMeta, method, realm, @@ -1569,6 +1581,7 @@ declare namespace createMethodFn { > = { attest?: (request: globalThis.Request) => Promise authorize?: Method.AuthorizeFn + credentialHeader?: string | undefined defaults?: defaults method: method events: ServerEventDispatcher @@ -1959,6 +1972,7 @@ async function resolveRouteChallenge(parameters: { defaults?: Record | undefined description?: string | undefined expires?: string | undefined + header?: string | undefined meta?: Record | undefined method: Method.Method realm?: string | undefined @@ -1993,6 +2007,7 @@ async function resolveRouteChallenge(parameters: { const challenge = Challenge.fromMethod(parameters.method, { description: parameters.description, expires: parameters.expires, + header: parameters.header, meta: parameters.meta, realm: effectiveRealm, request: request as never, @@ -2011,6 +2026,7 @@ function createFallbackChallenge(parameters: { defaults: Record description?: string | undefined expires?: string | undefined + header?: string | undefined meta?: Record | undefined method: Method.Method realm?: string | undefined @@ -2020,6 +2036,7 @@ function createFallbackChallenge(parameters: { return Challenge.fromMethod(parameters.method, { description: parameters.description, expires: parameters.expires, + header: parameters.header, meta: parameters.meta, realm: parameters.realm ?? @@ -2475,8 +2492,9 @@ function composeHandlers( // Try to extract a Payment credential to decide whether to dispatch or challenge. // Only gate on the Payment scheme — other auth schemes (Bearer, Basic, etc.) // should fall through to the merged-402 path so all offers are presented. - const header = input.headers.get(Constants.Headers.authorization) - const paymentHeader = header ? Credential.extractPaymentScheme(header) : null + const paymentHeader = Array.from(input.headers.values()) + .map((value) => Credential.extractPaymentScheme(value)) + .find((value): value is string => value !== null) if (paymentHeader) { // Parse the credential to find method+intent for dispatch. diff --git a/src/server/Transport.test.ts b/src/server/Transport.test.ts index cace31cfb..65174d1bc 100644 --- a/src/server/Transport.test.ts +++ b/src/server/Transport.test.ts @@ -1,4 +1,4 @@ -import { Challenge, Credential, Mcp, Receipt } from 'mppx' +import { Challenge, Constants, Credential, Mcp, Receipt } from 'mppx' import { Transport } from 'mppx/server' import { Methods } from 'mppx/tempo' import { describe, expect, test } from 'vp/test' @@ -98,6 +98,18 @@ describe('http', () => { expect(transport.getCredential(request)).toBeNull() }) + + test('uses Payment-Authorization when application authentication is required', () => { + const transport = Transport.http({ requiresAuth: true }) + const request = new Request('https://example.com', { + headers: { + Authorization: 'Bearer ordinary-authentication', + [Constants.Headers.paymentAuthorization]: Credential.serialize(credential), + }, + }) + + expect(transport.getCredential(request)?.challenge.id).toBe(challenge.id) + }) }) describe('respondChallenge', () => { diff --git a/src/server/Transport.ts b/src/server/Transport.ts index 6e595e645..08b48f9ee 100644 --- a/src/server/Transport.ts +++ b/src/server/Transport.ts @@ -128,11 +128,14 @@ export function from< /** * HTTP transport for server-side payment handling. * - * - Reads credentials from the `Authorization` header + * - Reads credentials from the configured payment credential header * - Issues challenges via `WWW-Authenticate` header with 402 status * - Attaches receipts via `Payment-Receipt` header */ -export function http(): Http { +export function http(options: http.Options = {}): Http { + const credentialHeader = options.requiresAuth + ? Constants.Headers.paymentAuthorization + : Constants.Headers.authorization return from({ name: 'http', @@ -146,7 +149,7 @@ export function http(): Http { }, getCredential(request) { - const header = request.headers.get(Constants.Headers.authorization) + const header = request.headers.get(credentialHeader) if (!header) return null const payment = Credential.extractPaymentScheme(header) if (!payment) return null @@ -220,6 +223,13 @@ export function http(): Http { }) } +export declare namespace http { + type Options = { + /** Uses `Payment-Authorization` for Payment credentials so `Authorization` remains available for application authentication. */ + requiresAuth?: boolean | undefined + } +} + function withPrivateCacheControl(value: string | null): string { if (!value) return 'private' const directives = value.split(',').map((directive) => directive.trim().toLowerCase()) diff --git a/src/server/internal/html/serviceWorker.client.ts b/src/server/internal/html/serviceWorker.client.ts index 4b0cf1bfc..385b662a3 100644 --- a/src/server/internal/html/serviceWorker.client.ts +++ b/src/server/internal/html/serviceWorker.client.ts @@ -1,6 +1,9 @@ import { params } from './constants.js' -export async function submitCredential(credential: string): Promise { +export async function submitCredential( + credential: string, + header = 'Authorization', +): Promise { const url = new URL(location.href) url.searchParams.set(params.serviceWorker, '') @@ -22,7 +25,7 @@ export async function submitCredential(credential: string): Promise { await new Promise((resolve) => { const channel = new MessageChannel() channel.port1.onmessage = () => resolve() - serviceWorker.postMessage({ credential }, [channel.port2]) + serviceWorker.postMessage({ credential, header }, [channel.port2]) }) location.reload() } diff --git a/src/server/internal/html/serviceWorker.ts b/src/server/internal/html/serviceWorker.ts index 5810ac50a..aaeb9d56e 100644 --- a/src/server/internal/html/serviceWorker.ts +++ b/src/server/internal/html/serviceWorker.ts @@ -1,6 +1,7 @@ const serviceWorker = self as unknown as ServiceWorkerGlobalScope let credential: string | undefined +let credentialHeader = 'Authorization' serviceWorker.addEventListener('activate', (event) => { event.waitUntil(serviceWorker.clients.claim()) @@ -11,6 +12,7 @@ serviceWorker.addEventListener('message', (event) => { const value = event.data?.credential if (typeof value !== 'string' || !value.startsWith('Payment ')) return credential = value + if (typeof event.data?.header === 'string') credentialHeader = event.data.header event.ports[0]?.postMessage('ack') }) @@ -19,7 +21,7 @@ serviceWorker.addEventListener('fetch', (event) => { if (new URL(event.request.url).origin !== serviceWorker.location.origin) return const headers = new Headers(event.request.headers) - headers.set('Authorization', credential) + headers.set(credentialHeader, credential) credential = undefined event.respondWith(fetch(event.request, { headers }))