@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.
- package/README.md +20 -11
- package/dist/core/SafePassageSDK.d.ts +221 -0
- package/dist/core/SafePassageSDK.js +587 -0
- package/dist/index.d.ts +19 -115
- package/dist/index.js +40 -7
- package/dist/safepassage.min.js +3 -3
- package/dist/tests/SafePassageSDK.test.d.ts +4 -0
- package/dist/tests/SafePassageSDK.test.js +130 -0
- package/dist/types/index.d.ts +139 -0
- package/dist/types/index.js +4 -0
- package/dist/utils/__mocks__/polyfills.d.ts +3 -0
- package/dist/utils/__mocks__/polyfills.js +10 -0
- package/dist/utils/crypto.d.ts +105 -0
- package/dist/utils/crypto.js +210 -0
- package/dist/utils/environment.d.ts +13 -0
- package/dist/utils/environment.js +96 -0
- package/dist/utils/polyfills.d.ts +12 -0
- package/dist/utils/polyfills.js +69 -0
- package/dist/utils/security.d.ts +50 -0
- package/dist/utils/security.js +220 -0
- package/dist/utils/validation.d.ts +21 -0
- package/dist/utils/validation.js +160 -0
- package/package.json +17 -13
- package/dist/components/SafePassageVerification.d.ts +0 -4
- package/dist/components/SafePassageVerification.js +0 -196
package/dist/index.d.ts
CHANGED
|
@@ -1,117 +1,21 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* SafePassage SDK
|
|
2
|
+
* SafePassage SDK - Redirect-based age verification
|
|
3
|
+
*
|
|
4
|
+
* @example
|
|
5
|
+
* ```javascript
|
|
6
|
+
* // Initialize SDK
|
|
7
|
+
* const sp = new SafePassage({
|
|
8
|
+
* apiKey: 'pk_live_xxxxx',
|
|
9
|
+
* returnUrl: 'https://merchant.com/verified',
|
|
10
|
+
* cancelUrl: 'https://merchant.com/cancelled'
|
|
11
|
+
* });
|
|
12
|
+
*
|
|
13
|
+
* // Trigger verification
|
|
14
|
+
* sp.verify({
|
|
15
|
+
* sessionId: generateUUID() // Merchant-generated UUID v4
|
|
16
|
+
* });
|
|
17
|
+
* ```
|
|
3
18
|
*/
|
|
4
|
-
export
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
*/
|
|
8
|
-
apiKey: string;
|
|
9
|
-
/**
|
|
10
|
-
* URL to redirect to after successful verification
|
|
11
|
-
* Must be pre-registered in dashboard
|
|
12
|
-
*/
|
|
13
|
-
returnUrl: string;
|
|
14
|
-
/**
|
|
15
|
-
* URL to redirect to if user cancels verification
|
|
16
|
-
* Must be pre-registered in dashboard
|
|
17
|
-
*/
|
|
18
|
-
cancelUrl: string;
|
|
19
|
-
/**
|
|
20
|
-
* Environment to use
|
|
21
|
-
* @default Auto-detected based on hostname
|
|
22
|
-
*/
|
|
23
|
-
environment?: 'production' | 'staging' | 'development';
|
|
24
|
-
/**
|
|
25
|
-
* Verification mode
|
|
26
|
-
* @default 'redirect'
|
|
27
|
-
*/
|
|
28
|
-
mode?: 'redirect' | 'new-tab';
|
|
29
|
-
/**
|
|
30
|
-
* Default challenge age (minimum 25)
|
|
31
|
-
* Can be overridden per verification
|
|
32
|
-
*/
|
|
33
|
-
defaultChallengeAge?: number;
|
|
34
|
-
/**
|
|
35
|
-
* Default verification mode
|
|
36
|
-
* Can be overridden per verification
|
|
37
|
-
*/
|
|
38
|
-
defaultVerificationMode?: 'L1' | 'L2';
|
|
39
|
-
/**
|
|
40
|
-
* Callback when verification completes (new-tab mode only)
|
|
41
|
-
*/
|
|
42
|
-
onComplete?: (result: VerificationResult) => void;
|
|
43
|
-
/**
|
|
44
|
-
* Callback when user cancels (new-tab mode only)
|
|
45
|
-
*/
|
|
46
|
-
onCancel?: () => void;
|
|
47
|
-
/**
|
|
48
|
-
* Callback for errors
|
|
49
|
-
*/
|
|
50
|
-
onError?: (error: Error) => void;
|
|
51
|
-
}
|
|
52
|
-
export interface VerificationOptions {
|
|
53
|
-
/**
|
|
54
|
-
* Merchant-generated UUID v4 for this verification session
|
|
55
|
-
* Required for private keys (sk_), optional for public keys (pk_)
|
|
56
|
-
* For public keys: SDK will generate session internally
|
|
57
|
-
*/
|
|
58
|
-
sessionId?: string;
|
|
59
|
-
/**
|
|
60
|
-
* Minimum age to verify (minimum 25)
|
|
61
|
-
* @default Uses merchant dashboard configuration
|
|
62
|
-
*/
|
|
63
|
-
challengeAge?: number;
|
|
64
|
-
/**
|
|
65
|
-
* Verification mode
|
|
66
|
-
* L1: Age estimation allowed if user appears older
|
|
67
|
-
* L2: Full ID verification required
|
|
68
|
-
* @default Uses merchant dashboard configuration
|
|
69
|
-
*/
|
|
70
|
-
verificationMode?: 'L1' | 'L2';
|
|
71
|
-
}
|
|
72
|
-
export interface VerificationResult {
|
|
73
|
-
/**
|
|
74
|
-
* The session ID that was verified
|
|
75
|
-
*/
|
|
76
|
-
sessionId: string;
|
|
77
|
-
/**
|
|
78
|
-
* Binary result: 'verified' or 'failed'
|
|
79
|
-
* Full details available via server-side API
|
|
80
|
-
*/
|
|
81
|
-
status: 'verified' | 'failed' | 'cancelled';
|
|
82
|
-
}
|
|
83
|
-
export interface StatePayload {
|
|
84
|
-
merchantId: string;
|
|
85
|
-
sessionId: string;
|
|
86
|
-
returnUrl: string;
|
|
87
|
-
cancelUrl: string;
|
|
88
|
-
challengeAge?: number;
|
|
89
|
-
verificationMode?: 'L1' | 'L2';
|
|
90
|
-
hasOverrides?: boolean;
|
|
91
|
-
timestamp: number;
|
|
92
|
-
}
|
|
93
|
-
export interface SessionValidationResponse {
|
|
94
|
-
sessionId: string;
|
|
95
|
-
merchantId: string;
|
|
96
|
-
status: 'verified' | 'failed';
|
|
97
|
-
verified: boolean;
|
|
98
|
-
estimatedAge?: number;
|
|
99
|
-
challengeAge: number;
|
|
100
|
-
verificationMode: 'L1' | 'L2';
|
|
101
|
-
verificationMethod?: 'facial' | 'document' | 'combined';
|
|
102
|
-
timestamp: string;
|
|
103
|
-
expiresAt: string;
|
|
104
|
-
}
|
|
105
|
-
export interface SessionCreationResponse {
|
|
106
|
-
sessionToken: string;
|
|
107
|
-
verifyUrl: string;
|
|
108
|
-
expiresAt: string;
|
|
109
|
-
}
|
|
110
|
-
export interface CreateSessionRequest {
|
|
111
|
-
sessionId: string;
|
|
112
|
-
returnUrl: string;
|
|
113
|
-
cancelUrl?: string;
|
|
114
|
-
challengeAge?: number;
|
|
115
|
-
verificationMode?: 'L1' | 'L2';
|
|
116
|
-
merchantName?: string;
|
|
117
|
-
}
|
|
19
|
+
export { SafePassage, SafePassage as default } from './core/SafePassageSDK';
|
|
20
|
+
export type { SafePassageConfig, VerificationOptions, VerificationResult, SessionValidationResponse, } from './types';
|
|
21
|
+
export declare const VERSION = "3.0.4";
|
package/dist/index.js
CHANGED
|
@@ -1,7 +1,40 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
//
|
|
7
|
-
|
|
1
|
+
/**
|
|
2
|
+
* SafePassage SDK - Redirect-based age verification
|
|
3
|
+
*
|
|
4
|
+
* @example
|
|
5
|
+
* ```javascript
|
|
6
|
+
* // Initialize SDK
|
|
7
|
+
* const sp = new SafePassage({
|
|
8
|
+
* apiKey: 'pk_live_xxxxx',
|
|
9
|
+
* returnUrl: 'https://merchant.com/verified',
|
|
10
|
+
* cancelUrl: 'https://merchant.com/cancelled'
|
|
11
|
+
* });
|
|
12
|
+
*
|
|
13
|
+
* // Trigger verification
|
|
14
|
+
* sp.verify({
|
|
15
|
+
* sessionId: generateUUID() // Merchant-generated UUID v4
|
|
16
|
+
* });
|
|
17
|
+
* ```
|
|
18
|
+
*/
|
|
19
|
+
// Setup polyfills and check browser compatibility
|
|
20
|
+
import { setupPolyfills, checkBrowserCompatibility } from './utils/polyfills';
|
|
21
|
+
// Initialize polyfills immediately
|
|
22
|
+
if (typeof window !== 'undefined') {
|
|
23
|
+
setupPolyfills();
|
|
24
|
+
checkBrowserCompatibility();
|
|
25
|
+
}
|
|
26
|
+
export { SafePassage, SafePassage as default } from './core/SafePassageSDK';
|
|
27
|
+
// Version - Updated to reflect security improvements
|
|
28
|
+
export const VERSION = '3.0.4';
|
|
29
|
+
// For UMD builds
|
|
30
|
+
if (typeof window !== 'undefined' && window) {
|
|
31
|
+
// Dynamic import for UMD builds
|
|
32
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
33
|
+
const { SafePassage } = require('./core/SafePassageSDK');
|
|
34
|
+
// Attach to window object for global access
|
|
35
|
+
const globalWindow = window;
|
|
36
|
+
globalWindow.SafePassage = SafePassage;
|
|
37
|
+
if (globalWindow.SafePassage) {
|
|
38
|
+
globalWindow.SafePassage.VERSION = VERSION;
|
|
39
|
+
}
|
|
40
|
+
}
|
package/dist/safepassage.min.js
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
/* SafePassage SDK v3.0.
|
|
2
|
-
"use strict";var SafePassageSDK=(()=>{var d=Object.defineProperty;var K=Object.getOwnPropertyDescriptor;var W=Object.getOwnPropertyNames;var H=Object.prototype.hasOwnProperty;var p=(t,e)=>()=>(t&&(e=t(t=0)),e);var u=(t,e)=>{for(var i in e)d(t,i,{get:e[i],enumerable:!0})},q=(t,e,i,n)=>{if(e&&typeof e=="object"||typeof e=="function")for(let o of W(e))!H.call(t,o)&&o!==i&&d(t,o,{get:()=>e[o],enumerable:!(n=K(e,o))||n.enumerable});return t};var I=t=>q(d({},"__esModule",{value:!0}),t);function F(t,e){return E[e].includes(t)}function P(t,e,i=[]){let{origin:n}=t;return F(n,e)||i.length>0&&i.some(r=>{if(r.startsWith("*.")){let s=r.slice(2);return n.endsWith(`.${s}`)||n===`https://${s}`||n===`http://${s}`}return n===r})?!0:(console.warn(`SafePassage Security: Blocked PostMessage from untrusted origin: ${n}`,{environment:e,trustedOrigins:E[e],allowedCustomOrigins:i,eventType:t.data?.type}),!1)}function T(t,e){let{data:i}=t;return!i||typeof i!="object"?{isValid:!1,error:"Invalid message format"}:i.type!=="safepassage:verification:complete"?{isValid:!1,error:"Invalid message type"}:!i.sessionId||i.sessionId!==e?{isValid:!1,error:"Session ID mismatch"}:!i.status||!["verified","failed","cancelled"].includes(i.status)?{isValid:!1,error:"Invalid status value"}:{isValid:!0}}function U(t){if(t==="production"&&window.location.protocol!=="https:"){let e=window.location.href.replace("http:","https:");console.error("SafePassage Security: HTTPS required in production. Redirecting...",{current:window.location.href,redirect:e}),window.location.replace(e)}}function f(t,e){try{let i=new URL(t);if(e==="production"&&i.protocol!=="https:")return{isValid:!1,error:"HTTPS required for return URLs in production"};if(e==="development"&&!(i.hostname==="localhost"||i.hostname==="127.0.0.1"||i.hostname.endsWith(".local"))&&i.protocol!=="https:")return{isValid:!1,error:"Non-localhost URLs must use HTTPS"};if(e==="staging"&&i.protocol!=="https:")return{isValid:!1,error:"HTTPS required for return URLs in staging"};let n=[/data:/i,/javascript:/i,/vbscript:/i,/file:/i,/ftp:/i];for(let o of n)if(o.test(t))return{isValid:!1,error:"Blocked suspicious URL scheme"};return{isValid:!0}}catch{return{isValid:!1,error:"Invalid URL format"}}}function a(t,e){console.warn(`SafePassage Security Event: ${t}`,{timestamp:new Date().toISOString(),userAgent:navigator.userAgent,url:window.location.href,...e})}var E,g,A,h=p(()=>{"use strict";E={production:["https://verify.safepassageapp.com","https://portal.safepassageapp.com","https://api.safepassageapp.com"],staging:["https://verify-staging.safepassageapp.com","https://portal-staging.safepassageapp.com","https://api-staging.safepassageapp.com"],development:["http://localhost:5173","http://localhost:3000","http://localhost:3001","http://localhost:3002","http://127.0.0.1:5173","http://127.0.0.1:3000","http://127.0.0.1:3001","http://127.0.0.1:3002"]};g=class{constructor(){this.attempts=new Map;this.maxAttempts=5;this.timeWindow=6e4}isAllowed(e){let i=Date.now(),o=(this.attempts.get(e)||[]).filter(r=>i-r<this.timeWindow);return o.length>=this.maxAttempts?(console.warn(`SafePassage Security: Rate limit exceeded for ${e}`),!1):(o.push(i),this.attempts.set(e,o),!0)}reset(e){this.attempts.delete(e)}},A=new g});var L={};u(L,{createSignedState:()=>J,generateHMAC:()=>m,generateSecureToken:()=>V,getSigningSecret:()=>w,parseSignedState:()=>B,verifyHMAC:()=>b});async function m(t,e){let i=new TextEncoder,n=i.encode(e),o=i.encode(t),r=await crypto.subtle.importKey("raw",n,{name:"HMAC",hash:"SHA-256"},!1,["sign"]),s=await crypto.subtle.sign("HMAC",r,o);return Array.from(new Uint8Array(s)).map(l=>l.toString(16).padStart(2,"0")).join("")}async function b(t,e,i){try{let n=await m(t,i);return j(e,n)}catch{return!1}}function j(t,e){if(t.length!==e.length)return!1;let i=0;for(let n=0;n<t.length;n++)i|=t.charCodeAt(n)^e.charCodeAt(n);return i===0}function V(t=32){let e=new Uint8Array(t);return crypto.getRandomValues(e),Array.from(e,i=>i.toString(16).padStart(2,"0")).join("")}function w(t){return{production:"safepassage-prod-hmac-2025",staging:"safepassage-stage-hmac-2025",development:"safepassage-dev-hmac-2025"}[t]}async function J(t,e){let i={...t,timestamp:Date.now(),nonce:V(16)},n=JSON.stringify(i),o=w(e),r=await m(n,o);return btoa(JSON.stringify({data:i,signature:r}))}async function B(t,e,i=10*60*1e3){try{let n=atob(t),o=JSON.parse(n);if(!o.data||!o.signature)return console.warn("SafePassage: Invalid signed state format"),null;let{data:r,signature:s}=o,l=JSON.stringify(r),$=w(e);if(!await b(l,s,$))return console.warn("SafePassage: State signature verification failed"),null;if(r.timestamp){let y=Date.now()-r.timestamp;if(y>i)return console.warn("SafePassage: State parameter expired",{age:y,maxAge:i}),null}let{timestamp:ie,nonce:ne,...k}=r;return k}catch(n){return console.warn("SafePassage: Failed to parse signed state",n),null}}var x=p(()=>{"use strict"});function M(t){if(!t.apiKey)throw new Error("apiKey is required");if(!G.test(t.apiKey))throw new Error("Invalid apiKey format. Expected pk_xxx (public) or sk_xxx (private)");if(!t.returnUrl)throw new Error("returnUrl is required");if(!t.cancelUrl)throw new Error("cancelUrl is required");let e=Y(),i=f(t.returnUrl,e);if(!i.isValid)throw new Error(`returnUrl validation failed: ${i.error}`);let n=f(t.cancelUrl,e);if(!n.isValid)throw new Error(`cancelUrl validation failed: ${n.error}`);if(t.defaultChallengeAge!==void 0&&t.defaultChallengeAge<C)throw new Error(`defaultChallengeAge must be at least ${C}`);if(t.defaultVerificationMode&&!["L1","L2"].includes(t.defaultVerificationMode))throw new Error("defaultVerificationMode must be L1 or L2");if(t.mode&&!["redirect","new-tab"].includes(t.mode))throw new Error("mode must be redirect or new-tab")}function Y(){let t=window.location.hostname;return t==="localhost"||t==="127.0.0.1"||t.includes(".local")?"development":t.includes("staging")||t.includes("stage")?"staging":"production"}async function R(t,e){let{createSignedState:i}=await Promise.resolve().then(()=>(x(),L));return i(t,e)}var C,G,O=p(()=>{"use strict";h();C=25,G=/^(pk_|sk_)[a-zA-Z0-9]+$/});function v(t){let e=z[t];if((t==="production"||t==="staging")&&!e.startsWith("https://"))throw new Error(`HTTPS required for ${t} environment`);return e}function Z(t){let i={production:"https://api.safepassageapp.com",staging:"https://api-staging.safepassageapp.com",development:"http://localhost:3001"}[t];if((t==="production"||t==="staging")&&!i.startsWith("https://"))throw new Error(`HTTPS required for API URLs in ${t} environment`);return i}function D(t){let e=window.location.protocol==="https:",i=window.location.hostname;switch(t){case"production":if(!e)throw new Error("SafePassage requires HTTPS in production environment");break;case"staging":e||console.warn("SafePassage Warning: HTTPS strongly recommended in staging environment");break;case"development":let n=i==="localhost"||i==="127.0.0.1"||i.includes(".local");!e&&!n&&console.warn("SafePassage Warning: HTTPS recommended for non-localhost development");break}try{v(t),Z(t)}catch(n){throw new Error(`Environment configuration validation failed: ${n}`)}}var z,N=p(()=>{"use strict";z={production:"https://verify.safepassageapp.com",staging:"https://verify-staging.safepassageapp.com",development:"http://localhost:5173"}});var _={};u(_,{SafePassage:()=>c,default:()=>X});var c,X,S=p(()=>{"use strict";O();N();h();c=class{constructor(e){this.popupWindow=null;this.messageListener=null;this.popupMonitorInterval=null;this.unloadListener=null;this.isVerificationInProgress=!1;this.currentSessionId=null;M(e),this.config={...e,environment:e.environment||this.detectEnvironment(),mode:e.mode||"redirect"},D(this.config.environment),U(this.config.environment),a("SDK_INITIALIZED",{environment:this.config.environment,mode:this.config.mode,origin:window.location.origin,protocol:window.location.protocol,hostname:window.location.hostname}),this.setupAutoCleanup()}async verify(e={}){let i=this.isPublicKey(),n=e.sessionId;if(i&&!n&&(n=await this.createInternalSession(e)),!i&&!n)throw new Error("sessionId is required for private API keys - must be a merchant-generated UUID v4");if(!n)throw new Error("Failed to create or obtain sessionId");if(this.isVerificationInProgress){let o=new Error(`Verification already in progress for session ${this.currentSessionId?.substring(0,8)}...`);throw a("RACE_CONDITION_PREVENTED",{currentSession:this.currentSessionId?.substring(0,8)+"...",attemptedSession:e.sessionId?e.sessionId.substring(0,8)+"...":"undefined",origin:window.location.origin}),this.config.onError?.(o),o}this.isVerificationInProgress=!0,this.currentSessionId=n;try{let o=`${this.config.apiKey}:${window.location.origin}`;if(!A.isAllowed(o)){let s=new Error("Too many verification attempts. Please wait before trying again.");throw a("RATE_LIMIT_EXCEEDED",{apiKey:this.config.apiKey.substring(0,8)+"...",origin:window.location.origin,sessionId:n?n.substring(0,8)+"...":"undefined"}),this.config.onError?.(s),s}let r=await this.buildVerificationUrl({...e,sessionId:n});a("VERIFICATION_INITIATED",{environment:this.config.environment,mode:this.config.mode,sessionId:n?n.substring(0,8)+"...":"undefined",origin:window.location.origin}),this.config.mode==="new-tab"?this.openNewTab(r,n):(this.unlockVerification(),this.redirect(r))}catch(o){throw this.unlockVerification(),o}}async buildVerificationUrl(e){let i=v(this.config.environment),n=e.challengeAge!==void 0,o=e.verificationMode!==void 0,r=n||o,s=await R({merchantId:this.config.apiKey,sessionId:e.sessionId,returnUrl:this.config.returnUrl,cancelUrl:this.config.cancelUrl,challengeAge:e.challengeAge||this.config.defaultChallengeAge,verificationMode:e.verificationMode||this.config.defaultVerificationMode,hasOverrides:r,timestamp:Date.now()},this.config.environment),l=new URLSearchParams({state:s,sessionId:e.sessionId,mode:this.config.mode});return`${i}/verify?${l.toString()}`}redirect(e){window.location.href=e}openNewTab(e,i){if(this.cleanup(),this.popupMonitorInterval&&(clearInterval(this.popupMonitorInterval),this.popupMonitorInterval=null),this.popupWindow=window.open(e,"safepassage-verify","width=600,height=700"),!this.popupWindow){this.config.onError?.(new Error("Failed to open verification window. Please check popup blocker settings."));return}this.messageListener=n=>{if(!P(n,this.config.environment)){a("POSTMESSAGE_ORIGIN_BLOCKED",{origin:n.origin,environment:this.config.environment,expectedOrigins:`SafePassage trusted origins for ${this.config.environment}`,messageType:n.data?.type});return}let o=T(n,i);if(!o.isValid){a("POSTMESSAGE_VALIDATION_FAILED",{error:o.error,origin:n.origin,sessionId:i.substring(0,8)+"...",messageType:n.data?.type});return}let r={sessionId:n.data.sessionId,status:n.data.status};a("VERIFICATION_COMPLETED",{status:r.status,sessionId:i.substring(0,8)+"...",origin:n.origin}),this.cleanup(),this.unlockVerification(),this.popupMonitorInterval&&(clearInterval(this.popupMonitorInterval),this.popupMonitorInterval=null),r.status==="verified"?this.config.onComplete?.(r):r.status==="cancelled"?this.config.onCancel?.():this.config.onError?.(new Error(`Verification failed: ${r.status}`))},window.addEventListener("message",this.messageListener),this.popupMonitorInterval=setInterval(()=>{this.popupWindow&&this.popupWindow.closed&&(a("POPUP_CLOSED_BY_USER",{sessionId:i.substring(0,8)+"...",environment:this.config.environment}),this.cleanup(),this.unlockVerification(),this.config.onCancel?.())},500)}setupAutoCleanup(){if(this.unloadListener=()=>{a("SDK_AUTO_CLEANUP",{environment:this.config.environment,trigger:"page_unload"}),this.cleanup(),this.unlockVerification()},window.addEventListener("beforeunload",this.unloadListener),window.addEventListener("pagehide",this.unloadListener),window.history&&window.history.pushState){let e=window.history.pushState;window.history.pushState=(...i)=>(this.cleanup(),this.unlockVerification(),e.apply(window.history,i))}}detectEnvironment(){let e=window.location.hostname;return e==="localhost"||e==="127.0.0.1"||e.includes(".local")?"development":e.includes("staging")||e.includes("stage")?"staging":"production"}unlockVerification(){this.isVerificationInProgress=!1,this.currentSessionId=null,a("VERIFICATION_UNLOCKED",{environment:this.config.environment,origin:window.location.origin})}cleanup(){this.popupWindow&&!this.popupWindow.closed&&this.popupWindow.close(),this.popupWindow=null,this.messageListener&&(window.removeEventListener("message",this.messageListener),this.messageListener=null),this.popupMonitorInterval&&(clearInterval(this.popupMonitorInterval),this.popupMonitorInterval=null)}removeAutoCleanupListeners(){this.unloadListener&&(window.removeEventListener("beforeunload",this.unloadListener),window.removeEventListener("pagehide",this.unloadListener),this.unloadListener=null)}destroy(){a("SDK_DESTROYED",{environment:this.config.environment,origin:window.location.origin}),this.cleanup(),this.unlockVerification(),this.removeAutoCleanupListeners()}isPublicKey(){return this.config.apiKey.startsWith("pk_")}async createInternalSession(e){let i=crypto.randomUUID();try{let n=this.getPortalApiUrl(),o=await fetch(`${n}/api/v1/sessions/create`,{method:"POST",headers:{"Content-Type":"application/json",Authorization:`Bearer ${this.config.apiKey}`},body:JSON.stringify({merchantId:this.config.apiKey,sessionId:i,returnUrl:this.config.returnUrl,cancelUrl:this.config.cancelUrl,challengeAge:e.challengeAge,verificationMode:e.verificationMode,merchantName:document.title||window.location.hostname})});if(!o.ok){let s=await o.json().catch(()=>({}));throw new Error(`Failed to create session: ${o.status} ${o.statusText}. ${s.message||""}`)}let r=await o.json();return a("INTERNAL_SESSION_CREATED",{sessionId:i.substring(0,8)+"...",environment:this.config.environment,apiKeyType:"public"}),i}catch(n){let o=n instanceof Error?n.message:String(n);throw a("INTERNAL_SESSION_FAILED",{error:o,environment:this.config.environment,apiKeyType:"public"}),this.config.onError?.(n),new Error(`Failed to create verification session: ${o}`)}}getPortalApiUrl(){switch(this.config.environment){case"production":return"https://api.safepassageapp.com";case"staging":return"https://api-staging.safepassageapp.com";case"development":default:return"http://localhost:3001"}}},X=c});var ee={};u(ee,{SafePassage:()=>c,VERSION:()=>Q,default:()=>c});S();var Q="3.0.0";typeof window<"u"&&window&&(window.SafePassage=(S(),I(_)).SafePassage);return I(ee);})();
|
|
3
|
-
if(typeof SafePassageSDK !== "undefined" && SafePassageSDK.SafePassage) { window.SafePassage = SafePassageSDK.SafePassage; }
|
|
1
|
+
/* SafePassage SDK v3.0.4 - Redirect Implementation with Enhanced Security */
|
|
2
|
+
"use strict";var SafePassageSDK=(()=>{var u=Object.defineProperty;var G=Object.getOwnPropertyDescriptor;var J=Object.getOwnPropertyNames;var X=Object.prototype.hasOwnProperty;var p=(t,e)=>()=>(t&&(e=t(t=0)),e);var g=(t,e)=>{for(var n in e)u(t,n,{get:e[n],enumerable:!0})},Y=(t,e,n,i)=>{if(e&&typeof e=="object"||typeof e=="function")for(let r of J(e))!X.call(t,r)&&r!==n&&u(t,r,{get:()=>e[r],enumerable:!(i=G(e,r))||i.enumerable});return t};var P=t=>Y(u({},"__esModule",{value:!0}),t);function z(t,e){return b[e].includes(t)}function T(t,e,n=[]){let{origin:i}=t;return z(i,e)||n.length>0&&n.some(o=>{if(o.startsWith("*.")){let a=o.slice(2);return i.endsWith(`.${a}`)||i===`https://${a}`||i===`http://${a}`}return i===o})?!0:(console.warn(`SafePassage Security: Blocked PostMessage from untrusted origin: ${i}`,{environment:e,trustedOrigins:b[e],allowedCustomOrigins:n,eventType:t.data?.type}),!1)}function x(t,e){let{data:n}=t;return!n||typeof n!="object"?{isValid:!1,error:"Invalid message format"}:n.type!=="safepassage:verification:complete"?{isValid:!1,error:"Invalid message type"}:!n.sessionId||n.sessionId!==e?{isValid:!1,error:"Session ID mismatch"}:!n.status||!["verified","failed","cancelled"].includes(n.status)?{isValid:!1,error:"Invalid status value"}:{isValid:!0}}function V(t){if(t==="production"&&window.location.protocol!=="https:"){let e=window.location.href.replace("http:","https:");console.error("SafePassage Security: HTTPS required in production. Redirecting...",{current:window.location.href,redirect:e}),window.location.replace(e)}}function h(t,e){try{let n=new URL(t);if(e==="production"&&n.protocol!=="https:")return{isValid:!1,error:"HTTPS required for return URLs in production"};if(e==="development"&&!(n.hostname==="localhost"||n.hostname==="127.0.0.1"||n.hostname.endsWith(".local"))&&n.protocol!=="https:")return{isValid:!1,error:"Non-localhost URLs must use HTTPS"};if(e==="staging"&&n.protocol!=="https:")return{isValid:!1,error:"HTTPS required for return URLs in staging"};let i=[/data:/i,/javascript:/i,/vbscript:/i,/file:/i,/ftp:/i];for(let r of i)if(r.test(t))return{isValid:!1,error:"Blocked suspicious URL scheme"};return{isValid:!0}}catch{return{isValid:!1,error:"Invalid URL format"}}}function s(t,e){console.warn(`SafePassage Security Event: ${t}`,{timestamp:new Date().toISOString(),userAgent:navigator.userAgent,url:window.location.href,...e})}var b,f,L,m=p(()=>{"use strict";b={production:["https://av.safepassageapp.com","https://portal.safepassageapp.com","https://api.safepassageapp.com"],staging:["https://av.safepassageapp.com","https://portal.safepassageapp.com","https://api.safepassageapp.com"],development:["http://localhost:5173","http://localhost:3000","http://localhost:3001","http://localhost:3002","http://127.0.0.1:5173","http://127.0.0.1:3000","http://127.0.0.1:3001","http://127.0.0.1:3002"]};f=class{constructor(){this.attempts=new Map;this.maxAttempts=5;this.timeWindow=6e4}isAllowed(e){let n=Date.now(),r=(this.attempts.get(e)||[]).filter(o=>n-o<this.timeWindow);return r.length>=this.maxAttempts?(console.warn(`SafePassage Security: Rate limit exceeded for ${e}`),!1):(r.push(n),this.attempts.set(e,r),!0)}reset(e){this.attempts.delete(e)}},L=new f});var C={};g(C,{createSignedState:()=>Q,generateHMAC:()=>w,generateSecureToken:()=>R,getSigningSecret:()=>v,parseSignedState:()=>ee,verifyHMAC:()=>M});async function w(t,e){let n=new TextEncoder,i=n.encode(e),r=n.encode(t),o=await crypto.subtle.importKey("raw",i,{name:"HMAC",hash:"SHA-256"},!1,["sign"]),a=await crypto.subtle.sign("HMAC",o,r);return Array.from(new Uint8Array(a)).map(l=>l.toString(16).padStart(2,"0")).join("")}async function M(t,e,n){try{let i=await w(t,n);return Z(e,i)}catch{return!1}}function Z(t,e){if(t.length!==e.length)return!1;let n=0;for(let i=0;i<t.length;i++)n|=t.charCodeAt(i)^e.charCodeAt(i);return n===0}function R(t=32){let e=new Uint8Array(t);return crypto.getRandomValues(e),Array.from(e,n=>n.toString(16).padStart(2,"0")).join("")}function v(t){return{production:"safepassage-prod-hmac-2025",staging:"safepassage-prod-hmac-2025",development:"safepassage-dev-hmac-2025"}[t]}async function Q(t,e){let n={...t,timestamp:Date.now(),nonce:R(16)},i=JSON.stringify(n),r=v(e),o=await w(i,r);return btoa(JSON.stringify({data:n,signature:o}))}async function ee(t,e,n=k){try{let i=atob(t),r=JSON.parse(i);if(!r.data||!r.signature)return console.warn("SafePassage: Invalid signed state format"),null;let{data:o,signature:a}=r,l=JSON.stringify(o),F=v(e);if(!await M(l,a,F))return console.warn("SafePassage: State signature verification failed"),null;if(o.timestamp){let E=Date.now()-o.timestamp;if(E>n)return console.warn("SafePassage: State parameter expired",{age:E,maxAge:n}),null}let{timestamp:ce,nonce:le,...B}=o;return B}catch(i){return console.warn("SafePassage: Failed to parse signed state",i),null}}var _=p(()=>{"use strict";S()});function N(t){if(!t.apiKey)throw new Error("apiKey is required");if(t.apiKey.length>D)throw new Error(`apiKey exceeds maximum length of ${D} characters`);if(!te.test(t.apiKey))throw new Error("Invalid apiKey format. Expected pk_xxx (public) or sk_xxx (private)");if(typeof window<"u"&&t.apiKey.startsWith("sk_"))throw new Error("Secret keys (sk_) should never be used in browser code for security reasons. Secret keys expose your account to unauthorized access if used client-side. Please use your public key (pk_) instead. If you need to use features that require a secret key (like custom challenge age), create the session server-side and pass the sessionId to startVerificationWithSession(). See: https://docs.safepassageapp.com/server-side-sessions");if(!t.returnUrl)throw new Error("returnUrl is required");if(t.returnUrl.length>d)throw new Error(`returnUrl exceeds maximum length of ${d} characters`);if(!t.cancelUrl)throw new Error("cancelUrl is required");if(t.cancelUrl.length>d)throw new Error(`cancelUrl exceeds maximum length of ${d} characters`);let e=ne(),n=h(t.returnUrl,e);if(!n.isValid)throw new Error(`returnUrl validation failed: ${n.error}`);let i=h(t.cancelUrl,e);if(!i.isValid)throw new Error(`cancelUrl validation failed: ${i.error}`);if(t.defaultChallengeAge!==void 0){if(t.defaultChallengeAge<O)throw new Error(`defaultChallengeAge must be at least ${O}`);if(t.defaultChallengeAge>$)throw new Error(`defaultChallengeAge cannot exceed ${$}`)}if(t.defaultVerificationMode&&!["L1","L2"].includes(t.defaultVerificationMode))throw new Error("defaultVerificationMode must be L1 or L2");if(t.mode&&!["redirect","new-tab"].includes(t.mode))throw new Error("mode must be redirect or new-tab")}function ne(){let t=window.location.hostname;return t==="localhost"||t==="127.0.0.1"||t.includes(".local")?"development":t.includes("staging")||t.includes("stage")?"staging":"production"}async function K(t,e){let{createSignedState:n}=await Promise.resolve().then(()=>(_(),C));return n(t,e)}var O,$,d,D,k,te,S=p(()=>{"use strict";m();O=25,$=150,d=2048,D=128,k=6e5,te=/^(pk_|sk_)[a-zA-Z0-9]+$/});function y(t){let e=ie[t];if((t==="production"||t==="staging")&&!e.startsWith("https://"))throw new Error(`HTTPS required for ${t} environment`);return e}function re(t){let n={production:"https://api.safepassageapp.com",staging:"https://api.safepassageapp.com",development:"http://localhost:3001"}[t];if((t==="production"||t==="staging")&&!n.startsWith("https://"))throw new Error(`HTTPS required for API URLs in ${t} environment`);return n}function W(t){let e=window.location.protocol==="https:",n=window.location.hostname;switch(t){case"production":if(!e)throw new Error("SafePassage requires HTTPS in production environment");break;case"staging":e||console.warn("SafePassage Warning: HTTPS strongly recommended in staging environment");break;case"development":{let i=n==="localhost"||n==="127.0.0.1"||n.includes(".local");!e&&!i&&console.warn("SafePassage Warning: HTTPS recommended for non-localhost development");break}}try{y(t),re(t)}catch(i){throw new Error(`Environment configuration validation failed: ${i}`)}}var ie,H=p(()=>{"use strict";ie={production:"https://av.safepassageapp.com",staging:"https://av.safepassageapp.com",development:"http://localhost:5173"}});var q={};g(q,{SafePassage:()=>c,default:()=>oe});var c,oe,I=p(()=>{"use strict";S();H();m();c=class{constructor(e){this.popupWindow=null;this.messageListener=null;this.popupMonitorInterval=null;this.unloadListener=null;this.isVerificationInProgress=!1;this.currentSessionId=null;N(e),this.config={...e,environment:e.environment||this.detectEnvironment(),mode:e.mode||"redirect"},W(this.config.environment),V(this.config.environment),s("SDK_INITIALIZED",{environment:this.config.environment,mode:this.config.mode,origin:window.location.origin,protocol:window.location.protocol,hostname:window.location.hostname}),this.setupAutoCleanup()}async verify(e={}){let n=this.isPublicKey(),i=e.sessionId;if(n&&!i&&(i=await this.createInternalSession(e)),!n&&!i)throw new Error("sessionId is required for private API keys - must be a merchant-generated UUID v4");if(!i)throw new Error("Failed to create or obtain sessionId");if(this.isVerificationInProgress){let r=new Error(`Verification already in progress for session ${this.currentSessionId?.substring(0,8)}...`);throw s("RACE_CONDITION_PREVENTED",{currentSession:this.currentSessionId?.substring(0,8)+"...",attemptedSession:e.sessionId?e.sessionId.substring(0,8)+"...":"undefined",origin:window.location.origin}),this.config.onError?.(r),r}this.isVerificationInProgress=!0,this.currentSessionId=i;try{let r=`${this.config.apiKey}:${window.location.origin}`;if(!L.isAllowed(r)){let a=new Error("Too many verification attempts. Please wait before trying again.");throw s("RATE_LIMIT_EXCEEDED",{apiKey:this.config.apiKey.substring(0,8)+"...",origin:window.location.origin,sessionId:i?i.substring(0,8)+"...":"undefined"}),this.config.onError?.(a),a}let o=await this.buildVerificationUrl({...e,sessionId:i});s("VERIFICATION_INITIATED",{environment:this.config.environment,mode:this.config.mode,sessionId:i?i.substring(0,8)+"...":"undefined",origin:window.location.origin}),this.config.mode==="new-tab"?this.openNewTab(o,i):(this.unlockVerification(),this.redirect(o))}catch(r){throw this.unlockVerification(),r}}async buildVerificationUrl(e){let n=y(this.config.environment),i=e.challengeAge!==void 0,r=e.verificationMode!==void 0,o=i||r,a=await K({merchantId:this.config.apiKey,sessionId:e.sessionId,returnUrl:this.config.returnUrl,cancelUrl:this.config.cancelUrl,challengeAge:e.challengeAge||this.config.defaultChallengeAge,verificationMode:e.verificationMode||this.config.defaultVerificationMode,hasOverrides:o,externalUserId:e.externalUserId,timestamp:Date.now(),apiUrl:this.getPortalApiUrl(),engineUrl:this.getEngineUrl(),wsUrl:this.getWebSocketUrl(),environment:this.config.environment,features:{captureMode:"enhanced_verification",testMode:!1,warmupPeriodMs:2e3,qualityThreshold:.6}},this.config.environment),l=new URLSearchParams({state:a,sessionId:e.sessionId,mode:this.config.mode});return`${n}/?${l.toString()}`}redirect(e){window.location.href=e}openNewTab(e,n){if(this.cleanup(),this.popupMonitorInterval&&(clearInterval(this.popupMonitorInterval),this.popupMonitorInterval=null),this.popupWindow=window.open(e,"safepassage-verify","width=600,height=700"),!this.popupWindow){this.config.onError?.(new Error("Failed to open verification window. Please check popup blocker settings."));return}this.messageListener=i=>{if(!T(i,this.config.environment)){s("POSTMESSAGE_ORIGIN_BLOCKED",{origin:i.origin,environment:this.config.environment,expectedOrigins:`SafePassage trusted origins for ${this.config.environment}`,messageType:i.data?.type});return}let r=x(i,n);if(!r.isValid){s("POSTMESSAGE_VALIDATION_FAILED",{error:r.error,origin:i.origin,sessionId:n.substring(0,8)+"...",messageType:i.data?.type});return}let o={sessionId:i.data.sessionId,status:i.data.status};s("VERIFICATION_COMPLETED",{status:o.status,sessionId:n.substring(0,8)+"...",origin:i.origin}),this.cleanup(),this.unlockVerification(),this.popupMonitorInterval&&(clearInterval(this.popupMonitorInterval),this.popupMonitorInterval=null),o.status==="verified"?this.config.onComplete?.(o):o.status==="cancelled"?this.config.onCancel?.():this.config.onError?.(new Error(`Verification failed: ${o.status}`))},window.addEventListener("message",this.messageListener),this.popupMonitorInterval=setInterval(()=>{this.popupWindow&&this.popupWindow.closed&&(s("POPUP_CLOSED_BY_USER",{sessionId:n.substring(0,8)+"...",environment:this.config.environment}),this.cleanup(),this.unlockVerification(),this.config.onCancel?.())},500)}setupAutoCleanup(){if(this.unloadListener=()=>{s("SDK_AUTO_CLEANUP",{environment:this.config.environment,trigger:"page_unload"}),this.cleanup(),this.unlockVerification()},window.addEventListener("beforeunload",this.unloadListener),window.addEventListener("pagehide",this.unloadListener),window.history&&window.history.pushState){let e=window.history.pushState;window.history.pushState=(...n)=>(this.cleanup(),this.unlockVerification(),e.apply(window.history,n))}}detectEnvironment(){let e=window.location.hostname;return e==="localhost"||e==="127.0.0.1"||e.includes(".local")?"development":e.includes("staging")||e.includes("stage")?"staging":"production"}unlockVerification(){this.isVerificationInProgress=!1,this.currentSessionId=null,s("VERIFICATION_UNLOCKED",{environment:this.config.environment,origin:window.location.origin})}cleanup(){this.popupWindow&&!this.popupWindow.closed&&this.popupWindow.close(),this.popupWindow=null,this.messageListener&&(window.removeEventListener("message",this.messageListener),this.messageListener=null),this.popupMonitorInterval&&(clearInterval(this.popupMonitorInterval),this.popupMonitorInterval=null)}removeAutoCleanupListeners(){this.unloadListener&&(window.removeEventListener("beforeunload",this.unloadListener),window.removeEventListener("pagehide",this.unloadListener),this.unloadListener=null)}destroy(){s("SDK_DESTROYED",{environment:this.config.environment,origin:window.location.origin}),this.cleanup(),this.unlockVerification(),this.removeAutoCleanupListeners()}getPortalApiUrl(){switch(this.config.environment){case"development":return"http://localhost:3001";case"staging":case"production":return"https://api.safepassageapp.com";default:return"https://api.safepassageapp.com"}}getEngineUrl(){switch(this.config.environment){case"development":return"http://localhost:8000";case"staging":case"production":return"https://engine.safepassageapp.com";default:return"https://engine.safepassageapp.com"}}getWebSocketUrl(){switch(this.config.environment){case"development":return"ws://localhost:8000/safe-passage-llm/api/v1/websocket/stream";case"staging":case"production":return"wss://engine.safepassageapp.com/safe-passage-llm/api/v1/websocket/stream";default:return"wss://engine.safepassageapp.com/safe-passage-llm/api/v1/websocket/stream"}}isPublicKey(){return this.config.apiKey.startsWith("pk_")}async createInternalSession(e){let n=crypto.randomUUID();try{let i=this.getPortalApiUrl(),r=await fetch(`${i}/api/v1/sessions/create`,{method:"POST",headers:{"Content-Type":"application/json",Authorization:`Bearer ${this.config.apiKey}`},body:JSON.stringify({merchantId:this.config.apiKey,sessionId:n,returnUrl:this.config.returnUrl,cancelUrl:this.config.cancelUrl,challengeAge:e.challengeAge,verificationMode:e.verificationMode,merchantName:document.title||window.location.hostname,externalUserId:e.externalUserId})});if(!r.ok){let o=await r.json().catch(()=>({}));throw new Error(`Failed to create session: ${r.status} ${r.statusText}. ${o.message||""}`)}return await r.json(),s("INTERNAL_SESSION_CREATED",{sessionId:n.substring(0,8)+"...",environment:this.config.environment,apiKeyType:"public"}),n}catch(i){let r=i instanceof Error?i.message:String(i);throw s("INTERNAL_SESSION_FAILED",{error:r,environment:this.config.environment,apiKeyType:"public"}),this.config.onError?.(i),new Error(`Failed to create verification session: ${r}`)}}},oe=c});var se={};g(se,{SafePassage:()=>c,VERSION:()=>j,default:()=>c});function U(){crypto.randomUUID||(crypto.randomUUID=function(){let t=new Uint8Array(16);crypto.getRandomValues(t),t[6]=t[6]&15|64,t[8]=t[8]&63|128;let e=Array.from(t).map(n=>n.toString(16).padStart(2,"0")).join("");return[e.slice(0,8),e.slice(8,12),e.slice(12,16),e.slice(16,20),e.slice(20,32)].join("-")})}function A(){let t=[];if(!window.crypto||!window.crypto.getRandomValues)throw new Error("SafePassage SDK requires Web Crypto API support");if(!window.crypto.subtle)throw new Error("SafePassage SDK requires Web Crypto subtle API for HMAC operations");crypto.randomUUID||t.push("crypto.randomUUID not supported, using polyfill"),window.URLSearchParams||t.push("URLSearchParams not supported, consider adding a polyfill for IE 11 support");try{if({a:{b:1}}?.a?.b!==1)throw new Error}catch{t.push("Optional chaining (?.) not supported, ensure transpilation for older browsers")}t.length>0&&console.warn("SafePassage SDK Browser Compatibility:",t.join("; "))}I();typeof window<"u"&&(U(),A());var j="3.0.4";if(typeof window<"u"&&window){let{SafePassage:t}=(I(),P(q)),e=window;e.SafePassage=t,e.SafePassage&&(e.SafePassage.VERSION=j)}return P(se);})();
|
|
3
|
+
if(typeof SafePassageSDK !== "undefined" && SafePassageSDK.SafePassage) { window.SafePassage = SafePassageSDK.SafePassage; window.SafePassage.VERSION = SafePassageSDK.VERSION; }
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for SafePassage SDK
|
|
3
|
+
*/
|
|
4
|
+
import { SafePassage } from '../core/SafePassageSDK';
|
|
5
|
+
describe('SafePassage SDK', () => {
|
|
6
|
+
let mockLocation;
|
|
7
|
+
let mockWindow;
|
|
8
|
+
beforeEach(() => {
|
|
9
|
+
// Mock window.location
|
|
10
|
+
mockLocation = {
|
|
11
|
+
href: 'https://merchant.com',
|
|
12
|
+
hostname: 'merchant.com',
|
|
13
|
+
origin: 'https://merchant.com'
|
|
14
|
+
};
|
|
15
|
+
// Mock window
|
|
16
|
+
mockWindow = {
|
|
17
|
+
location: mockLocation,
|
|
18
|
+
open: jest.fn(),
|
|
19
|
+
addEventListener: jest.fn(),
|
|
20
|
+
removeEventListener: jest.fn()
|
|
21
|
+
};
|
|
22
|
+
// Replace global window
|
|
23
|
+
global.window = mockWindow;
|
|
24
|
+
});
|
|
25
|
+
describe('constructor', () => {
|
|
26
|
+
it('should initialize with valid config', () => {
|
|
27
|
+
const config = {
|
|
28
|
+
apiKey: 'sk_test_123',
|
|
29
|
+
returnUrl: 'https://merchant.com/verified',
|
|
30
|
+
cancelUrl: 'https://merchant.com/cancelled'
|
|
31
|
+
};
|
|
32
|
+
const sp = new SafePassage(config);
|
|
33
|
+
expect(sp).toBeDefined();
|
|
34
|
+
});
|
|
35
|
+
it('should throw error for missing apiKey', () => {
|
|
36
|
+
const config = {
|
|
37
|
+
returnUrl: 'https://merchant.com/verified',
|
|
38
|
+
cancelUrl: 'https://merchant.com/cancelled'
|
|
39
|
+
};
|
|
40
|
+
expect(() => new SafePassage(config)).toThrow('apiKey is required');
|
|
41
|
+
});
|
|
42
|
+
it('should throw error for invalid apiKey format', () => {
|
|
43
|
+
const config = {
|
|
44
|
+
apiKey: 'invalid_key',
|
|
45
|
+
returnUrl: 'https://merchant.com/verified',
|
|
46
|
+
cancelUrl: 'https://merchant.com/cancelled'
|
|
47
|
+
};
|
|
48
|
+
expect(() => new SafePassage(config)).toThrow('Invalid apiKey format');
|
|
49
|
+
});
|
|
50
|
+
it('should throw error for missing returnUrl', () => {
|
|
51
|
+
const config = {
|
|
52
|
+
apiKey: 'sk_test_123',
|
|
53
|
+
cancelUrl: 'https://merchant.com/cancelled'
|
|
54
|
+
};
|
|
55
|
+
expect(() => new SafePassage(config)).toThrow('returnUrl is required');
|
|
56
|
+
});
|
|
57
|
+
it('should auto-detect development environment for localhost', () => {
|
|
58
|
+
mockLocation.hostname = 'localhost';
|
|
59
|
+
const config = {
|
|
60
|
+
apiKey: 'sk_test_123',
|
|
61
|
+
returnUrl: 'http://localhost:3000/verified',
|
|
62
|
+
cancelUrl: 'http://localhost:3000/cancelled'
|
|
63
|
+
};
|
|
64
|
+
const sp = new SafePassage(config);
|
|
65
|
+
expect(sp).toBeDefined();
|
|
66
|
+
});
|
|
67
|
+
it('should enforce minimum age of 25', () => {
|
|
68
|
+
const config = {
|
|
69
|
+
apiKey: 'sk_test_123',
|
|
70
|
+
returnUrl: 'https://merchant.com/verified',
|
|
71
|
+
cancelUrl: 'https://merchant.com/cancelled',
|
|
72
|
+
defaultChallengeAge: 21
|
|
73
|
+
};
|
|
74
|
+
expect(() => new SafePassage(config)).toThrow('defaultChallengeAge must be at least 25');
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
describe('verify', () => {
|
|
78
|
+
let sp;
|
|
79
|
+
beforeEach(() => {
|
|
80
|
+
const config = {
|
|
81
|
+
apiKey: 'sk_test_123',
|
|
82
|
+
returnUrl: 'https://merchant.com/verified',
|
|
83
|
+
cancelUrl: 'https://merchant.com/cancelled'
|
|
84
|
+
};
|
|
85
|
+
sp = new SafePassage(config);
|
|
86
|
+
});
|
|
87
|
+
it('should throw error for missing sessionId', () => {
|
|
88
|
+
expect(() => sp.verify({})).toThrow('sessionId is required');
|
|
89
|
+
});
|
|
90
|
+
it('should redirect in same-tab mode', () => {
|
|
91
|
+
const sessionId = '550e8400-e29b-41d4-a716-446655440000';
|
|
92
|
+
sp.verify({ sessionId });
|
|
93
|
+
expect(mockWindow.location.href).toContain('verify.safepassageapp.com');
|
|
94
|
+
expect(mockWindow.location.href).toContain(`sessionId=${sessionId}`);
|
|
95
|
+
});
|
|
96
|
+
it('should open new tab in new-tab mode', () => {
|
|
97
|
+
const config = {
|
|
98
|
+
apiKey: 'sk_test_123',
|
|
99
|
+
returnUrl: 'https://merchant.com/verified',
|
|
100
|
+
cancelUrl: 'https://merchant.com/cancelled',
|
|
101
|
+
mode: 'new-tab'
|
|
102
|
+
};
|
|
103
|
+
const sp = new SafePassage(config);
|
|
104
|
+
const sessionId = '550e8400-e29b-41d4-a716-446655440000';
|
|
105
|
+
sp.verify({ sessionId });
|
|
106
|
+
expect(mockWindow.open).toHaveBeenCalledWith(expect.stringContaining('verify.safepassageapp.com'), 'safepassage-verify', 'width=600,height=700');
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
describe('URL building', () => {
|
|
110
|
+
it('should build correct URL with all parameters', () => {
|
|
111
|
+
const config = {
|
|
112
|
+
apiKey: 'sk_test_123',
|
|
113
|
+
returnUrl: 'https://merchant.com/verified',
|
|
114
|
+
cancelUrl: 'https://merchant.com/cancelled',
|
|
115
|
+
environment: 'staging'
|
|
116
|
+
};
|
|
117
|
+
const sp = new SafePassage(config);
|
|
118
|
+
// Access private method for testing
|
|
119
|
+
const buildUrl = sp.buildVerificationUrl.bind(sp);
|
|
120
|
+
const url = buildUrl({
|
|
121
|
+
sessionId: '550e8400-e29b-41d4-a716-446655440000',
|
|
122
|
+
challengeAge: 30,
|
|
123
|
+
verificationMode: 'L2'
|
|
124
|
+
});
|
|
125
|
+
expect(url).toContain('verify-staging.safepassageapp.com');
|
|
126
|
+
expect(url).toContain('sessionId=550e8400-e29b-41d4-a716-446655440000');
|
|
127
|
+
expect(url).toContain('state=');
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
});
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SafePassage SDK Type Definitions
|
|
3
|
+
*/
|
|
4
|
+
export interface SafePassageConfig {
|
|
5
|
+
/**
|
|
6
|
+
* Public API key (starts with pk_live_ or pk_test_)
|
|
7
|
+
*/
|
|
8
|
+
apiKey: string;
|
|
9
|
+
/**
|
|
10
|
+
* URL to redirect to after successful verification
|
|
11
|
+
* Must be pre-registered in dashboard
|
|
12
|
+
*/
|
|
13
|
+
returnUrl: string;
|
|
14
|
+
/**
|
|
15
|
+
* URL to redirect to if user cancels verification
|
|
16
|
+
* Must be pre-registered in dashboard
|
|
17
|
+
*/
|
|
18
|
+
cancelUrl: string;
|
|
19
|
+
/**
|
|
20
|
+
* Environment to use
|
|
21
|
+
* @default Auto-detected based on hostname
|
|
22
|
+
*/
|
|
23
|
+
environment?: 'production' | 'staging' | 'development';
|
|
24
|
+
/**
|
|
25
|
+
* Verification mode
|
|
26
|
+
* @default 'redirect'
|
|
27
|
+
*/
|
|
28
|
+
mode?: 'redirect' | 'new-tab';
|
|
29
|
+
/**
|
|
30
|
+
* Default challenge age (minimum 25)
|
|
31
|
+
* Can be overridden per verification
|
|
32
|
+
*/
|
|
33
|
+
defaultChallengeAge?: number;
|
|
34
|
+
/**
|
|
35
|
+
* Default verification mode
|
|
36
|
+
* Can be overridden per verification
|
|
37
|
+
*/
|
|
38
|
+
defaultVerificationMode?: 'L1' | 'L2';
|
|
39
|
+
/**
|
|
40
|
+
* Callback when verification completes (new-tab mode only)
|
|
41
|
+
*/
|
|
42
|
+
onComplete?: (result: VerificationResult) => void;
|
|
43
|
+
/**
|
|
44
|
+
* Callback when user cancels (new-tab mode only)
|
|
45
|
+
*/
|
|
46
|
+
onCancel?: () => void;
|
|
47
|
+
/**
|
|
48
|
+
* Callback for errors
|
|
49
|
+
*/
|
|
50
|
+
onError?: (error: Error) => void;
|
|
51
|
+
}
|
|
52
|
+
export interface VerificationOptions {
|
|
53
|
+
/**
|
|
54
|
+
* Merchant-generated UUID v4 for this verification session
|
|
55
|
+
* Required for private keys (sk_), optional for public keys (pk_)
|
|
56
|
+
* For public keys: SDK will generate session internally
|
|
57
|
+
*/
|
|
58
|
+
sessionId?: string;
|
|
59
|
+
/**
|
|
60
|
+
* Minimum age to verify (minimum 25)
|
|
61
|
+
* @default Uses merchant dashboard configuration
|
|
62
|
+
*/
|
|
63
|
+
challengeAge?: number;
|
|
64
|
+
/**
|
|
65
|
+
* Verification mode
|
|
66
|
+
* L1: Age estimation allowed if user appears older
|
|
67
|
+
* L2: Full ID verification required
|
|
68
|
+
* @default Uses merchant dashboard configuration
|
|
69
|
+
*/
|
|
70
|
+
verificationMode?: 'L1' | 'L2';
|
|
71
|
+
/**
|
|
72
|
+
* External user identifier from merchant system
|
|
73
|
+
* Optional parameter that will be returned with verification results
|
|
74
|
+
* Useful for correlating SafePassage sessions with merchant user records
|
|
75
|
+
*/
|
|
76
|
+
externalUserId?: string;
|
|
77
|
+
}
|
|
78
|
+
export interface VerificationResult {
|
|
79
|
+
/**
|
|
80
|
+
* The session ID that was verified
|
|
81
|
+
*/
|
|
82
|
+
sessionId: string;
|
|
83
|
+
/**
|
|
84
|
+
* Binary result: 'verified' or 'failed'
|
|
85
|
+
* Full details available via server-side API
|
|
86
|
+
*/
|
|
87
|
+
status: 'verified' | 'failed' | 'cancelled';
|
|
88
|
+
/**
|
|
89
|
+
* External user identifier if provided during verification
|
|
90
|
+
*/
|
|
91
|
+
externalUserId?: string;
|
|
92
|
+
}
|
|
93
|
+
export interface StatePayload {
|
|
94
|
+
merchantId: string;
|
|
95
|
+
sessionId: string;
|
|
96
|
+
returnUrl: string;
|
|
97
|
+
cancelUrl: string;
|
|
98
|
+
challengeAge?: number;
|
|
99
|
+
verificationMode?: 'L1' | 'L2';
|
|
100
|
+
hasOverrides?: boolean;
|
|
101
|
+
externalUserId?: string;
|
|
102
|
+
timestamp: number;
|
|
103
|
+
apiUrl?: string;
|
|
104
|
+
engineUrl?: string;
|
|
105
|
+
wsUrl?: string;
|
|
106
|
+
environment?: 'production' | 'staging' | 'development';
|
|
107
|
+
features?: {
|
|
108
|
+
captureMode: 'basic_verification' | 'enhanced_verification';
|
|
109
|
+
testMode: boolean;
|
|
110
|
+
warmupPeriodMs: number;
|
|
111
|
+
qualityThreshold: number;
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
export interface SessionValidationResponse {
|
|
115
|
+
sessionId: string;
|
|
116
|
+
merchantId: string;
|
|
117
|
+
status: 'verified' | 'failed';
|
|
118
|
+
verified: boolean;
|
|
119
|
+
estimatedAge?: number;
|
|
120
|
+
challengeAge: number;
|
|
121
|
+
verificationMode: 'L1' | 'L2';
|
|
122
|
+
verificationMethod?: 'facial' | 'document' | 'combined';
|
|
123
|
+
timestamp: string;
|
|
124
|
+
expiresAt: string;
|
|
125
|
+
}
|
|
126
|
+
export interface SessionCreationResponse {
|
|
127
|
+
sessionToken: string;
|
|
128
|
+
verifyUrl: string;
|
|
129
|
+
expiresAt: string;
|
|
130
|
+
}
|
|
131
|
+
export interface CreateSessionRequest {
|
|
132
|
+
sessionId: string;
|
|
133
|
+
returnUrl: string;
|
|
134
|
+
cancelUrl?: string;
|
|
135
|
+
challengeAge?: number;
|
|
136
|
+
verificationMode?: 'L1' | 'L2';
|
|
137
|
+
merchantName?: string;
|
|
138
|
+
externalUserId?: string;
|
|
139
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// Mock polyfills to avoid browser API checks in tests
|
|
2
|
+
export function setupPolyfills() {
|
|
3
|
+
// Do nothing in tests
|
|
4
|
+
}
|
|
5
|
+
export function checkBrowserCompatibility() {
|
|
6
|
+
// Do nothing in tests
|
|
7
|
+
}
|
|
8
|
+
export function polyfillCryptoRandomUUID() {
|
|
9
|
+
// Already mocked in test setup
|
|
10
|
+
}
|