fix: use svix library for Polar webhook signature verification
This commit is contained in:
@@ -9,14 +9,6 @@ import { verifyPolarWebhook } from "@/lib/payments/polar";
|
||||
const PRO_VALIDITY_DAYS = 30;
|
||||
|
||||
export async function POST(req: NextRequest): Promise<NextResponse> {
|
||||
// Polar, Svix altyapısı kullandığından hem webhook-* hem svix-* header'ları destekle
|
||||
const webhookId =
|
||||
req.headers.get("webhook-id") ?? req.headers.get("svix-id") ?? "";
|
||||
const webhookTimestamp =
|
||||
req.headers.get("webhook-timestamp") ?? req.headers.get("svix-timestamp") ?? "";
|
||||
const webhookSignature =
|
||||
req.headers.get("webhook-signature") ?? req.headers.get("svix-signature") ?? "";
|
||||
|
||||
let rawBody: string;
|
||||
try {
|
||||
rawBody = await req.text();
|
||||
@@ -24,7 +16,11 @@ export async function POST(req: NextRequest): Promise<NextResponse> {
|
||||
return NextResponse.json({ error: "invalid body" }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!verifyPolarWebhook(webhookId, webhookTimestamp, webhookSignature, rawBody)) {
|
||||
// Svix tüm headerları obje olarak alır
|
||||
const headers: Record<string, string> = {};
|
||||
req.headers.forEach((value, key) => { headers[key] = value; });
|
||||
|
||||
if (!verifyPolarWebhook(headers, rawBody)) {
|
||||
console.error("[polar/callback] signature mismatch");
|
||||
return NextResponse.json({ error: "signature mismatch" }, { status: 403 });
|
||||
}
|
||||
@@ -46,7 +42,6 @@ export async function POST(req: NextRequest): Promise<NextResponse> {
|
||||
return new NextResponse("OK", { status: 200 });
|
||||
}
|
||||
|
||||
// metadata'dan crm_order_id al
|
||||
const metadata = (event.data as { metadata?: Record<string, string> }).metadata ?? {};
|
||||
const crmOrderId = metadata.crm_order_id ?? "";
|
||||
|
||||
@@ -73,7 +68,6 @@ export async function POST(req: NextRequest): Promise<NextResponse> {
|
||||
return new NextResponse("OK", { status: 200 });
|
||||
}
|
||||
|
||||
// Idempotency
|
||||
if (payment.status === "success" || payment.status === "failed") {
|
||||
return new NextResponse("OK", { status: 200 });
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import "server-only";
|
||||
|
||||
import { createHmac, timingSafeEqual } from "crypto";
|
||||
import { Webhook } from "svix";
|
||||
|
||||
const POLAR_API_BASE = "https://api.polar.sh";
|
||||
const ACCESS_TOKEN = process.env.POLAR_ACCESS_TOKEN ?? "";
|
||||
@@ -52,43 +52,17 @@ export async function createPolarCheckout(params: {
|
||||
return res.json() as Promise<PolarCheckout>;
|
||||
}
|
||||
|
||||
// Standard Webhooks imza doğrulama
|
||||
// Header: webhook-id, webhook-timestamp, webhook-signature
|
||||
// Signed content: "{webhook-id}.{webhook-timestamp}.{rawBody}"
|
||||
// Signature: "v1," + base64(HMAC-SHA256(secret, signedContent))
|
||||
// Svix kullanarak Polar webhook imzasını doğrula
|
||||
export function verifyPolarWebhook(
|
||||
webhookId: string,
|
||||
webhookTimestamp: string,
|
||||
webhookSignature: string,
|
||||
headers: Record<string, string>,
|
||||
rawBody: string,
|
||||
): boolean {
|
||||
if (!WEBHOOK_SECRET) return false;
|
||||
|
||||
// Timestamp replay koruması (1 saat — Polar retry aralığı uzun olabilir)
|
||||
const ts = parseInt(webhookTimestamp, 10);
|
||||
if (isNaN(ts) || Math.abs(Date.now() / 1000 - ts) > 3600) return false;
|
||||
|
||||
const signedContent = `${webhookId}.${webhookTimestamp}.${rawBody}`;
|
||||
|
||||
// Polar secret: "polar_whs_<base64>" — prefix soyulup base64 decode edilir
|
||||
let secretBytes: Buffer;
|
||||
try {
|
||||
const raw = WEBHOOK_SECRET.replace(/^(whsec_|polar_whs_)/, "");
|
||||
secretBytes = Buffer.from(raw, "base64");
|
||||
const wh = new Webhook(WEBHOOK_SECRET);
|
||||
wh.verify(rawBody, headers);
|
||||
return true;
|
||||
} catch {
|
||||
secretBytes = Buffer.from(WEBHOOK_SECRET);
|
||||
return false;
|
||||
}
|
||||
|
||||
const expected = createHmac("sha256", secretBytes).update(signedContent).digest("base64");
|
||||
const expectedFull = `v1,${expected}`;
|
||||
|
||||
// Header birden fazla imza içerebilir (space ile ayrılmış)
|
||||
const signatures = webhookSignature.split(" ");
|
||||
return signatures.some((sig) => {
|
||||
try {
|
||||
return timingSafeEqual(Buffer.from(expectedFull), Buffer.from(sig));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user