@truly-you/trulyyou-web-sdk 0.1.23 → 0.1.25

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.
@@ -1,2023 +0,0 @@
1
- import { TrulyYouSDKConfig, FetchOptions, SigningResult, FetchResult } from '../types'
2
- import Pusher from 'pusher-js'
3
-
4
- export class TrulyYouSDK {
5
- private config: TrulyYouSDKConfig
6
- private frontendUrl: string
7
- private apiUrl: string
8
- private authAppId: string | undefined
9
- private invisible: boolean
10
- private targetElement: string | HTMLElement | undefined
11
- private brandingCache: { primary?: string; background?: string; secondary?: string; textColor?: string; icon?: string; name?: string; authFlowId?: string; pusher?: { realtimeUrl?: string; appKey?: string } } | null = null
12
- private realtimeUrl: string
13
- private mockMobileDevice: boolean
14
-
15
- constructor(config: TrulyYouSDKConfig) {
16
- this.config = config
17
- this.frontendUrl = config.frontendUrl || this.getDefaultFrontendUrl()
18
- this.apiUrl = config.apiUrl || this.getDefaultApiUrl()
19
- this.authAppId = config.authAppId
20
- this.invisible = config.invisible || false
21
- this.targetElement = config.targetElement
22
- this.mockMobileDevice = config.mockMobileDevice || false
23
-
24
- // Determine realtime WebSocket URL
25
- if (config.realtimeUrl) {
26
- this.realtimeUrl = config.realtimeUrl
27
- console.log('[SDK]: Using provided realtimeUrl:', this.realtimeUrl)
28
- } else {
29
- // Infer from frontendUrl - replace http/https with ws/wss
30
- const url = new URL(this.frontendUrl)
31
- const wsProtocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
32
- this.realtimeUrl = `${wsProtocol}//${url.host}`
33
- console.log('[SDK]: No realtimeUrl provided, inferring from frontendUrl:', this.realtimeUrl)
34
- }
35
-
36
- // Fetch branding on instantiation if authAppId is provided
37
- if (this.authAppId) {
38
- this.fetchBranding().catch(err => {
39
- console.warn('[SDK]: Failed to fetch branding:', err)
40
- })
41
- }
42
- }
43
-
44
-
45
- /**
46
- * Helper to get keyId by authFlowId from localStorage
47
- * Keys are stored as: trulyYouKeyId_<authFlowId> with value being just the keyId
48
- */
49
- private getKeyIdByAuthFlowId(authFlowId: string): string | null {
50
- if (typeof window === 'undefined') return null
51
-
52
- const storageKey = `trulyYouKeyId_${authFlowId}`
53
- const keyId = localStorage.getItem(storageKey)
54
-
55
- if (keyId) {
56
- console.log(`[SDK]: Found keyId for authFlowId ${authFlowId}, key: ${storageKey}`)
57
- return keyId
58
- }
59
-
60
- console.log(`[SDK]: No keyId found for authFlowId: ${authFlowId}`)
61
- return null
62
- }
63
-
64
- /**
65
- * Fetch app branding from SDK backend
66
- * Returns true if authFlowId was successfully loaded
67
- */
68
- private async fetchBranding(): Promise<boolean> {
69
- if (!this.authAppId) {
70
- console.warn('[SDK]: Cannot fetch branding - authAppId not configured')
71
- return false
72
- }
73
-
74
- try {
75
- // Call SDK backend API directly
76
- const response = await fetch(`${this.apiUrl}/api/apps/${this.authAppId}`, {
77
- method: 'GET',
78
- headers: { 'Content-Type': 'application/json' }
79
- })
80
- if (response.ok) {
81
- const data = await response.json()
82
- // Handle both formats: { app: {...} } or direct app object
83
- const app = data.app || data
84
- if (app?.colors || app?.icon || app?.name || app?.authFlowId) {
85
- this.brandingCache = {
86
- primary: app.colors?.primary,
87
- background: app.colors?.background,
88
- secondary: app.colors?.secondary,
89
- textColor: app.colors?.textColor,
90
- icon: app.icon,
91
- name: app.name,
92
- authFlowId: app.authFlowId,
93
- ...(app.pusher && {
94
- pusher: {
95
- realtimeUrl: app.pusher.realtimeUrl,
96
- appKey: app.pusher.appKey
97
- }
98
- })
99
- }
100
- console.log('[SDK]: Branding and authFlowId fetched and cached:', this.brandingCache)
101
- return !!app.authFlowId
102
- }
103
- }
104
- console.warn('[SDK]: Failed to fetch branding - response not ok or missing app data')
105
- return false
106
- } catch (error) {
107
- console.warn('[SDK]: Error fetching branding:', error)
108
- return false
109
- }
110
- }
111
-
112
- /**
113
- * Ensure authFlowId is loaded before proceeding with signing operations
114
- * Throws error if authFlowId cannot be loaded
115
- */
116
- private async ensureAuthFlowIdLoaded(): Promise<void> {
117
- if (this.brandingCache?.authFlowId) {
118
- console.log('[SDK]: authFlowId already loaded:', this.brandingCache.authFlowId)
119
- return
120
- }
121
-
122
- console.log('[SDK]: authFlowId not yet loaded, fetching branding...')
123
- const success = await this.fetchBranding()
124
-
125
- if (!success || !this.brandingCache?.authFlowId) {
126
- throw new Error('authFlowId is required for signing but not available. Please check that your app configuration includes an authFlowId.')
127
- }
128
-
129
- console.log('[SDK]: authFlowId loaded successfully:', this.brandingCache.authFlowId)
130
- }
131
-
132
- /**
133
- * Generate QR code with app icon overlaid in center
134
- */
135
- private async generateQRCodeWithIcon(qrData: string, imgElement: HTMLImageElement): Promise<void> {
136
- return new Promise((resolve, reject) => {
137
- // Step 1: Generate base QR code
138
- const qrCodeUrl = `https://api.qrserver.com/v1/create-qr-code/?size=300x300&data=${encodeURIComponent(qrData)}`
139
- const qrImg = new Image()
140
- qrImg.crossOrigin = 'anonymous'
141
-
142
- qrImg.onload = () => {
143
- // Step 2: Create canvas and draw QR code
144
- const canvas = document.createElement('canvas')
145
- canvas.width = 300
146
- canvas.height = 300
147
- const ctx = canvas.getContext('2d')
148
-
149
- if (!ctx) {
150
- reject(new Error('Failed to get canvas context'))
151
- return
152
- }
153
-
154
- // Draw QR code
155
- ctx.drawImage(qrImg, 0, 0, 300, 300)
156
-
157
- // Step 3: Overlay app icon if available
158
- if (this.brandingCache?.icon) {
159
- // Try to load icon via proxy first (avoids CORS issues)
160
- // If proxy fails, try direct load, then fallback to QR without icon
161
- const tryLoadIcon = async (iconUrl: string, useProxy: boolean) => {
162
- try {
163
- let imageUrl = iconUrl
164
-
165
- if (useProxy) {
166
- // Fetch via proxy to avoid CORS
167
- const proxyUrl = `${this.apiUrl}/api/proxy-icon?url=${encodeURIComponent(iconUrl)}`
168
- const response = await fetch(proxyUrl)
169
- if (!response.ok) {
170
- throw new Error('Proxy fetch failed')
171
- }
172
- const blob = await response.blob()
173
- imageUrl = URL.createObjectURL(blob)
174
- }
175
-
176
- const iconImg = new Image()
177
- iconImg.crossOrigin = useProxy ? null : 'anonymous'
178
-
179
- await new Promise<void>((resolveIcon, rejectIcon) => {
180
- iconImg.onload = () => resolveIcon()
181
- iconImg.onerror = () => rejectIcon(new Error('Image load failed'))
182
- iconImg.src = imageUrl
183
- })
184
-
185
- // Calculate icon size (20% of QR code size)
186
- const iconSize = 60
187
- const iconX = (300 - iconSize) / 2
188
- const iconY = (300 - iconSize) / 2
189
-
190
- // Draw white background circle for icon
191
- ctx.fillStyle = '#ffffff'
192
- ctx.beginPath()
193
- ctx.arc(150, 150, iconSize / 2 + 4, 0, 2 * Math.PI)
194
- ctx.fill()
195
-
196
- // Draw icon
197
- ctx.drawImage(iconImg, iconX, iconY, iconSize, iconSize)
198
-
199
- // Clean up object URL if we created one
200
- if (useProxy && imageUrl.startsWith('blob:')) {
201
- URL.revokeObjectURL(imageUrl)
202
- }
203
-
204
- // Convert canvas to data URL and set as image source
205
- imgElement.src = canvas.toDataURL('image/png')
206
- resolve()
207
- } catch (error) {
208
- throw error
209
- }
210
- }
211
-
212
- // Try proxy first, then direct, then fallback
213
- const iconUrl = this.brandingCache.icon
214
- tryLoadIcon(iconUrl, true).catch(() => {
215
- // If proxy fails, try direct load
216
- tryLoadIcon(iconUrl, false).catch(() => {
217
- // If both fail, just use QR code without icon
218
- imgElement.src = canvas.toDataURL('image/png')
219
- resolve()
220
- })
221
- })
222
- } else {
223
- // No icon, just use QR code
224
- imgElement.src = canvas.toDataURL('image/png')
225
- resolve()
226
- }
227
- }
228
-
229
- qrImg.onerror = () => {
230
- reject(new Error('Failed to load QR code'))
231
- }
232
-
233
- qrImg.src = qrCodeUrl
234
- })
235
- }
236
-
237
- private getDefaultFrontendUrl(): string {
238
- if (typeof window !== 'undefined') {
239
- const hostname = window.location.hostname
240
- if (hostname === 'localhost' || hostname === '127.0.0.1') {
241
- return 'http://localhost:3002'
242
- }
243
- }
244
- return 'https://sdks.verification.local'
245
- }
246
-
247
- private getDefaultApiUrl(): string {
248
- if (typeof window !== 'undefined') {
249
- const hostname = window.location.hostname
250
- if (hostname === 'localhost' || hostname === '127.0.0.1') {
251
- return 'http://localhost:3003'
252
- }
253
- }
254
- return 'https://api.verification.local'
255
- }
256
-
257
- /**
258
- * Detect if running on iOS device
259
- */
260
- private isIOSDevice(): boolean {
261
- if (typeof window === 'undefined') return false
262
- return /iPad|iPhone|iPod/.test(navigator.userAgent) ||
263
- (navigator.platform === 'MacIntel' && (navigator as any).maxTouchPoints > 1)
264
- }
265
-
266
- /**
267
- * Detect if running on Safari browser
268
- */
269
- private isSafariBrowser(): boolean {
270
- if (typeof window === 'undefined') return false
271
- return /Safari/.test(navigator.userAgent) && !/Chrome/.test(navigator.userAgent)
272
- }
273
-
274
- /**
275
- * Detect if running on desktop device (not just viewport-based)
276
- * Returns false if mockMobileDevice is enabled (treats as mobile)
277
- */
278
- private isDesktopDevice(): boolean {
279
- // If mock mobile device is enabled, always return false (treat as mobile)
280
- if (this.mockMobileDevice === true) {
281
- console.log('[SDK]: Mock mobile device mode enabled - treating device as mobile (no handoff)')
282
- return false
283
- }
284
-
285
- if (typeof window === 'undefined') return false
286
-
287
- const userAgent = navigator.userAgent || navigator.vendor || (window as any).opera
288
- const isMobileUA = /android|webos|iphone|ipad|ipod|blackberry|iemobile|opera mini|mobile/i.test(userAgent.toLowerCase())
289
-
290
- // Additional checks for actual mobile devices
291
- const hasTouchScreen = 'ontouchstart' in window || navigator.maxTouchPoints > 0
292
- const isMobileOrientation = window.screen && 'orientation' in window.screen
293
-
294
- // Desktop if: not mobile UA pattern OR (mobile UA but no real touch/orientation support)
295
- const isDesktop = !isMobileUA || (isMobileUA && !hasTouchScreen && !isMobileOrientation)
296
-
297
- return isDesktop
298
- }
299
-
300
- /**
301
- * Resolve target element from string selector or HTMLElement
302
- */
303
- private resolveTargetElement(): HTMLElement | null {
304
- if (!this.targetElement) return null
305
-
306
- if (typeof this.targetElement === 'string') {
307
- return document.querySelector(this.targetElement)
308
- }
309
-
310
- return this.targetElement
311
- }
312
-
313
- /**
314
- * Probe iframe to check if key exists in iframe's localStorage
315
- * Also checks backend to verify key exists and is active
316
- * REQUIRES authFlowId to be loaded first
317
- * @param handoff - If true, probes for handoff keyId (with _handoff suffix)
318
- */
319
- private async probeIframeForKey(handoff: boolean = false): Promise<string | null> {
320
- // Ensure authFlowId is loaded before probing
321
- await this.ensureAuthFlowIdLoaded()
322
-
323
- if (!this.brandingCache?.authFlowId) {
324
- console.error('[SDK-PROBE]: authFlowId is required but not available after fetch')
325
- return null
326
- }
327
-
328
- return new Promise((resolve) => {
329
- try {
330
- const origin = window.location.origin
331
- const iframe = document.createElement('iframe')
332
- iframe.style.cssText = `position: fixed; top: -9999px; left: -9999px; width: 1px; height: 1px; border: none; opacity: 0; pointer-events: none;`
333
-
334
- // Create a probe endpoint URL - use dedicated probe.html for instant response (no React hydration)
335
- const probeUrl = new URL(`${this.frontendUrl}/probe.html`)
336
- probeUrl.searchParams.set('probe', 'true')
337
- probeUrl.searchParams.set('origin', origin)
338
-
339
- // Add authFlowId (guaranteed to exist now after ensureAuthFlowIdLoaded)
340
- const authFlowId = this.brandingCache!.authFlowId!
341
- probeUrl.searchParams.set('authFlowId', authFlowId)
342
- if (handoff) {
343
- probeUrl.searchParams.set('handoff', 'true')
344
- }
345
- console.log(`[SDK-PROBE]: Adding authFlowId to probe: ${authFlowId}${handoff ? ' (handoff)' : ''}`)
346
-
347
- iframe.src = probeUrl.toString()
348
-
349
- let timeout: NodeJS.Timeout | undefined
350
- let backendCheckPromise: Promise<boolean> | null = null
351
-
352
- const cleanup = () => {
353
- window.removeEventListener('message', handleMessage)
354
- if (iframe.parentNode) {
355
- iframe.parentNode.removeChild(iframe)
356
- }
357
- if (timeout) {
358
- clearTimeout(timeout)
359
- }
360
- }
361
-
362
- const handleMessage = async (event: MessageEvent) => {
363
- const data = event.data as any
364
-
365
- // Only process probe-related messages
366
- if (data?.type === 'KEY_CHECK_RESULT' || data?.type === 'KEY_CHECK_FAILED') {
367
- try {
368
- const frontendOrigin = new URL(this.frontendUrl).origin
369
- if (event.origin !== frontendOrigin) {
370
- return
371
- }
372
- } catch (e) {
373
- if (!event.origin.includes(window.location.hostname)) {
374
- return
375
- }
376
- }
377
-
378
- if (data?.type === 'KEY_CHECK_RESULT') {
379
- cleanup()
380
- if (data.hasKey && data.keyId) {
381
- resolve(data.keyId)
382
- } else {
383
- resolve(null)
384
- }
385
- } else if (data?.type === 'KEY_CHECK_FAILED') {
386
- cleanup()
387
- resolve(null)
388
- }
389
- }
390
- }
391
-
392
- window.addEventListener('message', handleMessage)
393
- document.body.appendChild(iframe)
394
-
395
- // Timeout to allow for React hydration on sign page
396
- timeout = setTimeout(() => {
397
- console.log('[SDK-PROBE]: Timeout reached (3000ms), no response from iframe')
398
- cleanup()
399
- resolve(null)
400
- }, 3000)
401
- } catch (error) {
402
- console.error('[SDK-PROBE]: Error in probeIframeForKey:', error)
403
- resolve(null)
404
- }
405
- })
406
- }
407
-
408
- /**
409
- * Open enrollment popup and wait for completion
410
- */
411
- private async enrollWithPopup(): Promise<string> {
412
- if (!this.authAppId) {
413
- throw new Error('authAppId is required for enrollment. Please configure authAppId in SDK config.')
414
- }
415
-
416
- // authAppId is guaranteed to exist here due to check above
417
- const appId = this.authAppId!
418
-
419
- // Get app to retrieve authFlowId
420
- const appResponse = await fetch(`${this.apiUrl}/api/apps/${appId}`)
421
-
422
- if (!appResponse.ok) {
423
- throw new Error('Failed to load app configuration')
424
- }
425
-
426
- const appData = await appResponse.json()
427
- const app = appData.app
428
-
429
- if (!app.authFlowId) {
430
- throw new Error('App does not have an authentication flow configured')
431
- }
432
-
433
- // Generate a clientId for this session
434
- const clientId = `client_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
435
-
436
- // Create session directly via SDK backend
437
- // Backend will fetch authFlowId from the app document
438
- const sessionResponse = await fetch(`${this.apiUrl}/api/sessions`, {
439
- method: 'POST',
440
- headers: { 'Content-Type': 'application/json' },
441
- body: JSON.stringify({
442
- appId: appId,
443
- clientId: clientId
444
- })
445
- })
446
-
447
- if (!sessionResponse.ok) {
448
- const errorData = await sessionResponse.json()
449
- throw new Error(errorData.error || 'Failed to create enrollment session')
450
- }
451
-
452
- const sessionData = await sessionResponse.json()
453
- const sessionId = sessionData.data.sessionId
454
-
455
- return new Promise((resolve, reject) => {
456
- const origin = window.location.origin
457
-
458
- // Build enrollment URL with branding colors from cache if available
459
- const enrollUrlParams = new URLSearchParams({
460
- authAppId: appId,
461
- sessionId: sessionId,
462
- returnTo: 'popup',
463
- origin: origin
464
- })
465
-
466
- // Add branding colors to URL params if cached
467
- if (this.brandingCache?.primary) {
468
- enrollUrlParams.set('primaryColor', this.brandingCache.primary)
469
- }
470
- if (this.brandingCache?.background) {
471
- enrollUrlParams.set('backgroundColor', this.brandingCache.background)
472
- }
473
- if (this.brandingCache?.secondary) {
474
- enrollUrlParams.set('secondaryColor', this.brandingCache.secondary)
475
- }
476
- if (this.brandingCache?.textColor) {
477
- enrollUrlParams.set('textColor', this.brandingCache.textColor)
478
- }
479
-
480
- const enrollUrl = `${this.frontendUrl}/enroll?${enrollUrlParams.toString()}`
481
-
482
- console.log('[SDK]: Opening enrollment popup:', enrollUrl)
483
-
484
- const popup = window.open(
485
- enrollUrl,
486
- 'trulyyou-enroll',
487
- 'width=600,height=700,scrollbars=yes,resizable=yes'
488
- )
489
-
490
- if (!popup) {
491
- reject(new Error('Failed to open enrollment popup. Please allow popups for this site.'))
492
- return
493
- }
494
-
495
- let timeout: NodeJS.Timeout
496
- let checkPopupClosed: NodeJS.Timeout
497
- let successReceived = false
498
-
499
- // Set up postMessage listener
500
- const handleMessage = (event: MessageEvent) => {
501
- console.log('[SDK]: Enrollment popup message received:', event.origin, event.data)
502
-
503
- // Verify origin matches frontend URL
504
- try {
505
- const frontendOrigin = new URL(this.frontendUrl).origin
506
- if (event.origin !== frontendOrigin) {
507
- console.log('[SDK]: Origin mismatch, ignoring message')
508
- return
509
- }
510
- } catch (e) {
511
- // If frontendUrl is relative or invalid, check if it matches current origin
512
- if (!event.origin.includes(window.location.hostname)) {
513
- console.log('[SDK]: Origin mismatch, ignoring message')
514
- return
515
- }
516
- }
517
-
518
- if (event.data && event.data.type === 'ENROLLMENT_SUCCESS') {
519
- successReceived = true
520
-
521
- // Clear timeouts
522
- if (timeout) clearTimeout(timeout)
523
- if (checkPopupClosed) clearInterval(checkPopupClosed)
524
-
525
- // Remove listener
526
- window.removeEventListener('message', handleMessage)
527
-
528
- // Get keyId from message (enrollment page already stored it with authFlowId)
529
- const keyIdFromMessage = event.data.keyId
530
-
531
- if (keyIdFromMessage) {
532
- console.log('[SDK]: Enrollment successful, keyId obtained from message:', keyIdFromMessage)
533
- // Note: keyId is already stored by enrollment page as trulyYouKeyId_<authFlowId>
534
- resolve(keyIdFromMessage)
535
- } else {
536
- // Fallback: check localStorage using authFlowId
537
- setTimeout(() => {
538
- let delayedKeyId: string | null = null
539
- if (this.brandingCache?.authFlowId) {
540
- delayedKeyId = this.getKeyIdByAuthFlowId(this.brandingCache.authFlowId)
541
- }
542
-
543
- if (delayedKeyId) {
544
- console.log('[SDK]: Enrollment successful, keyId obtained (delayed) using authFlowId:', delayedKeyId)
545
- resolve(delayedKeyId)
546
- } else {
547
- reject(new Error('Enrollment completed but no keyId found in message or localStorage'))
548
- }
549
- }, 500)
550
- }
551
- } else if (event.data && event.data.type === 'ENROLLMENT_ERROR') {
552
- successReceived = true
553
- if (timeout) clearTimeout(timeout)
554
- if (checkPopupClosed) clearInterval(checkPopupClosed)
555
- window.removeEventListener('message', handleMessage)
556
- reject(new Error(event.data.error || 'Enrollment failed'))
557
- }
558
- }
559
-
560
- window.addEventListener('message', handleMessage)
561
-
562
- // Timeout after 10 minutes
563
- timeout = setTimeout(() => {
564
- if (!successReceived) {
565
- window.removeEventListener('message', handleMessage)
566
- if (checkPopupClosed) clearInterval(checkPopupClosed)
567
- reject(new Error('Enrollment timeout'))
568
- }
569
- }, 10 * 60 * 1000)
570
-
571
- // Check if popup was closed manually
572
- checkPopupClosed = setInterval(() => {
573
- if (popup.closed && !successReceived) {
574
- clearInterval(checkPopupClosed)
575
- clearTimeout(timeout)
576
- window.removeEventListener('message', handleMessage)
577
- reject(new Error('Enrollment popup was closed'))
578
- }
579
- }, 500)
580
- })
581
- }
582
-
583
- /**
584
- * Sign with iframe (mobile device)
585
- */
586
- private async signWithIframe(apiCallStructure: {
587
- body: any
588
- uri: string
589
- method: string
590
- headers?: any
591
- }): Promise<SigningResult> {
592
- return new Promise((resolve, reject) => {
593
- try {
594
- const signId = `sign_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`
595
- const targetElement = this.resolveTargetElement()
596
-
597
- // Create signing loading overlay if we have a target element
598
- const createSigningLoadingOverlay = () => {
599
- if (!targetElement) return null
600
-
601
- const overlay = document.createElement('div')
602
- overlay.id = `signing-overlay-${signId}`
603
- overlay.style.cssText = `
604
- position: absolute;
605
- top: 0;
606
- left: 0;
607
- right: 0;
608
- bottom: 0;
609
- background-color: rgba(255, 255, 255, 0.9);
610
- display: flex;
611
- flex-direction: column;
612
- align-items: center;
613
- justify-content: center;
614
- z-index: 10000;
615
- border-radius: 8px;
616
- `
617
-
618
- const spinner = document.createElement('div')
619
- spinner.style.cssText = `
620
- width: 48px;
621
- height: 48px;
622
- border: 4px solid ${this.config.spinnerBgColor || '#e5e7eb'};
623
- border-top-color: ${this.config.spinnerColor || '#2563eb'};
624
- border-radius: 50%;
625
- animation: spin 1s linear infinite;
626
- margin-bottom: 16px;
627
- `
628
-
629
- const text = document.createElement('div')
630
- text.textContent = 'Authenticating...'
631
- text.style.cssText = `
632
- color: ${this.config.spinnerTextColor || '#6b7280'};
633
- font-size: 14px;
634
- font-weight: 500;
635
- `
636
-
637
- // Add CSS animation
638
- const style = document.createElement('style')
639
- style.textContent = `
640
- @keyframes spin {
641
- 0% { transform: rotate(0deg); }
642
- 100% { transform: rotate(360deg); }
643
- }
644
- `
645
- document.head.appendChild(style)
646
-
647
- overlay.appendChild(spinner)
648
- overlay.appendChild(text)
649
-
650
- return overlay
651
- }
652
-
653
- // Create iframe for signing
654
- const iframe = document.createElement('iframe')
655
- iframe.name = `trulyyou-sign-frame-${signId}`
656
- iframe.scrolling = 'no'
657
- iframe.setAttribute('scrolling', 'no')
658
- // Allow WebAuthn in iframe (required for cross-origin iframes)
659
- iframe.setAttribute('allow', 'publickey-credentials-get *; publickey-credentials-create *')
660
-
661
- // Encode payload
662
- const sortObjectByKeys = (obj: any): any => {
663
- if (obj === null || typeof obj !== 'object' || obj instanceof Array) {
664
- return obj
665
- }
666
- const sorted: any = {}
667
- Object.keys(obj).sort().forEach(key => {
668
- sorted[key] = sortObjectByKeys(obj[key])
669
- })
670
- return sorted
671
- }
672
-
673
- const createPayloadBase64 = (data: any, uri: string, method: string, queryParams: any) => {
674
- const dataToProcess = data === undefined || data === null ? {} : data
675
- const queryParamsToProcess = queryParams === undefined || queryParams === null ? {} : queryParams
676
-
677
- const sortedData = sortObjectByKeys(dataToProcess)
678
- const sortedQueryParams = sortObjectByKeys(queryParamsToProcess)
679
-
680
- const encodedBody = btoa(JSON.stringify(sortedData))
681
- const encodedQueryParams = btoa(JSON.stringify(sortedQueryParams))
682
-
683
- const payloadData = {
684
- method: method,
685
- uriId: uri,
686
- requestBody: encodedBody,
687
- queryParams: encodedQueryParams,
688
- }
689
-
690
- return {
691
- fullbase64: btoa(JSON.stringify(payloadData)),
692
- sortedData: sortedData,
693
- sortedQueryParams: sortedQueryParams,
694
- encodedBody: encodedBody,
695
- encodedQueryParams: encodedQueryParams,
696
- }
697
- }
698
-
699
- const base64EncodedResponse = createPayloadBase64(
700
- apiCallStructure.body || {},
701
- apiCallStructure.uri,
702
- apiCallStructure.method,
703
- {}
704
- )
705
- const encodedPayload = base64EncodedResponse.fullbase64
706
-
707
- // Prepare attestation data for iframe
708
- const attestationDataForIframe = {
709
- payload: encodedPayload,
710
- apiCallStructure: apiCallStructure
711
- }
712
-
713
- const encodedData = btoa(JSON.stringify(attestationDataForIframe))
714
-
715
- // Create form for POST submission to /sign/submit API route
716
- const form = document.createElement('form')
717
- form.method = 'POST'
718
- form.action = `${this.frontendUrl}/sign/submit`
719
- form.target = iframe.name
720
- form.style.display = 'none'
721
-
722
- // Add form data as hidden inputs
723
- const attestationDataInput = document.createElement('input')
724
- attestationDataInput.type = 'hidden'
725
- attestationDataInput.name = 'attestationData'
726
- attestationDataInput.value = encodedData
727
- form.appendChild(attestationDataInput)
728
-
729
- const signIdInput = document.createElement('input')
730
- signIdInput.type = 'hidden'
731
- signIdInput.name = 'signId'
732
- signIdInput.value = signId
733
- form.appendChild(signIdInput)
734
-
735
- const modeInput = document.createElement('input')
736
- modeInput.type = 'hidden'
737
- modeInput.name = 'mode'
738
- modeInput.value = 'embedded'
739
- form.appendChild(modeInput)
740
-
741
- // Determine if iframe will be visible (for mobile: not invisible, or iOS/Safari always visible)
742
- const isIframeVisible = !this.invisible || this.isIOSDevice() || this.isSafariBrowser()
743
-
744
- // Add branding colors and visibility flag to form data
745
- const brandingInput = document.createElement('input')
746
- brandingInput.type = 'hidden'
747
- brandingInput.name = 'branding'
748
- brandingInput.value = JSON.stringify({
749
- primary: this.brandingCache?.primary || this.config.spinnerColor || '#2563eb',
750
- background: this.brandingCache?.background || '#ffffff',
751
- secondary: this.brandingCache?.secondary || '#2563eb',
752
- textColor: this.brandingCache?.textColor || this.config.spinnerTextColor || '#6b7280',
753
- spinnerColor: this.config.spinnerColor || this.brandingCache?.primary || '#2563eb',
754
- spinnerBgColor: this.config.spinnerBgColor || '#e5e7eb',
755
- spinnerTextColor: this.config.spinnerTextColor || this.brandingCache?.textColor || '#6b7280',
756
- visible: isIframeVisible
757
- })
758
- form.appendChild(brandingInput)
759
-
760
- // Add authFlowId so sign page can retrieve keyId from localStorage
761
- const authFlowIdInput = document.createElement('input')
762
- authFlowIdInput.type = 'hidden'
763
- authFlowIdInput.name = 'authFlowId'
764
- authFlowIdInput.value = this.brandingCache?.authFlowId || ''
765
- form.appendChild(authFlowIdInput)
766
-
767
- // Mobile device logic (mock mobile OR actual mobile - both treated the same)
768
- const isMobile = this.mockMobileDevice || !this.isDesktopDevice()
769
-
770
- if (isMobile) {
771
- // Mobile: NEVER full screen
772
- // iOS/Safari: ALWAYS use target element if provided (WebAuthn requires visible iframe)
773
- if (this.isIOSDevice() || this.isSafariBrowser()) {
774
- if (targetElement) {
775
- // iOS/Safari: Use target element (required for WebAuthn)
776
- const targetStyle = window.getComputedStyle(targetElement)
777
- if (targetStyle.position === 'static') {
778
- targetElement.style.position = 'relative'
779
- }
780
- const originalHeight = targetElement.style.height || targetStyle.height
781
- if (!originalHeight || originalHeight === 'auto') {
782
- const computedHeight = targetElement.offsetHeight
783
- if (computedHeight > 0) {
784
- targetElement.style.height = `${computedHeight}px`
785
- }
786
- }
787
- iframe.style.cssText = `position: absolute; top: 0; left: 0; width: 100%; height: 100%; border: none; z-index: 9999;`
788
- targetElement.appendChild(iframe)
789
- // No overlay needed - sign page is hidden when embedded
790
- } else {
791
- // iOS/Safari with no target element: must use invisible (can't use full screen on mobile)
792
- console.warn('[SDK]: Mobile iOS/Safari with no target element - using invisible iframe (WebAuthn may fail)')
793
- iframe.style.cssText = `position: fixed; top: -9999px; left: -9999px; width: 1px; height: 1px; border: none; opacity: 0; pointer-events: none;`
794
- }
795
- } else {
796
- // Mobile non-iOS/Safari: invisible if invisible=true, target element if invisible=false
797
- if (this.invisible) {
798
- iframe.style.cssText = `position: fixed; top: -9999px; left: -9999px; width: 1px; height: 1px; border: none; opacity: 0; pointer-events: none;`
799
- } else if (targetElement) {
800
- // Visible iframe in target element
801
- const targetStyle = window.getComputedStyle(targetElement)
802
- if (targetStyle.position === 'static') {
803
- targetElement.style.position = 'relative'
804
- }
805
- const originalHeight = targetElement.style.height || targetStyle.height
806
- if (!originalHeight || originalHeight === 'auto') {
807
- const computedHeight = targetElement.offsetHeight
808
- if (computedHeight > 0) {
809
- targetElement.style.height = `${computedHeight}px`
810
- }
811
- }
812
- iframe.style.cssText = `position: absolute; top: 0; left: 0; width: 100%; height: 100%; border: none; z-index: 9999;`
813
- targetElement.appendChild(iframe)
814
- // No overlay - sign page is hidden when embedded
815
- } else {
816
- // No target element - use invisible (can't use full screen on mobile)
817
- console.warn('[SDK]: Mobile device with invisible=true but no target element - using invisible iframe')
818
- iframe.style.cssText = `position: fixed; top: -9999px; left: -9999px; width: 1px; height: 1px; border: none; opacity: 0; pointer-events: none;`
819
- }
820
- }
821
- } else {
822
- // Desktop device: can use full screen (only when opened directly on Device B after handoff)
823
- // But when called from SDK, prefer target element or invisible
824
- if (this.invisible) {
825
- iframe.style.cssText = `position: fixed; top: -9999px; left: -9999px; width: 1px; height: 1px; border: none; opacity: 0; pointer-events: none;`
826
- } else if (targetElement) {
827
- // Visible iframe in target element
828
- const targetStyle = window.getComputedStyle(targetElement)
829
- if (targetStyle.position === 'static') {
830
- targetElement.style.position = 'relative'
831
- }
832
- const originalHeight = targetElement.style.height || targetStyle.height
833
- if (!originalHeight || originalHeight === 'auto') {
834
- const computedHeight = targetElement.offsetHeight
835
- if (computedHeight > 0) {
836
- targetElement.style.height = `${computedHeight}px`
837
- }
838
- }
839
- iframe.style.cssText = `position: absolute; top: 0; left: 0; width: 100%; height: 100%; border: none; z-index: 9999;`
840
- targetElement.appendChild(iframe)
841
- const overlay = createSigningLoadingOverlay()
842
- if (overlay) {
843
- targetElement.appendChild(overlay)
844
- }
845
- } else {
846
- // Desktop: full screen overlay (only when no target element - Device B after handoff)
847
- iframe.style.cssText = `position: fixed; top: 0; left: 0; width: 100%; height: 100%; border: none; z-index: 9999; background: white;`
848
- }
849
- }
850
-
851
- // Append form to body
852
- document.body.appendChild(form)
853
-
854
- // Append iframe - only to body if not already appended to target element above
855
- // Check if iframe has a parent (was already appended to target element)
856
- if (!iframe.parentNode) {
857
- document.body.appendChild(iframe)
858
- }
859
-
860
- let timeout: NodeJS.Timeout | undefined
861
- let overlayElement: HTMLElement | null = null
862
-
863
- const cleanup = () => {
864
- window.removeEventListener('message', handleMessage)
865
- if (form.parentNode) {
866
- form.parentNode.removeChild(form)
867
- }
868
- if (iframe.parentNode) {
869
- iframe.parentNode.removeChild(iframe)
870
- }
871
- if (overlayElement && overlayElement.parentNode) {
872
- overlayElement.parentNode.removeChild(overlayElement)
873
- }
874
- if (timeout) {
875
- clearTimeout(timeout)
876
- }
877
- // Remove height constraint from target element
878
- if (targetElement && !this.invisible) {
879
- targetElement.style.height = ''
880
- }
881
- }
882
-
883
- const handleMessage = (event: MessageEvent) => {
884
- try {
885
- const frontendOrigin = new URL(this.frontendUrl).origin
886
- if (event.origin !== frontendOrigin) return
887
- } catch (e) {
888
- if (!event.origin.includes(window.location.hostname)) return
889
- }
890
-
891
- const data = event.data as any
892
- // Handle both SIGNING_COMPLETE and SIGNATURE_COMPLETE (sign page sends SIGNATURE_COMPLETE)
893
- if ((data?.type === 'SIGNING_COMPLETE' || data?.type === 'SIGNATURE_COMPLETE') && data?.signId === signId) {
894
- console.log('[SDK]: Signature complete message received:', { type: data.type, hasSignature: !!data.signature || !!data.signatureBase64 })
895
- cleanup()
896
- if (data.error) {
897
- reject(new Error(data.error))
898
- } else {
899
- // Sign page sends signatureBase64, but we expect signature - use either field
900
- const signature = data.signature || data.signatureBase64
901
- if (!signature) {
902
- reject(new Error('No signature in response'))
903
- return
904
- }
905
- resolve({
906
- signature: signature,
907
- keyId: data.keyId || ''
908
- })
909
- }
910
- } else if (data?.type === 'SIGNING_ERROR' && data?.signId === signId) {
911
- cleanup()
912
- reject(new Error(data.error || 'Signing failed'))
913
- }
914
- }
915
-
916
- window.addEventListener('message', handleMessage)
917
-
918
- // Submit form to iframe
919
- form.submit()
920
-
921
- // Remove form after submission
922
- setTimeout(() => {
923
- if (form.parentNode) {
924
- form.parentNode.removeChild(form)
925
- }
926
- }, 100)
927
-
928
- // Store overlay reference for cleanup
929
- if (targetElement && !this.invisible) {
930
- overlayElement = targetElement.querySelector(`#signing-overlay-${signId}`) as HTMLElement
931
- }
932
-
933
- // Timeout after 5 minutes
934
- timeout = setTimeout(() => {
935
- cleanup()
936
- reject(new Error('Signing timeout'))
937
- }, 5 * 60 * 1000)
938
-
939
- } catch (error: any) {
940
- reject(error)
941
- }
942
- })
943
- }
944
-
945
- /**
946
- * Sign with WebSocket handoff (desktop device)
947
- */
948
- private async signWithWebSocketHandoff(apiCallStructure: {
949
- body: any
950
- uri: string
951
- method: string
952
- headers?: any
953
- }, signatureId: string): Promise<SigningResult> {
954
- return new Promise((resolve, reject) => {
955
- try {
956
- console.log('[SDK]: Desktop device detected, using WebSocket handoff with signatureId:', signatureId)
957
-
958
- // Get primary color and app name from branding cache (already fetched on SDK instantiation)
959
- const primaryColor = this.brandingCache?.primary || '#2563eb'
960
- const authAppName = this.brandingCache?.name || 'TrulyYou'
961
-
962
- // Encode signatureId, primaryColor, and authAppName as base64 JSON for URL parameter
963
- const encodedData = {
964
- signatureId: signatureId,
965
- primaryColor: primaryColor,
966
- authAppName: authAppName
967
- }
968
- const encodedThing = btoa(JSON.stringify(encodedData))
969
-
970
- // Build sign URL with encoded parameter (no query params)
971
- const signUrl = `${this.frontendUrl}/sign/${encodedThing}`
972
-
973
- // Encode payload
974
- const sortObjectByKeys = (obj: any): any => {
975
- if (obj === null || typeof obj !== 'object' || obj instanceof Array) {
976
- return obj
977
- }
978
- const sorted: any = {}
979
- Object.keys(obj).sort().forEach(key => {
980
- sorted[key] = sortObjectByKeys(obj[key])
981
- })
982
- return sorted
983
- }
984
-
985
- const createPayloadBase64 = (data: any, uri: string, method: string, queryParams: any) => {
986
- const dataToProcess = data === undefined || data === null ? {} : data
987
- const queryParamsToProcess = queryParams === undefined || queryParams === null ? {} : queryParams
988
-
989
- const sortedData = sortObjectByKeys(dataToProcess)
990
- const sortedQueryParams = sortObjectByKeys(queryParamsToProcess)
991
-
992
- const encodedBody = btoa(JSON.stringify(sortedData))
993
- const encodedQueryParams = btoa(JSON.stringify(sortedQueryParams))
994
-
995
- const payloadData = {
996
- method: method,
997
- uriId: uri,
998
- requestBody: encodedBody,
999
- queryParams: encodedQueryParams,
1000
- }
1001
-
1002
- return {
1003
- fullbase64: btoa(JSON.stringify(payloadData)),
1004
- sortedData: sortedData,
1005
- sortedQueryParams: sortedQueryParams,
1006
- encodedBody: encodedBody,
1007
- encodedQueryParams: encodedQueryParams,
1008
- }
1009
- }
1010
-
1011
- const base64EncodedResponse = createPayloadBase64(
1012
- apiCallStructure.body || {},
1013
- apiCallStructure.uri,
1014
- apiCallStructure.method,
1015
- {}
1016
- )
1017
- const encodedPayload = base64EncodedResponse.fullbase64
1018
-
1019
- // Prepare attestation data for sending
1020
- const attestationData = {
1021
- payload: encodedPayload,
1022
- apiCallStructure: apiCallStructure
1023
- }
1024
-
1025
- // Get Pusher config from branding cache (from API) or fall back to config/constructor values
1026
- const pusherConfigFromBranding = this.brandingCache?.pusher
1027
- const realtimeUrlToUse = pusherConfigFromBranding?.realtimeUrl || this.realtimeUrl
1028
- const pusherAppKeyToUse = pusherConfigFromBranding?.appKey || this.config.pusherAppKey || 'app-key'
1029
-
1030
- // Parse realtimeUrl to extract host/port for Pusher
1031
- let pusherHost: string | undefined
1032
- let pusherPort: number | undefined
1033
- let forceTLS = false
1034
- let encrypted = true
1035
-
1036
- try {
1037
- const realtimeUrlObj = new URL(realtimeUrlToUse.startsWith('ws://') || realtimeUrlToUse.startsWith('wss://')
1038
- ? realtimeUrlToUse
1039
- : realtimeUrlToUse.replace(/^http:/, 'ws:').replace(/^https:/, 'wss:'))
1040
-
1041
- pusherHost = realtimeUrlObj.hostname
1042
- pusherPort = realtimeUrlObj.port ? parseInt(realtimeUrlObj.port, 10) : (realtimeUrlObj.protocol === 'wss:' ? 443 : 80)
1043
- forceTLS = realtimeUrlObj.protocol === 'wss:'
1044
- encrypted = realtimeUrlObj.protocol === 'wss:'
1045
- } catch (e) {
1046
- console.warn('[SDK]: Error parsing realtimeUrl, using defaults:', e)
1047
- pusherHost = '127.0.0.1'
1048
- pusherPort = 6001
1049
- }
1050
-
1051
- // Get Pusher app key from branding cache, config, or default
1052
- const pusherAppKey = pusherAppKeyToUse
1053
-
1054
- // Merge custom Pusher config with parsed values
1055
- // When using custom wsHost/wsPort, we need to set cluster to empty string to disable default cluster behavior
1056
- const pusherOptions: any = {
1057
- wsHost: this.config.pusherConfig?.wsHost || pusherHost,
1058
- wsPort: this.config.pusherConfig?.wsPort || pusherPort,
1059
- cluster: this.config.pusherConfig?.cluster || '', // Required when using custom wsHost/wsPort
1060
- forceTLS: this.config.pusherConfig?.forceTLS ?? forceTLS,
1061
- encrypted: this.config.pusherConfig?.encrypted ?? encrypted,
1062
- disableStats: this.config.pusherConfig?.disableStats ?? true,
1063
- enabledTransports: this.config.pusherConfig?.enabledTransports || ['ws', 'wss'],
1064
- }
1065
-
1066
- console.log('[SDK]: Initializing Pusher with config:', pusherOptions)
1067
- console.log('[SDK]: Pusher app key:', pusherAppKey ? `${pusherAppKey.substring(0, 10)}...` : 'not set')
1068
- console.log('[SDK]: Pusher config source:', pusherConfigFromBranding ? 'branding API' : (this.config.pusherAppKey ? 'constructor config' : 'default'))
1069
- const pusher = new Pusher(pusherAppKey, pusherOptions)
1070
-
1071
- // Use signatureId as the channel name (Soketi doesn't need 'public-' prefix)
1072
- const channel = pusher.subscribe(signatureId)
1073
- console.log('[SDK]: Attempting to subscribe to Pusher channel:', signatureId)
1074
-
1075
- // Log when channel subscription succeeds and bind events
1076
- channel.bind('pusher:subscription_succeeded', () => {
1077
- console.log('[SDK]: ✅ Successfully subscribed to channel:', signatureId)
1078
-
1079
- // Bind countdown events after subscription succeeds (backend uses client-* prefix)
1080
- channel.bind('countdown_update', (data: any) => {
1081
- console.log('[SDK]: 📊 Received countdown_update event:', data)
1082
- })
1083
- channel.bind('client-countdown_update', (data: any) => {
1084
- console.log('[SDK]: 📊 Received client-countdown_update event:', data)
1085
- })
1086
-
1087
- channel.bind('countdown_expired', () => {
1088
- console.log('[SDK]: ⏰ Received countdown_expired event')
1089
- })
1090
- channel.bind('client-countdown_expired', () => {
1091
- console.log('[SDK]: ⏰ Received client-countdown_expired event')
1092
- })
1093
- })
1094
-
1095
- // Store handlers in a variable that will be set later
1096
- let handleCountdownUpdate: ((data: any) => void) | null = null
1097
- let handleCountdownExpired: (() => void) | null = null
1098
-
1099
- // Bind events early - handlers will be assigned after QR setup
1100
- const earlyCountdownHandler = (data: any) => {
1101
- console.log('[SDK]: 📊 Received countdown_update event (early bind):', data)
1102
- if (handleCountdownUpdate) {
1103
- handleCountdownUpdate(data)
1104
- } else {
1105
- console.warn('[SDK]: Countdown handler not ready yet, storing event')
1106
- // Store event to process once handler is ready
1107
- setTimeout(() => {
1108
- if (handleCountdownUpdate) {
1109
- handleCountdownUpdate(data)
1110
- }
1111
- }, 100)
1112
- }
1113
- }
1114
- channel.bind('countdown_update', earlyCountdownHandler)
1115
- channel.bind('client-countdown_update', earlyCountdownHandler) // Backend sends as client event
1116
-
1117
- const earlyExpiredHandler = () => {
1118
- console.log('[SDK]: ⏰ Received countdown_expired event (early bind)')
1119
- if (handleCountdownExpired) {
1120
- handleCountdownExpired()
1121
- }
1122
- }
1123
- channel.bind('countdown_expired', earlyExpiredHandler)
1124
- channel.bind('client-countdown_expired', earlyExpiredHandler) // Backend sends as client event
1125
-
1126
- // Log connection errors
1127
- pusher.connection.bind('error', (err: any) => {
1128
- console.error('[SDK]: ❌ Pusher connection error:', err)
1129
- })
1130
-
1131
- // Log state changes
1132
- pusher.connection.bind('state_change', (states: any) => {
1133
- console.log('[SDK]: 🔄 Pusher state change:', states.previous, '->', states.current)
1134
- })
1135
-
1136
- let counterpartyConnected = false
1137
- let timeout: NodeJS.Timeout
1138
-
1139
- // Wait for Pusher connection
1140
- pusher.connection.bind('connected', () => {
1141
- console.log('[SDK]: Pusher connected, waiting for counterparty...')
1142
-
1143
- // Notify server that desktop device connected (if QR is shown inline, already sent above)
1144
- if (!targetElement || !qrContainer) {
1145
- channel.trigger('client-device_connected', { type: 'device_connected', deviceType: 'desktop' })
1146
- }
1147
-
1148
- // Timeout after 5 minutes
1149
- timeout = setTimeout(() => {
1150
- cleanup()
1151
- pusher.disconnect()
1152
- reject(new Error('WebSocket handoff timeout - no counterparty connected'))
1153
- }, 5 * 60 * 1000)
1154
- })
1155
-
1156
- // Listen for mobile device connection (client event from sign page)
1157
- // Device B now fetches payload from API, so we just mark as connected
1158
- channel.bind('client-device_connected', (data: any) => {
1159
- // Only react to mobile device connections (ignore our own desktop connection)
1160
- if (data.type === 'device_connected' && data.deviceType === 'mobile' && !counterpartyConnected) {
1161
- counterpartyConnected = true
1162
- console.log('[SDK]: Mobile device connected (payload will be fetched from API)')
1163
- // No longer sending payload via socket - Device B fetches from API
1164
- }
1165
- })
1166
-
1167
- // Listen for signature result (client event from mobile)
1168
- channel.bind('client-signature_result', (data: any) => {
1169
- console.log('[SDK]: Signature result event received, raw data:', data)
1170
- console.log('[SDK]: Data keys:', Object.keys(data || {}))
1171
- console.log('[SDK]: counterpartyConnected:', counterpartyConnected)
1172
-
1173
- // Extract signature from data - handle both direct and wrapped formats
1174
- const signature = data?.signature || data?.data?.signature
1175
- const error = data?.error || data?.data?.error
1176
- const keyId = data?.keyId || data?.data?.keyId
1177
-
1178
- console.log('[SDK]: Extracted values - signature:', !!signature, 'error:', error, 'keyId:', keyId)
1179
- console.log('[SDK]: Full socket data received:', JSON.stringify(data, null, 2))
1180
-
1181
- // Prevent multiple processing
1182
- if (!counterpartyConnected) {
1183
- console.warn('[SDK]: Received signature result but counterparty not marked as connected yet. Waiting...')
1184
- // Don't return - might be a timing issue, try to process anyway
1185
- }
1186
-
1187
- clearTimeout(timeout)
1188
-
1189
- if (error) {
1190
- console.error('[SDK]: Signature error:', error)
1191
- cleanup()
1192
- pusher.disconnect()
1193
- reject(new Error(error))
1194
- return
1195
- }
1196
-
1197
- if (signature) {
1198
- console.log('[SDK]: Signature received successfully, resolving promise...')
1199
- // Use keyId from Device B (not localStorage) - Device B sent this via socket
1200
- const deviceBKeyId = keyId || ''
1201
- if (!deviceBKeyId) {
1202
- console.warn('[SDK]: ⚠️ WARNING - No keyId received from Device B via socket! Data:', data)
1203
- }
1204
- console.log('[SDK]: ✅ Using keyId from Device B:', deviceBKeyId, '(extracted from socket)')
1205
-
1206
- // Store handoff keyId in localStorage (Device A - desktop)
1207
- if (deviceBKeyId && this.brandingCache?.authFlowId) {
1208
- const authFlowId = this.brandingCache.authFlowId
1209
- const handoffStorageKey = `trulyYouKeyId_${authFlowId}_handoff`
1210
-
1211
- // Store in same-origin localStorage
1212
- if (typeof window !== 'undefined') {
1213
- localStorage.setItem(handoffStorageKey, deviceBKeyId)
1214
- console.log('[SDK]: ✅ Stored handoff keyId in localStorage:', handoffStorageKey, '=', deviceBKeyId)
1215
- }
1216
-
1217
- // Also store in TrulyYou frontend's localStorage via iframe
1218
- try {
1219
- const frontendUrl = this.frontendUrl
1220
- const iframe = document.createElement('iframe')
1221
- iframe.style.cssText = 'position: fixed; top: -9999px; left: -9999px; width: 1px; height: 1px; border: none; opacity: 0; pointer-events: none;'
1222
-
1223
- const storeUrl = new URL(`${frontendUrl}/store-handoff-keyid.html`)
1224
- storeUrl.searchParams.set('authFlowId', authFlowId)
1225
- storeUrl.searchParams.set('keyId', deviceBKeyId)
1226
-
1227
- iframe.src = storeUrl.toString()
1228
- document.body.appendChild(iframe)
1229
-
1230
- // Clean up after a delay
1231
- setTimeout(() => {
1232
- if (iframe.parentNode) {
1233
- iframe.parentNode.removeChild(iframe)
1234
- }
1235
- }, 2000)
1236
-
1237
- console.log('[SDK]: ✅ Stored handoff keyId in TrulyYou frontend localStorage via iframe')
1238
- } catch (error) {
1239
- console.warn('[SDK]: Failed to store handoff keyId in TrulyYou frontend localStorage:', error)
1240
- }
1241
- }
1242
-
1243
- const result: SigningResult = {
1244
- signature: signature,
1245
- keyId: deviceBKeyId,
1246
- signatureId: signatureId
1247
- }
1248
- console.log('[SDK]: Resolving with SigningResult:', {
1249
- signatureLength: result.signature?.length,
1250
- keyId: result.keyId,
1251
- signatureId: result.signatureId,
1252
- keyIdSource: deviceBKeyId ? 'Device B (socket)' : 'MISSING - will fallback to localStorage'
1253
- })
1254
-
1255
- cleanup()
1256
- pusher.disconnect()
1257
- resolve(result)
1258
- return
1259
- }
1260
-
1261
- console.error('[SDK]: Invalid signature result data - no signature or error:', JSON.stringify(data))
1262
- cleanup()
1263
- pusher.disconnect()
1264
- reject(new Error('Invalid signature result received'))
1265
- })
1266
-
1267
- pusher.connection.bind('error', (error: any) => {
1268
- console.error('[SDK]: Pusher connection error:', error)
1269
- clearTimeout(timeout)
1270
- pusher.disconnect()
1271
- reject(new Error('Pusher connection error'))
1272
- })
1273
-
1274
- pusher.connection.bind('disconnected', () => {
1275
- console.log('[SDK]: Pusher disconnected')
1276
- clearTimeout(timeout)
1277
- })
1278
-
1279
- // Store signatureId and signUrl for enrollment page to use
1280
- // This will be handled by opening enrollment page with signatureId
1281
- // For now, we'll need to modify the enrollment flow to handle this
1282
-
1283
- // Actually, we need to open enrollment page instead of sign page
1284
- // The enrollment page will show QR code and handle WebSocket
1285
- // Let's modify the approach - we'll emit an event that the app can listen to
1286
-
1287
- // Store attestation data in sessionStorage for sign page to access
1288
- sessionStorage.setItem(`signHandoff_${signatureId}`, JSON.stringify(attestationData))
1289
-
1290
- // Show QR code inline in target element (Device A - desktop)
1291
- // Device B will scan QR code and open the sign page URL
1292
- const targetElement = this.resolveTargetElement()
1293
- let qrContainer: HTMLElement | null = null
1294
- let checkWindowClosed: NodeJS.Timeout | null = null
1295
-
1296
- if (targetElement) {
1297
- // Create QR code container
1298
- qrContainer = document.createElement('div')
1299
- qrContainer.id = `qr-handoff-${signatureId}`
1300
- qrContainer.style.cssText = `
1301
- position: absolute;
1302
- top: 0;
1303
- left: 0;
1304
- right: 0;
1305
- bottom: 0;
1306
- width: 100%;
1307
- height: 100%;
1308
- max-width: 100%;
1309
- max-height: 100%;
1310
- overflow: hidden;
1311
- background-color: rgba(255, 255, 255, 0.98);
1312
- display: flex;
1313
- flex-direction: column;
1314
- align-items: center;
1315
- justify-content: center;
1316
- z-index: 10001;
1317
- padding: 0;
1318
- box-sizing: border-box;
1319
- `
1320
-
1321
- // Create QR code wrapper with countdown SVG overlay
1322
- const qrWrapper = document.createElement('div')
1323
- qrWrapper.style.cssText = `
1324
- position: relative;
1325
- display: flex;
1326
- align-items: center;
1327
- justify-content: center;
1328
- width: 100%;
1329
- height: 100%;
1330
- max-width: 100%;
1331
- max-height: 100%;
1332
- `
1333
-
1334
- // Create loading spinner (shown while QR loads)
1335
- const spinnerContainer = document.createElement('div')
1336
- spinnerContainer.style.cssText = `
1337
- position: absolute;
1338
- top: 50%;
1339
- left: 50%;
1340
- transform: translate(-50%, -50%);
1341
- width: 48px;
1342
- height: 48px;
1343
- z-index: 1;
1344
- display: flex;
1345
- align-items: center;
1346
- justify-content: center;
1347
- `
1348
-
1349
- const spinner = document.createElement('div')
1350
- spinner.style.cssText = `
1351
- width: 48px;
1352
- height: 48px;
1353
- border: 4px solid ${this.brandingCache?.primary ? `${this.brandingCache.primary}20` : '#e5e7eb'};
1354
- border-top-color: ${this.brandingCache?.primary || '#2563eb'};
1355
- border-radius: 50%;
1356
- animation: spin 1s linear infinite;
1357
- `
1358
-
1359
- // Add spin animation
1360
- const style = document.createElement('style')
1361
- style.textContent = `
1362
- @keyframes spin {
1363
- to { transform: rotate(360deg); }
1364
- }
1365
- `
1366
- document.head.appendChild(style)
1367
-
1368
- spinnerContainer.appendChild(spinner)
1369
-
1370
- // Create QR code with app icon overlay in center
1371
- const qrImage = document.createElement('img')
1372
- qrImage.alt = 'QR Code for mobile signing (click to copy URL)'
1373
- qrImage.style.cssText = `
1374
- width: 100%;
1375
- height: 100%;
1376
- max-width: 100%;
1377
- max-height: 100%;
1378
- aspect-ratio: 1;
1379
- object-fit: contain;
1380
- cursor: pointer;
1381
- transition: opacity 0.2s;
1382
- position: relative;
1383
- z-index: 2;
1384
- display: none;
1385
- opacity: 0;
1386
- `
1387
- qrImage.title = 'Click to copy URL to clipboard'
1388
-
1389
- // Show spinner initially, hide when QR loads
1390
- qrImage.addEventListener('load', () => {
1391
- console.log('[SDK]: QR image loaded')
1392
- spinnerContainer.style.display = 'none'
1393
- qrImage.style.display = 'block'
1394
- qrImage.style.opacity = '1'
1395
- // Recalculate SVG position after QR is loaded and visible
1396
- setTimeout(() => {
1397
- if (setupCountdownRect) {
1398
- setupCountdownRect()
1399
- }
1400
- }, 50)
1401
- })
1402
-
1403
- qrImage.addEventListener('error', () => {
1404
- console.error('[SDK]: QR image failed to load')
1405
- spinnerContainer.style.display = 'none'
1406
- // Still try to show QR even if load failed
1407
- qrImage.style.display = 'block'
1408
- qrImage.style.opacity = '0.5'
1409
- })
1410
-
1411
- // Create countdown SVG overlay (positioned absolutely over QR image, matching exact size)
1412
- const countdownSvg = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
1413
- countdownSvg.id = `countdown-svg-${signatureId}`
1414
-
1415
- // Wait for QR image to load and get actual rendered dimensions, then size SVG to match exactly
1416
- const setupCountdownRect = () => {
1417
- // Get actual rendered size and position of QR image (after object-fit: contain is applied)
1418
- const qrRect = qrImage.getBoundingClientRect()
1419
- const qrWrapperRect = qrWrapper.getBoundingClientRect()
1420
-
1421
- // QR image with object-fit: contain and aspect-ratio: 1 will be square
1422
- // and fit inside the wrapper. Get its actual rendered size.
1423
- const actualQRSize = Math.min(qrRect.width, qrRect.height)
1424
-
1425
- // Make SVG slightly bigger than QR to create border space (5% larger on each side = 10% total)
1426
- const borderPadding = actualQRSize * 0.05 // 5% padding on each side
1427
- const svgSize = actualQRSize + (borderPadding * 2) // Total SVG size with border space
1428
-
1429
- // Calculate position relative to wrapper (not viewport)
1430
- // Use getBoundingClientRect for accurate positioning (accounts for transforms and object-fit)
1431
- // For centered QR with flexbox, calculate offset from QR center to wrapper center
1432
- const qrCenterX = qrRect.left + qrRect.width / 2 - qrWrapperRect.left
1433
- const qrCenterY = qrRect.top + qrRect.height / 2 - qrWrapperRect.top
1434
-
1435
- // SVG should be centered over QR but slightly bigger - position from top-left
1436
- const offsetX = qrCenterX - svgSize / 2
1437
- const offsetY = qrCenterY - svgSize / 2
1438
-
1439
- // Use getBoundingClientRect values as they account for transforms and positioning
1440
- console.log('[SDK]: QR positioning - centered offset:', offsetX, offsetY, 'QR size:', actualQRSize, 'SVG size:', svgSize)
1441
-
1442
- // Position and size SVG to be slightly bigger than QR (with border space)
1443
- countdownSvg.style.cssText = `
1444
- position: absolute;
1445
- top: ${offsetY}px;
1446
- left: ${offsetX}px;
1447
- width: ${svgSize}px;
1448
- height: ${svgSize}px;
1449
- pointer-events: none;
1450
- z-index: 2;
1451
- transform: rotate(0deg);
1452
- transform-origin: center center;
1453
- `
1454
- countdownSvg.setAttribute('viewBox', `0 0 ${svgSize} ${svgSize}`)
1455
- countdownSvg.setAttribute('preserveAspectRatio', 'xMidYMid meet')
1456
-
1457
- // Rounded rectangle parameters - use percentages relative to viewBox
1458
- // The SVG is bigger than QR to create border space, but rect should fit within SVG bounds
1459
- const rectWidth = svgSize
1460
- const rectHeight = svgSize
1461
- const cornerRadius = svgSize * 0.04 // 4% of size for rounded corners (scales with QR)
1462
- const strokeWidth = svgSize * 0.06 // 6% of size for stroke (3x thicker - scales with QR)
1463
-
1464
- // Clear existing rects if any
1465
- while (countdownSvg.firstChild) {
1466
- countdownSvg.removeChild(countdownSvg.firstChild)
1467
- }
1468
-
1469
- // Position rect so stroke sits OUTSIDE the QR image
1470
- // QR is centered in SVG, so it goes from borderPadding to svgSize - borderPadding
1471
- // We want the stroke to sit outside this, so:
1472
- // - Rect inner edge should be at borderPadding (QR edge)
1473
- // - Stroke is centered on path, so x = borderPadding - strokeWidth/2 (so stroke extends outside)
1474
- // - Rect width should be actualQRSize + strokeWidth (to account for stroke extending outside)
1475
- const rectX = borderPadding - strokeWidth / 2
1476
- const rectY = borderPadding - strokeWidth / 2
1477
- const rectW = actualQRSize + strokeWidth
1478
- const rectH = actualQRSize + strokeWidth
1479
-
1480
- // Background rounded rectangle outline - positioned OUTSIDE QR bounds
1481
- const bgRect = document.createElementNS('http://www.w3.org/2000/svg', 'rect')
1482
- bgRect.setAttribute('x', rectX.toString())
1483
- bgRect.setAttribute('y', rectY.toString())
1484
- bgRect.setAttribute('width', rectW.toString())
1485
- bgRect.setAttribute('height', rectH.toString())
1486
- bgRect.setAttribute('rx', cornerRadius.toString())
1487
- bgRect.setAttribute('ry', cornerRadius.toString())
1488
- bgRect.setAttribute('fill', 'none')
1489
- bgRect.setAttribute('stroke', '#e5e7eb')
1490
- bgRect.setAttribute('stroke-width', strokeWidth.toString())
1491
- countdownSvg.appendChild(bgRect)
1492
-
1493
- // Progress rounded rectangle outline (animated) - same positioning, OUTSIDE QR bounds
1494
- const progressRect = document.createElementNS('http://www.w3.org/2000/svg', 'rect')
1495
- progressRect.id = `countdown-progress-${signatureId}`
1496
- progressRect.setAttribute('x', rectX.toString())
1497
- progressRect.setAttribute('y', rectY.toString())
1498
- progressRect.setAttribute('width', rectW.toString())
1499
- progressRect.setAttribute('height', rectH.toString())
1500
- progressRect.setAttribute('rx', cornerRadius.toString())
1501
- progressRect.setAttribute('ry', cornerRadius.toString())
1502
- progressRect.setAttribute('fill', 'none')
1503
- progressRect.setAttribute('stroke', this.brandingCache?.primary || '#2563eb')
1504
- progressRect.setAttribute('stroke-width', strokeWidth.toString())
1505
- progressRect.setAttribute('stroke-linecap', 'round')
1506
- progressRect.setAttribute('stroke-linejoin', 'round')
1507
- // Recalculate perimeter with new dimensions (rect is outside QR bounds)
1508
- const actualInnerWidth = rectW
1509
- const actualInnerHeight = rectH
1510
- const actualInnerRx = cornerRadius
1511
- const actualStraightEdges = 2 * (actualInnerWidth + actualInnerHeight - 2 * actualInnerRx)
1512
- const actualCornerArcs = 2 * Math.PI * actualInnerRx
1513
- const actualPerimeter = actualStraightEdges + actualCornerArcs
1514
- progressRect.setAttribute('stroke-dasharray', actualPerimeter.toString())
1515
- progressRect.setAttribute('stroke-dashoffset', actualPerimeter.toString())
1516
- progressRect.style.transition = 'stroke-dashoffset 0.3s linear, stroke 0.3s linear'
1517
- countdownSvg.appendChild(progressRect)
1518
-
1519
- // Return actual perimeter for use in countdown updates
1520
- return { perimeter: actualPerimeter }
1521
- }
1522
-
1523
- // Setup when image loads or immediately if already loaded
1524
- if (qrImage.complete && qrImage.naturalWidth > 0) {
1525
- setupCountdownRect()
1526
- } else {
1527
- qrImage.addEventListener('load', () => {
1528
- setupCountdownRect()
1529
- })
1530
- // Fallback setup with default values
1531
- setupCountdownRect()
1532
- }
1533
-
1534
- // Countdown state
1535
- let countdownState = { remaining: 30, total: 30 }
1536
-
1537
- // Listen for countdown updates (bound both early and in subscription_succeeded)
1538
- // Assign handler to the stored variable so early binds can access it
1539
- handleCountdownUpdate = (data: any) => {
1540
- console.log('[SDK]: 🎯 Processing countdown_update:', data)
1541
- if (data && (data.type === 'countdown_update' || data.timeRemaining !== undefined)) {
1542
- countdownState = {
1543
- remaining: data.timeRemaining ?? countdownState.remaining,
1544
- total: data.totalTime ?? countdownState.total
1545
- }
1546
- console.log(`[SDK]: 📊 Updating countdown rectangle: ${countdownState.remaining}/${countdownState.total}`)
1547
-
1548
- // Calculate perimeter using actual SVG size from viewBox
1549
- const viewBoxAttr = countdownSvg.getAttribute('viewBox')
1550
- const svgSize = viewBoxAttr ? parseFloat(viewBoxAttr.split(' ')[2]) || 100 : 100
1551
- const cornerRadius = svgSize * 0.04
1552
- const strokeWidth = svgSize * 0.06 // 3x thicker
1553
- // Calculate border padding (5% of QR size, but we need to reverse-calculate from SVG size)
1554
- // SVG = QR + 2*borderPadding, so borderPadding = (SVG - QR) / 2
1555
- // We approximate QR as ~91% of SVG (since SVG = QR * 1.1, so QR ≈ SVG / 1.1)
1556
- const estimatedQRSize = svgSize / 1.1
1557
- const borderPadding = (svgSize - estimatedQRSize) / 2
1558
- // Rectangle is positioned OUTSIDE QR (at borderPadding - strokeWidth/2)
1559
- const rectW = estimatedQRSize + strokeWidth
1560
- const rectH = estimatedQRSize + strokeWidth
1561
- const actualInnerWidth = rectW
1562
- const actualInnerHeight = rectH
1563
- const actualInnerRx = cornerRadius
1564
- const straightEdges = 2 * (actualInnerWidth + actualInnerHeight - 2 * actualInnerRx)
1565
- const cornerArcs = 2 * Math.PI * actualInnerRx
1566
- const currentPerimeter = straightEdges + cornerArcs
1567
-
1568
- const percentage = countdownState.remaining / countdownState.total
1569
- const strokeDashoffset = currentPerimeter * (1 - percentage)
1570
-
1571
- // Update the progress rect if it exists
1572
- const progressRectElement = document.getElementById(`countdown-progress-${signatureId}`) as SVGElement | null
1573
- if (progressRectElement && progressRectElement instanceof SVGRectElement) {
1574
- progressRectElement.setAttribute('stroke-dashoffset', strokeDashoffset.toString())
1575
- console.log(`[SDK]: 📊 Set stroke-dashoffset to: ${strokeDashoffset} (percentage: ${percentage}, perimeter: ${currentPerimeter})`)
1576
-
1577
- // Change color when low
1578
- if (countdownState.remaining <= 5) {
1579
- progressRectElement.setAttribute('stroke', '#ef4444')
1580
- } else if (countdownState.remaining <= 10) {
1581
- progressRectElement.setAttribute('stroke', '#f59e0b')
1582
- } else {
1583
- progressRectElement.setAttribute('stroke', this.brandingCache?.primary || '#2563eb')
1584
- }
1585
- }
1586
- } else {
1587
- console.warn('[SDK]: Invalid countdown_update data:', data)
1588
- }
1589
- }
1590
-
1591
- handleCountdownExpired = () => {
1592
- console.log('[SDK]: ⏰ Countdown expired - cleaning up and resetting state')
1593
-
1594
- // Clean up Pusher connection first
1595
- if (pusher && pusher.connection.state === 'connected') {
1596
- pusher.disconnect()
1597
- }
1598
-
1599
- // Remove QR container and all its children completely
1600
- if (qrContainer) {
1601
- // Remove all child elements
1602
- while (qrContainer.firstChild) {
1603
- qrContainer.removeChild(qrContainer.firstChild)
1604
- }
1605
- // Remove container from DOM
1606
- if (qrContainer.parentNode) {
1607
- qrContainer.parentNode.removeChild(qrContainer)
1608
- }
1609
- qrContainer = null
1610
- }
1611
-
1612
- // Reset countdown SVG reference
1613
- const countdownSvgElement = document.getElementById(`countdown-svg-${signatureId}`)
1614
- if (countdownSvgElement && countdownSvgElement.parentNode) {
1615
- countdownSvgElement.parentNode.removeChild(countdownSvgElement)
1616
- }
1617
-
1618
- // Clean up any other references
1619
- cleanup()
1620
-
1621
- // Silently reject - don't show alert, just reset state
1622
- reject(new Error('Countdown expired'))
1623
- }
1624
-
1625
- channel.bind('countdown_update', handleCountdownUpdate)
1626
- channel.bind('client-countdown_update', handleCountdownUpdate) // Backend sends as client event
1627
- channel.bind('countdown_expired', handleCountdownExpired)
1628
- channel.bind('client-countdown_expired', handleCountdownExpired) // Backend sends as client event
1629
-
1630
- qrWrapper.appendChild(spinnerContainer)
1631
- qrWrapper.appendChild(qrImage)
1632
- qrWrapper.appendChild(countdownSvg)
1633
-
1634
- // Add click handler to copy URL to clipboard
1635
- qrImage.addEventListener('click', async () => {
1636
- try {
1637
- await navigator.clipboard.writeText(signUrl)
1638
- console.log('[SDK]: URL copied to clipboard:', signUrl)
1639
-
1640
- // Visual feedback - temporarily change opacity
1641
- const originalOpacity = qrImage.style.opacity || '1'
1642
- qrImage.style.opacity = '0.7'
1643
- setTimeout(() => {
1644
- qrImage.style.opacity = originalOpacity
1645
- }, 200)
1646
- } catch (err) {
1647
- console.error('[SDK]: Failed to copy URL to clipboard:', err)
1648
- // Fallback for older browsers
1649
- const textArea = document.createElement('textarea')
1650
- textArea.value = signUrl
1651
- textArea.style.position = 'fixed'
1652
- textArea.style.opacity = '0'
1653
- document.body.appendChild(textArea)
1654
- textArea.select()
1655
- try {
1656
- document.execCommand('copy')
1657
- console.log('[SDK]: URL copied to clipboard (fallback method)')
1658
- } catch (fallbackErr) {
1659
- console.error('[SDK]: Fallback copy failed:', fallbackErr)
1660
- }
1661
- document.body.removeChild(textArea)
1662
- }
1663
- })
1664
-
1665
- // Generate QR code with icon overlay
1666
- this.generateQRCodeWithIcon(signUrl, qrImage).catch((err: any) => {
1667
- console.warn('[SDK]: Error generating QR code with icon, using fallback:', err)
1668
- // Fallback to simple QR code without icon
1669
- const qrCodeUrl = `https://api.qrserver.com/v1/create-qr-code/?size=300x300&data=${encodeURIComponent(signUrl)}`
1670
- qrImage.src = qrCodeUrl
1671
- })
1672
-
1673
- qrContainer.appendChild(qrWrapper)
1674
-
1675
- // Make target element position relative if needed and add overflow constraints
1676
- const targetStyle = window.getComputedStyle(targetElement)
1677
- if (targetStyle.position === 'static') {
1678
- targetElement.style.position = 'relative'
1679
- }
1680
- // Ensure target element contains the overlay
1681
- if (targetStyle.overflow === 'visible') {
1682
- targetElement.style.overflow = 'hidden'
1683
- }
1684
-
1685
- targetElement.appendChild(qrContainer)
1686
- } else {
1687
- // No target element - fallback to opening in new window
1688
- console.warn('[SDK]: No target element available, opening sign page in new window')
1689
- const signWindow = window.open(signUrl, '_blank', 'width=600,height=700')
1690
-
1691
- if (!signWindow) {
1692
- pusher.disconnect()
1693
- reject(new Error('Failed to open sign page. Please allow popups for this site.'))
1694
- return
1695
- }
1696
-
1697
- // Monitor sign window for closure
1698
- checkWindowClosed = setInterval(() => {
1699
- if (signWindow.closed) {
1700
- if (checkWindowClosed) clearInterval(checkWindowClosed)
1701
- clearTimeout(timeout)
1702
- pusher.disconnect()
1703
- reject(new Error('Sign window was closed'))
1704
- }
1705
- }, 500)
1706
- }
1707
-
1708
- // Store pusher reference for cleanup
1709
- let pusherInstance: Pusher | null = pusher
1710
-
1711
- // Cleanup on completion
1712
- const cleanup = () => {
1713
- if (qrContainer && qrContainer.parentNode) {
1714
- qrContainer.parentNode.removeChild(qrContainer)
1715
- }
1716
- if (checkWindowClosed) {
1717
- clearInterval(checkWindowClosed)
1718
- }
1719
- clearTimeout(timeout)
1720
- if (pusherInstance) {
1721
- pusherInstance.disconnect()
1722
- pusherInstance = null
1723
- }
1724
- sessionStorage.removeItem(`signHandoff_${signatureId}`)
1725
- }
1726
-
1727
- } catch (error: any) {
1728
- reject(error)
1729
- }
1730
- })
1731
- }
1732
-
1733
- /**
1734
- * Sign a payload with WebAuthn without a server-generated challenge
1735
- * Encodes the payload directly as the challenge
1736
- * Uses iframe to extract key from iframe's localStorage, not host
1737
- */
1738
- async signPayload(apiCallStructure: {
1739
- body: any
1740
- uri: string
1741
- method: string
1742
- headers?: any
1743
- }, signatureId?: string, existingKeyId?: string): Promise<SigningResult> {
1744
- try {
1745
- // Step 1: Check if desktop device first - desktop uses WebSocket handoff (no key probe needed)
1746
- if (this.isDesktopDevice()) {
1747
- console.log('[SDK]: Desktop device detected, using WebSocket handoff (skipping key probe)')
1748
- if (!signatureId) {
1749
- throw new Error('signatureId is required for desktop handoff')
1750
- }
1751
- return await this.signWithWebSocketHandoff(apiCallStructure, signatureId)
1752
- }
1753
-
1754
- // Step 2: Mobile device - use existing keyId if provided, otherwise probe
1755
- let keyId: string | null | undefined = existingKeyId
1756
-
1757
- if (!keyId) {
1758
- console.log('[SDK]: Mobile device detected, probing iframe for existing key...')
1759
- keyId = await this.probeIframeForKey()
1760
-
1761
- if (!keyId) {
1762
- // Step 3: No valid active key found (missing, invalid, revoked, or expired) - open enrollment popup
1763
- console.log('[SDK]: No valid active key found (missing, invalid, revoked, or expired), opening enrollment popup...')
1764
- keyId = await this.enrollWithPopup()
1765
-
1766
- if (!keyId) {
1767
- throw new Error('Enrollment completed but no valid active key found')
1768
- }
1769
- }
1770
- } else {
1771
- console.log('[SDK]: Using existing keyId from enrollment:', keyId)
1772
- }
1773
-
1774
- console.log('[SDK]: Valid active key found, signing with keyId:', keyId)
1775
-
1776
- // Step 5: Sign using iframe (key is extracted from iframe's localStorage)
1777
- console.log('[SDK]: Using iframe signing for mobile device')
1778
- return await this.signWithIframe(apiCallStructure)
1779
-
1780
- } catch (error: any) {
1781
- console.error('[SDK]: Signing error:', error)
1782
- throw error
1783
- }
1784
- }
1785
-
1786
- /**
1787
- * Fetch with automatic payload signing
1788
- */
1789
- async fetchWithSignature(
1790
- url: string,
1791
- options: FetchOptions = {}
1792
- ): Promise<FetchResult> {
1793
- // Ensure authFlowId is loaded before any signing operations
1794
- await this.ensureAuthFlowIdLoaded()
1795
-
1796
- // Declare signatureId at function scope so it's accessible in catch block
1797
- let signatureId: string | undefined = undefined
1798
- try {
1799
- console.log('[SDK]: fetchWithSignature called for:', url)
1800
-
1801
- // Parse URL to get path and base URL
1802
- const urlObj = new URL(url)
1803
- const uriPath = urlObj.pathname + urlObj.search
1804
- const baseUrl = `${urlObj.protocol}//${urlObj.host}`
1805
-
1806
- // Prepare API call structure
1807
- const apiCallStructure = {
1808
- body: options.body ? JSON.parse(options.body as string) : {},
1809
- uri: uriPath,
1810
- method: (options.method || 'GET').toUpperCase(),
1811
- headers: options.headers || {}
1812
- }
1813
-
1814
- console.log('[SDK]: API call structure:', apiCallStructure)
1815
-
1816
- // Check if domain + signature template match (if authAppId is configured)
1817
- if (this.authAppId) {
1818
- try {
1819
- // Generate signatureId first (for desktop handoff, we need it before signing)
1820
- // Generate a long, unique signatureId
1821
- signatureId = `sig_${Date.now()}_${Math.random().toString(36).substring(2, 15)}${Math.random().toString(36).substring(2, 15)}${Math.random().toString(36).substring(2, 15)}${Math.random().toString(36).substring(2, 15)}`
1822
-
1823
- // Get keyId using authFlowId
1824
- let keyId: string | null = null
1825
- if (this.brandingCache?.authFlowId) {
1826
- keyId = this.getKeyIdByAuthFlowId(this.brandingCache.authFlowId)
1827
- }
1828
-
1829
- const userIdStr = localStorage.getItem('trulyYouUserId')
1830
- let userId: string | undefined
1831
- if (userIdStr) {
1832
- try {
1833
- userId = userIdStr
1834
- } catch (e) {
1835
- console.warn('[SDK]: Failed to parse userId from localStorage:', e)
1836
- }
1837
- }
1838
-
1839
- // Check if this will be a handoff flow (desktop device)
1840
- const isHandoff = this.isDesktopDevice()
1841
-
1842
- // For mobile devices (non-handoff), if no key exists, trigger enrollment
1843
- if (!isHandoff && !keyId) {
1844
- console.log('[SDK]: No key found in localStorage, triggering enrollment...')
1845
-
1846
- // Probe iframe first to check if key exists there
1847
- keyId = await this.probeIframeForKey()
1848
-
1849
- if (!keyId) {
1850
- // No key found, trigger enrollment
1851
- console.log('[SDK]: No key in iframe either, opening enrollment popup...')
1852
- keyId = await this.enrollWithPopup()
1853
-
1854
- if (!keyId) {
1855
- throw new Error('Enrollment completed but no key found. Please try again.')
1856
- }
1857
-
1858
- console.log('[SDK]: Enrollment successful, obtained keyId:', keyId)
1859
- }
1860
- }
1861
-
1862
- // Create signature document BEFORE signing (this also validates domain and template and starts countdown for handoff)
1863
- const createResponse = await fetch(`${this.apiUrl}/api/signatures/create`, {
1864
- method: 'POST',
1865
- headers: { 'Content-Type': 'application/json' },
1866
- body: JSON.stringify({
1867
- appId: this.authAppId,
1868
- baseUrl,
1869
- endpoint: uriPath,
1870
- method: apiCallStructure.method,
1871
- keyId: keyId || '',
1872
- userId: userId,
1873
- apiCallStructure,
1874
- signatureId,
1875
- isHandoff // Flag to indicate handoff flow
1876
- })
1877
- })
1878
-
1879
- if (createResponse.ok) {
1880
- const createData = await createResponse.json()
1881
- if (createData.success && createData.data?.signatureId) {
1882
- console.log('[SDK]: Signature document created with signatureId:', createData.data.signatureId)
1883
- } else {
1884
- throw new Error('Failed to create signature document: ' + (createData.error || 'No signatureId returned'))
1885
- }
1886
- } else {
1887
- const errorData = await createResponse.json().catch(() => ({}))
1888
- throw new Error('Failed to create signature: ' + (errorData.error || 'Creation request failed'))
1889
- }
1890
-
1891
- // Now sign the payload (pass signatureId for desktop handoff, and keyId if we have it)
1892
- const signingResult = await this.signPayload(apiCallStructure, signatureId, keyId || undefined)
1893
-
1894
- // Make the actual API call with signature and signatureId in header
1895
- // Use keyId from signingResult (Device B's keyId for handoff, or localStorage keyId for mobile)
1896
- const signingResultKeyId = signingResult.keyId || ''
1897
- console.log('[SDK]: 🔍 KeyId debug - signingResult.keyId:', signingResultKeyId, 'localStorage keyId:', keyId, 'keyId type:', typeof signingResultKeyId, 'empty?:', signingResultKeyId === '')
1898
- const keyIdForAuth = signingResultKeyId || keyId || ''
1899
- console.log('[SDK]: ✅ Final keyId for auth header:', keyIdForAuth, '(from', signingResultKeyId && signingResultKeyId !== '' ? 'Device B' : 'localStorage fallback', ')')
1900
- const authHeaderValue = btoa(JSON.stringify({ signature: signingResult.signature, keyId: keyIdForAuth, signatureId }))
1901
-
1902
- const response = await fetch(url, {
1903
- ...options,
1904
- headers: {
1905
- ...options.headers,
1906
- 'x-truly-auth': authHeaderValue
1907
- }
1908
- })
1909
-
1910
- console.log('[SDK]: Request complete, status:', response.status)
1911
-
1912
- return {
1913
- response,
1914
- signature: signingResult.signature,
1915
- signatureId
1916
- }
1917
- } catch (error: any) {
1918
- console.error('[SDK]: Signature creation error:', error)
1919
- // Silently fail for countdown expiry - don't throw error
1920
- if (error.message && error.message.includes('Countdown expired')) {
1921
- // Return empty result to indicate silent failure
1922
- return { response: null as any, signature: '', signatureId: signatureId || '' }
1923
- }
1924
- throw new Error('Signature creation failed: ' + (error.message || 'Unknown error'))
1925
- }
1926
- } else {
1927
- throw new Error('authAppId is required for signature validation. Please configure authAppId in SDK initialization.')
1928
- }
1929
-
1930
- } catch (error: any) {
1931
- console.error('[SDK]: fetchWithSignature error:', error)
1932
- // Silently fail for countdown expiry - just reset state
1933
- if (error.message && error.message.includes('Countdown expired')) {
1934
- // Don't throw error, just silently reset - return empty result
1935
- // signatureId is in scope from fetchWithSignature function
1936
- const expiredSignatureId = signatureId || ''
1937
- return { response: null as any, signature: '', signatureId: expiredSignatureId }
1938
- }
1939
- throw error
1940
- }
1941
- }
1942
-
1943
- /**
1944
- * Public method to probe for keyId - checks localStorage first, then probes TrulyYou frontend via iframe
1945
- * Returns the keyId if found, null otherwise
1946
- * @param handoff - If true, probes for handoff keyId (with _handoff suffix)
1947
- */
1948
- async probeForKeyId(handoff: boolean = false): Promise<string | null> {
1949
- // Ensure authFlowId is loaded first
1950
- await this.ensureAuthFlowIdLoaded()
1951
-
1952
- if (!this.brandingCache?.authFlowId) {
1953
- console.warn('[SDK-PROBE]: authFlowId is required but not available')
1954
- return null
1955
- }
1956
-
1957
- const authFlowId = this.brandingCache.authFlowId
1958
-
1959
- // First, check localStorage (same origin - this app's localStorage)
1960
- const storageKey = handoff ? `trulyYouKeyId_${authFlowId}_handoff` : `trulyYouKeyId_${authFlowId}`
1961
- const keyIdFromStorage = typeof window !== 'undefined' ? localStorage.getItem(storageKey) : null
1962
-
1963
- if (keyIdFromStorage) {
1964
- console.log(`[SDK-PROBE]: Found ${handoff ? 'handoff ' : ''}keyId in same-origin localStorage`)
1965
- return keyIdFromStorage
1966
- }
1967
-
1968
- // If not found in same-origin localStorage, probe TrulyYou frontend's localStorage via iframe
1969
- console.log(`[SDK-PROBE]: ${handoff ? 'Handoff ' : ''}KeyId not found in same-origin localStorage, probing TrulyYou frontend...`)
1970
- return await this.probeIframeForKey(handoff)
1971
- }
1972
-
1973
- /**
1974
- * Clear all keys (both handoff and non-handoff) from localStorage
1975
- * Clears from both same-origin localStorage and TrulyYou frontend's localStorage via iframe
1976
- */
1977
- async clearAllKeys(): Promise<void> {
1978
- // Ensure authFlowId is loaded first
1979
- await this.ensureAuthFlowIdLoaded()
1980
-
1981
- if (!this.brandingCache?.authFlowId) {
1982
- console.warn('[SDK-CLEAR]: authFlowId is required but not available')
1983
- return
1984
- }
1985
-
1986
- const authFlowId = this.brandingCache.authFlowId
1987
-
1988
- // Clear from same-origin localStorage
1989
- if (typeof window !== 'undefined') {
1990
- const regularKey = `trulyYouKeyId_${authFlowId}`
1991
- const handoffKey = `trulyYouKeyId_${authFlowId}_handoff`
1992
-
1993
- localStorage.removeItem(regularKey)
1994
- localStorage.removeItem(handoffKey)
1995
-
1996
- console.log('[SDK-CLEAR]: ✅ Cleared keys from same-origin localStorage:', regularKey, handoffKey)
1997
- }
1998
-
1999
- // Also clear from TrulyYou frontend's localStorage via iframe
2000
- try {
2001
- const iframe = document.createElement('iframe')
2002
- iframe.style.cssText = 'position: fixed; top: -9999px; left: -9999px; width: 1px; height: 1px; border: none; opacity: 0; pointer-events: none;'
2003
-
2004
- const clearUrl = new URL(`${this.frontendUrl}/clear-keys.html`)
2005
- clearUrl.searchParams.set('authFlowId', authFlowId)
2006
-
2007
- iframe.src = clearUrl.toString()
2008
- document.body.appendChild(iframe)
2009
-
2010
- // Clean up after a delay
2011
- setTimeout(() => {
2012
- if (iframe.parentNode) {
2013
- iframe.parentNode.removeChild(iframe)
2014
- }
2015
- }, 2000)
2016
-
2017
- console.log('[SDK-CLEAR]: ✅ Cleared keys from TrulyYou frontend localStorage via iframe')
2018
- } catch (error) {
2019
- console.warn('[SDK-CLEAR]: Failed to clear keys from TrulyYou frontend localStorage:', error)
2020
- }
2021
- }
2022
- }
2023
-