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 22:42:53 -05:00
|
|
|
import { getEnvValue } from '../config/env'
|
2026-06-16 21:37:57 -05:00
|
|
|
import * as crypto from 'crypto'
|
2026-06-16 01:17:58 -05:00
|
|
|
|
|
|
|
|
const router = Router()
|
|
|
|
|
|
2026-06-16 22:42:53 -05:00
|
|
|
/**
|
|
|
|
|
* 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',
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
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.
|
2026-06-16 22:42:53 -05:00
|
|
|
* Returns false for invalid signature/header; throws only for misconfiguration
|
|
|
|
|
* so the caller can return a 500 (fail-closed).
|
2026-06-16 21:53:53 -05:00
|
|
|
*/
|
|
|
|
|
function verifyRevenueCatSignature(req: Request): boolean {
|
|
|
|
|
const signingKey = process.env.REVENUECAT_SIGNING_KEY
|
|
|
|
|
|
2026-06-16 22:11:51 -05:00
|
|
|
// If signing key is not configured, fail closed (not dev mode)
|
2026-06-16 21:53:53 -05:00
|
|
|
if (!signingKey || signingKey.trim() === '') {
|
2026-06-16 22:11:51 -05:00
|
|
|
console.error('[webhook] REVENUECAT_SIGNING_KEY not set — rejecting all requests (fail-closed)')
|
|
|
|
|
throw new Error('REVENUECAT_SIGNING_KEY must be set in production')
|
2026-06-16 21:53:53 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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 {
|
2026-06-16 22:42:53 -05:00
|
|
|
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)
|
2026-06-16 21:53:53 -05:00
|
|
|
} catch (err) {
|
|
|
|
|
console.error('[webhook] Signature verification failed:', err)
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Verifies RevenueCat webhook secret using constant-time comparison.
|
2026-06-16 22:42:53 -05:00
|
|
|
* Kept as a fallback when signature verification is not configured.
|
|
|
|
|
* A missing secret causes verification to fail (fail-closed).
|
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 {
|
2026-06-16 22:42:53 -05:00
|
|
|
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
|
|
|
|
|
}
|
2026-06-16 21:53:53 -05:00
|
|
|
|
|
|
|
|
// Constant-time comparison to prevent timing attacks
|
|
|
|
|
const authBuffer = Buffer.from(auth)
|
|
|
|
|
const secretBuffer = Buffer.from(secret)
|
|
|
|
|
|
2026-06-16 22:42:53 -05:00
|
|
|
if (authBuffer.length !== secretBuffer.length) {
|
|
|
|
|
return false
|
|
|
|
|
}
|
2026-06-16 21:53:53 -05:00
|
|
|
|
2026-06-16 22:42:53 -05:00
|
|
|
return crypto.timingSafeEqual(authBuffer, secretBuffer)
|
2026-06-16 21:53:53 -05:00
|
|
|
} 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 22:42:53 -05:00
|
|
|
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
|
2026-06-16 22:11:51 -05:00
|
|
|
try {
|
2026-06-16 22:42:53 -05:00
|
|
|
verified = signatureKeyConfigured
|
|
|
|
|
? verifyRevenueCatSignature(req)
|
|
|
|
|
: verifyRevenueCatSecret(req)
|
2026-06-16 22:11:51 -05:00
|
|
|
} catch (err: any) {
|
2026-06-16 22:42:53 -05:00
|
|
|
// 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' })
|
2026-06-16 01:17:58 -05:00
|
|
|
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
|