@safepassage/sdk
Version:
SafePassage SDK - Lightweight redirect-based age verification
1,162 lines (1,152 loc) • 40.6 kB
JavaScript
var __defProp = Object.defineProperty;
var __defProps = Object.defineProperties;
var __getOwnPropDescs = Object.getOwnPropertyDescriptors;
var __getOwnPropNames = Object.getOwnPropertyNames;
var __getOwnPropSymbols = Object.getOwnPropertySymbols;
var __hasOwnProp = Object.prototype.hasOwnProperty;
var __propIsEnum = Object.prototype.propertyIsEnumerable;
var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value;
var __spreadValues = (a, b) => {
for (var prop in b || (b = {}))
if (__hasOwnProp.call(b, prop))
__defNormalProp(a, prop, b[prop]);
if (__getOwnPropSymbols)
for (var prop of __getOwnPropSymbols(b)) {
if (__propIsEnum.call(b, prop))
__defNormalProp(a, prop, b[prop]);
}
return a;
};
var __spreadProps = (a, b) => __defProps(a, __getOwnPropDescs(b));
var __objRest = (source, exclude) => {
var target = {};
for (var prop in source)
if (__hasOwnProp.call(source, prop) && exclude.indexOf(prop) < 0)
target[prop] = source[prop];
if (source != null && __getOwnPropSymbols)
for (var prop of __getOwnPropSymbols(source)) {
if (exclude.indexOf(prop) < 0 && __propIsEnum.call(source, prop))
target[prop] = source[prop];
}
return target;
};
var __esm = (fn, res) => function __init() {
return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
};
var __export = (target, all) => {
for (var name in all)
__defProp(target, name, { get: all[name], enumerable: true });
};
// src-redirect/utils/security.ts
function isOriginTrusted(origin, trustedOrigins) {
return trustedOrigins.includes(origin);
}
function validatePostMessageOrigin(event, trustedOrigins, allowedCustomOrigins = [], logLabel = "SDK") {
var _a;
const { origin } = event;
if (isOriginTrusted(origin, trustedOrigins)) {
return true;
}
if (allowedCustomOrigins.length > 0) {
const isCustomOriginAllowed = allowedCustomOrigins.some((allowedOrigin) => {
if (allowedOrigin.startsWith("*.")) {
const domain = allowedOrigin.slice(2);
return origin.endsWith(`.${domain}`) || origin === `https://${domain}` || origin === `http://${domain}`;
}
return origin === allowedOrigin;
});
if (isCustomOriginAllowed) {
return true;
}
}
console.warn(
`${logLabel} Security: Blocked PostMessage from untrusted origin: ${origin}`,
{
trustedOrigins,
allowedCustomOrigins,
eventType: (_a = event.data) == null ? void 0 : _a.type
}
);
return false;
}
function validateVerificationMessage(event, expectedSessionId, expectedMessageType, legacyMessageType) {
const { data } = event;
if (!data || typeof data !== "object") {
return { isValid: false, error: "Invalid message format" };
}
const allowedTypes = legacyMessageType ? [expectedMessageType, legacyMessageType] : [expectedMessageType];
if (!allowedTypes.includes(data.type)) {
return { isValid: false, error: "Invalid message type" };
}
if (!data.sessionId || data.sessionId !== expectedSessionId) {
return { isValid: false, error: "Session ID mismatch" };
}
if (!data.status || !["verified", "failed", "cancelled"].includes(data.status)) {
return { isValid: false, error: "Invalid status value" };
}
return { isValid: true };
}
function enforceHTTPS(environment, logLabel = "SDK") {
if (environment === "production" && window.location.protocol !== "https:") {
console.warn(
`${logLabel} Warning: HTTPS recommended for production environment`,
{ current: window.location.href }
);
}
}
function validateReturnUrl(url, environment, _logLabel = "SDK") {
try {
const parsed = new URL(url);
if (parsed.protocol === "file:") {
return {
isValid: false,
error: "file:// URLs are not supported. The verification redirect cannot return to local files. Please use a local web server (e.g., npx serve . or python3 -m http.server) instead of opening the HTML file directly."
};
}
if (parsed.protocol !== "https:") {
const isLocalhost = parsed.hostname === "localhost" || parsed.hostname === "127.0.0.1";
if (!isLocalhost) {
return {
isValid: false,
error: `HTTPS required for return URLs in ${environment}`
};
}
}
const suspiciousPatterns = [
/data:/i,
/javascript:/i,
/vbscript:/i,
/file:/i,
/ftp:/i
];
for (const pattern of suspiciousPatterns) {
if (pattern.test(url)) {
return { isValid: false, error: "Blocked suspicious URL scheme" };
}
}
return { isValid: true };
} catch (e) {
return { isValid: false, error: "Invalid URL format" };
}
}
function logSecurityEvent(event, metadata, logLabel = "SDK") {
const context = {
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
userAgent: typeof navigator !== "undefined" ? navigator.userAgent : "unknown",
url: typeof window !== "undefined" ? window.location.href : "unknown"
};
console.warn(`${logLabel} Security Event: ${event}`, __spreadValues(__spreadValues({}, context), metadata));
}
var VerificationRateLimit, verificationRateLimit;
var init_security = __esm({
"src-redirect/utils/security.ts"() {
"use strict";
VerificationRateLimit = class {
constructor() {
this.attempts = /* @__PURE__ */ new Map();
this.maxAttempts = 5;
this.timeWindow = 6e4;
}
// 1 minute
isAllowed(identifier, logLabel = "SDK") {
const now = Date.now();
const attempts = this.attempts.get(identifier) || [];
const recentAttempts = attempts.filter(
(time) => now - time < this.timeWindow
);
if (recentAttempts.length >= this.maxAttempts) {
console.warn(
`${logLabel} Security: Rate limit exceeded for ${identifier}`
);
return false;
}
recentAttempts.push(now);
this.attempts.set(identifier, recentAttempts);
return true;
}
reset(identifier) {
this.attempts.delete(identifier);
}
};
verificationRateLimit = new VerificationRateLimit();
}
});
// src-redirect/utils/crypto.ts
var crypto_exports = {};
__export(crypto_exports, {
createSignedState: () => createSignedState,
generateHMAC: () => generateHMAC,
generateSecureToken: () => generateSecureToken,
parseSignedState: () => parseSignedState,
verifyHMAC: () => verifyHMAC
});
async function generateHMAC(data, secret) {
const encoder = new TextEncoder();
const keyData = encoder.encode(secret);
const dataBuffer = encoder.encode(data);
const key = await crypto.subtle.importKey(
"raw",
keyData,
{ name: "HMAC", hash: "SHA-256" },
false,
["sign"]
);
const signature = await crypto.subtle.sign("HMAC", key, dataBuffer);
return Array.from(new Uint8Array(signature)).map((b) => b.toString(16).padStart(2, "0")).join("");
}
async function verifyHMAC(data, signature, secret) {
try {
const expectedSignature = await generateHMAC(data, secret);
return constantTimeCompare(signature, expectedSignature);
} catch (e) {
return false;
}
}
function constantTimeCompare(a, b) {
if (a.length !== b.length) {
return false;
}
let result = 0;
for (let i = 0; i < a.length; i++) {
result |= a.charCodeAt(i) ^ b.charCodeAt(i);
}
return result === 0;
}
function generateSecureToken(length = 32) {
const array = new Uint8Array(length);
crypto.getRandomValues(array);
return Array.from(array, (b) => b.toString(16).padStart(2, "0")).join("");
}
async function createSignedState(payload, hmacSecret) {
const timestampedPayload = __spreadProps(__spreadValues({}, payload), {
timestamp: Date.now(),
nonce: generateSecureToken(16)
});
const dataString = JSON.stringify(timestampedPayload);
const signature = await generateHMAC(dataString, hmacSecret);
const signedPayload = {
data: timestampedPayload,
signature
};
return btoa(JSON.stringify(signedPayload));
}
async function parseSignedState(signedState, hmacSecret, maxAge = STATE_EXPIRY_MS, logLabel = "SDK") {
try {
const json = atob(signedState);
const signedPayload = JSON.parse(json);
if (!signedPayload.data || !signedPayload.signature) {
console.warn(`${logLabel}: Invalid signed state format`);
return null;
}
const { data, signature } = signedPayload;
const dataString = JSON.stringify(data);
const isValid = await verifyHMAC(dataString, signature, hmacSecret);
if (!isValid) {
console.warn(`${logLabel}: State signature verification failed`);
return null;
}
if (data.timestamp) {
const age = Date.now() - data.timestamp;
if (age > maxAge) {
console.warn(`${logLabel}: State parameter expired`, { age, maxAge });
return null;
}
}
const _a = data, { timestamp, nonce } = _a, payload = __objRest(_a, ["timestamp", "nonce"]);
void timestamp;
void nonce;
return payload;
} catch (error) {
console.warn(`${logLabel}: Failed to parse signed state`, error);
return null;
}
}
var init_crypto = __esm({
"src-redirect/utils/crypto.ts"() {
"use strict";
init_validation();
}
});
// src-redirect/utils/validation.ts
function validateConfig(config, context) {
if (!config.apiKey) {
throw new Error("apiKey is required");
}
if (config.apiKey.length > MAX_API_KEY_LENGTH) {
throw new Error(
`apiKey exceeds maximum length of ${MAX_API_KEY_LENGTH} characters`
);
}
if (!API_KEY_PATTERN.test(config.apiKey)) {
throw new Error(
"Invalid apiKey format. Expected pk_xxx (public) or sk_xxx (private)"
);
}
if (typeof window !== "undefined" && config.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: ${context.docsUrl}/server-side-sessions`
);
}
if (!config.returnUrl) {
throw new Error("returnUrl is required");
}
if (config.returnUrl.length > MAX_URL_LENGTH) {
throw new Error(
`returnUrl exceeds maximum length of ${MAX_URL_LENGTH} characters`
);
}
const environment = detectEnvironment();
const returnUrlValidation = validateReturnUrl(config.returnUrl, environment, context.brandName);
if (!returnUrlValidation.isValid) {
throw new Error(
`returnUrl validation failed: ${returnUrlValidation.error}`
);
}
if (config.cancelUrl) {
if (config.cancelUrl.length > MAX_URL_LENGTH) {
throw new Error(
`cancelUrl exceeds maximum length of ${MAX_URL_LENGTH} characters`
);
}
const cancelUrlValidation = validateReturnUrl(config.cancelUrl, environment, context.brandName);
if (!cancelUrlValidation.isValid) {
throw new Error(
`cancelUrl validation failed: ${cancelUrlValidation.error}`
);
}
}
if (config.defaultChallengeAge !== void 0) {
if (config.defaultChallengeAge < MINIMUM_AGE) {
throw new Error(`defaultChallengeAge must be at least ${MINIMUM_AGE}`);
}
if (config.defaultChallengeAge > MAXIMUM_AGE) {
throw new Error(`defaultChallengeAge cannot exceed ${MAXIMUM_AGE}`);
}
}
if (config.defaultVerificationMode && !["L1", "L2"].includes(config.defaultVerificationMode)) {
throw new Error("defaultVerificationMode must be L1 or L2");
}
if (config.mode && !["redirect", "new-tab"].includes(config.mode)) {
throw new Error("mode must be redirect or new-tab");
}
if (config.newTabTarget && !["popup", "tab"].includes(config.newTabTarget)) {
throw new Error("newTabTarget must be popup or tab");
}
}
function detectEnvironment() {
if (typeof window === "undefined") {
return "production";
}
const hostname = window.location.hostname;
if (hostname.includes("staging") || hostname.includes("stage")) {
return "staging";
}
return "production";
}
async function generateState(payload, environment, hmacSecret, _logLabel = "SDK") {
const { createSignedState: createSignedState2 } = await Promise.resolve().then(() => (init_crypto(), crypto_exports));
return createSignedState2(payload, hmacSecret);
}
var MINIMUM_AGE, MAXIMUM_AGE, MAX_URL_LENGTH, MAX_API_KEY_LENGTH, STATE_EXPIRY_MS, API_KEY_PATTERN;
var init_validation = __esm({
"src-redirect/utils/validation.ts"() {
"use strict";
init_security();
MINIMUM_AGE = 25;
MAXIMUM_AGE = 150;
MAX_URL_LENGTH = 2048;
MAX_API_KEY_LENGTH = 128;
STATE_EXPIRY_MS = 6e5;
API_KEY_PATTERN = /^(pk_|sk_)[a-zA-Z0-9_]+$/;
}
});
// src-redirect/utils/polyfills.ts
function setupPolyfills() {
if (!crypto.randomUUID) {
crypto.randomUUID = function() {
const array = new Uint8Array(16);
crypto.getRandomValues(array);
array[6] = array[6] & 15 | 64;
array[8] = array[8] & 63 | 128;
const hex = Array.from(array).map((b) => b.toString(16).padStart(2, "0")).join("");
return [
hex.slice(0, 8),
hex.slice(8, 12),
hex.slice(12, 16),
hex.slice(16, 20),
hex.slice(20, 32)
].join("-");
};
}
}
function checkBrowserCompatibility(logLabel = "SDK") {
const warnings = [];
if (!window.crypto || !window.crypto.getRandomValues) {
throw new Error(`${logLabel} requires Web Crypto API support`);
}
if (!window.crypto.subtle) {
throw new Error(
`${logLabel} requires Web Crypto subtle API for HMAC operations`
);
}
if (!crypto.randomUUID) {
warnings.push("crypto.randomUUID not supported, using polyfill");
}
if (!window.URLSearchParams) {
warnings.push(
"URLSearchParams not supported, consider adding a polyfill for IE 11 support"
);
}
if (warnings.length > 0) {
console.warn(`${logLabel} Browser Compatibility:`, warnings.join("; "));
}
}
// src-redirect/core/VerificationSDK.ts
init_validation();
// src-redirect/utils/environment.ts
function getEnvironmentUrl(environment, urls) {
const url = urls.verifyUiUrl;
if (!url || !url.startsWith("https://")) {
throw new Error(`HTTPS required for ${environment} environment`);
}
return url;
}
function getApiUrl(environment, urls) {
const url = urls.apiUrl;
if (!url || !url.startsWith("https://")) {
throw new Error(
`HTTPS required for API URLs in ${environment} environment`
);
}
return url;
}
function validateEnvironmentSecurity(environment, urls, logLabel = "SDK") {
const isSecure = window.location.protocol === "https:";
switch (environment) {
case "production":
if (!isSecure) {
console.warn(`${logLabel} Warning: HTTPS recommended for production environment`);
}
break;
case "staging":
if (!isSecure) {
console.warn(
`${logLabel} Warning: HTTPS strongly recommended in staging environment`
);
}
break;
}
try {
getEnvironmentUrl(environment, urls);
getApiUrl(environment, urls);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
throw new Error(`Environment configuration validation failed: ${errorMessage}`);
}
}
// src-redirect/core/VerificationSDK.ts
init_security();
var _VerificationSDK = class _VerificationSDK {
/**
* Initialize SDK
*
* Validates configuration, sets up security measures, and prepares the SDK
* for verification operations. Performs comprehensive environment validation
* and security initialization.
*/
constructor(config, brandUrls, brandConstants) {
this.popupWindow = null;
this.messageListener = null;
this.popupMonitorInterval = null;
this.unloadListener = null;
this.isVerificationInProgress = false;
this.currentSessionId = null;
this.hasReceivedResult = false;
// Server-provided verify URL (includes sessionToken)
this.lastVerifyUrl = null;
// Server-provided session token (WS auth)
this.lastSessionToken = null;
// External user ID provided during verify() for cancellation redirects
this.lastExternalUserId = null;
// Sandbox mode flag from session creation (for UI labeling)
this.lastSandboxMode = null;
// Temporary storage for QR handoff token to include in state
this.temporaryHandoffToken = null;
this.brandUrls = brandUrls;
this.brandConstants = brandConstants;
validateConfig(config, {
brandName: this.brandConstants.name,
docsUrl: this.brandConstants.docsUrl
});
let normalizedEnvironment = config.environment || this.detectEnvironment();
if (normalizedEnvironment !== "staging" && normalizedEnvironment !== "production") {
console.warn(
`${this.brandConstants.name} SDK: Unknown environment '${normalizedEnvironment}', defaulting to 'production'`
);
normalizedEnvironment = "production";
}
this.config = __spreadProps(__spreadValues({}, config), {
environment: normalizedEnvironment,
mode: config.mode || "redirect",
newTabTarget: config.newTabTarget || "popup"
});
validateEnvironmentSecurity(this.config.environment, this.getUrlConfig(), this.brandConstants.name);
enforceHTTPS(this.config.environment, this.brandConstants.name);
logSecurityEvent("SDK_INITIALIZED", {
environment: this.config.environment,
mode: this.config.mode,
origin: window.location.origin,
protocol: window.location.protocol,
hostname: window.location.hostname
}, this.brandConstants.name);
this.setupAutoCleanup();
}
/**
* Initiate verification with race condition protection
*/
async verify(options = {}) {
var _a, _b, _c, _d, _e, _f;
const isPublicKey = this.isPublicKey();
let sessionId;
if (isPublicKey) {
sessionId = await this.createInternalSession(options);
} else {
throw new Error(
"Private API keys (sk_) should use the direct API, not the SDK. The SDK is designed for browser-based public key usage only."
);
}
if (!sessionId) {
throw new Error("Failed to obtain sessionId from server");
}
if (this.isVerificationInProgress) {
const error = new Error(
`Verification already in progress for session ${(_a = this.currentSessionId) == null ? void 0 : _a.substring(0, 8)}...`
);
logSecurityEvent("RACE_CONDITION_PREVENTED", {
currentSession: ((_b = this.currentSessionId) == null ? void 0 : _b.substring(0, 8)) + "...",
attemptedSession: "new-session-attempt",
origin: window.location.origin
}, this.brandConstants.name);
(_d = (_c = this.config).onError) == null ? void 0 : _d.call(_c, error);
throw error;
}
this.isVerificationInProgress = true;
this.currentSessionId = sessionId;
this.lastExternalUserId = options.externalUserId || null;
try {
const rateLimitKey = `${this.config.apiKey}:${window.location.origin}`;
if (!verificationRateLimit.isAllowed(rateLimitKey, this.brandConstants.name)) {
const error = new Error(
"Too many verification attempts. Please wait before trying again."
);
logSecurityEvent("RATE_LIMIT_EXCEEDED", {
apiKey: this.config.apiKey.substring(0, 8) + "...",
origin: window.location.origin,
sessionId: sessionId ? sessionId.substring(0, 8) + "..." : "undefined"
}, this.brandConstants.name);
(_f = (_e = this.config).onError) == null ? void 0 : _f.call(_e, error);
throw error;
}
const verificationUrl = await this.buildVerificationUrl(
options,
sessionId
);
logSecurityEvent("VERIFICATION_INITIATED", {
environment: this.config.environment,
mode: this.config.mode,
sessionId: sessionId ? sessionId.substring(0, 8) + "..." : "undefined",
origin: window.location.origin
}, this.brandConstants.name);
if (this.config.mode === "new-tab") {
this.openNewTab(verificationUrl, sessionId);
} else {
this.unlockVerification();
this.redirect(verificationUrl);
}
} catch (error) {
this.unlockVerification();
throw error;
}
}
/**
* Build verification URL with HMAC-signed state
*/
async buildVerificationUrl(options, sessionId) {
var _a;
const baseUrl = this.config.verifyUrl || getEnvironmentUrl(this.config.environment, this.getUrlConfig());
const hasExplicitChallengeAge = options.challengeAge !== void 0;
const hasExplicitVerificationMode = options.verificationMode !== void 0;
const hasOverrides = hasExplicitChallengeAge || hasExplicitVerificationMode;
const state = await generateState(
{
merchantId: this.config.apiKey,
sessionId,
returnUrl: this.config.returnUrl,
cancelUrl: this.config.cancelUrl,
challengeAge: options.challengeAge || this.config.defaultChallengeAge,
verificationMode: options.verificationMode || this.config.defaultVerificationMode,
hasOverrides,
// Flag to indicate explicit overrides
externalUserId: options.externalUserId,
timestamp: Date.now(),
// Additional config for self-contained verify-ui
apiUrl: this.getPortalApiUrl(),
engineUrl: this.getEngineUrl(),
wsUrl: this.getWebSocketUrl(),
environment: this.config.environment,
features: {
testMode: false,
warmupPeriodMs: 500,
qualityThreshold: 0.6,
sandboxMode: (_a = this.lastSandboxMode) != null ? _a : false
},
// Include handoffToken if available (for QR code desktop flow)
handoffToken: this.temporaryHandoffToken || void 0,
// Include sessionToken and verifyUrl to make UI auth deterministic
sessionToken: this.lastSessionToken || void 0,
verifyUrl: this.lastVerifyUrl || void 0
},
this.config.environment,
this.getHmacSecret(),
this.brandConstants.name
);
const language = options.language || this.config.language;
if (this.lastVerifyUrl) {
try {
const resolvedVerifyUrl = this.applyLocalVerifyOverride(this.lastVerifyUrl);
const url = new URL(resolvedVerifyUrl);
url.searchParams.set("state", state);
url.searchParams.set("mode", this.config.mode);
if (options.skipIntro) {
url.searchParams.set("skip_intro", "true");
}
if (options.autoReturn) {
url.searchParams.set("auto_return", "true");
}
if (language) {
url.searchParams.set("lang", language);
}
return url.toString();
} catch (e) {
}
}
const params = new URLSearchParams({ state, sessionId, mode: this.config.mode });
if (options.skipIntro) {
params.set("skip_intro", "true");
}
if (options.autoReturn) {
params.set("auto_return", "true");
}
if (language) {
params.set("lang", language);
}
return `${baseUrl}/?${params.toString()}`;
}
/**
* Redirect in same tab
*/
redirect(url) {
window.location.href = url;
}
/**
* Open in new tab with PostMessage communication and proper cleanup
*/
openNewTab(url, sessionId) {
var _a, _b;
this.cleanup();
this.hasReceivedResult = false;
if (this.popupMonitorInterval) {
clearInterval(this.popupMonitorInterval);
this.popupMonitorInterval = null;
}
const target = this.config.newTabTarget || "popup";
if (target === "tab") {
this.popupWindow = window.open(url, "_blank");
} else {
this.popupWindow = window.open(
url,
this.brandConstants.popupName,
"width=600,height=700"
);
}
if (!this.popupWindow) {
(_b = (_a = this.config).onError) == null ? void 0 : _b.call(
_a,
new Error(
"Failed to open verification window. Please check popup blocker settings."
)
);
return;
}
const trustedOrigins = this.getTrustedOrigins();
const allowedCustomOrigins = this.getAllowedCustomOrigins(url);
const expectedMessageType = this.brandConstants.messageType;
const legacyMessageType = this.brandConstants.legacyMessageType;
this.messageListener = (event) => {
var _a2, _b2, _c, _d, _e, _f, _g;
const messageType = (_a2 = event.data) == null ? void 0 : _a2.type;
const allowedTypes = legacyMessageType ? [expectedMessageType, legacyMessageType] : [expectedMessageType];
if (!messageType || typeof messageType !== "string" || !allowedTypes.includes(messageType)) {
return;
}
if (!validatePostMessageOrigin(event, trustedOrigins, allowedCustomOrigins, this.brandConstants.name)) {
logSecurityEvent("POSTMESSAGE_ORIGIN_BLOCKED", {
origin: event.origin,
environment: this.config.environment,
expectedOrigins: `${this.brandConstants.name} trusted origins for ${this.config.environment}`,
messageType: (_b2 = event.data) == null ? void 0 : _b2.type
}, this.brandConstants.name);
return;
}
const messageValidation = validateVerificationMessage(
event,
sessionId,
expectedMessageType,
legacyMessageType
);
if (!messageValidation.isValid) {
logSecurityEvent("POSTMESSAGE_VALIDATION_FAILED", {
error: messageValidation.error,
origin: event.origin,
sessionId: sessionId.substring(0, 8) + "...",
messageType: (_c = event.data) == null ? void 0 : _c.type
}, this.brandConstants.name);
return;
}
const status = event.data.status;
if (status === "cancelled") {
this.handleCancellation(sessionId, "postmessage");
return;
}
const result = {
sessionId: event.data.sessionId,
status,
timestamp: event.data.timestamp,
externalUserId: event.data.externalUserId
};
this.hasReceivedResult = true;
logSecurityEvent("VERIFICATION_COMPLETED", {
status: result.status,
sessionId: sessionId.substring(0, 8) + "...",
origin: event.origin
}, this.brandConstants.name);
this.cleanup({ closePopup: false });
this.unlockVerification();
if (this.popupMonitorInterval) {
clearInterval(this.popupMonitorInterval);
this.popupMonitorInterval = null;
}
if (result.status === "verified") {
(_e = (_d = this.config).onComplete) == null ? void 0 : _e.call(_d, result);
} else {
(_g = (_f = this.config).onError) == null ? void 0 : _g.call(
_f,
new Error(`Verification failed: ${result.status}`)
);
}
};
window.addEventListener("message", this.messageListener);
this.popupMonitorInterval = setInterval(() => {
if (this.popupWindow && this.popupWindow.closed) {
logSecurityEvent("POPUP_CLOSED_BY_USER", {
sessionId: sessionId.substring(0, 8) + "...",
environment: this.config.environment
}, this.brandConstants.name);
if (!this.hasReceivedResult) {
this.handleCancellation(sessionId, "popup-closed");
}
}
}, 500);
}
/**
* Set up automatic cleanup on page unload to prevent memory leaks
*/
setupAutoCleanup() {
this.unloadListener = () => {
logSecurityEvent("SDK_AUTO_CLEANUP", {
environment: this.config.environment,
trigger: "page_unload"
}, this.brandConstants.name);
this.cleanup();
this.unlockVerification();
};
window.addEventListener("beforeunload", this.unloadListener);
window.addEventListener("pagehide", this.unloadListener);
if (window.history && window.history.pushState) {
const originalPushState = window.history.pushState;
window.history.pushState = (...args) => {
this.cleanup();
this.unlockVerification();
return originalPushState.apply(window.history, args);
};
}
}
/**
* Auto-detect environment based on current URL
*/
detectEnvironment() {
const hostname = window.location.hostname;
if (hostname.includes("staging") || hostname.includes("stage")) {
return "staging";
}
return "production";
}
/**
* Get the current environment
*/
getEnvironment() {
return this.config.environment;
}
/**
* Unlock verification process to allow new verifications
*/
unlockVerification() {
this.isVerificationInProgress = false;
this.currentSessionId = null;
logSecurityEvent("VERIFICATION_UNLOCKED", {
environment: this.config.environment,
origin: window.location.origin
}, this.brandConstants.name);
}
/**
* Internal cleanup method to prevent memory leaks
*/
cleanup(options = {}) {
const shouldClosePopup = options.closePopup !== false;
if (this.popupWindow) {
if (shouldClosePopup && !this.popupWindow.closed) {
this.popupWindow.close();
}
if (shouldClosePopup || this.popupWindow.closed) {
this.popupWindow = null;
}
}
if (this.messageListener) {
window.removeEventListener("message", this.messageListener);
this.messageListener = null;
}
if (this.popupMonitorInterval) {
clearInterval(this.popupMonitorInterval);
this.popupMonitorInterval = null;
}
}
handleCancellation(sessionId, source) {
if (this.hasReceivedResult) {
return;
}
this.hasReceivedResult = true;
logSecurityEvent("VERIFICATION_CANCELLED", {
source,
sessionId: sessionId.substring(0, 8) + "...",
environment: this.config.environment,
origin: window.location.origin
}, this.brandConstants.name);
this.cleanup();
this.unlockVerification();
let shouldRedirect = true;
if (this.config.onCancel) {
try {
const result = this.config.onCancel();
if (result === false) {
shouldRedirect = false;
}
} catch (error) {
logSecurityEvent("CANCEL_CALLBACK_FAILED", {
error: error instanceof Error ? error.message : String(error),
sessionId: sessionId.substring(0, 8) + "..."
}, this.brandConstants.name);
}
}
if (shouldRedirect) {
this.redirectToCancelUrl(sessionId);
}
}
redirectToCancelUrl(sessionId) {
if (!this.config.cancelUrl) {
return;
}
try {
const decodedUrl = decodeURIComponent(this.config.cancelUrl);
const redirectUrl = new URL(decodedUrl);
redirectUrl.searchParams.set("sessionId", sessionId);
redirectUrl.searchParams.set("status", "cancelled");
redirectUrl.searchParams.set("timestamp", Date.now().toString());
if (this.lastExternalUserId) {
redirectUrl.searchParams.set("externalUserId", this.lastExternalUserId);
}
window.location.href = redirectUrl.toString();
} catch (error) {
logSecurityEvent("CANCEL_REDIRECT_FAILED", {
error: error instanceof Error ? error.message : String(error),
sessionId: sessionId.substring(0, 8) + "..."
}, this.brandConstants.name);
}
}
/**
* Remove auto-cleanup listeners
*/
removeAutoCleanupListeners() {
if (this.unloadListener) {
window.removeEventListener("beforeunload", this.unloadListener);
window.removeEventListener("pagehide", this.unloadListener);
this.unloadListener = null;
}
}
/**
* Public cleanup method for manual resource management
*/
destroy() {
logSecurityEvent("SDK_DESTROYED", {
environment: this.config.environment,
origin: window.location.origin
}, this.brandConstants.name);
this.cleanup();
this.unlockVerification();
this.removeAutoCleanupListeners();
}
/**
* Get Portal API URL based on environment and brand
*/
getPortalApiUrl() {
if (this.config.apiUrl) {
return this.config.apiUrl;
}
return this.getUrlConfig().apiUrl;
}
/**
* Get Engine URL based on environment and brand
*/
getEngineUrl() {
return this.getUrlConfig().engineUrl;
}
/**
* Get WebSocket URL based on environment and brand
*/
getWebSocketUrl() {
return this.getUrlConfig().wsUrl;
}
/**
* Detect if this is a public key (pk_ prefix) vs private key (sk_ prefix)
*/
isPublicKey() {
return this.config.apiKey.startsWith("pk_");
}
/**
* Create session internally for public keys
*/
async createInternalSession(options) {
var _a, _b;
try {
const portalApiUrl = this.getPortalApiUrl();
const response = await fetch(`${portalApiUrl}/api/v1/sessions/create`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${this.config.apiKey}`
},
body: JSON.stringify({
merchantId: this.config.apiKey,
returnUrl: this.config.returnUrl,
cancelUrl: this.config.cancelUrl,
challengeAge: options.challengeAge,
verificationMode: options.verificationMode,
merchantName: document.title || window.location.hostname,
externalUserId: options.externalUserId
})
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
const errorCode = errorData == null ? void 0 : errorData.code;
if (this.isBillingBlockError(errorCode)) {
const language = options.language || this.config.language;
this.openBillingBlockPage(errorCode, errorData == null ? void 0 : errorData.portalUrl, language);
}
throw new Error(
`Failed to create session: ${response.status} ${response.statusText}. ${errorData.message || ""}`
);
}
const sessionData = await response.json();
const sessionId = sessionData.sessionId;
if (!sessionId) {
throw new Error("Server did not return a sessionId");
}
if (sessionData.verifyUrl) {
this.lastVerifyUrl = sessionData.verifyUrl;
}
if (sessionData.sessionToken) {
this.lastSessionToken = sessionData.sessionToken;
}
if (sessionData.handoffToken) {
this.temporaryHandoffToken = sessionData.handoffToken;
}
if (typeof sessionData.sandboxMode === "boolean") {
this.lastSandboxMode = sessionData.sandboxMode;
} else {
this.lastSandboxMode = null;
}
logSecurityEvent("INTERNAL_SESSION_CREATED", {
sessionId: sessionId.substring(0, 8) + "...",
environment: this.config.environment,
apiKeyType: "public"
}, this.brandConstants.name);
return sessionId;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
logSecurityEvent("INTERNAL_SESSION_FAILED", {
error: errorMessage,
environment: this.config.environment,
apiKeyType: "public"
}, this.brandConstants.name);
(_b = (_a = this.config).onError) == null ? void 0 : _b.call(_a, error);
throw new Error(`Failed to create verification session: ${errorMessage}`);
}
}
isBillingBlockError(code) {
return code === "SUBSCRIPTION_REQUIRED" || code === "PLAN_LIMIT_REACHED" || code === "SANDBOX_LIMIT_REACHED";
}
openBillingBlockPage(code, portalUrl, language) {
var _a, _b, _c, _d;
try {
const baseUrl = this.config.verifyUrl || getEnvironmentUrl(this.config.environment, this.getUrlConfig());
const resolvedUrl = this.applyLocalVerifyOverride(baseUrl);
const url = new URL(resolvedUrl);
url.searchParams.set("blocked", code);
if (portalUrl) {
url.searchParams.set("portalUrl", portalUrl);
}
const effectiveLanguage = language || this.config.language;
if (effectiveLanguage) {
url.searchParams.set("lang", effectiveLanguage);
}
if (this.config.mode === "new-tab") {
const target = this.config.newTabTarget || "popup";
const popup = target === "tab" ? window.open(url.toString(), "_blank") : window.open(
url.toString(),
this.brandConstants.popupName,
"width=600,height=700"
);
if (!popup) {
(_b = (_a = this.config).onError) == null ? void 0 : _b.call(
_a,
new Error(
"Failed to open billing notice window. Please check popup blocker settings."
)
);
}
return;
}
this.redirect(url.toString());
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
(_d = (_c = this.config).onError) == null ? void 0 : _d.call(
_c,
new Error(`Failed to open billing notice: ${errorMessage}`)
);
}
}
getUrlConfig() {
return this.brandUrls[this.config.environment] || this.brandUrls.production;
}
getTrustedOrigins() {
return this.getUrlConfig().trustedOrigins;
}
getAllowedCustomOrigins(verificationUrl) {
const origins = /* @__PURE__ */ new Set();
const overrideOrigin = this.getLocalOrigin(this.config.verifyUrl || null);
const verificationOrigin = this.getLocalOrigin(verificationUrl || null);
if (overrideOrigin) {
origins.add(overrideOrigin);
}
if (verificationOrigin) {
origins.add(verificationOrigin);
}
return Array.from(origins);
}
getLocalOrigin(urlValue) {
if (!urlValue) {
return null;
}
try {
const parsed = new URL(urlValue);
if (_VerificationSDK.LOCAL_HOSTNAMES.has(parsed.hostname)) {
return parsed.origin;
}
} catch (e) {
return null;
}
return null;
}
applyLocalVerifyOverride(rawUrl) {
const overrideOrigin = this.getLocalOrigin(this.config.verifyUrl || null);
if (!overrideOrigin) {
return rawUrl;
}
try {
const overrideUrl = new URL(overrideOrigin);
const targetUrl = new URL(rawUrl);
targetUrl.protocol = overrideUrl.protocol;
targetUrl.host = overrideUrl.host;
return targetUrl.toString();
} catch (e) {
return rawUrl;
}
}
getHmacSecret() {
return this.config.environment === "staging" ? this.brandConstants.hmacSecretStaging : this.brandConstants.hmacSecretProd;
}
};
// Local override hostnames allowed for internal testing
_VerificationSDK.LOCAL_HOSTNAMES = /* @__PURE__ */ new Set(["localhost", "127.0.0.1", "::1"]);
var VerificationSDK = _VerificationSDK;
// src-redirect/brands/safepassage/urls.ts
var BRAND_URLS = {
production: {
apiUrl: "https://api.safepassageapp.com",
verifyUiUrl: "https://av.safepassageapp.com",
engineUrl: "https://engine.safepassageapp.com",
wsUrl: "wss://engine.safepassageapp.com/api/websocket/stream",
trustedOrigins: [
"https://av.safepassageapp.com",
"https://portal.safepassageapp.com",
"https://api.safepassageapp.com"
]
},
staging: {
apiUrl: "https://api.verityav-staging-usw1a.safepassageapp.com",
verifyUiUrl: "https://av.verityav-staging-usw1a.safepassageapp.com",
engineUrl: "https://engine.verityav-staging-usw1a.safepassageapp.com",
wsUrl: "wss://engine.verityav-staging-usw1a.safepassageapp.com/api/websocket/stream",
trustedOrigins: [
"https://av.verityav-staging-usw1a.safepassageapp.com",
"https://portal.verityav-staging-usw1a.safepassageapp.com",
"https://api.verityav-staging-usw1a.safepassageapp.com"
]
}
};
var BRAND_CONSTANTS = {
name: "SafePassage",
hmacSecretProd: "safepassage-prod-hmac-2025",
hmacSecretStaging: "safepassage-stage-hmac-2025",
messageType: "safepassage:verification:complete",
legacyMessageType: "safepassage-verification",
popupName: "safepassage-verify",
docsUrl: "https://docs.safepassageapp.com"
};
// src-redirect/brands/safepassage/index.ts
var SafePassage = class extends VerificationSDK {
constructor(config) {
super(config, BRAND_URLS, BRAND_CONSTANTS);
}
};
var VERSION = "3.5.2";
SafePassage.VERSION = VERSION;
if (typeof window !== "undefined") {
setupPolyfills();
checkBrowserCompatibility(`${BRAND_CONSTANTS.name} SDK`);
}
var index_default = SafePassage;
export {
SafePassage,
VERSION,
index_default as default
};