@imtbl/auth 2.12.5-alpha.2 → 2.12.5-alpha.21

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