@imtbl/auth 2.12.5 → 2.12.6-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,906 @@
1
+ /**
2
+ * Standalone login functions for stateless authentication flows.
3
+ * These functions handle OAuth login without managing session state,
4
+ * making them ideal for use with external session managers like NextAuth.
5
+ */
6
+
7
+ import { Detail, getDetail, track } from '@imtbl/metrics';
8
+ import { decodeJwtPayload } from '../utils/jwt';
9
+ import type {
10
+ DirectLoginOptions, IdTokenPayload, MarketingConsentStatus, ZkEvmInfo,
11
+ } from '../types';
12
+ import { PASSPORT_OVERLAY_CONTENTS_ID } from '../overlay/constants';
13
+ import { buildLogoutUrl as internalBuildLogoutUrl } from '../logout';
14
+
15
+ // ============================================================================
16
+ // Types
17
+ // ============================================================================
18
+
19
+ /**
20
+ * Configuration for standalone login functions
21
+ */
22
+ export interface LoginConfig {
23
+ /** Your Immutable application client ID */
24
+ clientId: string;
25
+ /** The OAuth redirect URI for your application */
26
+ redirectUri: string;
27
+ /** Optional separate redirect URI for popup flows */
28
+ popupRedirectUri?: string;
29
+ /** OAuth audience (default: "platform_api") */
30
+ audience?: string;
31
+ /** OAuth scopes (default: "openid profile email offline_access transact") */
32
+ scope?: string;
33
+ /** Authentication domain (default: "https://auth.immutable.com") */
34
+ authenticationDomain?: string;
35
+ }
36
+
37
+ // Embedded login prompt types
38
+ const EMBEDDED_LOGIN_PROMPT_EVENT_TYPE = 'im_passport_embedded_login_prompt';
39
+ const LOGIN_PROMPT_IFRAME_ID = 'passport-embedded-login-iframe';
40
+ const PASSPORT_OVERLAY_ID = 'passport-overlay';
41
+
42
+ enum EmbeddedLoginPromptReceiveMessage {
43
+ LOGIN_METHOD_SELECTED = 'login_method_selected',
44
+ LOGIN_PROMPT_ERROR = 'login_prompt_error',
45
+ LOGIN_PROMPT_CLOSED = 'login_prompt_closed',
46
+ }
47
+
48
+ interface EmbeddedLoginPromptResult {
49
+ marketingConsentStatus: MarketingConsentStatus;
50
+ imPassportTraceId: string;
51
+ directLoginMethod: string;
52
+ email?: string;
53
+ }
54
+
55
+ /**
56
+ * Token response from successful authentication
57
+ */
58
+ export interface TokenResponse {
59
+ /** OAuth access token for API calls */
60
+ accessToken: string;
61
+ /** OAuth refresh token for token renewal */
62
+ refreshToken?: string;
63
+ /** OpenID Connect ID token */
64
+ idToken?: string;
65
+ /** Unix timestamp (ms) when the access token expires */
66
+ accessTokenExpires: number;
67
+ /** User profile information */
68
+ profile: {
69
+ sub: string;
70
+ email?: string;
71
+ nickname?: string;
72
+ };
73
+ /** zkEVM wallet information if available */
74
+ zkEvm?: ZkEvmInfo;
75
+ }
76
+
77
+ /**
78
+ * Extended login options for popup/redirect flows
79
+ */
80
+ export interface StandaloneLoginOptions {
81
+ /** Direct login options (social provider, email, etc.) */
82
+ directLoginOptions?: DirectLoginOptions;
83
+ }
84
+
85
+ // ============================================================================
86
+ // Constants
87
+ // ============================================================================
88
+
89
+ const DEFAULT_AUTH_DOMAIN = 'https://auth.immutable.com';
90
+ const DEFAULT_AUDIENCE = 'platform_api';
91
+ const DEFAULT_SCOPE = 'openid profile email offline_access transact';
92
+ const AUTHORIZE_ENDPOINT = '/authorize';
93
+ const TOKEN_ENDPOINT = '/oauth/token';
94
+
95
+ // Storage key for PKCE data
96
+ const PKCE_STORAGE_KEY = 'imtbl_pkce_data';
97
+
98
+ // ============================================================================
99
+ // Utility Functions
100
+ // ============================================================================
101
+
102
+ function base64URLEncode(buffer: ArrayBuffer | Uint8Array): string {
103
+ return btoa(String.fromCharCode(...new Uint8Array(buffer)))
104
+ .replace(/\+/g, '-')
105
+ .replace(/\//g, '_')
106
+ .replace(/=/g, '');
107
+ }
108
+
109
+ async function sha256(value: string): Promise<ArrayBuffer> {
110
+ const encoder = new TextEncoder();
111
+ const data = encoder.encode(value);
112
+ return window.crypto.subtle.digest('SHA-256', data);
113
+ }
114
+
115
+ function generateRandomString(): string {
116
+ return base64URLEncode(window.crypto.getRandomValues(new Uint8Array(32)));
117
+ }
118
+
119
+ function getAuthDomain(config: LoginConfig): string {
120
+ return config.authenticationDomain || DEFAULT_AUTH_DOMAIN;
121
+ }
122
+
123
+ function getTokenExpiry(accessToken: string): number {
124
+ try {
125
+ const payload = decodeJwtPayload<{ exp?: number }>(accessToken);
126
+ if (payload.exp) {
127
+ return payload.exp * 1000; // Convert to milliseconds
128
+ }
129
+ } catch {
130
+ // Fall back to 1 hour from now if we can't decode
131
+ }
132
+ return Date.now() + 3600 * 1000;
133
+ }
134
+
135
+ function mapTokenResponseToResult(
136
+ tokenData: {
137
+ access_token: string;
138
+ refresh_token?: string;
139
+ id_token?: string;
140
+ },
141
+ ): TokenResponse {
142
+ const { access_token: accessToken, refresh_token: refreshToken, id_token: idToken } = tokenData;
143
+
144
+ let profile: TokenResponse['profile'] = { sub: '' };
145
+ let zkEvm: TokenResponse['zkEvm'] | undefined;
146
+
147
+ if (idToken) {
148
+ try {
149
+ const {
150
+ sub, email, nickname, passport,
151
+ } = decodeJwtPayload<IdTokenPayload>(idToken);
152
+ profile = { sub, email, nickname };
153
+
154
+ if (passport?.zkevm_eth_address && passport?.zkevm_user_admin_address) {
155
+ zkEvm = {
156
+ ethAddress: passport.zkevm_eth_address as `0x${string}`,
157
+ userAdminAddress: passport.zkevm_user_admin_address as `0x${string}`,
158
+ };
159
+ }
160
+ } catch {
161
+ // If we can't decode the ID token, we'll have minimal profile info
162
+ }
163
+ }
164
+
165
+ return {
166
+ accessToken,
167
+ refreshToken,
168
+ idToken,
169
+ accessTokenExpires: getTokenExpiry(accessToken),
170
+ profile,
171
+ zkEvm,
172
+ };
173
+ }
174
+
175
+ // ============================================================================
176
+ // PKCE Storage (session-only, not persisted)
177
+ // ============================================================================
178
+
179
+ interface PKCEData {
180
+ state: string;
181
+ verifier: string;
182
+ redirectUri: string;
183
+ }
184
+
185
+ function savePKCEData(data: PKCEData): void {
186
+ if (typeof window !== 'undefined' && window.sessionStorage) {
187
+ window.sessionStorage.setItem(PKCE_STORAGE_KEY, JSON.stringify(data));
188
+ }
189
+ }
190
+
191
+ function getPKCEData(): PKCEData | null {
192
+ if (typeof window !== 'undefined' && window.sessionStorage) {
193
+ const data = window.sessionStorage.getItem(PKCE_STORAGE_KEY);
194
+ if (data) {
195
+ try {
196
+ return JSON.parse(data) as PKCEData;
197
+ } catch {
198
+ return null;
199
+ }
200
+ }
201
+ }
202
+ return null;
203
+ }
204
+
205
+ function clearPKCEData(): void {
206
+ if (typeof window !== 'undefined' && window.sessionStorage) {
207
+ window.sessionStorage.removeItem(PKCE_STORAGE_KEY);
208
+ }
209
+ }
210
+
211
+ // ============================================================================
212
+ // Embedded Login Prompt
213
+ // ============================================================================
214
+
215
+ function appendEmbeddedLoginPromptStyles(): void {
216
+ const styleId = 'passport-embedded-login-keyframes';
217
+ if (document.getElementById(styleId)) {
218
+ return;
219
+ }
220
+
221
+ const style = document.createElement('style');
222
+ style.id = styleId;
223
+ style.textContent = `
224
+ @keyframes passportEmbeddedLoginPromptPopBounceIn {
225
+ 0% {
226
+ opacity: 0.5;
227
+ }
228
+ 50% {
229
+ opacity: 1;
230
+ transform: scale(1.05);
231
+ }
232
+ 75% {
233
+ transform: scale(0.98);
234
+ }
235
+ 100% {
236
+ opacity: 1;
237
+ transform: scale(1);
238
+ }
239
+ }
240
+
241
+ @media (max-height: 400px) {
242
+ #${LOGIN_PROMPT_IFRAME_ID} {
243
+ width: 100% !important;
244
+ max-width: none !important;
245
+ }
246
+ }
247
+
248
+ @keyframes passportEmbeddedLoginPromptOverlayFadeIn {
249
+ from {
250
+ opacity: 0;
251
+ }
252
+ to {
253
+ opacity: 1;
254
+ }
255
+ }
256
+ `;
257
+
258
+ document.head.appendChild(style);
259
+ }
260
+
261
+ function createEmbeddedLoginIFrame(authDomain: string, clientId: string): HTMLIFrameElement {
262
+ const runtimeId = getDetail(Detail.RUNTIME_ID);
263
+ const iframe = document.createElement('iframe');
264
+ iframe.id = LOGIN_PROMPT_IFRAME_ID;
265
+ iframe.src = `${authDomain}/im-embedded-login-prompt?client_id=${clientId}&rid=${runtimeId}`;
266
+ iframe.style.height = '100vh';
267
+ iframe.style.width = '100vw';
268
+ iframe.style.maxHeight = '660px';
269
+ iframe.style.maxWidth = '440px';
270
+ iframe.style.borderRadius = '16px';
271
+ iframe.style.border = 'none';
272
+ iframe.style.opacity = '0';
273
+ iframe.style.transform = 'scale(0.6)';
274
+ iframe.style.animation = 'passportEmbeddedLoginPromptPopBounceIn 1s ease forwards';
275
+ appendEmbeddedLoginPromptStyles();
276
+ return iframe;
277
+ }
278
+
279
+ function createOverlayElement(): HTMLDivElement {
280
+ const overlay = document.createElement('div');
281
+ overlay.id = PASSPORT_OVERLAY_ID;
282
+ overlay.style.cssText = `
283
+ position: fixed;
284
+ top: 0;
285
+ left: 0;
286
+ width: 100%;
287
+ height: 100%;
288
+ display: flex;
289
+ flex-direction: column;
290
+ justify-content: center;
291
+ align-items: center;
292
+ z-index: 2147483647;
293
+ background: rgba(247, 247, 247, 0.24);
294
+ animation-name: passportEmbeddedLoginPromptOverlayFadeIn;
295
+ animation-duration: 0.8s;
296
+ `;
297
+
298
+ const contents = document.createElement('div');
299
+ contents.id = PASSPORT_OVERLAY_CONTENTS_ID;
300
+ contents.style.cssText = `
301
+ display: flex;
302
+ flex-direction: column;
303
+ align-items: center;
304
+ width: 100%;
305
+ `;
306
+
307
+ overlay.appendChild(contents);
308
+ return overlay;
309
+ }
310
+
311
+ function removeOverlay(): void {
312
+ const overlay = document.getElementById(PASSPORT_OVERLAY_ID);
313
+ overlay?.remove();
314
+ }
315
+
316
+ function displayEmbeddedLoginPrompt(
317
+ authDomain: string,
318
+ clientId: string,
319
+ ): Promise<EmbeddedLoginPromptResult> {
320
+ return new Promise((resolve, reject) => {
321
+ const iframe = createEmbeddedLoginIFrame(authDomain, clientId);
322
+ const overlay = createOverlayElement();
323
+
324
+ const messageHandler = ({ data, origin }: MessageEvent) => {
325
+ if (
326
+ origin !== authDomain
327
+ || data.eventType !== EMBEDDED_LOGIN_PROMPT_EVENT_TYPE
328
+ ) {
329
+ return;
330
+ }
331
+
332
+ switch (data.messageType as EmbeddedLoginPromptReceiveMessage) {
333
+ case EmbeddedLoginPromptReceiveMessage.LOGIN_METHOD_SELECTED: {
334
+ const result = data.payload as EmbeddedLoginPromptResult;
335
+ window.removeEventListener('message', messageHandler);
336
+ removeOverlay();
337
+ resolve(result);
338
+ break;
339
+ }
340
+ case EmbeddedLoginPromptReceiveMessage.LOGIN_PROMPT_ERROR: {
341
+ window.removeEventListener('message', messageHandler);
342
+ removeOverlay();
343
+ reject(new Error('Error during embedded login prompt', { cause: data.payload }));
344
+ break;
345
+ }
346
+ case EmbeddedLoginPromptReceiveMessage.LOGIN_PROMPT_CLOSED: {
347
+ window.removeEventListener('message', messageHandler);
348
+ removeOverlay();
349
+ reject(new Error('Login closed by user'));
350
+ break;
351
+ }
352
+ default:
353
+ window.removeEventListener('message', messageHandler);
354
+ removeOverlay();
355
+ reject(new Error(`Unsupported message type: ${data.messageType}`));
356
+ break;
357
+ }
358
+ };
359
+
360
+ // Close when clicking overlay background
361
+ const overlayClickHandler = (e: MouseEvent) => {
362
+ if (e.target === overlay) {
363
+ window.removeEventListener('message', messageHandler);
364
+ overlay.removeEventListener('click', overlayClickHandler);
365
+ removeOverlay();
366
+ reject(new Error('Login closed by user'));
367
+ }
368
+ };
369
+
370
+ window.addEventListener('message', messageHandler);
371
+ overlay.addEventListener('click', overlayClickHandler);
372
+
373
+ const contents = overlay.querySelector(`#${PASSPORT_OVERLAY_CONTENTS_ID}`);
374
+ if (contents) {
375
+ contents.appendChild(iframe);
376
+ }
377
+ document.body.appendChild(overlay);
378
+ });
379
+ }
380
+
381
+ // ============================================================================
382
+ // Authorization URL Builder
383
+ // ============================================================================
384
+
385
+ async function buildAuthorizationUrl(
386
+ config: LoginConfig,
387
+ options?: StandaloneLoginOptions,
388
+ ): Promise<{ url: string; verifier: string; state: string }> {
389
+ const authDomain = getAuthDomain(config);
390
+ const verifier = generateRandomString();
391
+ const challenge = base64URLEncode(await sha256(verifier));
392
+ const state = generateRandomString();
393
+
394
+ const url = new URL(AUTHORIZE_ENDPOINT, authDomain);
395
+ url.searchParams.set('response_type', 'code');
396
+ url.searchParams.set('code_challenge', challenge);
397
+ url.searchParams.set('code_challenge_method', 'S256');
398
+ url.searchParams.set('client_id', config.clientId);
399
+ url.searchParams.set('redirect_uri', config.redirectUri);
400
+ url.searchParams.set('state', state);
401
+ url.searchParams.set('scope', config.scope || DEFAULT_SCOPE);
402
+
403
+ if (config.audience) {
404
+ url.searchParams.set('audience', config.audience);
405
+ } else {
406
+ url.searchParams.set('audience', DEFAULT_AUDIENCE);
407
+ }
408
+
409
+ // Add direct login options if provided
410
+ const directLoginOptions = options?.directLoginOptions;
411
+ if (directLoginOptions) {
412
+ if (directLoginOptions.directLoginMethod === 'email') {
413
+ if (directLoginOptions.email) {
414
+ url.searchParams.set('direct', 'email');
415
+ url.searchParams.set('email', directLoginOptions.email);
416
+ }
417
+ } else {
418
+ url.searchParams.set('direct', directLoginOptions.directLoginMethod);
419
+ }
420
+ if (directLoginOptions.marketingConsentStatus) {
421
+ url.searchParams.set('marketingConsent', directLoginOptions.marketingConsentStatus);
422
+ }
423
+ }
424
+
425
+ return { url: url.toString(), verifier, state };
426
+ }
427
+
428
+ // ============================================================================
429
+ // Token Exchange
430
+ // ============================================================================
431
+
432
+ async function exchangeCodeForTokens(
433
+ config: LoginConfig,
434
+ code: string,
435
+ verifier: string,
436
+ redirectUri: string,
437
+ ): Promise<TokenResponse> {
438
+ const authDomain = getAuthDomain(config);
439
+ const tokenUrl = `${authDomain}${TOKEN_ENDPOINT}`;
440
+
441
+ const response = await fetch(tokenUrl, {
442
+ method: 'POST',
443
+ headers: {
444
+ 'Content-Type': 'application/x-www-form-urlencoded',
445
+ },
446
+ body: new URLSearchParams({
447
+ grant_type: 'authorization_code',
448
+ client_id: config.clientId,
449
+ code_verifier: verifier,
450
+ code,
451
+ redirect_uri: redirectUri,
452
+ }),
453
+ });
454
+
455
+ if (!response.ok) {
456
+ const errorText = await response.text();
457
+ let errorMessage = `Token exchange failed with status ${response.status}`;
458
+ try {
459
+ const errorData = JSON.parse(errorText);
460
+ if (errorData.error_description) {
461
+ errorMessage = errorData.error_description;
462
+ } else if (errorData.error) {
463
+ errorMessage = errorData.error;
464
+ }
465
+ } catch {
466
+ if (errorText) {
467
+ errorMessage = errorText;
468
+ }
469
+ }
470
+ throw new Error(errorMessage);
471
+ }
472
+
473
+ const tokenData = await response.json();
474
+ return mapTokenResponseToResult(tokenData);
475
+ }
476
+
477
+ // ============================================================================
478
+ // Public API
479
+ // ============================================================================
480
+
481
+ /**
482
+ * Login with a popup window.
483
+ * Opens a popup for OAuth authentication and returns tokens when complete.
484
+ *
485
+ * @param config - Login configuration
486
+ * @param options - Optional login options (direct login, etc.)
487
+ * @returns Promise resolving to token response
488
+ * @throws Error if popup is blocked or login fails
489
+ *
490
+ * @example
491
+ * ```typescript
492
+ * import { loginWithPopup } from '@imtbl/auth';
493
+ *
494
+ * const tokens = await loginWithPopup({
495
+ * clientId: 'your-client-id',
496
+ * redirectUri: 'https://your-app.com/callback',
497
+ * });
498
+ * console.log(tokens.accessToken);
499
+ * ```
500
+ */
501
+ export async function loginWithPopup(
502
+ config: LoginConfig,
503
+ options?: StandaloneLoginOptions,
504
+ ): Promise<TokenResponse> {
505
+ track('passport', 'standaloneLoginWithPopup');
506
+
507
+ const popupRedirectUri = config.popupRedirectUri || config.redirectUri;
508
+ const popupConfig = { ...config, redirectUri: popupRedirectUri };
509
+
510
+ const { url, verifier, state } = await buildAuthorizationUrl(popupConfig, options);
511
+
512
+ return new Promise((resolve, reject) => {
513
+ // Open popup
514
+ const width = 500;
515
+ const height = 600;
516
+ const left = window.screenX + (window.outerWidth - width) / 2;
517
+ const top = window.screenY + (window.outerHeight - height) / 2;
518
+
519
+ const popup = window.open(
520
+ url,
521
+ 'immutable_login',
522
+ `width=${width},height=${height},left=${left},top=${top},toolbar=no,menubar=no`,
523
+ );
524
+
525
+ if (!popup) {
526
+ reject(new Error('Popup was blocked. Please allow popups for this site.'));
527
+ return;
528
+ }
529
+
530
+ // Poll for popup completion
531
+ const pollInterval = setInterval(() => {
532
+ try {
533
+ if (popup.closed) {
534
+ clearInterval(pollInterval);
535
+ reject(new Error('Login popup was closed'));
536
+ return;
537
+ }
538
+
539
+ // Check if we can access the popup URL (same origin after redirect)
540
+ const popupUrl = popup.location.href;
541
+ if (popupUrl && popupUrl.startsWith(popupRedirectUri)) {
542
+ clearInterval(pollInterval);
543
+ popup.close();
544
+
545
+ const urlParams = new URL(popupUrl);
546
+ const code = urlParams.searchParams.get('code');
547
+ const returnedState = urlParams.searchParams.get('state');
548
+ const error = urlParams.searchParams.get('error');
549
+ const errorDescription = urlParams.searchParams.get('error_description');
550
+
551
+ if (error) {
552
+ reject(new Error(errorDescription || error));
553
+ return;
554
+ }
555
+
556
+ if (!code) {
557
+ reject(new Error('No authorization code received'));
558
+ return;
559
+ }
560
+
561
+ if (returnedState !== state) {
562
+ reject(new Error('State mismatch - possible CSRF attack'));
563
+ return;
564
+ }
565
+
566
+ // Exchange code for tokens
567
+ exchangeCodeForTokens(popupConfig, code, verifier, popupRedirectUri)
568
+ .then(resolve)
569
+ .catch(reject);
570
+ }
571
+ } catch {
572
+ // Cross-origin access will throw - this is expected while on auth domain
573
+ }
574
+ }, 100);
575
+
576
+ // Timeout after 5 minutes
577
+ setTimeout(() => {
578
+ clearInterval(pollInterval);
579
+ if (!popup.closed) {
580
+ popup.close();
581
+ }
582
+ reject(new Error('Login timed out'));
583
+ }, 5 * 60 * 1000);
584
+ });
585
+ }
586
+
587
+ /**
588
+ * Login with an embedded iframe modal.
589
+ * First displays a modal for the user to select their login method (email, Google, etc.),
590
+ * then opens a popup for OAuth authentication and returns tokens when complete.
591
+ *
592
+ * This provides a smoother user experience compared to loginWithPopup as the user
593
+ * can choose their login method before the OAuth popup opens.
594
+ *
595
+ * @param config - Login configuration
596
+ * @returns Promise resolving to token response
597
+ * @throws Error if modal is closed or login fails
598
+ *
599
+ * @example
600
+ * ```typescript
601
+ * import { loginWithEmbedded } from '@imtbl/auth';
602
+ *
603
+ * const tokens = await loginWithEmbedded({
604
+ * clientId: 'your-client-id',
605
+ * redirectUri: 'https://your-app.com/callback',
606
+ * });
607
+ * console.log(tokens.accessToken);
608
+ * ```
609
+ */
610
+ export async function loginWithEmbedded(
611
+ config: LoginConfig,
612
+ ): Promise<TokenResponse> {
613
+ track('passport', 'standaloneLoginWithEmbedded');
614
+
615
+ const authDomain = getAuthDomain(config);
616
+
617
+ // Display the embedded login prompt modal
618
+ const embeddedResult = await displayEmbeddedLoginPrompt(authDomain, config.clientId);
619
+
620
+ // Build login options from the embedded prompt result
621
+ const loginOptions: StandaloneLoginOptions = {
622
+ directLoginOptions: {
623
+ directLoginMethod: embeddedResult.directLoginMethod,
624
+ marketingConsentStatus: embeddedResult.marketingConsentStatus,
625
+ ...(embeddedResult.directLoginMethod === 'email' && embeddedResult.email
626
+ ? { email: embeddedResult.email }
627
+ : {}),
628
+ } as DirectLoginOptions,
629
+ };
630
+
631
+ // Proceed with popup login using the selected method
632
+ return loginWithPopup(config, loginOptions);
633
+ }
634
+
635
+ /**
636
+ * Login with redirect.
637
+ * Redirects the current page to OAuth authentication.
638
+ * After authentication, the user will be redirected back to your redirectUri.
639
+ * Use `handleLoginCallback` to complete the flow.
640
+ *
641
+ * @param config - Login configuration
642
+ * @param options - Optional login options (direct login, etc.)
643
+ *
644
+ * @example
645
+ * ```typescript
646
+ * import { loginWithRedirect } from '@imtbl/auth';
647
+ *
648
+ * // In your login button handler
649
+ * loginWithRedirect({
650
+ * clientId: 'your-client-id',
651
+ * redirectUri: 'https://your-app.com/callback',
652
+ * });
653
+ * ```
654
+ */
655
+ export async function loginWithRedirect(
656
+ config: LoginConfig,
657
+ options?: StandaloneLoginOptions,
658
+ ): Promise<void> {
659
+ track('passport', 'standaloneLoginWithRedirect');
660
+
661
+ const { url, verifier, state } = await buildAuthorizationUrl(config, options);
662
+
663
+ // Store PKCE data for callback
664
+ savePKCEData({
665
+ state,
666
+ verifier,
667
+ redirectUri: config.redirectUri,
668
+ });
669
+
670
+ // Redirect to authorization URL
671
+ window.location.href = url;
672
+ }
673
+
674
+ /**
675
+ * Handle the OAuth callback after redirect-based login.
676
+ * Extracts the authorization code from the URL and exchanges it for tokens.
677
+ *
678
+ * @param config - Login configuration (must match what was used in loginWithRedirect)
679
+ * @returns Promise resolving to token response, or undefined if not a valid callback
680
+ *
681
+ * @example
682
+ * ```typescript
683
+ * // In your callback page
684
+ * import { handleLoginCallback } from '@imtbl/auth';
685
+ *
686
+ * const tokens = await handleLoginCallback({
687
+ * clientId: 'your-client-id',
688
+ * redirectUri: 'https://your-app.com/callback',
689
+ * });
690
+ *
691
+ * if (tokens) {
692
+ * // Login successful, tokens contains accessToken, refreshToken, etc.
693
+ * await signIn('immutable', { tokens: JSON.stringify(tokens) });
694
+ * }
695
+ * ```
696
+ */
697
+ export async function handleLoginCallback(
698
+ config: LoginConfig,
699
+ ): Promise<TokenResponse | undefined> {
700
+ track('passport', 'standaloneHandleCallback');
701
+
702
+ if (typeof window === 'undefined') {
703
+ return undefined;
704
+ }
705
+
706
+ const urlParams = new URLSearchParams(window.location.search);
707
+ const code = urlParams.get('code');
708
+ const returnedState = urlParams.get('state');
709
+ const error = urlParams.get('error');
710
+ const errorDescription = urlParams.get('error_description');
711
+
712
+ // Check for OAuth error
713
+ if (error) {
714
+ throw new Error(errorDescription || error);
715
+ }
716
+
717
+ // No code means this isn't a callback
718
+ if (!code) {
719
+ return undefined;
720
+ }
721
+
722
+ // Get stored PKCE data
723
+ const pkceData = getPKCEData();
724
+ if (!pkceData) {
725
+ throw new Error('No PKCE data found. Login may have been initiated in a different session.');
726
+ }
727
+
728
+ // Validate state
729
+ if (returnedState !== pkceData.state) {
730
+ clearPKCEData();
731
+ throw new Error('State mismatch - possible CSRF attack');
732
+ }
733
+
734
+ // Exchange code for tokens
735
+ const tokens = await exchangeCodeForTokens(
736
+ config,
737
+ code,
738
+ pkceData.verifier,
739
+ pkceData.redirectUri,
740
+ );
741
+
742
+ // Clear PKCE data after successful exchange
743
+ clearPKCEData();
744
+
745
+ return tokens;
746
+ }
747
+
748
+ // ============================================================================
749
+ // Logout Types
750
+ // ============================================================================
751
+
752
+ /**
753
+ * Configuration for standalone logout functions
754
+ */
755
+ export interface LogoutConfig {
756
+ /** Your Immutable application client ID */
757
+ clientId: string;
758
+ /** URL to redirect to after logout completes (must be registered in your app settings) */
759
+ logoutRedirectUri?: string;
760
+ /** Authentication domain (default: "https://auth.immutable.com") */
761
+ authenticationDomain?: string;
762
+ }
763
+
764
+ // ============================================================================
765
+ // Logout Functions
766
+ // ============================================================================
767
+
768
+ /**
769
+ * Build the logout URL for federated logout.
770
+ * This URL can be used to redirect to the auth domain's logout endpoint,
771
+ * which clears the session on the auth server (including social provider sessions).
772
+ *
773
+ * @param config - Logout configuration
774
+ * @returns The full logout URL
775
+ *
776
+ * @example
777
+ * ```typescript
778
+ * import { buildLogoutUrl } from '@imtbl/auth';
779
+ *
780
+ * const logoutUrl = buildLogoutUrl({
781
+ * clientId: 'your-client-id',
782
+ * logoutRedirectUri: 'https://your-app.com',
783
+ * });
784
+ * // => "https://auth.immutable.com/v2/logout?client_id=your-client-id&returnTo=https://your-app.com"
785
+ * ```
786
+ */
787
+ export function buildLogoutUrl(config: LogoutConfig): string {
788
+ // Use internal implementation (crossSdkBridgeEnabled defaults to false for public API)
789
+ return internalBuildLogoutUrl(config);
790
+ }
791
+
792
+ /**
793
+ * Logout with redirect.
794
+ * Redirects the current page to the auth domain's logout endpoint,
795
+ * which clears the session on the auth server (including social provider sessions like Google).
796
+ *
797
+ * This is the recommended logout method for most applications as it ensures
798
+ * complete session cleanup. After logout, the user will be redirected to
799
+ * the `logoutRedirectUri` if provided.
800
+ *
801
+ * @param config - Logout configuration
802
+ *
803
+ * @example
804
+ * ```typescript
805
+ * import { logoutWithRedirect } from '@imtbl/auth';
806
+ *
807
+ * // In your logout button handler
808
+ * logoutWithRedirect({
809
+ * clientId: 'your-client-id',
810
+ * logoutRedirectUri: 'https://your-app.com',
811
+ * });
812
+ * // Page will redirect to auth domain, then back to your app
813
+ * ```
814
+ */
815
+ export function logoutWithRedirect(config: LogoutConfig): void {
816
+ track('passport', 'standaloneLogoutWithRedirect');
817
+
818
+ const logoutUrl = buildLogoutUrl(config);
819
+ window.location.href = logoutUrl;
820
+ }
821
+
822
+ /**
823
+ * Logout silently using a hidden iframe.
824
+ * Clears the session on the auth server without redirecting the current page.
825
+ *
826
+ * Note: Silent logout may not work in all browsers due to third-party cookie
827
+ * restrictions. For more reliable session cleanup, use `logoutWithRedirect`.
828
+ *
829
+ * @param config - Logout configuration
830
+ * @param timeout - Timeout in milliseconds (default: 5000)
831
+ * @returns Promise that resolves when logout is complete or times out
832
+ *
833
+ * @example
834
+ * ```typescript
835
+ * import { logoutSilent } from '@imtbl/auth';
836
+ *
837
+ * try {
838
+ * await logoutSilent({
839
+ * clientId: 'your-client-id',
840
+ * });
841
+ * console.log('Logged out silently');
842
+ * } catch (error) {
843
+ * console.error('Silent logout failed:', error);
844
+ * // Fall back to redirect logout
845
+ * }
846
+ * ```
847
+ */
848
+ export async function logoutSilent(
849
+ config: LogoutConfig,
850
+ timeout: number = 5000,
851
+ ): Promise<void> {
852
+ track('passport', 'standaloneLogoutSilent');
853
+
854
+ return new Promise((resolve, reject) => {
855
+ const logoutUrl = buildLogoutUrl(config);
856
+
857
+ // Create hidden iframe
858
+ const iframe = document.createElement('iframe');
859
+ iframe.style.display = 'none';
860
+ iframe.setAttribute('aria-hidden', 'true');
861
+
862
+ let timeoutId: ReturnType<typeof setTimeout>;
863
+ let resolved = false;
864
+
865
+ const cleanup = () => {
866
+ if (timeoutId) {
867
+ clearTimeout(timeoutId);
868
+ }
869
+ iframe.remove();
870
+ };
871
+
872
+ const handleLoad = () => {
873
+ if (!resolved) {
874
+ resolved = true;
875
+ cleanup();
876
+ resolve();
877
+ }
878
+ };
879
+
880
+ const handleError = () => {
881
+ if (!resolved) {
882
+ resolved = true;
883
+ cleanup();
884
+ reject(new Error('Silent logout failed: iframe load error'));
885
+ }
886
+ };
887
+
888
+ iframe.addEventListener('load', handleLoad);
889
+ iframe.addEventListener('error', handleError);
890
+
891
+ // Set timeout
892
+ timeoutId = setTimeout(() => {
893
+ if (!resolved) {
894
+ resolved = true;
895
+ cleanup();
896
+ // Resolve instead of reject on timeout - the logout request was sent,
897
+ // we just can't confirm it completed due to cross-origin restrictions
898
+ resolve();
899
+ }
900
+ }, timeout);
901
+
902
+ // Start logout
903
+ iframe.src = logoutUrl;
904
+ document.body.appendChild(iframe);
905
+ });
906
+ }