@imtbl/auth-next-client 2.12.7-alpha.1 → 2.12.7-alpha.11

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.
package/src/hooks.tsx CHANGED
@@ -1,6 +1,8 @@
1
1
  'use client';
2
2
 
3
- import { useCallback, useRef, useState } from 'react';
3
+ import {
4
+ useCallback, useEffect, useRef, useState,
5
+ } from 'react';
4
6
  import { useSession, signIn, signOut } from 'next-auth/react';
5
7
  import type { Session } from 'next-auth';
6
8
  import type {
@@ -16,12 +18,75 @@ import {
16
18
  loginWithRedirect as rawLoginWithRedirect,
17
19
  logoutWithRedirect as rawLogoutWithRedirect,
18
20
  } from '@imtbl/auth';
19
- import { IMMUTABLE_PROVIDER_ID } from './constants';
21
+ import { deriveDefaultRedirectUri } from './defaultConfig';
22
+ import {
23
+ IMMUTABLE_PROVIDER_ID,
24
+ TOKEN_EXPIRY_BUFFER_MS,
25
+ DEFAULT_SANDBOX_CLIENT_ID,
26
+ DEFAULT_LOGOUT_REDIRECT_URI_PATH,
27
+ DEFAULT_AUTH_DOMAIN,
28
+ DEFAULT_SCOPE,
29
+ DEFAULT_AUDIENCE,
30
+ } from './constants';
31
+ import { storeIdToken, getStoredIdToken, clearStoredIdToken } from './idTokenStorage';
32
+
33
+ // ---------------------------------------------------------------------------
34
+ // Module-level deduplication for session refresh
35
+ // ---------------------------------------------------------------------------
20
36
 
21
37
  /**
22
- * Extended session type with Immutable token data
38
+ * Deduplicates concurrent session refresh calls.
39
+ * Multiple components may mount useImmutableSession simultaneously; without
40
+ * deduplication each would trigger its own update() call, which could fail
41
+ * if the auth server rotates refresh tokens.
23
42
  */
24
- export interface ImmutableSession extends Session {
43
+ let pendingRefresh: Promise<Session | null | undefined> | null = null;
44
+
45
+ function deduplicatedUpdate(
46
+ update: () => Promise<Session | null | undefined>,
47
+ ): Promise<Session | null | undefined> {
48
+ if (!pendingRefresh) {
49
+ pendingRefresh = update().finally(() => { pendingRefresh = null; });
50
+ }
51
+ return pendingRefresh;
52
+ }
53
+
54
+ // ---------------------------------------------------------------------------
55
+ // Sandbox defaults for zero-config (no config or full config - no merge)
56
+ // ---------------------------------------------------------------------------
57
+
58
+ function getSandboxLoginConfig(): LoginConfig {
59
+ const redirectUri = deriveDefaultRedirectUri();
60
+ return {
61
+ clientId: DEFAULT_SANDBOX_CLIENT_ID,
62
+ redirectUri,
63
+ popupRedirectUri: redirectUri,
64
+ scope: DEFAULT_SCOPE,
65
+ audience: DEFAULT_AUDIENCE,
66
+ authenticationDomain: DEFAULT_AUTH_DOMAIN,
67
+ };
68
+ }
69
+
70
+ function getSandboxLogoutConfig(): LogoutConfig {
71
+ if (typeof window === 'undefined') {
72
+ throw new Error(
73
+ '[auth-next-client] getSandboxLogoutConfig requires window. '
74
+ + 'Logout runs in the browser when the user triggers it.',
75
+ );
76
+ }
77
+ const logoutRedirectUri = window.location.origin + DEFAULT_LOGOUT_REDIRECT_URI_PATH;
78
+ return {
79
+ clientId: DEFAULT_SANDBOX_CLIENT_ID,
80
+ logoutRedirectUri,
81
+ authenticationDomain: DEFAULT_AUTH_DOMAIN,
82
+ };
83
+ }
84
+
85
+ /**
86
+ * Internal session type with full token data (not exported).
87
+ * Used internally by the hook for token validation, refresh logic, and getUser/getAccessToken.
88
+ */
89
+ interface ImmutableSessionInternal extends Session {
25
90
  accessToken: string;
26
91
  refreshToken?: string;
27
92
  idToken?: string;
@@ -30,6 +95,15 @@ export interface ImmutableSession extends Session {
30
95
  error?: string;
31
96
  }
32
97
 
98
+ /**
99
+ * Public session type exposed to consumers.
100
+ *
101
+ * Does **not** include `accessToken` -- consumers must use the `getAccessToken()`
102
+ * function returned by `useImmutableSession()` to obtain a guaranteed-fresh token.
103
+ * This prevents accidental use of stale/expired tokens.
104
+ */
105
+ export type ImmutableSession = Omit<ImmutableSessionInternal, 'accessToken'>;
106
+
33
107
  /**
34
108
  * Return type for useImmutableSession hook
35
109
  */
@@ -53,6 +127,13 @@ export interface UseImmutableSessionReturn {
53
127
  * The refreshed session will include updated zkEvm data if available.
54
128
  */
55
129
  getUser: (forceRefresh?: boolean) => Promise<User | null>;
130
+ /**
131
+ * Get a guaranteed-fresh access token.
132
+ * Returns immediately if the current token is valid.
133
+ * If expired, triggers a refresh and blocks (awaits) until the fresh token is available.
134
+ * Throws if the user is not authenticated or if refresh fails.
135
+ */
136
+ getAccessToken: () => Promise<string>;
56
137
  }
57
138
 
58
139
  /**
@@ -92,8 +173,8 @@ export function useImmutableSession(): UseImmutableSessionReturn {
92
173
  // Track when a manual refresh is in progress (via getUser(true))
93
174
  const [isRefreshing, setIsRefreshing] = useState(false);
94
175
 
95
- // Cast session to our extended type
96
- const session = sessionData as ImmutableSession | null;
176
+ // Cast session to our internal type (includes accessToken for internal logic)
177
+ const session = sessionData as ImmutableSessionInternal | null;
97
178
 
98
179
  const isLoading = status === 'loading';
99
180
 
@@ -114,7 +195,7 @@ export function useImmutableSession(): UseImmutableSessionReturn {
114
195
  // Use a ref to always have access to the latest session.
115
196
  // This avoids stale closure issues when the wallet stores the getUser function
116
197
  // and calls it later - the ref always points to the current session.
117
- const sessionRef = useRef<ImmutableSession | null>(session);
198
+ const sessionRef = useRef<ImmutableSessionInternal | null>(session);
118
199
  sessionRef.current = session;
119
200
 
120
201
  // Also store update in a ref so the callback is stable
@@ -125,6 +206,44 @@ export function useImmutableSession(): UseImmutableSessionReturn {
125
206
  const setIsRefreshingRef = useRef(setIsRefreshing);
126
207
  setIsRefreshingRef.current = setIsRefreshing;
127
208
 
209
+ // ---------------------------------------------------------------------------
210
+ // Proactive token refresh
211
+ // ---------------------------------------------------------------------------
212
+
213
+ // Reactive refresh: when the effect runs and the token is already expired
214
+ // (e.g., after tab regains focus), trigger an immediate silent refresh.
215
+ // For tokens that are still valid, getAccessToken() handles refresh on demand.
216
+ //
217
+ // NOTE: This intentionally does NOT set isRefreshing. isRefreshing is reserved
218
+ // for explicit user-triggered refreshes (e.g., getUser(true) after wallet
219
+ // registration). Background token refreshes must be invisible to consumers --
220
+ // setting isRefreshing would cause downstream hooks that gate SWR keys on
221
+ // `!isRefreshing` to briefly lose their cached data, resulting in UI flicker.
222
+ useEffect(() => {
223
+ if (!session?.accessTokenExpires) return;
224
+
225
+ const timeUntilExpiry = session.accessTokenExpires - Date.now() - TOKEN_EXPIRY_BUFFER_MS;
226
+
227
+ if (timeUntilExpiry <= 0) {
228
+ // Already expired -- refresh silently
229
+ deduplicatedUpdate(() => updateRef.current());
230
+ }
231
+ }, [session?.accessTokenExpires]);
232
+
233
+ // ---------------------------------------------------------------------------
234
+ // Sync idToken to localStorage
235
+ // ---------------------------------------------------------------------------
236
+
237
+ // The idToken is stripped from the cookie by jwt.encode on the server to avoid
238
+ // CloudFront 413 errors. It is only present in the session response transiently
239
+ // after sign-in or token refresh. When present, persist it in localStorage so
240
+ // that getUser() can always return it (used by wallet's MagicTEESigner).
241
+ useEffect(() => {
242
+ if (session?.idToken) {
243
+ storeIdToken(session.idToken);
244
+ }
245
+ }, [session?.idToken]);
246
+
128
247
  /**
129
248
  * Get user function for wallet integration.
130
249
  * Returns a User object compatible with @imtbl/wallet's getUser option.
@@ -135,7 +254,7 @@ export function useImmutableSession(): UseImmutableSessionReturn {
135
254
  * @param forceRefresh - When true, triggers a server-side token refresh
136
255
  */
137
256
  const getUser = useCallback(async (forceRefresh?: boolean): Promise<User | null> => {
138
- let currentSession: ImmutableSession | null;
257
+ let currentSession: ImmutableSessionInternal | null;
139
258
 
140
259
  // If forceRefresh is requested, trigger server-side refresh via NextAuth
141
260
  // This calls the jwt callback with trigger='update' and sessionUpdate.forceRefresh=true
@@ -145,10 +264,14 @@ export function useImmutableSession(): UseImmutableSessionReturn {
145
264
  try {
146
265
  // update() returns the refreshed session
147
266
  const updatedSession = await updateRef.current({ forceRefresh: true });
148
- currentSession = updatedSession as ImmutableSession | null;
267
+ currentSession = updatedSession as ImmutableSessionInternal | null;
149
268
  // Also update the ref so subsequent calls get the fresh data
150
269
  if (currentSession) {
151
270
  sessionRef.current = currentSession;
271
+ // Immediately persist fresh idToken to localStorage (avoids race with useEffect)
272
+ if (currentSession.idToken) {
273
+ storeIdToken(currentSession.idToken);
274
+ }
152
275
  }
153
276
  } catch (error) {
154
277
  // eslint-disable-next-line no-console
@@ -158,6 +281,20 @@ export function useImmutableSession(): UseImmutableSessionReturn {
158
281
  } finally {
159
282
  setIsRefreshingRef.current(false);
160
283
  }
284
+ } else if (pendingRefresh) {
285
+ // If a refresh is in-flight (proactive timer or another getAccessToken call),
286
+ // wait for it and use the refreshed session rather than returning a stale token.
287
+ const refreshed = await pendingRefresh;
288
+ if (refreshed) {
289
+ currentSession = refreshed as ImmutableSessionInternal;
290
+ sessionRef.current = currentSession;
291
+ // Persist fresh idToken to localStorage immediately
292
+ if (currentSession.idToken) {
293
+ storeIdToken(currentSession.idToken);
294
+ }
295
+ } else {
296
+ currentSession = sessionRef.current;
297
+ }
161
298
  } else {
162
299
  // Read from ref - instant, no network call
163
300
  // The ref is always updated on each render with the latest session
@@ -178,7 +315,9 @@ export function useImmutableSession(): UseImmutableSessionReturn {
178
315
  return {
179
316
  accessToken: currentSession.accessToken,
180
317
  refreshToken: currentSession.refreshToken,
181
- idToken: currentSession.idToken,
318
+ // Prefer session idToken (fresh after sign-in or refresh, before useEffect
319
+ // stores it), fall back to localStorage for normal reads (cookie has no idToken).
320
+ idToken: currentSession.idToken || getStoredIdToken(),
182
321
  profile: {
183
322
  sub: currentSession.user?.sub ?? '',
184
323
  email: currentSession.user?.email ?? undefined,
@@ -188,26 +327,71 @@ export function useImmutableSession(): UseImmutableSessionReturn {
188
327
  };
189
328
  }, []); // Empty deps - uses refs for latest values
190
329
 
330
+ /**
331
+ * Get a guaranteed-fresh access token.
332
+ * Returns immediately if the current token is valid (fast path, no network call).
333
+ * If expired, triggers a server-side refresh and blocks (awaits) until the fresh
334
+ * token is available. Piggybacks on any in-flight refresh to avoid duplicate calls.
335
+ *
336
+ * @throws Error if the user is not authenticated or if the refresh fails.
337
+ */
338
+ const getAccessToken = useCallback(async (): Promise<string> => {
339
+ const currentSession = sessionRef.current;
340
+
341
+ // Fast path: token is valid -- return immediately
342
+ if (
343
+ currentSession?.accessToken
344
+ && currentSession.accessTokenExpires
345
+ && Date.now() < currentSession.accessTokenExpires - TOKEN_EXPIRY_BUFFER_MS
346
+ && !currentSession.error
347
+ ) {
348
+ return currentSession.accessToken;
349
+ }
350
+
351
+ // Token is expired or missing -- wait for in-flight refresh or trigger one
352
+ const refreshed = await deduplicatedUpdate(
353
+ () => updateRef.current(),
354
+ ) as ImmutableSessionInternal | null;
355
+
356
+ if (!refreshed?.accessToken || refreshed.error) {
357
+ throw new Error(
358
+ `[auth-next-client] Failed to get access token: ${refreshed?.error || 'no session'}`,
359
+ );
360
+ }
361
+
362
+ // Update ref so subsequent sync reads get the fresh data
363
+ sessionRef.current = refreshed;
364
+ return refreshed.accessToken;
365
+ }, []); // Empty deps -- uses refs for latest values
366
+
367
+ // Cast to public type (omits accessToken) to prevent consumers from
368
+ // accidentally using a potentially stale token. Use getAccessToken() instead.
369
+ const publicSession = session as ImmutableSession | null;
370
+
191
371
  return {
192
- session,
372
+ session: publicSession,
193
373
  status,
194
374
  isLoading,
195
375
  isAuthenticated,
196
376
  isRefreshing,
197
377
  getUser,
378
+ getAccessToken,
198
379
  };
199
380
  }
200
381
 
201
382
  /**
202
383
  * Return type for useLogin hook
384
+ *
385
+ * Config is optional - when omitted, defaults are auto-derived (clientId, redirectUri, etc.).
386
+ * When provided, must be a complete LoginConfig.
203
387
  */
204
388
  export interface UseLoginReturn {
205
389
  /** Start login with popup flow */
206
- loginWithPopup: (config: LoginConfig, options?: StandaloneLoginOptions) => Promise<void>;
390
+ loginWithPopup: (config?: LoginConfig, options?: StandaloneLoginOptions) => Promise<void>;
207
391
  /** Start login with embedded modal flow */
208
- loginWithEmbedded: (config: LoginConfig) => Promise<void>;
392
+ loginWithEmbedded: (config?: LoginConfig) => Promise<void>;
209
393
  /** Start login with redirect flow (navigates away from page) */
210
- loginWithRedirect: (config: LoginConfig, options?: StandaloneLoginOptions) => Promise<void>;
394
+ loginWithRedirect: (config?: LoginConfig, options?: StandaloneLoginOptions) => Promise<void>;
211
395
  /** Whether login is currently in progress */
212
396
  isLoggingIn: boolean;
213
397
  /** Error message from the last login attempt, or null if none */
@@ -215,39 +399,70 @@ export interface UseLoginReturn {
215
399
  }
216
400
 
217
401
  /**
218
- * Hook to handle Immutable authentication login flows.
402
+ * Hook to handle Immutable authentication login flows with automatic defaults.
219
403
  *
220
404
  * Provides login functions that:
221
405
  * 1. Handle OAuth authentication via popup, embedded modal, or redirect
222
406
  * 2. Automatically sign in to NextAuth after successful authentication
223
407
  * 3. Track loading and error states
408
+ * 4. Auto-detect clientId and redirectUri if not provided (uses defaults)
224
409
  *
225
- * Config is passed at call time to allow different configurations for different
226
- * login methods (e.g., different redirectUri vs popupRedirectUri).
410
+ * Config can be passed at call time or omitted to use sensible defaults:
411
+ * - `clientId`: Auto-detected based on environment (sandbox vs production)
412
+ * - `redirectUri`: Auto-derived from `window.location.origin + '/callback'`
413
+ * - `popupRedirectUri`: Auto-derived from `window.location.origin + '/callback'` (same as redirectUri)
414
+ * - `logoutRedirectUri`: Auto-derived from `window.location.origin`
415
+ * - `scope`: `'openid profile email offline_access transact'`
416
+ * - `audience`: `'platform_api'`
417
+ * - `authenticationDomain`: `'https://auth.immutable.com'`
227
418
  *
228
419
  * Must be used within a SessionProvider from next-auth/react.
229
420
  *
230
- * @example
421
+ * @example Minimal usage (uses all defaults)
231
422
  * ```tsx
232
423
  * import { useLogin, useImmutableSession } from '@imtbl/auth-next-client';
233
424
  *
234
- * const config = {
235
- * clientId: process.env.NEXT_PUBLIC_IMMUTABLE_CLIENT_ID!,
236
- * redirectUri: `${process.env.NEXT_PUBLIC_BASE_URL}/callback`,
237
- * popupRedirectUri: `${process.env.NEXT_PUBLIC_BASE_URL}/callback/popup`,
238
- * };
425
+ * function LoginButton() {
426
+ * const { isAuthenticated } = useImmutableSession();
427
+ * const { loginWithPopup, isLoggingIn, error } = useLogin();
428
+ *
429
+ * if (isAuthenticated) {
430
+ * return <p>You are logged in!</p>;
431
+ * }
432
+ *
433
+ * return (
434
+ * <>
435
+ * <button onClick={() => loginWithPopup()} disabled={isLoggingIn}>
436
+ * {isLoggingIn ? 'Signing in...' : 'Sign In'}
437
+ * </button>
438
+ * {error && <p style={{ color: 'red' }}>{error}</p>}
439
+ * </>
440
+ * );
441
+ * }
442
+ * ```
443
+ *
444
+ * @example With custom configuration
445
+ * ```tsx
446
+ * import { useLogin, useImmutableSession } from '@imtbl/auth-next-client';
239
447
  *
240
448
  * function LoginButton() {
241
449
  * const { isAuthenticated } = useImmutableSession();
242
450
  * const { loginWithPopup, isLoggingIn, error } = useLogin();
243
451
  *
452
+ * const handleLogin = () => {
453
+ * loginWithPopup({
454
+ * clientId: process.env.NEXT_PUBLIC_IMMUTABLE_CLIENT_ID,
455
+ * redirectUri: `${window.location.origin}/callback`,
456
+ * });
457
+ * };
458
+ *
244
459
  * if (isAuthenticated) {
245
460
  * return <p>You are logged in!</p>;
246
461
  * }
247
462
  *
248
463
  * return (
249
464
  * <>
250
- * <button onClick={() => loginWithPopup(config)} disabled={isLoggingIn}>
465
+ * <button onClick={handleLogin} disabled={isLoggingIn}>
251
466
  * {isLoggingIn ? 'Signing in...' : 'Sign In'}
252
467
  * </button>
253
468
  * {error && <p style={{ color: 'red' }}>{error}</p>}
@@ -271,6 +486,12 @@ export function useLogin(): UseLoginReturn {
271
486
  profile: { sub: string; email?: string; nickname?: string };
272
487
  zkEvm?: ZkEvmInfo;
273
488
  }) => {
489
+ // Persist idToken to localStorage before signIn so it's available immediately.
490
+ // The cookie won't contain idToken (stripped by jwt.encode on the server).
491
+ if (tokens.idToken) {
492
+ storeIdToken(tokens.idToken);
493
+ }
494
+
274
495
  const result = await signIn(IMMUTABLE_PROVIDER_ID, {
275
496
  tokens: JSON.stringify(tokens),
276
497
  redirect: false,
@@ -287,16 +508,18 @@ export function useLogin(): UseLoginReturn {
287
508
  /**
288
509
  * Login with a popup window.
289
510
  * Opens a popup for OAuth authentication, then signs in to NextAuth.
511
+ * Config is optional - defaults will be auto-derived if not provided.
290
512
  */
291
513
  const loginWithPopup = useCallback(async (
292
- config: LoginConfig,
514
+ config?: LoginConfig,
293
515
  options?: StandaloneLoginOptions,
294
516
  ): Promise<void> => {
295
517
  setIsLoggingIn(true);
296
518
  setError(null);
297
519
 
298
520
  try {
299
- const tokens = await rawLoginWithPopup(config, options);
521
+ const fullConfig = config ?? getSandboxLoginConfig();
522
+ const tokens = await rawLoginWithPopup(fullConfig, options);
300
523
  await signInWithTokens(tokens);
301
524
  } catch (err) {
302
525
  const errorMessage = err instanceof Error ? err.message : 'Login failed';
@@ -310,13 +533,15 @@ export function useLogin(): UseLoginReturn {
310
533
  /**
311
534
  * Login with an embedded modal.
312
535
  * Shows a modal for login method selection, then opens a popup for OAuth.
536
+ * Config is optional - defaults will be auto-derived if not provided.
313
537
  */
314
- const loginWithEmbedded = useCallback(async (config: LoginConfig): Promise<void> => {
538
+ const loginWithEmbedded = useCallback(async (config?: LoginConfig): Promise<void> => {
315
539
  setIsLoggingIn(true);
316
540
  setError(null);
317
541
 
318
542
  try {
319
- const tokens = await rawLoginWithEmbedded(config);
543
+ const fullConfig = config ?? getSandboxLoginConfig();
544
+ const tokens = await rawLoginWithEmbedded(fullConfig);
320
545
  await signInWithTokens(tokens);
321
546
  } catch (err) {
322
547
  const errorMessage = err instanceof Error ? err.message : 'Login failed';
@@ -332,16 +557,18 @@ export function useLogin(): UseLoginReturn {
332
557
  * Redirects the page to OAuth authentication.
333
558
  * After authentication, the user will be redirected to your callback page.
334
559
  * Use the CallbackPage component to complete the flow.
560
+ * Config is optional - defaults will be auto-derived if not provided.
335
561
  */
336
562
  const loginWithRedirect = useCallback(async (
337
- config: LoginConfig,
563
+ config?: LoginConfig,
338
564
  options?: StandaloneLoginOptions,
339
565
  ): Promise<void> => {
340
566
  setIsLoggingIn(true);
341
567
  setError(null);
342
568
 
343
569
  try {
344
- await rawLoginWithRedirect(config, options);
570
+ const fullConfig = config ?? getSandboxLoginConfig();
571
+ await rawLoginWithRedirect(fullConfig, options);
345
572
  // Note: The page will redirect, so this code may not run
346
573
  } catch (err) {
347
574
  const errorMessage = err instanceof Error ? err.message : 'Login failed';
@@ -371,9 +598,11 @@ export interface UseLogoutReturn {
371
598
  * This ensures that when the user logs in again, they will be prompted to select
372
599
  * an account instead of being automatically logged in with the previous account.
373
600
  *
374
- * @param config - Logout configuration with clientId and optional redirectUri
601
+ * Config is optional - defaults will be auto-derived if not provided.
602
+ *
603
+ * @param config - Optional logout configuration with clientId and optional redirectUri
375
604
  */
376
- logout: (config: LogoutConfig) => Promise<void>;
605
+ logout: (config?: LogoutConfig) => Promise<void>;
377
606
  /** Whether logout is currently in progress */
378
607
  isLoggingOut: boolean;
379
608
  /** Error message from the last logout attempt, or null if none */
@@ -391,16 +620,38 @@ export interface UseLogoutReturn {
391
620
  * an account (for social logins like Google) instead of being automatically logged
392
621
  * in with the previous account.
393
622
  *
623
+ * Config is optional - defaults will be auto-derived if not provided:
624
+ * - `clientId`: Auto-detected based on environment (sandbox vs production)
625
+ * - `logoutRedirectUri`: Auto-derived from `window.location.origin`
626
+ *
394
627
  * Must be used within a SessionProvider from next-auth/react.
395
628
  *
396
- * @example
629
+ * @example Minimal usage (uses all defaults)
397
630
  * ```tsx
398
631
  * import { useLogout, useImmutableSession } from '@imtbl/auth-next-client';
399
632
  *
400
- * const logoutConfig = {
401
- * clientId: process.env.NEXT_PUBLIC_IMMUTABLE_CLIENT_ID!,
402
- * logoutRedirectUri: process.env.NEXT_PUBLIC_BASE_URL!,
403
- * };
633
+ * function LogoutButton() {
634
+ * const { isAuthenticated } = useImmutableSession();
635
+ * const { logout, isLoggingOut, error } = useLogout();
636
+ *
637
+ * if (!isAuthenticated) {
638
+ * return null;
639
+ * }
640
+ *
641
+ * return (
642
+ * <>
643
+ * <button onClick={() => logout()} disabled={isLoggingOut}>
644
+ * {isLoggingOut ? 'Signing out...' : 'Sign Out'}
645
+ * </button>
646
+ * {error && <p style={{ color: 'red' }}>{error}</p>}
647
+ * </>
648
+ * );
649
+ * }
650
+ * ```
651
+ *
652
+ * @example With custom configuration
653
+ * ```tsx
654
+ * import { useLogout, useImmutableSession } from '@imtbl/auth-next-client';
404
655
  *
405
656
  * function LogoutButton() {
406
657
  * const { isAuthenticated } = useImmutableSession();
@@ -412,7 +663,13 @@ export interface UseLogoutReturn {
412
663
  *
413
664
  * return (
414
665
  * <>
415
- * <button onClick={() => logout(logoutConfig)} disabled={isLoggingOut}>
666
+ * <button
667
+ * onClick={() => logout({
668
+ * clientId: process.env.NEXT_PUBLIC_IMMUTABLE_CLIENT_ID,
669
+ * logoutRedirectUri: `${window.location.origin}/custom-logout`,
670
+ * })}
671
+ * disabled={isLoggingOut}
672
+ * >
416
673
  * {isLoggingOut ? 'Signing out...' : 'Sign Out'}
417
674
  * </button>
418
675
  * {error && <p style={{ color: 'red' }}>{error}</p>}
@@ -428,20 +685,27 @@ export function useLogout(): UseLogoutReturn {
428
685
  /**
429
686
  * Logout with federated logout.
430
687
  * First clears the NextAuth session, then redirects to the auth domain's logout endpoint.
688
+ * Config is optional - defaults will be auto-derived if not provided.
431
689
  */
432
- const logout = useCallback(async (config: LogoutConfig): Promise<void> => {
690
+ const logout = useCallback(async (config?: LogoutConfig): Promise<void> => {
433
691
  setIsLoggingOut(true);
434
692
  setError(null);
435
693
 
436
694
  try {
695
+ // Clear idToken from localStorage before clearing session
696
+ clearStoredIdToken();
697
+
437
698
  // First, clear the NextAuth session (this clears the JWT cookie)
438
699
  // We use redirect: false to handle the redirect ourselves for federated logout
439
700
  await signOut({ redirect: false });
440
701
 
702
+ // Create full config with defaults
703
+ const fullConfig = config ?? getSandboxLogoutConfig();
704
+
441
705
  // Redirect to the auth domain's logout endpoint using the standalone function
442
706
  // This clears the upstream session (Auth0/Immutable) so that on next login,
443
707
  // the user will be prompted to select an account instead of auto-logging in
444
- rawLogoutWithRedirect(config);
708
+ rawLogoutWithRedirect(fullConfig);
445
709
  } catch (err) {
446
710
  const errorMessage = err instanceof Error ? err.message : 'Logout failed';
447
711
  setError(errorMessage);
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Utility for persisting idToken in localStorage.
3
+ *
4
+ * The idToken is stripped from the NextAuth session cookie (via a custom
5
+ * jwt.encode in @imtbl/auth-next-server) to keep cookie size under CDN header
6
+ * limits (CloudFront 20 KB). Instead, the client stores idToken in
7
+ * localStorage so that wallet operations (e.g., MagicTEESigner) can still
8
+ * access it via getUser().
9
+ *
10
+ * All functions are safe to call during SSR or in restricted environments
11
+ * (e.g., incognito mode with localStorage disabled) -- they silently no-op.
12
+ */
13
+
14
+ const ID_TOKEN_STORAGE_KEY = 'imtbl_id_token';
15
+
16
+ /**
17
+ * Store the idToken in localStorage.
18
+ * @param idToken - The raw ID token JWT string
19
+ */
20
+ export function storeIdToken(idToken: string): void {
21
+ try {
22
+ if (typeof window !== 'undefined' && window.localStorage) {
23
+ window.localStorage.setItem(ID_TOKEN_STORAGE_KEY, idToken);
24
+ }
25
+ } catch {
26
+ // Silently ignore -- localStorage may be unavailable (SSR, incognito, etc.)
27
+ }
28
+ }
29
+
30
+ /**
31
+ * Retrieve the idToken from localStorage.
32
+ * @returns The stored idToken, or undefined if not available.
33
+ */
34
+ export function getStoredIdToken(): string | undefined {
35
+ try {
36
+ if (typeof window !== 'undefined' && window.localStorage) {
37
+ return window.localStorage.getItem(ID_TOKEN_STORAGE_KEY) ?? undefined;
38
+ }
39
+ } catch {
40
+ // Silently ignore
41
+ }
42
+ return undefined;
43
+ }
44
+
45
+ /**
46
+ * Remove the idToken from localStorage (e.g., on logout).
47
+ */
48
+ export function clearStoredIdToken(): void {
49
+ try {
50
+ if (typeof window !== 'undefined' && window.localStorage) {
51
+ window.localStorage.removeItem(ID_TOKEN_STORAGE_KEY);
52
+ }
53
+ } catch {
54
+ // Silently ignore
55
+ }
56
+ }
package/src/index.ts CHANGED
@@ -59,3 +59,15 @@ export type {
59
59
  LogoutConfig,
60
60
  } from '@imtbl/auth';
61
61
  export { MarketingConsentStatus } from '@imtbl/auth';
62
+
63
+ // Re-export constants and default config helpers for consumer convenience
64
+ export {
65
+ DEFAULT_AUTH_DOMAIN,
66
+ DEFAULT_AUDIENCE,
67
+ DEFAULT_SCOPE,
68
+ IMMUTABLE_PROVIDER_ID,
69
+ DEFAULT_SANDBOX_CLIENT_ID,
70
+ DEFAULT_REDIRECT_URI_PATH,
71
+ DEFAULT_LOGOUT_REDIRECT_URI_PATH,
72
+ } from './constants';
73
+ export { deriveDefaultRedirectUri } from './defaultConfig';