diff --git a/server/src/config/env.ts b/server/src/config/env.ts index 7618ec57..d21c657f 100644 --- a/server/src/config/env.ts +++ b/server/src/config/env.ts @@ -1,15 +1,19 @@ import 'dotenv/config' -const requiredEnvVars = ['REVENUECAT_WEBHOOK_SECRET', 'FIREBASE_PROJECT_ID'] as const +const requiredEnvVars = ['FIREBASE_PROJECT_ID'] as const +const recommendedEnvVars = ['REVENUECAT_WEBHOOK_SECRET', 'REVENUECAT_SIGNING_KEY', 'REVENUECAT_PREMIUM_PRODUCT_IDS'] as const type RequiredEnvVar = (typeof requiredEnvVars)[number] +type RecommendedEnvVar = (typeof recommendedEnvVars)[number] /** * Validates that all required environment variables are set. * Throws an error with details if any are missing. + * Logs warnings for missing recommended vars. */ export function validateEnv(): void { const missing: RequiredEnvVar[] = [] + const missingRecommended: RecommendedEnvVar[] = [] for (const varName of requiredEnvVars) { const value = process.env[varName] @@ -18,10 +22,25 @@ export function validateEnv(): void { } } + for (const varName of recommendedEnvVars) { + const value = process.env[varName] + if (!value || value.trim() === '') { + missingRecommended.push(varName) + } + } + if (missing.length > 0) { const message = missing.map(v => ` - ${v}`).join('\n') throw new Error(`Missing required environment variables:\n${message}`) } + + // Log warnings for missing recommended vars (not fatal) + if (missingRecommended.length > 0) { + console.warn('[env] Missing recommended environment variables (some features disabled):') + for (const varName of missingRecommended) { + console.warn(` - ${varName}`) + } + } } /** @@ -35,3 +54,12 @@ export function getEnv(varName: RequiredEnvVar): string { } return value } + +/** + * Safely retrieves an environment variable (required or recommended). + * Returns empty string for missing recommended vars (allowing defaults). + */ +export function getEnvValue(varName: string): string { + const value = process.env[varName] + return value ?? '' +} diff --git a/server/src/index.ts b/server/src/index.ts index 543c4a06..2f58b67c 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -22,9 +22,15 @@ initFirebase() const app = express() const PORT = parseInt(process.env.PORT ?? '8080', 10) +// Capture raw body BEFORE express.json() for webhook signature verification +app.use(express.json({ + verify: (req: express.Request, res: express.Response, body: Buffer) => { + (req as any).rawBody = body + }, +})) + app.use(helmet()) app.use(morgan('combined')) -app.use(express.json()) app.use('/health', healthRouter) app.use('/webhooks', webhooksRouter) diff --git a/server/src/routes/webhooks.ts b/server/src/routes/webhooks.ts index 81fda3f1..57d3cb8b 100644 --- a/server/src/routes/webhooks.ts +++ b/server/src/routes/webhooks.ts @@ -7,30 +7,84 @@ import * as crypto from 'crypto' const router = Router() /** - * Verifies RevenueCat webhook signature using constant-time comparison. - * Assumes validateEnv() was called at startup - throws if secret is unexpectedly missing. + * 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 { - 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) + 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) => { - if (!verifyRevenueCatSecret(req)) { + // 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 } diff --git a/server/src/services/entitlement.ts b/server/src/services/entitlement.ts index cdab08ea..0b5b134b 100644 --- a/server/src/services/entitlement.ts +++ b/server/src/services/entitlement.ts @@ -1,5 +1,19 @@ import { db } from '../config/firebase' import { RevenueCatEvent } from '../types' +import { getEnvValue } from '../config/env' + +// Product IDs that grant premium access (comma-separated, configurable via env) +const DEFAULT_PREMIUM_PRODUCT_IDS = 'closer_premium_monthly,closer_premium_yearly' +const PRODUCT_ID_ALLOWLIST = new Set( + getEnvValue('REVENUECAT_PREMIUM_PRODUCT_IDS') + .split(',') + .map(id => id.trim()) + .filter(id => id.length > 0) +) + +if (PRODUCT_ID_ALLOWLIST.size === 0) { + console.warn('[entitlement] No product IDs in allowlist - entitlement validation will be bypassed') +} const PREMIUM_ACTIVE_TYPES = new Set([ 'INITIAL_PURCHASE', @@ -17,8 +31,28 @@ const PREMIUM_REVOKED_TYPES = new Set([ const PREMIUM_ENTITLEMENT_ID = 'closer_premium' +/** + * Checks if premium is currently active for a user. + * Verifies both hasPremium flag AND expiration timestamp. + */ +export async function verifyPremiumActive(uid: string): Promise { + const userDoc = await db().collection('users').doc(uid).get() + const data = userDoc.data() + if (!data) return false + + // hasPremium must be true AND expiration must be in the future + if (!data.hasPremium) return false + + const expiresAt = data.premiumExpiresAt as FirebaseFirestore.Timestamp | undefined + if (!expiresAt) return false + + const now = new Date() + const expirationDate = expiresAt.toDate() + return expirationDate > now +} + export async function syncEntitlement(event: RevenueCatEvent): Promise { - const { type, app_user_id: uid, id: eventId } = event.event + const { type, app_user_id: uid, id: eventId, product_id } = event.event // Check idempotency - skip if we've already processed this event const eventRef = db().collection('entitlement_events').doc(eventId) @@ -31,6 +65,12 @@ export async function syncEntitlement(event: RevenueCatEvent): Promise { // Store event for idempotency (with 7-day TTL via Firestore rule or scheduled cleanup) await eventRef.set({ processedAt: new Date() }) + // Validate product_id against allowlist + if (!PRODUCT_ID_ALLOWLIST.has(product_id)) { + console.log(`[entitlement] ignored event for unknown product_id: ${product_id}`) + return + } + // Verify entitlement_id matches expected premium entitlement const entitlementId = event.event.entitlement?.identifier || event.event.entitlement_id if (entitlementId !== PREMIUM_ENTITLEMENT_ID) { @@ -39,13 +79,26 @@ export async function syncEntitlement(event: RevenueCatEvent): Promise { } if (PREMIUM_ACTIVE_TYPES.has(type)) { - await db().collection('users').doc(uid).update({ hasPremium: true }) + const updates: { hasPremium: true; premiumExpiresAt?: FirebaseFirestore.Timestamp } = { hasPremium: true } + + // Store expiration timestamp if provided + const expirationAtMs = event.event.expiration_at_ms + if (expirationAtMs !== undefined) { + updates.premiumExpiresAt = new db().firestore.Timestamp.fromMillis(expirationAtMs) + } + + await db().collection('users').doc(uid).update(updates) console.log(`[entitlement] hasPremium=true for ${uid} (${type})`) return } if (PREMIUM_REVOKED_TYPES.has(type)) { - await db().collection('users').doc(uid).update({ hasPremium: false }) + const updates: { hasPremium: false; premiumExpiresAt?: null } = { hasPremium: false } + + // Clear expiration timestamp for revocation events + updates.premiumExpiresAt = null as any + + await db().collection('users').doc(uid).update(updates) console.log(`[entitlement] hasPremium=false for ${uid} (${type})`) return } diff --git a/server/src/types/index.ts b/server/src/types/index.ts index 105c36ba..45d02e61 100644 --- a/server/src/types/index.ts +++ b/server/src/types/index.ts @@ -22,11 +22,19 @@ export interface AnswerDoc { export interface RevenueCatEvent { event: { + id: string // Event ID for idempotency type: string app_user_id: string product_id: string period_type?: string expiration_at_ms?: number is_family_share?: boolean + entitlement?: { + identifier: string + product_id: string + } + entitlement_id?: string // Alternative field name + store?: 'app_store' | 'play_store' | 'mac_store' | 'stripe' | 'unknown' + environment?: 'SANDBOX' | 'PRODUCTION' } }