UNPKG

@getalby/lightning-tools

Version:

Collection of helpful building blocks and tools to develop Bitcoin Lightning web apps

180 lines (176 loc) 6.34 kB
'use strict'; /** * Parse a `WWW-Authenticate: Payment …` header produced by a * draft-lightning-charge-00 server. Expected format: * * Payment id="<id>", realm="<realm>", method="lightning", * intent="charge", request="<base64url>" [, expires="<rfc3339>"] * * Returns null when the header is not a Payment lightning/charge challenge. */ const parseMppChallenge = (header) => { if (!header.trimStart().toLowerCase().startsWith("payment")) { return null; } const rest = header .slice(header.toLowerCase().indexOf("payment") + "payment".length) .trim(); const result = {}; 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. */ const decodeBase64url = (input) => { const base64 = input.replace(/-/g, "+").replace(/_/g, "/"); const binary = atob(base64); const bytes = new Uint8Array(binary.length); for (let i = 0; i < binary.length; i++) { bytes[i] = binary.charCodeAt(i); } return new TextDecoder("utf-8").decode(bytes); }; /** Encode a UTF-8 string to base64url without padding. */ const encodeBase64url = (input) => { const bytes = new TextEncoder().encode(input); let binary = ""; for (let i = 0; i < bytes.length; i++) { binary += String.fromCharCode(bytes[i]); } return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, ""); }; /** * JSON Canonicalization Scheme (RFC 8785). * Produces compact JSON with object keys sorted lexicographically. */ const jcs = (value) => { if (value === null || typeof value !== "object") { return JSON.stringify(value); } if (Array.isArray(value)) { return "[" + value.map(jcs).join(",") + "]"; } const keys = Object.keys(value).sort(); return ("{" + keys .map((k) => JSON.stringify(k) + ":" + jcs(value[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. */ const buildMppCredential = (challenge, preimage, source) => { const challengeEcho = { id: challenge.id, intent: challenge.intent, method: challenge.method, realm: challenge.realm, request: challenge.request, }; if (challenge.expires) { challengeEcho.expires = challenge.expires; } const credential = { challenge: challengeEcho, payload: { preimage }, }; return encodeBase64url(jcs(credential)); }; /** * 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 <credential>` header. * 5. Retry the original request with the credential. */ const handleMppChargePayment = async (wwwAuthHeader, url, fetchArgs, headers, wallet) => { const challenge = parseMppChallenge(wwwAuthHeader); if (!challenge) { throw new Error("mpp: invalid or unsupported WWW-Authenticate challenge (expected Payment method=lightning intent=charge)"); } let request; 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 <base64url-token> (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. */ const fetchWithMpp = async (url, fetchArgs, options) => { 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); }; exports.fetchWithMpp = fetchWithMpp; //# sourceMappingURL=mpp.cjs.map