Skip to content
Closed
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
13 changes: 13 additions & 0 deletions apps/pwa/src/lib/coinpay-webhook.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { config } from "../config.mjs";
import { verifySignature } from "./signature.mjs";

export function verifyCoinPayWebhookRequest(req) {
if (!config.coinpay.webhookSecret) {
return { ok: false, status: 503, error: "coinpay webhook secret not configured" };
}
const signature = req.get("x-coinpay-signature") || req.get("webhook-signature");
if (!verifySignature(signature, req.rawBody || "", config.coinpay.webhookSecret)) {
return { ok: false, status: 401, error: "bad signature" };
}
return { ok: true };
}
8 changes: 4 additions & 4 deletions apps/pwa/src/routes/credits.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { get, run } from "../db.mjs";
import { config } from "../config.mjs";
import { id } from "../lib/crypto.mjs";
import { grant } from "../lib/credits.mjs";
import { verifySignature } from "../lib/signature.mjs";
import { verifyCoinPayWebhookRequest } from "../lib/coinpay-webhook.mjs";
import { requireAuth } from "../lib/session.mjs";

export const creditsRouter = Router();
Expand Down Expand Up @@ -48,9 +48,9 @@ creditsRouter.post("/credits/buy", requireAuth, async (req, res) => {

// CoinPay confirms payment → credit the balance (idempotent on purchase id).
creditsRouter.post("/webhooks/coinpay", async (req, res) => {
if (config.coinpay.webhookSecret && !verifySignature(req.get("x-coinpay-signature") || req.get("webhook-signature"), req.rawBody || "", config.coinpay.webhookSecret)) {
return res.status(401).json({ error: "bad signature" });
}
const auth = verifyCoinPayWebhookRequest(req);
if (!auth.ok) return res.status(auth.status).json({ error: auth.error });

const event = req.body?.type || req.body?.event;
const payId = req.body?.data?.id || req.body?.payment_id || req.body?.id;
if (event && /confirmed|completed|paid/i.test(event) && payId) {
Expand Down
51 changes: 51 additions & 0 deletions apps/pwa/test/credits-webhook.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import assert from "node:assert/strict";
import crypto from "node:crypto";
import test from "node:test";
import { config } from "../src/config.mjs";
import { verifyCoinPayWebhookRequest } from "../src/lib/coinpay-webhook.mjs";

function reqFor({ signature = "", rawBody = "{}" } = {}) {
return {
rawBody,
get(name) {
return name.toLowerCase() === "x-coinpay-signature" ? signature : "";
},
};
}

function sign(rawBody, secret, ts = Math.floor(Date.now() / 1000)) {
const v1 = crypto.createHmac("sha256", secret).update(`${ts}.${rawBody}`).digest("hex");
return `t=${ts},v1=${v1}`;
}

test("CoinPay webhooks fail closed when no secret is configured", () => {
const previous = config.coinpay.webhookSecret;
config.coinpay.webhookSecret = "";
try {
assert.deepEqual(verifyCoinPayWebhookRequest(reqFor()), {
ok: false,
status: 503,
error: "coinpay webhook secret not configured",
});
} finally {
config.coinpay.webhookSecret = previous;
}
});

test("CoinPay webhooks require a valid HMAC signature", () => {
const previous = config.coinpay.webhookSecret;
config.coinpay.webhookSecret = "test-secret";
const rawBody = JSON.stringify({ event: "paid", id: "pay_123" });
try {
assert.deepEqual(verifyCoinPayWebhookRequest(reqFor({ signature: "bad", rawBody })), {
ok: false,
status: 401,
error: "bad signature",
});
assert.deepEqual(verifyCoinPayWebhookRequest(reqFor({ signature: sign(rawBody, "test-secret"), rawBody })), {
ok: true,
});
} finally {
config.coinpay.webhookSecret = previous;
}
});
Loading