import { Router, Request, Response } from 'express' import { syncEntitlement } from '../services/entitlement' import { RevenueCatEvent } from '../types' import { getEnvValue } from '../config/env' import * as crypto from 'crypto' const router = Router() /** * Loads a RevenueCat Ed25519 public key from its base64 form. * RevenueCat typically distributes the raw 32-byte public key (base64). * We also accept DER SPKI form for forward compatibility. */ function loadRevenueCatPublicKey(base64Key: string): crypto.KeyObject { const keyBytes = Buffer.from(base64Key, 'base64') if (keyBytes.length === 32) { // Raw Ed25519 public key -> encode as JWK return crypto.createPublicKey({ key: { kty: 'OKP', crv: 'Ed25519', x: keyBytes.toString('base64url'), }, format: 'jwk', }) } // Otherwise assume DER-encoded SubjectPublicKeyInfo return crypto.createPublicKey({ key: keyBytes, format: 'der', type: 'spki', }) } /** * Verifies RevenueCat webhook signature using Ed25519. * RevenueCat signs the raw request body with their signing key. * Returns false for invalid signature/header; throws only for misconfiguration * so the caller can return a 500 (fail-closed). */ function verifyRevenueCatSignature(req: Request): boolean { const signingKey = process.env.REVENUECAT_SIGNING_KEY // If signing key is not configured, fail closed (not dev mode) if (!signingKey || signingKey.trim() === '') { console.error('[webhook] REVENUECAT_SIGNING_KEY not set — rejecting all requests (fail-closed)') throw new Error('REVENUECAT_SIGNING_KEY must be set in production') } const signatureHeader = req.headers['x-revenuecat-signature'] if (!signatureHeader) { console.error('[webhook] Missing X-RevenueCat-Signature header') return false } // The raw body should have been captured by middleware const rawBody = (req as any).rawBody if (!rawBody || !Buffer.isBuffer(rawBody)) { console.error('[webhook] Raw body not available for signature verification') return false } try { const signature = Buffer.from( Array.isArray(signatureHeader) ? signatureHeader[0] : signatureHeader, 'base64' ) const publicKey = loadRevenueCatPublicKey(signingKey) // Ed25519 uses no digest algorithm in Node.js return crypto.verify(null, rawBody, publicKey, signature) } catch (err) { console.error('[webhook] Signature verification failed:', err) return false } } /** * Verifies RevenueCat webhook secret using constant-time comparison. * Kept as a fallback when signature verification is not configured. * A missing secret causes verification to fail (fail-closed). */ function verifyRevenueCatSecret(req: Request): boolean { try { const secret = getEnvValue('REVENUECAT_WEBHOOK_SECRET') const authHeader = req.headers['authorization'] const auth = (Array.isArray(authHeader) ? authHeader[0] : authHeader) ?? '' // Missing secret = fail closed if (!secret) { console.error('[webhook] REVENUECAT_WEBHOOK_SECRET not set — rejecting all requests (fail-closed)') return false } // Constant-time comparison to prevent timing attacks const authBuffer = Buffer.from(auth) const secretBuffer = Buffer.from(secret) if (authBuffer.length !== secretBuffer.length) { return false } return crypto.timingSafeEqual(authBuffer, secretBuffer) } catch (err) { console.error('[webhook] Secret verification error:', err) return false } } router.post('/revenuecat', async (req: Request, res: Response) => { const signatureKeyConfigured = !!process.env.REVENUECAT_SIGNING_KEY?.trim() const secretConfigured = !!process.env.REVENUECAT_WEBHOOK_SECRET?.trim() // Fail closed: at least one auth method must be configured if (!signatureKeyConfigured && !secretConfigured) { console.error('[webhook] No webhook auth configured — rejecting all requests (fail-closed)') res.status(500).json({ error: 'webhook_auth_not_configured' }) return } let verified = false try { verified = signatureKeyConfigured ? verifyRevenueCatSignature(req) : verifyRevenueCatSecret(req) } catch (err: any) { // Configuration/internal errors are surfaced as 500 (fail-closed) console.error('[webhook] Auth verification error:', err) res.status(500).json({ error: 'auth_verification_failed', message: err.message }) return } if (!verified) { res.status(401).json({ error: 'unauthorized' }) return } const body = req.body as RevenueCatEvent if (!body?.event?.type || !body?.event?.app_user_id) { res.status(400).json({ error: 'malformed payload' }) return } // Acknowledge immediately — RC retries if we don't respond within 10s res.status(200).json({ received: true }) try { await syncEntitlement(body) } catch (err) { console.error('[webhook] entitlement sync failed:', err) } }) export default router