11import { User } from '@supabase/auth-js' ;
22import React from 'react' ;
33import { Polar } from '@polar-sh/sdk' ;
4- import crypto from 'crypto' ;
4+ import { validateEvent } from '@polar-sh/sdk/webhooks' ;
5+
56import {
67 PaymentProviderInterface ,
78 PaymentIntent ,
@@ -109,6 +110,16 @@ const polarTranslations = {
109110
110111const defaultAppUrl = process . env . NEXT_PUBLIC_APP_URL || 'https://demo.ever.works' ;
111112
113+ /**
114+ * Cache entry for webhook ID deduplication
115+ * Stores webhook IDs with TTL to prevent replay attacks
116+ */
117+ interface WebhookIdCacheEntry {
118+ webhookId : string ;
119+ processedAt : number ;
120+ expiresAt : number ;
121+ }
122+
112123export class PolarProvider implements PaymentProviderInterface {
113124 private polar : Polar ;
114125 private webhookSecret : string ;
@@ -117,6 +128,13 @@ export class PolarProvider implements PaymentProviderInterface {
117128 private apiKey : string ;
118129 private isSandbox : boolean = false ;
119130 private configuredApiUrl ?: string ;
131+ // In-memory cache for webhook ID deduplication (replay protection)
132+ // Key: webhook-id, Value: cache entry with expiration
133+ private webhookIdCache : Map < string , WebhookIdCacheEntry > = new Map ( ) ;
134+ // Default replay protection window: ±300 seconds (5 minutes)
135+ private readonly REPLAY_PROTECTION_WINDOW_SECONDS : number = 300 ;
136+ // TTL for webhook ID cache entries: window * 2 + 60 seconds buffer (660 seconds = 11 minutes)
137+ private readonly WEBHOOK_ID_CACHE_TTL_MS : number = 660000 ; // (300 * 2 + 60) * 1000
120138
121139 constructor ( config : PolarConfig ) {
122140 if ( ! config . apiKey ) {
@@ -134,7 +152,10 @@ export class PolarProvider implements PaymentProviderInterface {
134152 this . organizationId = config . options ?. organizationId ;
135153 // Clean appUrl: remove quotes, trailing slashes, and whitespace
136154 const rawAppUrl = config . options ?. appUrl || defaultAppUrl ;
137- this . appUrl = rawAppUrl . trim ( ) . replace ( / ^ [ " ' ] | [ " ' ] $ / g, '' ) . replace ( / \/ + $ / , '' ) ;
155+ this . appUrl = rawAppUrl
156+ . trim ( )
157+ . replace ( / ^ [ " ' ] | [ " ' ] $ / g, '' )
158+ . replace ( / \/ + $ / , '' ) ;
138159
139160 this . configuredApiUrl = config . options ?. apiUrl ;
140161
@@ -1056,16 +1077,16 @@ export class PolarProvider implements PaymentProviderInterface {
10561077 }
10571078
10581079 /**
1059- * Verifies webhook signature using Polar's official signature format
1060- * Polar uses: HMAC SHA256 of raw request body only (hex digest)
1061- * The webhook secret must be base64-decoded before HMAC computation
1080+ * Verifies webhook signature using Polar SDK 's validateEvent function
1081+ * Uses the official @polar-sh/sdk webhook validation utility
1082+ * Also implements replay protection via timestamp and webhook-id checks
10621083 *
1063- * @param signature - Received signature from header (hex format )
1084+ * @param signature - Received signature header value (should include "v1," prefix, e.g., "v1,<hex_signature>" )
10641085 * @param rawBody - Raw request body (required, no fallback)
10651086 * @param payload - Parsed payload (unused, kept for compatibility)
1066- * @param timestamp - Webhook timestamp (optional, used for replay protection only )
1067- * @param webhookId - Webhook ID (optional, not used in signature )
1068- * @throws Error if signature verification fails
1087+ * @param timestamp - Webhook timestamp (optional, used for replay protection)
1088+ * @param webhookId - Webhook ID (optional, used for idempotency/replay protection )
1089+ * @throws Error if signature verification fails or replay protection checks fail
10691090 */
10701091 private verifyWebhookSignature (
10711092 signature : string ,
@@ -1092,77 +1113,215 @@ export class PolarProvider implements PaymentProviderInterface {
10921113 throw new Error ( 'Missing webhook-signature header required for signature verification' ) ;
10931114 }
10941115
1095- // Validate timestamp replay protection if provided (optional but recommended)
1096- if ( timestamp ) {
1097- const webhookTime = parseInt ( timestamp , 10 ) ;
1098- const currentTime = Math . floor ( Date . now ( ) / 1000 ) ;
1099- const tolerance = 300 ; // 5 minutes in seconds
1116+ try {
1117+ // Build headers object for validateEvent
1118+ // Polar SDK validateEvent expects headers with webhook-signature (full value including "v1," prefix),
1119+ // webhook-timestamp, and webhook-id
1120+ // The signature should be in format "v1,<hex_signature>" as sent by Polar
1121+ const headers : Record < string , string > = {
1122+ 'webhook-signature' : signature
1123+ } ;
11001124
1101- if ( isNaN ( webhookTime ) ) {
1102- this . logger . error ( 'Invalid webhook timestamp format' , {
1103- bodyLength : rawBody . length
1104- } ) ;
1105- throw new Error ( 'Invalid webhook timestamp format' ) ;
1125+ if ( timestamp ) {
1126+ headers [ 'webhook-timestamp' ] = timestamp ;
11061127 }
11071128
1108- if ( Math . abs ( currentTime - webhookTime ) > tolerance ) {
1109- this . logger . error ( 'Webhook timestamp is outside acceptable window' , {
1110- timeDifference : Math . abs ( currentTime - webhookTime ) ,
1111- tolerance,
1112- bodyLength : rawBody . length
1113- } ) ;
1114- throw new Error ( 'Webhook timestamp is outside acceptable window' ) ;
1129+ if ( webhookId ) {
1130+ headers [ 'webhook-id' ] = webhookId ;
11151131 }
1116- }
11171132
1118- // Base64-decode the webhook secret before HMAC computation (per Polar docs)
1119- let secretKey : Buffer ;
1120- try {
1121- secretKey = Buffer . from ( this . webhookSecret , 'base64' ) ;
1133+ // Step 1: Verify HMAC signature using Polar SDK
1134+ // validateEvent takes the raw body, headers object, and secret
1135+ // It expects the webhook-signature header to contain the full value "v1,<signature>"
1136+ validateEvent ( rawBody , headers , this . webhookSecret ) ;
1137+
1138+ this . logger . debug ( 'Webhook signature verified successfully using @polar-sh/sdk validateEvent' , {
1139+ bodyLength : rawBody . length ,
1140+ hasTimestamp : ! ! timestamp ,
1141+ signatureFormat : signature . startsWith ( 'v1,' ) ? 'v1,<signature>' : 'raw'
1142+ } ) ;
1143+
1144+ // Step 2: Replay protection - Timestamp validation
1145+ // Reject webhooks outside the acceptable time window (±300 seconds by default)
1146+ this . validateWebhookTimestamp ( timestamp , rawBody . length , webhookId ) ;
1147+
1148+ // Step 3: Replay protection - Idempotency check via webhook-id
1149+ // Ensure we haven't processed this webhook-id before
1150+ this . checkWebhookIdempotency ( webhookId , rawBody . length , timestamp ) ;
11221151 } catch ( error ) {
1123- this . logger . error ( 'Failed to base64-decode webhook secret' , {
1124- error : error instanceof Error ? error . message : String ( error )
1152+ // Re-throw errors from replay protection checks as-is
1153+ if (
1154+ error instanceof Error &&
1155+ ( error . message . includes ( 'timestamp' ) || error . message . includes ( 'webhook-id' ) )
1156+ ) {
1157+ throw error ;
1158+ }
1159+
1160+ this . logger . error ( 'Webhook signature verification failed' , {
1161+ error : error instanceof Error ? error . message : String ( error ) ,
1162+ bodyLength : rawBody . length ,
1163+ hasTimestamp : ! ! timestamp ,
1164+ hasWebhookId : ! ! webhookId ,
1165+ signatureFormat : signature . startsWith ( 'v1,' ) ? 'v1,<signature>' : 'raw'
11251166 } ) ;
1126- throw new Error ( 'Invalid webhook secret format (must be base64-encoded)' ) ;
1167+ throw new Error (
1168+ `Invalid webhook signature: ${ error instanceof Error ? error . message : 'Verification failed' } `
1169+ ) ;
11271170 }
1171+ }
11281172
1129- // Compute HMAC-SHA256 over raw body only (no webhookId or timestamp)
1130- const expectedSignature = crypto . createHmac ( 'sha256' , secretKey ) . update ( rawBody , 'utf8' ) . digest ( 'hex' ) ;
1173+ /**
1174+ * Validates webhook timestamp to prevent replay attacks
1175+ * Rejects webhooks outside the acceptable time window
1176+ *
1177+ * @param timestamp - Webhook timestamp from header (Unix timestamp as string)
1178+ * @param bodyLength - Length of the request body (for logging)
1179+ * @param webhookId - Webhook ID (for logging)
1180+ * @throws Error if timestamp is missing, invalid, or outside acceptable window
1181+ */
1182+ private validateWebhookTimestamp (
1183+ timestamp : string | undefined ,
1184+ bodyLength : number ,
1185+ webhookId : string | undefined
1186+ ) : void {
1187+ // Timestamp is required for replay protection
1188+ if ( ! timestamp || typeof timestamp !== 'string' || timestamp . trim ( ) . length === 0 ) {
1189+ this . logger . error ( 'Webhook timestamp missing or invalid' , {
1190+ bodyLength,
1191+ webhookId : webhookId || 'unknown' ,
1192+ hasTimestamp : ! ! timestamp
1193+ } ) ;
1194+ throw new Error ( 'Webhook timestamp is required for replay protection' ) ;
1195+ }
11311196
1132- // Convert incoming signature from hex to buffer for timing-safe comparison
1133- let signatureBuffer : Buffer ;
1134- try {
1135- signatureBuffer = Buffer . from ( signature , 'hex' ) ;
1136- } catch ( error ) {
1137- this . logger . error ( 'Invalid signature format (expected hex)' , {
1138- error : error instanceof Error ? error . message : String ( error )
1197+ // Parse timestamp (Polar sends Unix timestamp as string)
1198+ const webhookTimestamp = parseInt ( timestamp , 10 ) ;
1199+ if ( isNaN ( webhookTimestamp ) || webhookTimestamp <= 0 ) {
1200+ this . logger . error ( 'Webhook timestamp is not a valid number' , {
1201+ bodyLength ,
1202+ webhookId : webhookId || 'unknown' ,
1203+ timestamp
11391204 } ) ;
1140- throw new Error ( ' Invalid webhook signature format (expected hexadecimal)' ) ;
1205+ throw new Error ( ` Invalid webhook timestamp format: ${ timestamp } ` ) ;
11411206 }
11421207
1143- const expectedSignatureBuffer = Buffer . from ( expectedSignature , 'hex' ) ;
1208+ // Calculate time difference in seconds
1209+ const currentTimestamp = Math . floor ( Date . now ( ) / 1000 ) ;
1210+ const timeDifference = Math . abs ( currentTimestamp - webhookTimestamp ) ;
1211+
1212+ // Reject if timestamp is outside acceptable window
1213+ if ( timeDifference > this . REPLAY_PROTECTION_WINDOW_SECONDS ) {
1214+ this . logger . error ( 'Webhook timestamp outside acceptable window' , {
1215+ bodyLength,
1216+ webhookId : webhookId || 'unknown' ,
1217+ webhookTimestamp,
1218+ currentTimestamp,
1219+ timeDifferenceSeconds : timeDifference ,
1220+ windowSeconds : this . REPLAY_PROTECTION_WINDOW_SECONDS ,
1221+ isTooOld : webhookTimestamp < currentTimestamp
1222+ } ) ;
1223+ throw new Error (
1224+ `Webhook timestamp outside acceptable window: ${ timeDifference } seconds difference (max: ${ this . REPLAY_PROTECTION_WINDOW_SECONDS } seconds)`
1225+ ) ;
1226+ }
11441227
1145- // Use constant-time comparison to prevent timing attacks
1146- // timingSafeEqual requires buffers of equal length
1147- const signaturesMatch =
1148- signatureBuffer . length === expectedSignatureBuffer . length &&
1149- crypto . timingSafeEqual ( signatureBuffer , expectedSignatureBuffer ) ;
1228+ this . logger . debug ( 'Webhook timestamp validation passed' , {
1229+ bodyLength,
1230+ webhookId : webhookId || 'unknown' ,
1231+ timeDifferenceSeconds : timeDifference
1232+ } ) ;
1233+ }
11501234
1151- if ( ! signaturesMatch ) {
1152- this . logger . error ( 'Invalid webhook signature' , {
1153- expectedLength : expectedSignature . length ,
1154- receivedLength : signature . length ,
1155- bodyLength : rawBody . length
1235+ /**
1236+ * Checks webhook idempotency to prevent duplicate processing
1237+ * Uses in-memory cache with TTL to track processed webhook IDs
1238+ *
1239+ * @param webhookId - Webhook ID from header
1240+ * @param bodyLength - Length of the request body (for logging)
1241+ * @param timestamp - Webhook timestamp (for logging)
1242+ * @throws Error if webhook-id is missing or has already been processed
1243+ */
1244+ private checkWebhookIdempotency (
1245+ webhookId : string | undefined ,
1246+ bodyLength : number ,
1247+ timestamp : string | undefined
1248+ ) : void {
1249+ // Webhook ID is required for idempotency checks
1250+ if ( ! webhookId || typeof webhookId !== 'string' || webhookId . trim ( ) . length === 0 ) {
1251+ this . logger . error ( 'Webhook ID missing or invalid' , {
1252+ bodyLength,
1253+ timestamp : timestamp || 'unknown' ,
1254+ hasWebhookId : ! ! webhookId
11561255 } ) ;
1157- throw new Error ( 'Invalid webhook signature' ) ;
1256+ throw new Error ( 'Webhook ID is required for idempotency protection' ) ;
1257+ }
1258+
1259+ // Clean up expired cache entries periodically (every 100 checks, approximately)
1260+ if ( Math . random ( ) < 0.01 ) {
1261+ this . cleanupExpiredWebhookIds ( ) ;
1262+ }
1263+
1264+ // Check if webhook ID was already processed
1265+ const existingEntry = this . webhookIdCache . get ( webhookId ) ;
1266+ const now = Date . now ( ) ;
1267+
1268+ if ( existingEntry ) {
1269+ // Check if entry is still valid (not expired)
1270+ if ( existingEntry . expiresAt > now ) {
1271+ this . logger . error ( 'Webhook ID already processed (duplicate/replay detected)' , {
1272+ bodyLength,
1273+ webhookId,
1274+ timestamp : timestamp || 'unknown' ,
1275+ previouslyProcessedAt : new Date ( existingEntry . processedAt ) . toISOString ( ) ,
1276+ timeSinceFirstProcessing : Math . floor ( ( now - existingEntry . processedAt ) / 1000 ) + ' seconds'
1277+ } ) ;
1278+ throw new Error ( `Webhook ID already processed: ${ webhookId } (replay attack detected)` ) ;
1279+ } else {
1280+ // Entry expired, remove it
1281+ this . webhookIdCache . delete ( webhookId ) ;
1282+ }
11581283 }
11591284
1160- this . logger . debug ( 'Webhook signature verified successfully' , {
1161- bodyLength : rawBody . length ,
1162- hasTimestamp : ! ! timestamp
1285+ // Record this webhook ID with TTL
1286+ const cacheEntry : WebhookIdCacheEntry = {
1287+ webhookId,
1288+ processedAt : now ,
1289+ expiresAt : now + this . WEBHOOK_ID_CACHE_TTL_MS
1290+ } ;
1291+
1292+ this . webhookIdCache . set ( webhookId , cacheEntry ) ;
1293+
1294+ this . logger . debug ( 'Webhook ID recorded for idempotency' , {
1295+ bodyLength,
1296+ webhookId,
1297+ timestamp : timestamp || 'unknown' ,
1298+ expiresAt : new Date ( cacheEntry . expiresAt ) . toISOString ( )
11631299 } ) ;
11641300 }
11651301
1302+ /**
1303+ * Cleans up expired webhook ID cache entries
1304+ * Removes entries that have exceeded their TTL
1305+ */
1306+ private cleanupExpiredWebhookIds ( ) : void {
1307+ const now = Date . now ( ) ;
1308+ let cleanedCount = 0 ;
1309+
1310+ for ( const [ webhookId , entry ] of this . webhookIdCache . entries ( ) ) {
1311+ if ( entry . expiresAt <= now ) {
1312+ this . webhookIdCache . delete ( webhookId ) ;
1313+ cleanedCount ++ ;
1314+ }
1315+ }
1316+
1317+ if ( cleanedCount > 0 ) {
1318+ this . logger . debug ( 'Cleaned up expired webhook ID cache entries' , {
1319+ cleanedCount,
1320+ remainingEntries : this . webhookIdCache . size
1321+ } ) ;
1322+ }
1323+ }
1324+
11661325 /**
11671326 * Maps Polar webhook event types to internal event format
11681327 *
0 commit comments