@safepassage/sdk 3.0.3 → 3.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,587 @@
1
+ /**
2
+ * SafePassage SDK - Redirect-based age verification
3
+ *
4
+ * Lightweight SDK for integrating SafePassage age verification using redirect flow.
5
+ * Provides a secure, easy-to-implement solution for age verification with comprehensive
6
+ * security features and flexible integration options.
7
+ *
8
+ * Key Features:
9
+ * - Redirect and new-tab verification modes
10
+ * - Automatic session management for public API keys
11
+ * - HMAC-signed state parameters for security
12
+ * - Rate limiting and race condition protection
13
+ * - Comprehensive security validation and logging
14
+ * - PostMessage communication for new-tab mode
15
+ * - Automatic cleanup and resource management
16
+ * - Environment-specific configuration
17
+ *
18
+ * Security Features:
19
+ * - Origin validation for PostMessage communication
20
+ * - HTTPS enforcement in production
21
+ * - Rate limiting per API key and origin
22
+ * - State parameter signing with timestamps and nonces
23
+ * - Comprehensive security event logging
24
+ * - Protection against race conditions and replay attacks
25
+ *
26
+ * @author SafePassage Engineering
27
+ * @version 2.0.0
28
+ * @since 1.0.0
29
+ */
30
+ import { generateState, validateConfig } from '../utils/validation';
31
+ import { getEnvironmentUrl, validateEnvironmentSecurity, } from '../utils/environment';
32
+ import { validatePostMessageOrigin, validateSafePassageMessage, enforceHTTPS, verificationRateLimit, logSecurityEvent, } from '../utils/security';
33
+ /**
34
+ * SafePassage SDK Main Class
35
+ *
36
+ * Primary SDK class that manages age verification sessions with comprehensive
37
+ * security and error handling. Supports both redirect and new-tab modes with
38
+ * automatic session management and PostMessage communication.
39
+ *
40
+ * The class implements multiple security layers including origin validation,
41
+ * rate limiting, state signing, and comprehensive event logging to ensure
42
+ * secure verification flows.
43
+ */
44
+ export class SafePassage {
45
+ /**
46
+ * Initialize SafePassage SDK
47
+ *
48
+ * Validates configuration, sets up security measures, and prepares the SDK
49
+ * for verification operations. Performs comprehensive environment validation
50
+ * and security initialization.
51
+ *
52
+ * @param {SafePassageConfig} config - SDK configuration object
53
+ * @throws {Error} If configuration validation fails
54
+ */
55
+ constructor(config) {
56
+ this.popupWindow = null;
57
+ this.messageListener = null;
58
+ this.popupMonitorInterval = null;
59
+ this.unloadListener = null;
60
+ this.isVerificationInProgress = false;
61
+ this.currentSessionId = null;
62
+ validateConfig(config);
63
+ this.config = {
64
+ ...config,
65
+ environment: config.environment || this.detectEnvironment(),
66
+ mode: config.mode || 'redirect',
67
+ };
68
+ // Comprehensive environment security validation
69
+ validateEnvironmentSecurity(this.config.environment);
70
+ // Enforce HTTPS in production (additional layer)
71
+ enforceHTTPS(this.config.environment);
72
+ // Log initialization for security monitoring
73
+ logSecurityEvent('SDK_INITIALIZED', {
74
+ environment: this.config.environment,
75
+ mode: this.config.mode,
76
+ origin: window.location.origin,
77
+ protocol: window.location.protocol,
78
+ hostname: window.location.hostname,
79
+ });
80
+ // Set up automatic cleanup on page unload
81
+ this.setupAutoCleanup();
82
+ }
83
+ /**
84
+ * Initiate age verification with race condition protection
85
+ *
86
+ * Main verification method that handles session creation, security validation,
87
+ * and verification flow initiation. Includes race condition protection and
88
+ * comprehensive error handling.
89
+ *
90
+ * For public keys (pk_*), automatically creates sessions via the portal API.
91
+ * For private keys (sk_*), requires a pre-created sessionId.
92
+ *
93
+ * @param {VerificationOptions} [options={}] - Verification options
94
+ * @param {string} [options.sessionId] - Session ID (required for private keys)
95
+ * @param {number} [options.challengeAge] - Age challenge override
96
+ * @param {string} [options.verificationMode] - Verification mode override
97
+ * @param {string} [options.externalUserId] - External user identifier
98
+ * @returns {Promise<void>} Promise that resolves when verification is initiated
99
+ * @throws {Error} If verification cannot be started or is already in progress
100
+ */
101
+ async verify(options = {}) {
102
+ const isPublicKey = this.isPublicKey();
103
+ let sessionId = options.sessionId;
104
+ // For public keys, generate session internally if not provided
105
+ if (isPublicKey && !sessionId) {
106
+ sessionId = await this.createInternalSession(options);
107
+ }
108
+ // For private keys, sessionId is required
109
+ if (!isPublicKey && !sessionId) {
110
+ throw new Error('sessionId is required for private API keys - must be a merchant-generated UUID v4');
111
+ }
112
+ if (!sessionId) {
113
+ throw new Error('Failed to create or obtain sessionId');
114
+ }
115
+ // Race condition check - prevent multiple simultaneous verifications
116
+ if (this.isVerificationInProgress) {
117
+ const error = new Error(`Verification already in progress for session ${this.currentSessionId?.substring(0, 8)}...`);
118
+ logSecurityEvent('RACE_CONDITION_PREVENTED', {
119
+ currentSession: this.currentSessionId?.substring(0, 8) + '...',
120
+ attemptedSession: options.sessionId
121
+ ? options.sessionId.substring(0, 8) + '...'
122
+ : 'undefined',
123
+ origin: window.location.origin,
124
+ });
125
+ this.config.onError?.(error);
126
+ throw error;
127
+ }
128
+ // Lock verification process
129
+ this.isVerificationInProgress = true;
130
+ this.currentSessionId = sessionId;
131
+ try {
132
+ // Rate limiting check
133
+ const rateLimitKey = `${this.config.apiKey}:${window.location.origin}`;
134
+ if (!verificationRateLimit.isAllowed(rateLimitKey)) {
135
+ const error = new Error('Too many verification attempts. Please wait before trying again.');
136
+ logSecurityEvent('RATE_LIMIT_EXCEEDED', {
137
+ apiKey: this.config.apiKey.substring(0, 8) + '...',
138
+ origin: window.location.origin,
139
+ sessionId: sessionId
140
+ ? sessionId.substring(0, 8) + '...'
141
+ : 'undefined',
142
+ });
143
+ this.config.onError?.(error);
144
+ throw error;
145
+ }
146
+ const verificationUrl = await this.buildVerificationUrl({
147
+ ...options,
148
+ sessionId,
149
+ });
150
+ // Log verification attempt
151
+ logSecurityEvent('VERIFICATION_INITIATED', {
152
+ environment: this.config.environment,
153
+ mode: this.config.mode,
154
+ sessionId: sessionId ? sessionId.substring(0, 8) + '...' : 'undefined',
155
+ origin: window.location.origin,
156
+ });
157
+ if (this.config.mode === 'new-tab') {
158
+ this.openNewTab(verificationUrl, sessionId);
159
+ }
160
+ else {
161
+ // For redirect mode, unlock immediately since we're leaving the page
162
+ this.unlockVerification();
163
+ this.redirect(verificationUrl);
164
+ }
165
+ }
166
+ catch (error) {
167
+ // Always unlock on error
168
+ this.unlockVerification();
169
+ throw error;
170
+ }
171
+ }
172
+ /**
173
+ * Build verification URL with HMAC-signed state
174
+ *
175
+ * Constructs the verification URL with signed state parameter containing
176
+ * all necessary configuration and security information. The state parameter
177
+ * includes HMAC signature for integrity protection.
178
+ *
179
+ * @param {VerificationOptions} options - Verification options
180
+ * @returns {Promise<string>} Complete verification URL
181
+ * @private
182
+ */
183
+ async buildVerificationUrl(options) {
184
+ const baseUrl = getEnvironmentUrl(this.config.environment);
185
+ // Determine if we have explicit overrides
186
+ const hasExplicitChallengeAge = options.challengeAge !== undefined;
187
+ const hasExplicitVerificationMode = options.verificationMode !== undefined;
188
+ const hasOverrides = hasExplicitChallengeAge || hasExplicitVerificationMode;
189
+ const state = await generateState({
190
+ merchantId: this.config.apiKey,
191
+ sessionId: options.sessionId,
192
+ returnUrl: this.config.returnUrl,
193
+ cancelUrl: this.config.cancelUrl,
194
+ challengeAge: options.challengeAge || this.config.defaultChallengeAge,
195
+ verificationMode: options.verificationMode || this.config.defaultVerificationMode,
196
+ hasOverrides: hasOverrides, // Flag to indicate explicit overrides
197
+ externalUserId: options.externalUserId,
198
+ timestamp: Date.now(),
199
+ // Phase 2: Include config for self-contained verify-ui
200
+ apiUrl: this.getPortalApiUrl(),
201
+ engineUrl: this.getEngineUrl(),
202
+ wsUrl: this.getWebSocketUrl(),
203
+ environment: this.config.environment,
204
+ features: {
205
+ captureMode: 'enhanced_verification',
206
+ testMode: false,
207
+ warmupPeriodMs: 2000,
208
+ qualityThreshold: 0.6,
209
+ },
210
+ }, this.config.environment);
211
+ const params = new URLSearchParams({
212
+ state,
213
+ sessionId: options.sessionId,
214
+ mode: this.config.mode,
215
+ });
216
+ return `${baseUrl}/?${params.toString()}`;
217
+ }
218
+ /**
219
+ * Redirect in same tab
220
+ *
221
+ * Performs a full page redirect to the verification URL.
222
+ * Used for redirect mode verification.
223
+ *
224
+ * @param {string} url - Verification URL to redirect to
225
+ * @private
226
+ */
227
+ redirect(url) {
228
+ window.location.href = url;
229
+ }
230
+ /**
231
+ * Open in new tab with PostMessage communication and proper cleanup
232
+ *
233
+ * Opens verification URL in a new tab/window and sets up secure PostMessage
234
+ * communication for receiving verification results. Includes comprehensive
235
+ * security validation and automatic cleanup.
236
+ *
237
+ * @param {string} url - Verification URL to open
238
+ * @param {string} sessionId - Session ID for result correlation
239
+ * @private
240
+ */
241
+ openNewTab(url, sessionId) {
242
+ // Clean up any existing resources
243
+ this.cleanup();
244
+ // Clear any existing popup monitor interval
245
+ if (this.popupMonitorInterval) {
246
+ clearInterval(this.popupMonitorInterval);
247
+ this.popupMonitorInterval = null;
248
+ }
249
+ // Open new tab
250
+ this.popupWindow = window.open(url, 'safepassage-verify', 'width=600,height=700');
251
+ if (!this.popupWindow) {
252
+ this.config.onError?.(new Error('Failed to open verification window. Please check popup blocker settings.'));
253
+ return;
254
+ }
255
+ // Set up PostMessage listener with enhanced security
256
+ this.messageListener = (event) => {
257
+ // Enhanced origin validation with strict allowlist
258
+ if (!validatePostMessageOrigin(event, this.config.environment)) {
259
+ logSecurityEvent('POSTMESSAGE_ORIGIN_BLOCKED', {
260
+ origin: event.origin,
261
+ environment: this.config.environment,
262
+ expectedOrigins: `SafePassage trusted origins for ${this.config.environment}`,
263
+ messageType: event.data?.type,
264
+ });
265
+ return;
266
+ }
267
+ // Enhanced message validation
268
+ const messageValidation = validateSafePassageMessage(event, sessionId);
269
+ if (!messageValidation.isValid) {
270
+ logSecurityEvent('POSTMESSAGE_VALIDATION_FAILED', {
271
+ error: messageValidation.error,
272
+ origin: event.origin,
273
+ sessionId: sessionId.substring(0, 8) + '...',
274
+ messageType: event.data?.type,
275
+ });
276
+ return;
277
+ }
278
+ const result = {
279
+ sessionId: event.data.sessionId,
280
+ status: event.data.status,
281
+ };
282
+ // Log successful verification completion
283
+ logSecurityEvent('VERIFICATION_COMPLETED', {
284
+ status: result.status,
285
+ sessionId: sessionId.substring(0, 8) + '...',
286
+ origin: event.origin,
287
+ });
288
+ // Clean up resources
289
+ this.cleanup();
290
+ // Unlock verification after successful completion
291
+ this.unlockVerification();
292
+ // Clear monitoring interval
293
+ if (this.popupMonitorInterval) {
294
+ clearInterval(this.popupMonitorInterval);
295
+ this.popupMonitorInterval = null;
296
+ }
297
+ // Trigger appropriate callback
298
+ if (result.status === 'verified') {
299
+ this.config.onComplete?.(result);
300
+ }
301
+ else if (result.status === 'cancelled') {
302
+ this.config.onCancel?.();
303
+ }
304
+ else {
305
+ this.config.onError?.(new Error(`Verification failed: ${result.status}`));
306
+ }
307
+ };
308
+ window.addEventListener('message', this.messageListener);
309
+ // Monitor popup window with proper cleanup
310
+ this.popupMonitorInterval = setInterval(() => {
311
+ if (this.popupWindow && this.popupWindow.closed) {
312
+ // Log popup closed event
313
+ logSecurityEvent('POPUP_CLOSED_BY_USER', {
314
+ sessionId: sessionId.substring(0, 8) + '...',
315
+ environment: this.config.environment,
316
+ });
317
+ // Clean up and trigger cancel callback
318
+ this.cleanup();
319
+ // Unlock verification after popup closed
320
+ this.unlockVerification();
321
+ this.config.onCancel?.();
322
+ }
323
+ }, 500);
324
+ }
325
+ /**
326
+ * Set up automatic cleanup on page unload to prevent memory leaks
327
+ *
328
+ * Registers event listeners for page unload events to ensure proper
329
+ * cleanup of resources and verification state. Handles both traditional
330
+ * page navigation and single-page application route changes.
331
+ *
332
+ * @private
333
+ */
334
+ setupAutoCleanup() {
335
+ this.unloadListener = () => {
336
+ // Log automatic cleanup
337
+ logSecurityEvent('SDK_AUTO_CLEANUP', {
338
+ environment: this.config.environment,
339
+ trigger: 'page_unload',
340
+ });
341
+ // Clean up all resources and unlock verification
342
+ this.cleanup();
343
+ this.unlockVerification();
344
+ };
345
+ // Listen for page unload events
346
+ window.addEventListener('beforeunload', this.unloadListener);
347
+ window.addEventListener('pagehide', this.unloadListener);
348
+ // For single-page applications, also listen for route changes
349
+ if (window.history && window.history.pushState) {
350
+ const originalPushState = window.history.pushState;
351
+ window.history.pushState = (...args) => {
352
+ this.cleanup();
353
+ this.unlockVerification();
354
+ return originalPushState.apply(window.history, args);
355
+ };
356
+ }
357
+ }
358
+ /**
359
+ * Auto-detect environment based on current URL
360
+ *
361
+ * Analyzes the current hostname to determine the appropriate environment
362
+ * configuration. Used when environment is not explicitly specified.
363
+ *
364
+ * @returns {'production' | 'staging' | 'development'} Detected environment
365
+ * @private
366
+ */
367
+ detectEnvironment() {
368
+ const hostname = window.location.hostname;
369
+ if (hostname === 'localhost' ||
370
+ hostname === '127.0.0.1' ||
371
+ hostname.includes('.local')) {
372
+ return 'development';
373
+ }
374
+ if (hostname.includes('staging') || hostname.includes('stage')) {
375
+ return 'staging';
376
+ }
377
+ return 'production';
378
+ }
379
+ /**
380
+ * Unlock verification process to allow new verifications
381
+ *
382
+ * Resets the verification lock state to allow new verification attempts.
383
+ * Called after successful completion, errors, or cleanup.
384
+ *
385
+ * @private
386
+ */
387
+ unlockVerification() {
388
+ this.isVerificationInProgress = false;
389
+ this.currentSessionId = null;
390
+ logSecurityEvent('VERIFICATION_UNLOCKED', {
391
+ environment: this.config.environment,
392
+ origin: window.location.origin,
393
+ });
394
+ }
395
+ /**
396
+ * Internal cleanup method to prevent memory leaks
397
+ *
398
+ * Cleans up popup windows, event listeners, and intervals.
399
+ * Does not unlock verification state - that's handled by specific callers.
400
+ *
401
+ * @private
402
+ */
403
+ cleanup() {
404
+ // Close popup window
405
+ if (this.popupWindow && !this.popupWindow.closed) {
406
+ this.popupWindow.close();
407
+ }
408
+ this.popupWindow = null;
409
+ // Remove message listener
410
+ if (this.messageListener) {
411
+ window.removeEventListener('message', this.messageListener);
412
+ this.messageListener = null;
413
+ }
414
+ // Clear monitoring interval
415
+ if (this.popupMonitorInterval) {
416
+ clearInterval(this.popupMonitorInterval);
417
+ this.popupMonitorInterval = null;
418
+ }
419
+ // Note: Verification unlocking is handled by specific callers
420
+ }
421
+ /**
422
+ * Remove auto-cleanup listeners
423
+ *
424
+ * Removes page unload event listeners that were set up for automatic cleanup.
425
+ *
426
+ * @private
427
+ */
428
+ removeAutoCleanupListeners() {
429
+ if (this.unloadListener) {
430
+ window.removeEventListener('beforeunload', this.unloadListener);
431
+ window.removeEventListener('pagehide', this.unloadListener);
432
+ this.unloadListener = null;
433
+ }
434
+ }
435
+ /**
436
+ * Public cleanup method for manual resource management
437
+ *
438
+ * Completely destroys the SDK instance, cleaning up all resources and
439
+ * removing all event listeners. Should be called when the SDK is no longer needed.
440
+ *
441
+ * @public
442
+ */
443
+ destroy() {
444
+ // Log destruction for security monitoring
445
+ logSecurityEvent('SDK_DESTROYED', {
446
+ environment: this.config.environment,
447
+ origin: window.location.origin,
448
+ });
449
+ // Clean up all resources
450
+ this.cleanup();
451
+ // Unlock verification
452
+ this.unlockVerification();
453
+ // Remove auto-cleanup listeners
454
+ this.removeAutoCleanupListeners();
455
+ }
456
+ /**
457
+ * Get Portal API URL based on environment
458
+ *
459
+ * Returns the appropriate portal-api URL for the current environment.
460
+ *
461
+ * @returns {string} Portal API URL
462
+ * @private
463
+ */
464
+ getPortalApiUrl() {
465
+ switch (this.config.environment) {
466
+ case 'development':
467
+ return 'http://localhost:3001';
468
+ case 'staging':
469
+ case 'production':
470
+ return 'https://api.safepassageapp.com';
471
+ default:
472
+ return 'https://api.safepassageapp.com';
473
+ }
474
+ }
475
+ /**
476
+ * Get Engine URL based on environment
477
+ *
478
+ * Returns the appropriate verify-engine URL for the current environment.
479
+ * In production, engine access is proxied through portal-api.
480
+ *
481
+ * @returns {string} Engine URL
482
+ * @private
483
+ */
484
+ getEngineUrl() {
485
+ switch (this.config.environment) {
486
+ case 'development':
487
+ return 'http://localhost:8000';
488
+ case 'staging':
489
+ case 'production':
490
+ // Direct access to verify-engine
491
+ return 'https://engine.safepassageapp.com';
492
+ default:
493
+ return 'https://engine.safepassageapp.com';
494
+ }
495
+ }
496
+ /**
497
+ * Get WebSocket URL based on environment
498
+ *
499
+ * Returns the appropriate WebSocket URL for the current environment.
500
+ * In production, WebSocket connections are proxied through portal-api.
501
+ *
502
+ * @returns {string} WebSocket URL
503
+ * @private
504
+ */
505
+ getWebSocketUrl() {
506
+ switch (this.config.environment) {
507
+ case 'development':
508
+ return 'ws://localhost:8000/safe-passage-llm/api/v1/websocket/stream';
509
+ case 'staging':
510
+ case 'production':
511
+ // Direct WebSocket connection to verify-engine
512
+ return 'wss://engine.safepassageapp.com/safe-passage-llm/api/v1/websocket/stream';
513
+ default:
514
+ return 'wss://engine.safepassageapp.com/safe-passage-llm/api/v1/websocket/stream';
515
+ }
516
+ }
517
+ /**
518
+ * Detect if this is a public key (pk_ prefix) vs private key (sk_ prefix)
519
+ *
520
+ * Determines API key type based on prefix to handle different authentication flows.
521
+ *
522
+ * @returns {boolean} True if public key, false if private key
523
+ * @private
524
+ */
525
+ isPublicKey() {
526
+ return this.config.apiKey.startsWith('pk_');
527
+ }
528
+ /**
529
+ * Create session internally for public keys
530
+ *
531
+ * Creates a verification session via the portal API for public key authentication.
532
+ * Generates a UUID session ID and submits session creation request with
533
+ * verification parameters.
534
+ *
535
+ * @param {VerificationOptions} options - Verification options
536
+ * @returns {Promise<string>} Created session ID
537
+ * @throws {Error} If session creation fails
538
+ * @private
539
+ */
540
+ async createInternalSession(options) {
541
+ const sessionId = crypto.randomUUID();
542
+ try {
543
+ const portalApiUrl = this.getPortalApiUrl();
544
+ const response = await fetch(`${portalApiUrl}/api/v1/sessions/create`, {
545
+ method: 'POST',
546
+ headers: {
547
+ 'Content-Type': 'application/json',
548
+ Authorization: `Bearer ${this.config.apiKey}`,
549
+ },
550
+ body: JSON.stringify({
551
+ merchantId: this.config.apiKey, // API expects merchantId even though it uses auth header
552
+ sessionId,
553
+ returnUrl: this.config.returnUrl,
554
+ cancelUrl: this.config.cancelUrl,
555
+ challengeAge: options.challengeAge,
556
+ verificationMode: options.verificationMode,
557
+ merchantName: document.title || window.location.hostname,
558
+ externalUserId: options.externalUserId,
559
+ }),
560
+ });
561
+ if (!response.ok) {
562
+ const errorData = await response.json().catch(() => ({}));
563
+ throw new Error(`Failed to create session: ${response.status} ${response.statusText}. ${errorData.message || ''}`);
564
+ }
565
+ await response.json();
566
+ // Log successful session creation
567
+ logSecurityEvent('INTERNAL_SESSION_CREATED', {
568
+ sessionId: sessionId.substring(0, 8) + '...',
569
+ environment: this.config.environment,
570
+ apiKeyType: 'public',
571
+ });
572
+ return sessionId;
573
+ }
574
+ catch (error) {
575
+ const errorMessage = error instanceof Error ? error.message : String(error);
576
+ logSecurityEvent('INTERNAL_SESSION_FAILED', {
577
+ error: errorMessage,
578
+ environment: this.config.environment,
579
+ apiKeyType: 'public',
580
+ });
581
+ this.config.onError?.(error);
582
+ throw new Error(`Failed to create verification session: ${errorMessage}`);
583
+ }
584
+ }
585
+ }
586
+ // For backwards compatibility
587
+ export default SafePassage;