@imtbl/auth 2.12.5 → 2.12.6-alpha.0

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