mirror of
https://github.com/nestriness/nestri.git
synced 2026-09-19 09:15:19 +03:00
fix(billing): verify the signing scheme the secret actually says it is
Every delivery was refused as a signature mismatch, and nothing in the error said why. The provider signs one of two ways. A `whsec_` prefix means Standard Webhooks, where the secret is a base64 key the verifier decodes; anything else is the older scheme, where the secret is used as its own raw bytes. Which one a secret belongs to is decided by when it was created, and every secret created now is the new one. The SDK's helper only implements the older scheme — it base64-encodes whatever it is handed, so a Standard Webhooks secret becomes the literal bytes of the string including its prefix, and every signature then fails against a key derived quite differently. The scheme is now read off the secret rather than configured, so rotating one cannot put the two out of step. The verifier for the new scheme is the same library the SDK uses underneath; nothing here hand-rolls crypto. Tested with a real signature rather than only a rejection. A mismatch is easy to assert by accident, and a test that only proved bad input is refused would have passed against the broken version too. Also teaches the product script to check shape and not just name: a one-time product where a subscription is wanted cannot be subscribed to at all, and reporting it as already-there hands back an id that fails at its first use.
This commit is contained in:
2
bun.lock
2
bun.lock
@@ -81,6 +81,7 @@
|
|||||||
"drizzle-orm": "^0.45.2",
|
"drizzle-orm": "^0.45.2",
|
||||||
"postgres": "^3.4.9",
|
"postgres": "^3.4.9",
|
||||||
"postgresql": "^0.0.1",
|
"postgresql": "^0.0.1",
|
||||||
|
"standardwebhooks": "catalog:",
|
||||||
"zod": "catalog:",
|
"zod": "catalog:",
|
||||||
"zod-openapi": "^6.0.0",
|
"zod-openapi": "^6.0.0",
|
||||||
},
|
},
|
||||||
@@ -101,6 +102,7 @@
|
|||||||
"@types/bun": "latest",
|
"@types/bun": "latest",
|
||||||
"@types/node": "^26.1.1",
|
"@types/node": "^26.1.1",
|
||||||
"hono": "^4.12.31",
|
"hono": "^4.12.31",
|
||||||
|
"standardwebhooks": "^1.1.1",
|
||||||
"typescript": "^7.0.1-rc",
|
"typescript": "^7.0.1-rc",
|
||||||
"zod": "^4.4.3",
|
"zod": "^4.4.3",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -15,7 +15,8 @@
|
|||||||
"hono": "^4.12.31",
|
"hono": "^4.12.31",
|
||||||
"typescript": "^7.0.1-rc",
|
"typescript": "^7.0.1-rc",
|
||||||
"zod": "^4.4.3",
|
"zod": "^4.4.3",
|
||||||
"@polar-sh/sdk": "^0.49.0"
|
"@polar-sh/sdk": "^0.49.0",
|
||||||
|
"standardwebhooks": "^1.1.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -22,6 +22,7 @@
|
|||||||
"drizzle-orm": "^0.45.2",
|
"drizzle-orm": "^0.45.2",
|
||||||
"postgres": "^3.4.9",
|
"postgres": "^3.4.9",
|
||||||
"postgresql": "^0.0.1",
|
"postgresql": "^0.0.1",
|
||||||
|
"standardwebhooks": "catalog:",
|
||||||
"zod": "catalog:",
|
"zod": "catalog:",
|
||||||
"zod-openapi": "^6.0.0"
|
"zod-openapi": "^6.0.0"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -22,6 +22,8 @@
|
|||||||
import { Polar } from '@polar-sh/sdk';
|
import { Polar } from '@polar-sh/sdk';
|
||||||
import type { PresentmentCurrency } from '@polar-sh/sdk/models/components/presentmentcurrency.js';
|
import type { PresentmentCurrency } from '@polar-sh/sdk/models/components/presentmentcurrency.js';
|
||||||
|
|
||||||
|
type Price = { currency: PresentmentCurrency; amount: number };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The paid rung of the self-serve ladder.
|
* The paid rung of the self-serve ladder.
|
||||||
*
|
*
|
||||||
@@ -32,7 +34,19 @@ import type { PresentmentCurrency } from '@polar-sh/sdk/models/components/presen
|
|||||||
*
|
*
|
||||||
* Amounts are in minor units: 2000 is 20.00.
|
* Amounts are in minor units: 2000 is 20.00.
|
||||||
*/
|
*/
|
||||||
const PRODUCT = {
|
const PRODUCTS = [
|
||||||
|
{
|
||||||
|
env: 'POLAR_FREE_PRODUCT_ID',
|
||||||
|
name: 'Nestri Free',
|
||||||
|
description: 'Sessions on hardware you own, and a monthly burn allowance.',
|
||||||
|
// Recurring, and priced at nothing. It has to be a *subscription* rather
|
||||||
|
// than a one-time purchase, because a team is put on it outright at
|
||||||
|
// signup and a one-time product has no subscription to create.
|
||||||
|
recurringInterval: 'month' as const,
|
||||||
|
prices: [{ currency: 'usd', amount: 0 }] satisfies Price[]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
env: 'POLAR_PRODUCT_ID',
|
||||||
name: 'Nestri Pro',
|
name: 'Nestri Pro',
|
||||||
description: 'Cloud sessions on Nestri hardware, and a larger burn allowance.',
|
description: 'Cloud sessions on Nestri hardware, and a larger burn allowance.',
|
||||||
recurringInterval: 'month' as const,
|
recurringInterval: 'month' as const,
|
||||||
@@ -40,8 +54,9 @@ const PRODUCT = {
|
|||||||
{ currency: 'usd', amount: 2000 },
|
{ currency: 'usd', amount: 2000 },
|
||||||
{ currency: 'eur', amount: 2000 },
|
{ currency: 'eur', amount: 2000 },
|
||||||
{ currency: 'gbp', amount: 2000 }
|
{ currency: 'gbp', amount: 2000 }
|
||||||
] satisfies { currency: PresentmentCurrency; amount: number }[]
|
] satisfies Price[]
|
||||||
};
|
}
|
||||||
|
];
|
||||||
|
|
||||||
const apply = process.argv.includes('--apply');
|
const apply = process.argv.includes('--apply');
|
||||||
const accessToken = process.env.POLAR_ACCESS_TOKEN;
|
const accessToken = process.env.POLAR_ACCESS_TOKEN;
|
||||||
@@ -80,34 +95,49 @@ console.log(`server: ${server}`);
|
|||||||
console.log(`organization: ${organization.name} (${organization.id})`);
|
console.log(`organization: ${organization.name} (${organization.id})`);
|
||||||
|
|
||||||
const existing = await polar.products.list({ organizationId: organization.id, limit: 100 });
|
const existing = await polar.products.list({ organizationId: organization.id, limit: 100 });
|
||||||
const clash = existing.result.items.find((p) => p.name === PRODUCT.name && !p.isArchived);
|
|
||||||
|
for (const product of PRODUCTS) {
|
||||||
|
const clash = existing.result.items.find((p) => p.name === product.name && !p.isArchived);
|
||||||
if (clash) {
|
if (clash) {
|
||||||
console.log(`\nalready there: ${PRODUCT.name} (${clash.id})`);
|
// Same name is not the same product. A one-time product where a
|
||||||
console.log('nothing to do. Archive it first if you meant to replace it.');
|
// subscription is wanted cannot be subscribed to at all, and reporting
|
||||||
process.exit(0);
|
// it as already-there would hand back an id that fails at the first use.
|
||||||
|
if (clash.recurringInterval !== product.recurringInterval) {
|
||||||
|
console.log(`\nwrong shape: ${product.name} (${clash.id})`);
|
||||||
|
console.log(
|
||||||
|
` wanted every ${product.recurringInterval}, found ${clash.recurringInterval ?? 'one-time'}`
|
||||||
|
);
|
||||||
|
console.log(' archive it first, then run this again.');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
console.log(`\nalready there: ${product.name}`);
|
||||||
|
console.log(` ${product.env}=${clash.id}`);
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`\nwould create: ${PRODUCT.name}, every ${PRODUCT.recurringInterval}`);
|
console.log(`\nwould create: ${product.name}, every ${product.recurringInterval}`);
|
||||||
for (const price of PRODUCT.prices) {
|
for (const price of product.prices) {
|
||||||
console.log(` ${price.currency.toUpperCase()} ${(price.amount / 100).toFixed(2)}`);
|
console.log(` ${price.currency.toUpperCase()} ${(price.amount / 100).toFixed(2)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!apply) {
|
if (!apply) {
|
||||||
console.log('\nnothing written. Re-run with --apply to create it.');
|
continue;
|
||||||
process.exit(0);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const created = await polar.products.create({
|
const created = await polar.products.create({
|
||||||
...(scopedToOrganization ? {} : { organizationId: organization.id }),
|
...(scopedToOrganization ? {} : { organizationId: organization.id }),
|
||||||
name: PRODUCT.name,
|
name: product.name,
|
||||||
description: PRODUCT.description,
|
description: product.description,
|
||||||
recurringInterval: PRODUCT.recurringInterval,
|
recurringInterval: product.recurringInterval,
|
||||||
prices: PRODUCT.prices.map((price) => ({
|
prices: product.prices.map((price) => ({
|
||||||
amountType: 'fixed' as const,
|
amountType: 'fixed' as const,
|
||||||
priceCurrency: price.currency,
|
priceCurrency: price.currency,
|
||||||
priceAmount: price.amount
|
priceAmount: price.amount
|
||||||
}))
|
}))
|
||||||
});
|
});
|
||||||
|
console.log(` created: ${product.env}=${created.id}`);
|
||||||
|
}
|
||||||
|
|
||||||
console.log(`\ncreated: ${created.id}`);
|
if (!apply) {
|
||||||
console.log(`set POLAR_PRODUCT_ID=${created.id} for the ${server} deployment.`);
|
console.log('\nnothing written. Re-run with --apply to create them.');
|
||||||
|
}
|
||||||
|
|||||||
@@ -137,3 +137,53 @@ describe('Webhooks', () => {
|
|||||||
expect(() => Polar.receive({ body: '{}', headers: {} })).toThrow(/not configured/);
|
expect(() => Polar.receive({ body: '{}', headers: {} })).toThrow(/not configured/);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Both signing schemes', () => {
|
||||||
|
// A `whsec_` secret is Standard Webhooks, where the secret is a base64 key.
|
||||||
|
// The SDK helper only implements the older scheme and base64-encodes
|
||||||
|
// whatever it is handed, so a Standard Webhooks secret verified through it
|
||||||
|
// fails every time, for a reason no error message mentions. These assert the
|
||||||
|
// prefix is what picks, so rotating a secret cannot put the two out of step.
|
||||||
|
test('a Standard Webhooks secret is verified, and a real signature passes', async () => {
|
||||||
|
const { Webhook } = await import('standardwebhooks');
|
||||||
|
const key = Buffer.from('a'.repeat(32)).toString('base64');
|
||||||
|
const secret = `whsec_${key}`;
|
||||||
|
configure({ POLAR_WEBHOOK_SECRET: secret });
|
||||||
|
|
||||||
|
const body = JSON.stringify({
|
||||||
|
type: 'subscription.active',
|
||||||
|
data: { customer: { externalId: 'tem_x' }, productId: PAID }
|
||||||
|
});
|
||||||
|
const id = 'msg_1';
|
||||||
|
const timestamp = new Date();
|
||||||
|
const signature = new Webhook(secret).sign(id, timestamp, body);
|
||||||
|
|
||||||
|
const delivery = Polar.receive({
|
||||||
|
body,
|
||||||
|
headers: {
|
||||||
|
'webhook-id': id,
|
||||||
|
'webhook-timestamp': Math.floor(timestamp.getTime() / 1000).toString(),
|
||||||
|
'webhook-signature': signature
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(delivery.type).toBe('subscription.active');
|
||||||
|
expect(delivery.teamId).toBe('tem_x');
|
||||||
|
expect(delivery.standing).toEqual({ plan: 'paid', status: 'active' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a tampered body under a valid-looking signature is refused', () => {
|
||||||
|
const secret = `whsec_${Buffer.from('a'.repeat(32)).toString('base64')}`;
|
||||||
|
configure({ POLAR_WEBHOOK_SECRET: secret });
|
||||||
|
expect(() =>
|
||||||
|
Polar.receive({
|
||||||
|
body: '{"type":"subscription.revoked"}',
|
||||||
|
headers: {
|
||||||
|
'webhook-id': 'msg_1',
|
||||||
|
'webhook-timestamp': '1',
|
||||||
|
'webhook-signature': 'v1,AAAA'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
).toThrow(/Signature/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Polar as PolarSdk } from '@polar-sh/sdk';
|
import { Polar as PolarSdk } from '@polar-sh/sdk';
|
||||||
import { validateEvent, WebhookVerificationError } from '@polar-sh/sdk/webhooks';
|
import { validateEvent, WebhookVerificationError } from '@polar-sh/sdk/webhooks';
|
||||||
|
import { Webhook as StandardWebhook } from 'standardwebhooks';
|
||||||
import z from 'zod';
|
import z from 'zod';
|
||||||
|
|
||||||
import { Env } from '../env.js';
|
import { Env } from '../env.js';
|
||||||
@@ -257,11 +258,31 @@ export namespace Polar {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let event;
|
// Two signing schemes, told apart by the secret itself.
|
||||||
|
//
|
||||||
|
// A `whsec_` prefix means Standard Webhooks, where the secret is a
|
||||||
|
// base64 key the library decodes. Anything else is the older scheme,
|
||||||
|
// where the secret is used as its own raw bytes. The SDK's helper
|
||||||
|
// only implements the older one — it base64-encodes whatever it is
|
||||||
|
// handed, which turns a Standard Webhooks secret into the literal
|
||||||
|
// bytes of the string including the prefix, and then every signature
|
||||||
|
// fails to match for a reason no error message mentions.
|
||||||
|
//
|
||||||
|
// Reading the prefix rather than configuring which scheme is in use
|
||||||
|
// means rotating a secret cannot put the two out of step.
|
||||||
|
let event: { type: string; data?: Record<string, unknown> };
|
||||||
try {
|
try {
|
||||||
event = validateEvent(input.body, input.headers, webhookSecret);
|
if (webhookSecret.startsWith('whsec_')) {
|
||||||
|
const verified = new StandardWebhook(webhookSecret).verify(input.body, input.headers);
|
||||||
|
event = verified as { type: string; data?: Record<string, unknown> };
|
||||||
|
} else {
|
||||||
|
event = validateEvent(input.body, input.headers, webhookSecret) as {
|
||||||
|
type: string;
|
||||||
|
data?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof WebhookVerificationError) {
|
if (error instanceof WebhookVerificationError || error instanceof Error) {
|
||||||
throw new VisibleError(
|
throw new VisibleError(
|
||||||
'authentication',
|
'authentication',
|
||||||
ErrorCodes.Authentication.INVALID_TOKEN,
|
ErrorCodes.Authentication.INVALID_TOKEN,
|
||||||
@@ -271,7 +292,7 @@ export namespace Polar {
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = (event as { data?: Record<string, unknown> }).data ?? {};
|
const data = event.data ?? {};
|
||||||
const customer = data.customer as { externalId?: string | null } | undefined;
|
const customer = data.customer as { externalId?: string | null } | undefined;
|
||||||
// `externalId` is the team id we put on the customer. A delivery
|
// `externalId` is the team id we put on the customer. A delivery
|
||||||
// without one is about a customer created some other way — by hand in
|
// without one is about a customer created some other way — by hand in
|
||||||
|
|||||||
Reference in New Issue
Block a user