Closer/server/src/routes/webhooks.ts

109 lines
3.5 KiB
TypeScript
Raw Normal View History

import { Router, Request, Response } from 'express'
import { syncEntitlement } from '../services/entitlement'
import { RevenueCatEvent } from '../types'
import { getEnv } from '../config/env'
import * as crypto from 'crypto'
const router = Router()
/**
* Verifies RevenueCat webhook signature using Ed25519.
* RevenueCat signs the raw request body with their signing key.
* If REVENUECAT_SIGNING_KEY is not set, logs warning but skips verification (dev mode).
*/
function verifyRevenueCatSignature(req: Request): boolean {
const signingKey = process.env.REVENUECAT_SIGNING_KEY
// If signing key is not configured, skip verification (development mode)
if (!signingKey || signingKey.trim() === '') {
console.warn('[webhook] REVENUECAT_SIGNING_KEY not set — skipping signature verification')
return true
}
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 {
// RevenueCat sends base64-encoded Ed25519 signature
const signature = Buffer.from(signatureHeader, 'base64')
const publicKey = Buffer.from(signingKey, 'base64')
// Verify using Ed25519
const verify = crypto.createVerify('Ed25519')
verify.update(rawBody)
return verify.verify(publicKey, signature)
} catch (err) {
console.error('[webhook] Signature verification failed:', err)
return false
}
}
/**
* Verifies RevenueCat webhook secret using constant-time comparison.
* DEPRECATED: Now uses signature verification. Kept for backwards compatibility.
*/
function verifyRevenueCatSecret(req: Request): boolean {
try {
const secret = getEnv('REVENUECAT_WEBHOOK_SECRET')
const auth = req.headers['authorization'] ?? ''
// Constant-time comparison to prevent timing attacks
const authBuffer = Buffer.from(auth)
const secretBuffer = Buffer.from(secret)
// Use timingSafeEqual with fixed buffer length to prevent length leakage
const maxLength = Math.max(authBuffer.length, secretBuffer.length)
const paddedAuth = Buffer.alloc(maxLength)
const paddedSecret = Buffer.alloc(maxLength)
authBuffer.copy(paddedAuth)
secretBuffer.copy(paddedSecret)
return crypto.timingSafeEqual(paddedAuth, paddedSecret)
} catch (err) {
console.error('[webhook] Secret verification error:', err)
return false
}
}
router.post('/revenuecat', async (req: Request, res: Response) => {
// Try signature verification first (modern)
const signatureValid = verifyRevenueCatSignature(req)
if (!signatureValid) {
res.status(401).json({ error: 'unauthorized' })
return
}
// Fallback to secret verification if no signature (legacy)
if (!signatureValid && !verifyRevenueCatSecret(req)) {
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