fix: use svix library for Polar webhook signature verification
This commit is contained in:
@@ -15,6 +15,7 @@
|
|||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@hookform/resolvers": "^5.2.2",
|
"@hookform/resolvers": "^5.2.2",
|
||||||
|
"@polar-sh/sdk": "^0.47.1",
|
||||||
"@radix-ui/react-accordion": "^1.2.12",
|
"@radix-ui/react-accordion": "^1.2.12",
|
||||||
"@radix-ui/react-avatar": "^1.1.11",
|
"@radix-ui/react-avatar": "^1.1.11",
|
||||||
"@radix-ui/react-checkbox": "^1.3.3",
|
"@radix-ui/react-checkbox": "^1.3.3",
|
||||||
@@ -55,6 +56,7 @@
|
|||||||
"react-resizable-panels": "^3.0.4",
|
"react-resizable-panels": "^3.0.4",
|
||||||
"recharts": "3.6.0",
|
"recharts": "3.6.0",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
|
"svix": "^1.92.2",
|
||||||
"tailwind-merge": "^3.4.0",
|
"tailwind-merge": "^3.4.0",
|
||||||
"vaul": "^1.1.2",
|
"vaul": "^1.1.2",
|
||||||
"zod": "^4.3.2",
|
"zod": "^4.3.2",
|
||||||
|
|||||||
Generated
+39
@@ -23,6 +23,9 @@ importers:
|
|||||||
'@hookform/resolvers':
|
'@hookform/resolvers':
|
||||||
specifier: ^5.2.2
|
specifier: ^5.2.2
|
||||||
version: 5.2.2(react-hook-form@7.69.0(react@19.2.3))
|
version: 5.2.2(react-hook-form@7.69.0(react@19.2.3))
|
||||||
|
'@polar-sh/sdk':
|
||||||
|
specifier: ^0.47.1
|
||||||
|
version: 0.47.1
|
||||||
'@radix-ui/react-accordion':
|
'@radix-ui/react-accordion':
|
||||||
specifier: ^1.2.12
|
specifier: ^1.2.12
|
||||||
version: 1.2.12(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
version: 1.2.12(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||||
@@ -143,6 +146,9 @@ importers:
|
|||||||
sonner:
|
sonner:
|
||||||
specifier: ^2.0.7
|
specifier: ^2.0.7
|
||||||
version: 2.0.7(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
version: 2.0.7(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||||
|
svix:
|
||||||
|
specifier: ^1.92.2
|
||||||
|
version: 1.92.2
|
||||||
tailwind-merge:
|
tailwind-merge:
|
||||||
specifier: ^3.4.0
|
specifier: ^3.4.0
|
||||||
version: 3.4.0
|
version: 3.4.0
|
||||||
@@ -617,6 +623,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==}
|
resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==}
|
||||||
engines: {node: '>=12.4.0'}
|
engines: {node: '>=12.4.0'}
|
||||||
|
|
||||||
|
'@polar-sh/sdk@0.47.1':
|
||||||
|
resolution: {integrity: sha512-fkz7wPLbqfuDmY9LxuXpE2uP2TAV6J0q/YN5hJ4UBxpjbkB0hKM6c4R35N89t83dfzMlG6EOlqOn+Rd1T6XrJQ==}
|
||||||
|
|
||||||
'@radix-ui/number@1.1.1':
|
'@radix-ui/number@1.1.1':
|
||||||
resolution: {integrity: sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==}
|
resolution: {integrity: sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==}
|
||||||
|
|
||||||
@@ -1209,6 +1218,9 @@ packages:
|
|||||||
'@rtsao/scc@1.1.0':
|
'@rtsao/scc@1.1.0':
|
||||||
resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==}
|
resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==}
|
||||||
|
|
||||||
|
'@stablelib/base64@1.0.1':
|
||||||
|
resolution: {integrity: sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==}
|
||||||
|
|
||||||
'@standard-schema/spec@1.1.0':
|
'@standard-schema/spec@1.1.0':
|
||||||
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
|
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
|
||||||
|
|
||||||
@@ -1999,6 +2011,9 @@ packages:
|
|||||||
fast-levenshtein@2.0.6:
|
fast-levenshtein@2.0.6:
|
||||||
resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==}
|
resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==}
|
||||||
|
|
||||||
|
fast-sha256@1.3.0:
|
||||||
|
resolution: {integrity: sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==}
|
||||||
|
|
||||||
fastq@1.20.1:
|
fastq@1.20.1:
|
||||||
resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==}
|
resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==}
|
||||||
|
|
||||||
@@ -2801,6 +2816,9 @@ packages:
|
|||||||
stable-hash@0.0.5:
|
stable-hash@0.0.5:
|
||||||
resolution: {integrity: sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==}
|
resolution: {integrity: sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==}
|
||||||
|
|
||||||
|
standardwebhooks@1.0.0:
|
||||||
|
resolution: {integrity: sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==}
|
||||||
|
|
||||||
stop-iteration-iterator@1.1.0:
|
stop-iteration-iterator@1.1.0:
|
||||||
resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==}
|
resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
@@ -2857,6 +2875,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==}
|
resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
svix@1.92.2:
|
||||||
|
resolution: {integrity: sha512-ZmuA3UVvlnF9EgxlzmPtF7CKjQb64Z6OFlyfdDfU0sdcC7dJa+3aOYX5B9mA+RS6ch1AxBa4UP/l6KmqfGtWBQ==}
|
||||||
|
|
||||||
tailwind-merge@3.4.0:
|
tailwind-merge@3.4.0:
|
||||||
resolution: {integrity: sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g==}
|
resolution: {integrity: sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g==}
|
||||||
|
|
||||||
@@ -3437,6 +3458,11 @@ snapshots:
|
|||||||
|
|
||||||
'@nolyfill/is-core-module@1.0.39': {}
|
'@nolyfill/is-core-module@1.0.39': {}
|
||||||
|
|
||||||
|
'@polar-sh/sdk@0.47.1':
|
||||||
|
dependencies:
|
||||||
|
standardwebhooks: 1.0.0
|
||||||
|
zod: 4.3.2
|
||||||
|
|
||||||
'@radix-ui/number@1.1.1': {}
|
'@radix-ui/number@1.1.1': {}
|
||||||
|
|
||||||
'@radix-ui/primitive@1.1.3': {}
|
'@radix-ui/primitive@1.1.3': {}
|
||||||
@@ -4052,6 +4078,8 @@ snapshots:
|
|||||||
|
|
||||||
'@rtsao/scc@1.1.0': {}
|
'@rtsao/scc@1.1.0': {}
|
||||||
|
|
||||||
|
'@stablelib/base64@1.0.1': {}
|
||||||
|
|
||||||
'@standard-schema/spec@1.1.0': {}
|
'@standard-schema/spec@1.1.0': {}
|
||||||
|
|
||||||
'@standard-schema/utils@0.3.0': {}
|
'@standard-schema/utils@0.3.0': {}
|
||||||
@@ -4970,6 +4998,8 @@ snapshots:
|
|||||||
|
|
||||||
fast-levenshtein@2.0.6: {}
|
fast-levenshtein@2.0.6: {}
|
||||||
|
|
||||||
|
fast-sha256@1.3.0: {}
|
||||||
|
|
||||||
fastq@1.20.1:
|
fastq@1.20.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
reusify: 1.1.0
|
reusify: 1.1.0
|
||||||
@@ -5792,6 +5822,11 @@ snapshots:
|
|||||||
|
|
||||||
stable-hash@0.0.5: {}
|
stable-hash@0.0.5: {}
|
||||||
|
|
||||||
|
standardwebhooks@1.0.0:
|
||||||
|
dependencies:
|
||||||
|
'@stablelib/base64': 1.0.1
|
||||||
|
fast-sha256: 1.3.0
|
||||||
|
|
||||||
stop-iteration-iterator@1.1.0:
|
stop-iteration-iterator@1.1.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
es-errors: 1.3.0
|
es-errors: 1.3.0
|
||||||
@@ -5864,6 +5899,10 @@ snapshots:
|
|||||||
|
|
||||||
supports-preserve-symlinks-flag@1.0.0: {}
|
supports-preserve-symlinks-flag@1.0.0: {}
|
||||||
|
|
||||||
|
svix@1.92.2:
|
||||||
|
dependencies:
|
||||||
|
standardwebhooks: 1.0.0
|
||||||
|
|
||||||
tailwind-merge@3.4.0: {}
|
tailwind-merge@3.4.0: {}
|
||||||
|
|
||||||
tailwindcss@4.1.18: {}
|
tailwindcss@4.1.18: {}
|
||||||
|
|||||||
@@ -9,14 +9,6 @@ import { verifyPolarWebhook } from "@/lib/payments/polar";
|
|||||||
const PRO_VALIDITY_DAYS = 30;
|
const PRO_VALIDITY_DAYS = 30;
|
||||||
|
|
||||||
export async function POST(req: NextRequest): Promise<NextResponse> {
|
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;
|
let rawBody: string;
|
||||||
try {
|
try {
|
||||||
rawBody = await req.text();
|
rawBody = await req.text();
|
||||||
@@ -24,7 +16,11 @@ export async function POST(req: NextRequest): Promise<NextResponse> {
|
|||||||
return NextResponse.json({ error: "invalid body" }, { status: 400 });
|
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");
|
console.error("[polar/callback] signature mismatch");
|
||||||
return NextResponse.json({ error: "signature mismatch" }, { status: 403 });
|
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 });
|
return new NextResponse("OK", { status: 200 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// metadata'dan crm_order_id al
|
|
||||||
const metadata = (event.data as { metadata?: Record<string, string> }).metadata ?? {};
|
const metadata = (event.data as { metadata?: Record<string, string> }).metadata ?? {};
|
||||||
const crmOrderId = metadata.crm_order_id ?? "";
|
const crmOrderId = metadata.crm_order_id ?? "";
|
||||||
|
|
||||||
@@ -73,7 +68,6 @@ export async function POST(req: NextRequest): Promise<NextResponse> {
|
|||||||
return new NextResponse("OK", { status: 200 });
|
return new NextResponse("OK", { status: 200 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Idempotency
|
|
||||||
if (payment.status === "success" || payment.status === "failed") {
|
if (payment.status === "success" || payment.status === "failed") {
|
||||||
return new NextResponse("OK", { status: 200 });
|
return new NextResponse("OK", { status: 200 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import "server-only";
|
import "server-only";
|
||||||
|
|
||||||
import { createHmac, timingSafeEqual } from "crypto";
|
import { Webhook } from "svix";
|
||||||
|
|
||||||
const POLAR_API_BASE = "https://api.polar.sh";
|
const POLAR_API_BASE = "https://api.polar.sh";
|
||||||
const ACCESS_TOKEN = process.env.POLAR_ACCESS_TOKEN ?? "";
|
const ACCESS_TOKEN = process.env.POLAR_ACCESS_TOKEN ?? "";
|
||||||
@@ -52,43 +52,17 @@ export async function createPolarCheckout(params: {
|
|||||||
return res.json() as Promise<PolarCheckout>;
|
return res.json() as Promise<PolarCheckout>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Standard Webhooks imza doğrulama
|
// Svix kullanarak Polar webhook imzasını doğrula
|
||||||
// Header: webhook-id, webhook-timestamp, webhook-signature
|
|
||||||
// Signed content: "{webhook-id}.{webhook-timestamp}.{rawBody}"
|
|
||||||
// Signature: "v1," + base64(HMAC-SHA256(secret, signedContent))
|
|
||||||
export function verifyPolarWebhook(
|
export function verifyPolarWebhook(
|
||||||
webhookId: string,
|
headers: Record<string, string>,
|
||||||
webhookTimestamp: string,
|
|
||||||
webhookSignature: string,
|
|
||||||
rawBody: string,
|
rawBody: string,
|
||||||
): boolean {
|
): boolean {
|
||||||
if (!WEBHOOK_SECRET) return false;
|
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 {
|
try {
|
||||||
const raw = WEBHOOK_SECRET.replace(/^(whsec_|polar_whs_)/, "");
|
const wh = new Webhook(WEBHOOK_SECRET);
|
||||||
secretBytes = Buffer.from(raw, "base64");
|
wh.verify(rawBody, headers);
|
||||||
|
return true;
|
||||||
} catch {
|
} 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