feat: implement Ed25519 RevenueCat webhook signature verification
This commit is contained in:
parent
ede7c70ea7
commit
534bb076c7
|
|
@ -34,6 +34,7 @@ var __importStar = (this && this.__importStar) || (function () {
|
|||
})();
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.revenueCatWebhook = void 0;
|
||||
const crypto = __importStar(require("crypto"));
|
||||
const functions = __importStar(require("firebase-functions"));
|
||||
const entitlementLogic_1 = require("./entitlementLogic");
|
||||
/**
|
||||
|
|
@ -42,29 +43,31 @@ const entitlementLogic_1 = require("./entitlementLogic");
|
|||
* Path: POST /revenueCatWebhook (registered as an HTTPS function)
|
||||
* Triggered by RevenueCat server-to-server events.
|
||||
*
|
||||
* Security:
|
||||
* - Real signature verification is intentionally a placeholder. Add Ed25519
|
||||
* signature verification using process.env.REVENUECAT_SIGNING_KEY before
|
||||
* shipping to production.
|
||||
* - The function immediately acknowledges valid-looking events so RevenueCat
|
||||
* does not retry; downstream failures are logged and should be monitored.
|
||||
* Authentication:
|
||||
* - Verifies the Ed25519 signature in the X-Signature request header.
|
||||
* - REVENUECAT_SIGNING_KEY must be set to the base64-encoded DER/SPKI Ed25519
|
||||
* public key from your RevenueCat project dashboard.
|
||||
* - Missing key → 500 (our config error). Invalid/absent signature → 401.
|
||||
*
|
||||
* Processing:
|
||||
* - Parses the RevenueCat event payload.
|
||||
* - Writes entitlement state to Firestore at users/{userId}/entitlements.
|
||||
* - Handles TRANSFER, RENEWAL, CANCELLATION, EXPIRATION, and BILLING_ISSUE.
|
||||
*/
|
||||
exports.revenueCatWebhook = functions.https.onRequest(async (req, res) => {
|
||||
var _a;
|
||||
// Only accept POST requests.
|
||||
if (req.method !== 'POST') {
|
||||
res.status(405).json({ error: 'method_not_allowed' });
|
||||
return;
|
||||
}
|
||||
// TODO: verify RevenueCat webhook signature / secret before trusting payload.
|
||||
// Placeholder verification returns true for now.
|
||||
const verified = verifyRevenueCatWebhook(req);
|
||||
if (!verified) {
|
||||
try {
|
||||
verifySignature(req);
|
||||
}
|
||||
catch (err) {
|
||||
if (err instanceof ConfigError) {
|
||||
console.error('[revenueCatWebhook] configuration error:', err.message);
|
||||
res.status(500).json({ error: 'internal_error' });
|
||||
return;
|
||||
}
|
||||
res.status(401).json({ error: 'unauthorized' });
|
||||
return;
|
||||
}
|
||||
|
|
@ -82,13 +85,59 @@ exports.revenueCatWebhook = functions.https.onRequest(async (req, res) => {
|
|||
console.error('[revenueCatWebhook] entitlement sync failed:', err);
|
||||
}
|
||||
});
|
||||
class ConfigError extends Error {
|
||||
}
|
||||
class AuthError extends Error {
|
||||
}
|
||||
/**
|
||||
* Placeholder webhook verification.
|
||||
* Replace with real Ed25519 signature verification before production.
|
||||
* Verifies the Ed25519 signature RevenueCat sends in the X-Signature header.
|
||||
*
|
||||
* Throws ConfigError when REVENUECAT_SIGNING_KEY is absent or malformed —
|
||||
* these are deployment problems, not auth failures.
|
||||
* Throws AuthError for any missing or invalid signature — these are 401s.
|
||||
*/
|
||||
function verifyRevenueCatWebhook(
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
_req) {
|
||||
return true;
|
||||
function verifySignature(req) {
|
||||
const signingKey = process.env.REVENUECAT_SIGNING_KEY;
|
||||
if (!signingKey) {
|
||||
throw new ConfigError('REVENUECAT_SIGNING_KEY environment variable is not set');
|
||||
}
|
||||
// Firebase Functions expose the raw request body as req.rawBody (Buffer).
|
||||
const rawBody = req.rawBody;
|
||||
if (!rawBody || rawBody.length === 0) {
|
||||
throw new AuthError('request body missing');
|
||||
}
|
||||
const sigHeader = req.headers['x-signature'];
|
||||
const signature = Array.isArray(sigHeader) ? sigHeader[0] : sigHeader;
|
||||
if (!signature) {
|
||||
throw new AuthError('x-signature header missing');
|
||||
}
|
||||
let publicKey;
|
||||
try {
|
||||
publicKey = crypto.createPublicKey({
|
||||
key: Buffer.from(signingKey, 'base64'),
|
||||
format: 'der',
|
||||
type: 'spki',
|
||||
});
|
||||
}
|
||||
catch (_a) {
|
||||
throw new ConfigError('REVENUECAT_SIGNING_KEY is malformed — expected base64-encoded DER/SPKI Ed25519 public key');
|
||||
}
|
||||
let sigBuffer;
|
||||
try {
|
||||
sigBuffer = Buffer.from(signature, 'base64');
|
||||
}
|
||||
catch (_b) {
|
||||
throw new AuthError('x-signature is not valid base64');
|
||||
}
|
||||
let valid;
|
||||
try {
|
||||
valid = crypto.verify(null, rawBody, publicKey, sigBuffer);
|
||||
}
|
||||
catch (_c) {
|
||||
throw new AuthError('signature verification failed');
|
||||
}
|
||||
if (!valid) {
|
||||
throw new AuthError('signature mismatch');
|
||||
}
|
||||
}
|
||||
//# sourceMappingURL=revenueCatWebhook.js.map
|
||||
|
|
@ -1 +1 @@
|
|||
{"version":3,"file":"revenueCatWebhook.js","sourceRoot":"","sources":["../../src/billing/revenueCatWebhook.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAAA,8DAA+C;AAC/C,yDAA4E;AAE5E;;;;;;;;;;;;;;;;;GAiBG;AACU,QAAA,iBAAiB,GAAG,SAAS,CAAC,KAAK,CAAC,SAAS,CAAC,KAAK,EAAE,GAAG,EAAE,GAAG,EAAE,EAAE;;IAC5E,6BAA6B;IAC7B,IAAI,GAAG,CAAC,MAAM,KAAK,MAAM,EAAE,CAAC;QAC1B,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,EAAE,KAAK,EAAE,oBAAoB,EAAE,CAAC,CAAA;QACrD,OAAM;IACR,CAAC;IAED,8EAA8E;IAC9E,iDAAiD;IACjD,MAAM,QAAQ,GAAG,uBAAuB,CAAC,GAAG,CAAC,CAAA;IAC7C,IAAI,CAAC,QAAQ,EAAE,CAAC;QACd,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,EAAE,KAAK,EAAE,cAAc,EAAE,CAAC,CAAA;QAC/C,OAAM;IACR,CAAC;IAED,MAAM,KAAK,GAAG,MAAA,GAAG,CAAC,IAAI,0CAAE,KAAqC,CAAA;IAC7D,IAAI,CAAC,KAAK,IAAI,CAAC,KAAK,CAAC,IAAI,IAAI,CAAC,KAAK,CAAC,WAAW,EAAE,CAAC;QAChD,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,EAAE,KAAK,EAAE,mBAAmB,EAAE,CAAC,CAAA;QACpD,OAAM;IACR,CAAC;IAED,uDAAuD;IACvD,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,EAAE,QAAQ,EAAE,IAAI,EAAE,CAAC,CAAA;IAExC,IAAI,CAAC;QACH,MAAM,IAAA,wCAAqB,EAAC,KAAK,CAAC,CAAA;IACpC,CAAC;IAAC,OAAO,GAAG,EAAE,CAAC;QACb,OAAO,CAAC,KAAK,CAAC,8CAA8C,EAAE,GAAG,CAAC,CAAA;IACpE,CAAC;AACH,CAAC,CAAC,CAAA;AAEF;;;GAGG;AACH,SAAS,uBAAuB;AAC9B,6DAA6D;AAC7D,IAA6B;IAE7B,OAAO,IAAI,CAAA;AACb,CAAC"}
|
||||
{"version":3,"file":"revenueCatWebhook.js","sourceRoot":"","sources":["../../src/billing/revenueCatWebhook.ts"],"names":[],"mappings":"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"}
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
import * as crypto from 'crypto'
|
||||
import * as functions from 'firebase-functions'
|
||||
import { applyEntitlementEvent, EntitlementEvent } from './entitlementLogic'
|
||||
|
||||
|
|
@ -7,29 +8,30 @@ import { applyEntitlementEvent, EntitlementEvent } from './entitlementLogic'
|
|||
* Path: POST /revenueCatWebhook (registered as an HTTPS function)
|
||||
* Triggered by RevenueCat server-to-server events.
|
||||
*
|
||||
* Security:
|
||||
* - Real signature verification is intentionally a placeholder. Add Ed25519
|
||||
* signature verification using process.env.REVENUECAT_SIGNING_KEY before
|
||||
* shipping to production.
|
||||
* - The function immediately acknowledges valid-looking events so RevenueCat
|
||||
* does not retry; downstream failures are logged and should be monitored.
|
||||
* Authentication:
|
||||
* - Verifies the Ed25519 signature in the X-Signature request header.
|
||||
* - REVENUECAT_SIGNING_KEY must be set to the base64-encoded DER/SPKI Ed25519
|
||||
* public key from your RevenueCat project dashboard.
|
||||
* - Missing key → 500 (our config error). Invalid/absent signature → 401.
|
||||
*
|
||||
* Processing:
|
||||
* - Parses the RevenueCat event payload.
|
||||
* - Writes entitlement state to Firestore at users/{userId}/entitlements.
|
||||
* - Handles TRANSFER, RENEWAL, CANCELLATION, EXPIRATION, and BILLING_ISSUE.
|
||||
*/
|
||||
export const revenueCatWebhook = functions.https.onRequest(async (req, res) => {
|
||||
// Only accept POST requests.
|
||||
if (req.method !== 'POST') {
|
||||
res.status(405).json({ error: 'method_not_allowed' })
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: verify RevenueCat webhook signature / secret before trusting payload.
|
||||
// Placeholder verification returns true for now.
|
||||
const verified = verifyRevenueCatWebhook(req)
|
||||
if (!verified) {
|
||||
try {
|
||||
verifySignature(req)
|
||||
} catch (err) {
|
||||
if (err instanceof ConfigError) {
|
||||
console.error('[revenueCatWebhook] configuration error:', err.message)
|
||||
res.status(500).json({ error: 'internal_error' })
|
||||
return
|
||||
}
|
||||
res.status(401).json({ error: 'unauthorized' })
|
||||
return
|
||||
}
|
||||
|
|
@ -50,13 +52,62 @@ export const revenueCatWebhook = functions.https.onRequest(async (req, res) => {
|
|||
}
|
||||
})
|
||||
|
||||
class ConfigError extends Error {}
|
||||
class AuthError extends Error {}
|
||||
|
||||
/**
|
||||
* Placeholder webhook verification.
|
||||
* Replace with real Ed25519 signature verification before production.
|
||||
* Verifies the Ed25519 signature RevenueCat sends in the X-Signature header.
|
||||
*
|
||||
* Throws ConfigError when REVENUECAT_SIGNING_KEY is absent or malformed —
|
||||
* these are deployment problems, not auth failures.
|
||||
* Throws AuthError for any missing or invalid signature — these are 401s.
|
||||
*/
|
||||
function verifyRevenueCatWebhook(
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
_req: functions.https.Request
|
||||
): boolean {
|
||||
return true
|
||||
function verifySignature(req: functions.https.Request): void {
|
||||
const signingKey = process.env.REVENUECAT_SIGNING_KEY
|
||||
if (!signingKey) {
|
||||
throw new ConfigError('REVENUECAT_SIGNING_KEY environment variable is not set')
|
||||
}
|
||||
|
||||
// Firebase Functions expose the raw request body as req.rawBody (Buffer).
|
||||
const rawBody = (req as unknown as { rawBody?: Buffer }).rawBody
|
||||
if (!rawBody || rawBody.length === 0) {
|
||||
throw new AuthError('request body missing')
|
||||
}
|
||||
|
||||
const sigHeader = req.headers['x-signature']
|
||||
const signature = Array.isArray(sigHeader) ? sigHeader[0] : sigHeader
|
||||
if (!signature) {
|
||||
throw new AuthError('x-signature header missing')
|
||||
}
|
||||
|
||||
let publicKey: crypto.KeyObject
|
||||
try {
|
||||
publicKey = crypto.createPublicKey({
|
||||
key: Buffer.from(signingKey, 'base64'),
|
||||
format: 'der',
|
||||
type: 'spki',
|
||||
})
|
||||
} catch {
|
||||
throw new ConfigError(
|
||||
'REVENUECAT_SIGNING_KEY is malformed — expected base64-encoded DER/SPKI Ed25519 public key'
|
||||
)
|
||||
}
|
||||
|
||||
let sigBuffer: Buffer
|
||||
try {
|
||||
sigBuffer = Buffer.from(signature, 'base64')
|
||||
} catch {
|
||||
throw new AuthError('x-signature is not valid base64')
|
||||
}
|
||||
|
||||
let valid: boolean
|
||||
try {
|
||||
valid = crypto.verify(null, rawBody, publicKey, sigBuffer)
|
||||
} catch {
|
||||
throw new AuthError('signature verification failed')
|
||||
}
|
||||
|
||||
if (!valid) {
|
||||
throw new AuthError('signature mismatch')
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue