@getalby/lightning-tools
Version:
Collection of helpful building blocks and tools to develop Bitcoin Lightning web apps
180 lines (176 loc) • 6.34 kB
JavaScript
;
/**
* 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