Closer/functions/dist/billing/revenueCatWebhook.js

179 lines
7.7 KiB
JavaScript
Raw Normal View History

"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
Object.defineProperty(exports, "__esModule", { value: true });
exports.AuthError = exports.ConfigError = exports.revenueCatWebhook = void 0;
exports.verifyWebhookSignature = verifyWebhookSignature;
const crypto = __importStar(require("crypto"));
const https_1 = require("firebase-functions/v2/https");
const params_1 = require("firebase-functions/params");
const entitlementLogic_1 = require("./entitlementLogic");
const log_1 = require("../log");
/**
* RevenueCat webhook handler.
*
* Path: POST /revenueCatWebhook (registered as an HTTPS function)
* Triggered by RevenueCat server-to-server events.
*
* Authentication HMAC-SHA256 (RevenueCat's webhook "signature verification"):
* - RevenueCat sends `X-RevenueCat-Webhook-Signature: t=<unix_ts>,v1=<hmac_sha256_hex>`.
* - v1 = HMAC-SHA256(secret, "<t>.<raw_body>"), hex-encoded, computed over the RAW request
* body bytes exactly as received (before any JSON parsing / re-serialization).
* - REVENUECAT_WEBHOOK_SECRET is the integration's signing secret from the RevenueCat
* dashboard (Integrations Webhooks enable signature verification). It is bound as a
* Secret Manager secret (below) and injected into process.env at runtime.
* - Missing secret 500 (our config error). Missing/invalid signature, or a timestamp
* outside the tolerance window (replay guard) 401.
*
* NOTE: RevenueCat does NOT offer an Ed25519 / public-key signing scheme HMAC-SHA256 with a
* shared secret is the only cryptographic option (plus the optional static Authorization header).
*
* Processing:
* - Parses the RevenueCat event payload.
* - Writes entitlement state to Firestore at users/{userId}/entitlements.
*/
const revenueCatWebhookSecret = (0, params_1.defineSecret)('REVENUECAT_WEBHOOK_SECRET');
/** Reject signatures whose timestamp is further than this from now (replay protection). */
const SIGNATURE_TOLERANCE_MS = 5 * 60 * 1000;
exports.revenueCatWebhook = (0, https_1.onRequest)({ secrets: [revenueCatWebhookSecret] }, async (req, res) => {
var _a;
if (req.method !== 'POST') {
res.status(405).json({ error: 'method_not_allowed' });
return;
}
try {
verifyRequest(req, Date.now());
}
catch (err) {
if (err instanceof ConfigError) {
log_1.logger.error('[revenueCatWebhook] configuration error', { error: err.message });
res.status(500).json({ error: 'internal_error' });
return;
}
res.status(401).json({ error: 'unauthorized' });
return;
}
const event = (_a = req.body) === null || _a === void 0 ? void 0 : _a.event;
if (!event || !event.type || !event.app_user_id) {
res.status(400).json({ error: 'malformed_payload' });
return;
}
// Security review Batch 2: process BEFORE acking. Previously we returned 200 up front
// and only logged failures, so a failed entitlement sync was silently dropped (no retry).
// RevenueCat retries on non-2xx, and applyEntitlementEvent is idempotent (it sets state),
// so returning 500 on failure recovers the event safely.
try {
await (0, entitlementLogic_1.applyEntitlementEvent)(event);
}
catch (err) {
log_1.logger.error('[revenueCatWebhook] entitlement sync failed', { error: String(err) });
res.status(500).json({ error: 'processing_failed' });
return;
}
res.status(200).json({ received: true });
});
class ConfigError extends Error {
}
exports.ConfigError = ConfigError;
class AuthError extends Error {
}
exports.AuthError = AuthError;
/**
* Reads the secret + signature header off the request and delegates to the pure verifier.
* Throws ConfigError when our secret is unset (500); AuthError for any auth failure (401).
*/
function verifyRequest(req, nowMs) {
const secret = process.env.REVENUECAT_WEBHOOK_SECRET;
if (!secret) {
throw new ConfigError('REVENUECAT_WEBHOOK_SECRET environment variable is not set');
}
// Firebase Functions expose the raw request body as req.rawBody (Buffer).
const rawBody = req.rawBody;
const sigHeader = req.headers['x-revenuecat-webhook-signature'];
const header = Array.isArray(sigHeader) ? sigHeader[0] : sigHeader;
verifyWebhookSignature({ rawBody, header, secret, nowMs });
}
/**
* Pure, testable HMAC-SHA256 verification of a RevenueCat webhook signature.
*
* Parses `t=<unix_ts>,v1=<hex>` from the header, recomputes HMAC-SHA256 over "<t>.<rawBody>"
* with the signing secret, constant-time-compares against v1, and rejects timestamps outside
* the tolerance window. Throws AuthError on any failure (caller maps to 401).
*/
function verifyWebhookSignature(opts) {
var _a;
const { rawBody, header, secret, nowMs } = opts;
const toleranceMs = (_a = opts.toleranceMs) !== null && _a !== void 0 ? _a : SIGNATURE_TOLERANCE_MS;
if (!rawBody || rawBody.length === 0) {
throw new AuthError('request body missing');
}
if (!header) {
throw new AuthError('signature header missing');
}
// Parse "t=<ts>,v1=<hex>" — order-independent, tolerant of extra fields.
let t;
let v1;
for (const part of header.split(',')) {
const seg = part.trim();
const eq = seg.indexOf('=');
if (eq < 0)
continue;
const key = seg.slice(0, eq);
const val = seg.slice(eq + 1);
if (key === 't')
t = val;
else if (key === 'v1')
v1 = val;
}
if (!t || !v1) {
throw new AuthError('signature header malformed');
}
const tsMs = Number(t) * 1000;
if (!Number.isFinite(tsMs)) {
throw new AuthError('signature timestamp invalid');
}
if (Math.abs(nowMs - tsMs) > toleranceMs) {
throw new AuthError('signature timestamp outside tolerance');
}
// HMAC over the RAW body bytes, prefixed with "<t>." — never re-serialize the JSON.
const signedPayload = Buffer.concat([Buffer.from(`${t}.`, 'utf8'), rawBody]);
const expected = crypto.createHmac('sha256', secret).update(signedPayload).digest('hex');
// Constant-time compare. timingSafeEqual throws on length mismatch, so guard the length first.
const expectedBuf = Buffer.from(expected, 'utf8');
const providedBuf = Buffer.from(v1, 'utf8');
if (expectedBuf.length !== providedBuf.length || !crypto.timingSafeEqual(expectedBuf, providedBuf)) {
throw new AuthError('signature mismatch');
}
}
//# sourceMappingURL=revenueCatWebhook.js.map