diff --git a/package.json b/package.json index 8a6c096..4449789 100644 --- a/package.json +++ b/package.json @@ -54,6 +54,11 @@ "require": "./dist/cjs/x402.cjs", "types": "./dist/types/x402.d.ts" }, + "./mpp": { + "import": "./dist/esm/mpp.js", + "require": "./dist/cjs/mpp.cjs", + "types": "./dist/types/mpp.d.ts" + }, "./lnurl": { "import": "./dist/esm/lnurl.js", "require": "./dist/cjs/lnurl.cjs", diff --git a/rollup.config.js b/rollup.config.js index ddf108e..a8cff66 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -22,6 +22,7 @@ const entries = [ { name: "402", input: "src/402/index.ts" }, { name: "l402", input: "src/402/l402/index.ts" }, { name: "x402", input: "src/402/x402/index.ts" }, + { name: "mpp", input: "src/402/mpp/index.ts" }, { name: "lnurl", input: "src/lnurl/index.ts" }, { name: "podcasting2", input: "src/podcasting2/index.ts" }, ]; diff --git a/src/402/fetch402.ts b/src/402/fetch402.ts index e681818..e530b7d 100644 --- a/src/402/fetch402.ts +++ b/src/402/fetch402.ts @@ -2,6 +2,7 @@ import { KVStorage, NoStorage, Wallet } from "./utils"; import { buildX402PaymentSignature } from "./x402/utils"; import { HEADER_KEY, handleL402Payment } from "./l402/l402"; import { handleX402Payment } from "./x402/x402"; +import { handleMppChargePayment } from "./mpp/mpp"; const noStorage = new NoStorage(); @@ -59,10 +60,19 @@ export const fetch402 = async ( headers.set("Accept-Authenticate", HEADER_KEY); const initResp = await fetch(url, fetchArgs); - const l402Header = initResp.headers.get("www-authenticate"); - if (l402Header) { + const wwwAuthHeader = initResp.headers.get("www-authenticate"); + if (wwwAuthHeader) { + if (wwwAuthHeader.trimStart().toLowerCase().startsWith("payment")) { + return handleMppChargePayment( + wwwAuthHeader, + url, + fetchArgs, + headers, + wallet, + ); + } return handleL402Payment( - l402Header, + wwwAuthHeader, url, fetchArgs, headers, diff --git a/src/402/mpp/index.ts b/src/402/mpp/index.ts new file mode 100644 index 0000000..7fb8557 --- /dev/null +++ b/src/402/mpp/index.ts @@ -0,0 +1,2 @@ +export * from "./mpp"; +export * from "./utils"; diff --git a/src/402/mpp/mpp.test.ts b/src/402/mpp/mpp.test.ts new file mode 100644 index 0000000..3f42142 --- /dev/null +++ b/src/402/mpp/mpp.test.ts @@ -0,0 +1,503 @@ +import fetchMock from "jest-fetch-mock"; +import { fetchWithMpp } from "./mpp"; +import { NoStorage } from "../utils"; +import { + buildMppCredential, + decodeBase64url, + encodeMppChargeRequest, + makeMppWwwAuthenticateHeader, + MppChargeRequest, + parseMppChallenge, +} from "./utils"; + +// --------------------------------------------------------------------------- +// Test fixtures +// --------------------------------------------------------------------------- + +const INVOICE = + "lnbc100n1pjkse4mpp5q22x8xdwrmpw0t6cww6sey7fn6klnnr5303vj7h44tr3dm2c9y9qdq8f4f5z4qcqzzsxqyz5vqsp5mmhp6cx4xxysc8xvxaj984eue9pm83lxgezmk3umx6wxr9rrq2ns9qyyssqmmrrwthves6z3d85nafj2ds4z20qju2vpaatep8uwrvxz0xs4kznm99m7f6pmkzax09k2k9saldy34z0p0l8gm0zm5xsmg2g667pnlqp7a0qdz"; +const PREIMAGE = + "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"; +const CHALLENGE_ID = "kM9xPqWvT2nJrHsY4aDfEb"; +const REALM = "api.example.com"; + +const MPP_URL = "https://api.example.com/protected"; + +const CHARGE_REQUEST: MppChargeRequest = { + amount: "10", + currency: "sat", + methodDetails: { + invoice: INVOICE, + network: "mainnet", + }, +}; + +const ENCODED_REQUEST = encodeMppChargeRequest(CHARGE_REQUEST); + +function makeWallet(preimage: string = PREIMAGE) { + return { + payInvoice: jest.fn().mockResolvedValue({ preimage }), + }; +} + +beforeEach(() => { + fetchMock.resetMocks(); +}); + +// --------------------------------------------------------------------------- +// parseMppChallenge +// --------------------------------------------------------------------------- +describe("parseMppChallenge", () => { + test("parses a standard Payment header", () => { + const header = makeMppWwwAuthenticateHeader({ + id: CHALLENGE_ID, + realm: REALM, + request: ENCODED_REQUEST, + }); + const result = parseMppChallenge(header); + expect(result).toEqual({ + id: CHALLENGE_ID, + realm: REALM, + method: "lightning", + intent: "charge", + request: ENCODED_REQUEST, + }); + }); + + test("parses optional expires field", () => { + const expires = "2026-12-31T23:59:59Z"; + const header = makeMppWwwAuthenticateHeader({ + id: CHALLENGE_ID, + realm: REALM, + request: ENCODED_REQUEST, + expires, + }); + const result = parseMppChallenge(header); + expect(result?.expires).toBe(expires); + }); + + test("returns null for non-Payment header (e.g. L402)", () => { + expect( + parseMppChallenge(`L402 macaroon="tok", invoice="${INVOICE}"`), + ).toBeNull(); + }); + + test("returns null for wrong method", () => { + const header = `Payment id="${CHALLENGE_ID}", realm="${REALM}", method="onchain", intent="charge", request="${ENCODED_REQUEST}"`; + expect(parseMppChallenge(header)).toBeNull(); + }); + + test("returns null for wrong intent", () => { + const header = `Payment id="${CHALLENGE_ID}", realm="${REALM}", method="lightning", intent="subscribe", request="${ENCODED_REQUEST}"`; + expect(parseMppChallenge(header)).toBeNull(); + }); + + test("returns null when id is missing", () => { + const header = `Payment realm="${REALM}", method="lightning", intent="charge", request="${ENCODED_REQUEST}"`; + expect(parseMppChallenge(header)).toBeNull(); + }); + + test("returns null when realm is missing", () => { + const header = `Payment id="${CHALLENGE_ID}", method="lightning", intent="charge", request="${ENCODED_REQUEST}"`; + expect(parseMppChallenge(header)).toBeNull(); + }); + + test("returns null when request is missing", () => { + const header = `Payment id="${CHALLENGE_ID}", realm="${REALM}", method="lightning", intent="charge"`; + expect(parseMppChallenge(header)).toBeNull(); + }); +}); + +// --------------------------------------------------------------------------- +// buildMppCredential +// --------------------------------------------------------------------------- +describe("buildMppCredential", () => { + test("produces a base64url-encoded JCS credential", () => { + const challenge = { + id: CHALLENGE_ID, + realm: REALM, + method: "lightning", + intent: "charge", + request: ENCODED_REQUEST, + }; + const credential = buildMppCredential(challenge, PREIMAGE); + + // Must not contain base64 padding + expect(credential).not.toMatch(/=/); + // Must use base64url alphabet + expect(credential).toMatch(/^[A-Za-z0-9_-]+$/); + + const decoded = JSON.parse(decodeBase64url(credential)); + expect(decoded.challenge.id).toBe(CHALLENGE_ID); + expect(decoded.challenge.realm).toBe(REALM); + expect(decoded.challenge.intent).toBe("charge"); + expect(decoded.challenge.method).toBe("lightning"); + expect(decoded.challenge.request).toBe(ENCODED_REQUEST); + expect(decoded.payload.preimage).toBe(PREIMAGE); + }); + + test("challenge keys are sorted lexicographically (JCS)", () => { + const challenge = { + id: CHALLENGE_ID, + realm: REALM, + method: "lightning", + intent: "charge", + request: ENCODED_REQUEST, + expires: "2026-12-31T23:59:59Z", + }; + const credential = buildMppCredential(challenge, PREIMAGE); + const decoded = JSON.parse(decodeBase64url(credential)); + + const challengeKeys = Object.keys(decoded.challenge); + expect(challengeKeys).toEqual([...challengeKeys].sort()); + }); + + test("top-level credential keys are sorted lexicographically (JCS)", () => { + const challenge = { + id: CHALLENGE_ID, + realm: REALM, + method: "lightning", + intent: "charge", + request: ENCODED_REQUEST, + }; + const credential = buildMppCredential(challenge, PREIMAGE); + const decoded = JSON.parse(decodeBase64url(credential)); + + const topKeys = Object.keys(decoded); + expect(topKeys).toEqual([...topKeys].sort()); + }); + + test("includes optional source field when provided", () => { + const challenge = { + id: CHALLENGE_ID, + realm: REALM, + method: "lightning", + intent: "charge", + request: ENCODED_REQUEST, + }; + const source = "did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK"; + const credential = buildMppCredential(challenge, PREIMAGE, source); + const decoded = JSON.parse(decodeBase64url(credential)); + expect(decoded.source).toBe(source); + }); + + test("includes optional expires in echoed challenge", () => { + const expires = "2026-12-31T23:59:59Z"; + const challenge = { + id: CHALLENGE_ID, + realm: REALM, + method: "lightning", + intent: "charge", + request: ENCODED_REQUEST, + expires, + }; + const credential = buildMppCredential(challenge, PREIMAGE); + const decoded = JSON.parse(decodeBase64url(credential)); + expect(decoded.challenge.expires).toBe(expires); + }); +}); + +// --------------------------------------------------------------------------- +// fetchWithMpp +// --------------------------------------------------------------------------- +describe("fetchWithMpp", () => { + test("returns initial response when no www-authenticate header", async () => { + const wallet = makeWallet(); + + fetchMock.mockResponseOnce(JSON.stringify({ data: "free content" }), { + status: 200, + }); + + const response = await fetchWithMpp(MPP_URL, {}, { wallet }); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ data: "free content" }); + expect(wallet.payInvoice).not.toHaveBeenCalled(); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + test("returns initial response when www-authenticate is not Payment (e.g. L402)", async () => { + const wallet = makeWallet(); + + fetchMock.mockResponseOnce("Payment Required", { + status: 402, + headers: { + "www-authenticate": `L402 macaroon="tok123", invoice="${INVOICE}"`, + }, + }); + + const response = await fetchWithMpp(MPP_URL, {}, { wallet }); + + // Returns the 402 response without attempting payment + expect(response.status).toBe(402); + expect(wallet.payInvoice).not.toHaveBeenCalled(); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + test("pays invoice and retries on Payment lightning/charge challenge", async () => { + const wallet = makeWallet(); + + fetchMock.mockResponseOnce("Payment Required", { + status: 402, + headers: { + "www-authenticate": makeMppWwwAuthenticateHeader({ + id: CHALLENGE_ID, + realm: REALM, + request: ENCODED_REQUEST, + }), + }, + }); + fetchMock.mockResponseOnce(JSON.stringify({ data: "paid content" }), { + status: 200, + }); + + const response = await fetchWithMpp(MPP_URL, {}, { wallet }); + + expect(wallet.payInvoice).toHaveBeenCalledTimes(1); + expect(wallet.payInvoice).toHaveBeenCalledWith({ invoice: INVOICE }); + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ data: "paid content" }); + }); + + test("sets correct Authorization: Payment header on retry", async () => { + const wallet = makeWallet(); + + fetchMock.mockResponseOnce("Payment Required", { + status: 402, + headers: { + "www-authenticate": makeMppWwwAuthenticateHeader({ + id: CHALLENGE_ID, + realm: REALM, + request: ENCODED_REQUEST, + }), + }, + }); + fetchMock.mockResponseOnce(JSON.stringify({ ok: true }), { status: 200 }); + + await fetchWithMpp(MPP_URL, {}, { wallet }); + + const secondCallInit = fetchMock.mock.calls[1][1] as RequestInit; + const headers = secondCallInit.headers as Headers; + const authHeader = headers.get("Authorization")!; + + // Must be "Payment " — no credential="" wrapper + expect(authHeader).toMatch(/^Payment [A-Za-z0-9_-]+$/); + + const token = authHeader.replace(/^Payment /, ""); + const decoded = JSON.parse(decodeBase64url(token)); + + expect(decoded.challenge.id).toBe(CHALLENGE_ID); + expect(decoded.challenge.realm).toBe(REALM); + expect(decoded.challenge.method).toBe("lightning"); + expect(decoded.challenge.intent).toBe("charge"); + expect(decoded.challenge.request).toBe(ENCODED_REQUEST); + expect(decoded.payload.preimage).toBe(PREIMAGE); + }); + + test("echoes expires in credential challenge when present in header", async () => { + const wallet = makeWallet(); + const expires = "2026-12-31T23:59:59Z"; + + fetchMock.mockResponseOnce("Payment Required", { + status: 402, + headers: { + "www-authenticate": makeMppWwwAuthenticateHeader({ + id: CHALLENGE_ID, + realm: REALM, + request: ENCODED_REQUEST, + expires, + }), + }, + }); + fetchMock.mockResponseOnce(JSON.stringify({ ok: true }), { status: 200 }); + + await fetchWithMpp(MPP_URL, {}, { wallet }); + + const secondCallInit = fetchMock.mock.calls[1][1] as RequestInit; + const headers = secondCallInit.headers as Headers; + const token = headers.get("Authorization")!.replace(/^Payment /, ""); + const decoded = JSON.parse(decodeBase64url(token)); + + expect(decoded.challenge.expires).toBe(expires); + }); + + test("sets cache to no-store and mode to cors", async () => { + const wallet = makeWallet(); + + fetchMock.mockResponseOnce(JSON.stringify({ ok: true }), { status: 200 }); + + await fetchWithMpp(MPP_URL, {}, { wallet }); + + const fetchInit = fetchMock.mock.calls[0][1] as RequestInit; + expect(fetchInit.cache).toBe("no-store"); + expect(fetchInit.mode).toBe("cors"); + }); + + test("passes custom fetchArgs through to all fetch calls", async () => { + const wallet = makeWallet(); + + fetchMock.mockResponseOnce("Payment Required", { + status: 402, + headers: { + "www-authenticate": makeMppWwwAuthenticateHeader({ + id: CHALLENGE_ID, + realm: REALM, + request: ENCODED_REQUEST, + }), + }, + }); + fetchMock.mockResponseOnce(JSON.stringify({ ok: true }), { status: 200 }); + + await fetchWithMpp( + MPP_URL, + { method: "POST", headers: { "X-Custom": "value" } }, + { wallet }, + ); + + for (const call of fetchMock.mock.calls) { + const fetchInit = call[1] as RequestInit; + const headers = fetchInit.headers as Headers; + expect(fetchInit.method).toBe("POST"); + expect(headers.get("X-Custom")).toBe("value"); + } + }); + + test("propagates wallet.payInvoice errors", async () => { + const wallet = { + payInvoice: jest.fn().mockRejectedValue(new Error("payment failed")), + }; + + fetchMock.mockResponseOnce("Payment Required", { + status: 402, + headers: { + "www-authenticate": makeMppWwwAuthenticateHeader({ + id: CHALLENGE_ID, + realm: REALM, + request: ENCODED_REQUEST, + }), + }, + }); + + await expect(fetchWithMpp(MPP_URL, {}, { wallet })).rejects.toThrow( + "payment failed", + ); + }); + + test("works with NoStorage option (accepted for API compat)", async () => { + const wallet = makeWallet(); + const store = new NoStorage(); + + fetchMock.mockResponseOnce("Payment Required", { + status: 402, + headers: { + "www-authenticate": makeMppWwwAuthenticateHeader({ + id: CHALLENGE_ID, + realm: REALM, + request: ENCODED_REQUEST, + }), + }, + }); + fetchMock.mockResponseOnce(JSON.stringify({ ok: true }), { status: 200 }); + + const response = await fetchWithMpp(MPP_URL, {}, { wallet, store }); + expect(response.status).toBe(200); + expect(wallet.payInvoice).toHaveBeenCalledTimes(1); + }); + + test("throws when challenge is invalid (wrong method/intent)", async () => { + const wallet = makeWallet(); + + fetchMock.mockResponseOnce("Payment Required", { + status: 402, + headers: { + "www-authenticate": `Payment id="${CHALLENGE_ID}", realm="${REALM}", method="onchain", intent="charge", request="${ENCODED_REQUEST}"`, + }, + }); + + await expect(fetchWithMpp(MPP_URL, {}, { wallet })).rejects.toThrow( + "mpp: invalid or unsupported WWW-Authenticate challenge", + ); + }); + + test("throws when request auth-param is not valid base64url JSON", async () => { + const wallet = makeWallet(); + + fetchMock.mockResponseOnce("Payment Required", { + status: 402, + headers: { + "www-authenticate": `Payment id="${CHALLENGE_ID}", realm="${REALM}", method="lightning", intent="charge", request="not-valid!!!"`, + }, + }); + + await expect(fetchWithMpp(MPP_URL, {}, { wallet })).rejects.toThrow( + "mpp: invalid request auth-param (not valid base64url-encoded JSON)", + ); + }); + + test("throws when invoice is missing from request methodDetails", async () => { + const wallet = makeWallet(); + const badRequest = encodeMppChargeRequest({ + amount: "10", + currency: "sat", + methodDetails: { invoice: "" }, + }); + + fetchMock.mockResponseOnce("Payment Required", { + status: 402, + headers: { + "www-authenticate": makeMppWwwAuthenticateHeader({ + id: CHALLENGE_ID, + realm: REALM, + request: badRequest, + }), + }, + }); + + await expect(fetchWithMpp(MPP_URL, {}, { wallet })).rejects.toThrow( + "mpp: missing invoice in charge request", + ); + }); + + test("pays invoice twice on two sequential calls (consume-once, no caching)", async () => { + const wallet = makeWallet(); + + // First call + fetchMock.mockResponseOnce("Payment Required", { + status: 402, + headers: { + "www-authenticate": makeMppWwwAuthenticateHeader({ + id: CHALLENGE_ID, + realm: REALM, + request: ENCODED_REQUEST, + }), + }, + }); + fetchMock.mockResponseOnce(JSON.stringify({ first: true }), { + status: 200, + }); + + await fetchWithMpp(MPP_URL, {}, { wallet }); + + // Second call — server issues a fresh challenge with a new id + fetchMock.mockResponseOnce("Payment Required", { + status: 402, + headers: { + "www-authenticate": makeMppWwwAuthenticateHeader({ + id: "newId_second_456", + realm: REALM, + request: ENCODED_REQUEST, + }), + }, + }); + fetchMock.mockResponseOnce(JSON.stringify({ second: true }), { + status: 200, + }); + + await fetchWithMpp(MPP_URL, {}, { wallet }); + + // Must have paid twice — no caching of credentials + expect(wallet.payInvoice).toHaveBeenCalledTimes(2); + expect(fetchMock).toHaveBeenCalledTimes(4); + }); +}); diff --git a/src/402/mpp/mpp.ts b/src/402/mpp/mpp.ts new file mode 100644 index 0000000..9ffdb7c --- /dev/null +++ b/src/402/mpp/mpp.ts @@ -0,0 +1,100 @@ +import { KVStorage, NoStorage, Wallet } from "../utils"; +import { + buildMppCredential, + decodeBase64url, + MppChargeRequest, + parseMppChallenge, +} from "./utils"; + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +const noStorage = new NoStorage(); + +/** + * Handle a `WWW-Authenticate: Payment …` challenge produced by a + * draft-lightning-charge-00 server. + * + * Flow: + * 1. Parse the challenge from the header. + * 2. Decode the `request` auth-param to find the BOLT11 invoice. + * 3. Pay the invoice via the wallet; receive the HTLC preimage. + * 4. Build the `Authorization: Payment ` header. + * 5. Retry the original request with the credential. + */ +export const handleMppChargePayment = async ( + wwwAuthHeader: string, + url: string, + fetchArgs: RequestInit, + headers: Headers, + wallet: Wallet, +): Promise => { + const challenge = parseMppChallenge(wwwAuthHeader); + if (!challenge) { + throw new Error( + "mpp: invalid or unsupported WWW-Authenticate challenge (expected Payment method=lightning intent=charge)", + ); + } + + let request: MppChargeRequest; + try { + request = JSON.parse(decodeBase64url(challenge.request)); + } catch (_) { + throw new Error( + "mpp: invalid request auth-param (not valid base64url-encoded JSON)", + ); + } + + const invoice = request.methodDetails?.invoice; + if (!invoice) { + throw new Error("mpp: missing invoice in charge request"); + } + + const invResp = await wallet.payInvoice({ invoice }); + + // Per spec: Authorization: Payment (single token, no wrapper) + const credential = buildMppCredential(challenge, invResp.preimage); + headers.set("Authorization", `Payment ${credential}`); + + return fetch(url, fetchArgs); +}; + +/** + * Fetch a resource protected by the draft-lightning-charge-00 payment + * authentication protocol. + * + * On a `402 Payment Required` response that carries a + * `WWW-Authenticate: Payment method="lightning" intent="charge" …` header + * the function pays the embedded BOLT11 invoice and retries with the + * resulting preimage as the credential. + * + * Note: lightning-charge uses consume-once challenge semantics – each + * challenge embeds a fresh invoice, so paid credentials cannot be reused. + * The `store` option is accepted for API consistency but is not used. + */ +export const fetchWithMpp = async ( + url: string, + fetchArgs: RequestInit, + options: { wallet: Wallet; store?: KVStorage }, +): Promise => { + const wallet = options.wallet; + if (!wallet) { + throw new Error("wallet is missing"); + } + if (!fetchArgs) { + fetchArgs = {}; + } + fetchArgs.cache = "no-store"; + fetchArgs.mode = "cors"; + const headers = new Headers(fetchArgs.headers ?? undefined); + fetchArgs.headers = headers; + + const initResp = await fetch(url, fetchArgs); + const wwwAuthHeader = initResp.headers.get("www-authenticate"); + if ( + !wwwAuthHeader || + !wwwAuthHeader.trimStart().toLowerCase().startsWith("payment") + ) { + return initResp; + } + + return handleMppChargePayment(wwwAuthHeader, url, fetchArgs, headers, wallet); +}; diff --git a/src/402/mpp/utils.ts b/src/402/mpp/utils.ts new file mode 100644 index 0000000..c19e73d --- /dev/null +++ b/src/402/mpp/utils.ts @@ -0,0 +1,165 @@ +export interface MppChallenge { + id: string; + realm: string; + method: string; + intent: string; + request: string; + expires?: string; +} + +export interface MppChargeRequest { + amount: string; + currency: string; + description?: string; + recipient?: string; + externalId?: string; + methodDetails: { + invoice: string; + paymentHash?: string; + network?: string; + }; +} + +/** + * Parse a `WWW-Authenticate: Payment …` header produced by a + * draft-lightning-charge-00 server. Expected format: + * + * Payment id="", realm="", method="lightning", + * intent="charge", request="" [, expires=""] + * + * Returns null when the header is not a Payment lightning/charge challenge. + */ +export const parseMppChallenge = (header: string): MppChallenge | null => { + if (!header.trimStart().toLowerCase().startsWith("payment")) { + return null; + } + const rest = header + .slice(header.toLowerCase().indexOf("payment") + "payment".length) + .trim(); + const result: Record = {}; + const regex = /(\w+)=("([^"]*)"|'([^']*)'|([^,\s]*))/g; + let match; + while ((match = regex.exec(rest)) !== null) { + result[match[1]] = match[3] ?? match[4] ?? match[5] ?? ""; + } + + if ( + result.method !== "lightning" || + result.intent !== "charge" || + !result.id || + !result.realm || + !result.request + ) { + return null; + } + + return { + id: result.id, + realm: result.realm, + method: result.method, + intent: result.intent, + request: result.request, + ...(result.expires ? { expires: result.expires } : {}), + }; +}; + +/** Decode a base64url string (no padding required) to a UTF-8 string. */ +export const decodeBase64url = (input: string): string => + decodeURIComponent(escape(atob(input.replace(/-/g, "+").replace(/_/g, "/")))); + +/** Encode a UTF-8 string to base64url without padding. */ +const encodeBase64url = (input: string): string => + btoa(unescape(encodeURIComponent(input))) + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=/g, ""); + +/** + * JSON Canonicalization Scheme (RFC 8785). + * Produces compact JSON with object keys sorted lexicographically. + */ +const jcs = (value: unknown): string => { + if (value === null || typeof value !== "object") { + return JSON.stringify(value); + } + if (Array.isArray(value)) { + return "[" + (value as unknown[]).map(jcs).join(",") + "]"; + } + const keys = Object.keys(value as object).sort(); + return ( + "{" + + keys + .map( + (k) => + JSON.stringify(k) + ":" + jcs((value as Record)[k]), + ) + .join(",") + + "}" + ); +}; + +/** + * Build the base64url-encoded credential token for the `Authorization` header. + * + * Per the spec the credential is a JCS-serialised JSON object that echoes all + * challenge auth-params (id, realm, method, intent, request, expires) and + * carries the HTLC preimage that proves payment: + * + * { + * "challenge": { "id": "…", "intent": "charge", + * "method": "lightning", "realm": "…", "request": "…" }, + * "payload": { "preimage": "<64-char lowercase hex>" } + * } + * + * Keys are sorted lexicographically at every level per JCS. + */ +export const buildMppCredential = ( + challenge: MppChallenge, + preimage: string, + source?: string, +): string => { + const challengeEcho: Record = { + id: challenge.id, + intent: challenge.intent, + method: challenge.method, + realm: challenge.realm, + request: challenge.request, + }; + if (challenge.expires) { + challengeEcho.expires = challenge.expires; + } + + const credential: Record = { + challenge: challengeEcho, + payload: { preimage }, + }; + if (source) { + credential.source = source; + } + + return encodeBase64url(jcs(credential)); +}; + +/** + * Construct a `WWW-Authenticate` header for testing / server implementations. + * + * The auth scheme is `Payment` per [I-D.httpauth-payment]. + */ +export const makeMppWwwAuthenticateHeader = (args: { + id: string; + realm: string; + request: string; + expires?: string; +}): string => { + let header = + `Payment id="${args.id}", realm="${args.realm}", method="lightning",` + + ` intent="charge", request="${args.request}"`; + if (args.expires) { + header += `, expires="${args.expires}"`; + } + return header; +}; + +/** Encode an MppChargeRequest as a base64url string suitable for the `request` auth-param. */ +export const encodeMppChargeRequest = (request: MppChargeRequest): string => + encodeBase64url(jcs(request));