2026-06-16 01:17:58 -05:00
|
|
|
import { Router, Request, Response } from 'express'
|
|
|
|
|
import { syncEntitlement } from '../services/entitlement'
|
|
|
|
|
import { RevenueCatEvent } from '../types'
|
2026-06-16 21:37:57 -05:00
|
|
|
import { getEnv } from '../config/env'
|
|
|
|
|
import * as crypto from 'crypto'
|
2026-06-16 01:17:58 -05:00
|
|
|
|
|
|
|
|
const router = Router()
|
|
|
|
|
|
2026-06-16 21:37:57 -05:00
|
|
|
/**
|
2026-06-16 21:53:53 -05:00
|
|
|
* 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.
|
2026-06-16 21:37:57 -05:00
|
|
|
*/
|
2026-06-16 01:17:58 -05:00
|
|
|
function verifyRevenueCatSecret(req: Request): boolean {
|
2026-06-16 21:53:53 -05:00
|
|
|
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
|
|
|
|
|
}
|
2026-06-16 01:17:58 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
router.post('/revenuecat', async (req: Request, res: Response) => {
|
2026-06-16 21:53:53 -05:00
|
|
|
// 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)) {
|
2026-06-16 01:17:58 -05:00
|
|
|
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
|