-
Notifications
You must be signed in to change notification settings - Fork 38
Expand file tree
/
Copy pathhooks.tsx
More file actions
736 lines (679 loc) · 25.9 KB
/
hooks.tsx
File metadata and controls
736 lines (679 loc) · 25.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
'use client';
import {
useCallback, useEffect, useRef, useState,
} from 'react';
import { useSession, signIn, signOut } from 'next-auth/react';
import type { Session } from 'next-auth';
import type {
User,
LoginConfig,
StandaloneLoginOptions,
LogoutConfig,
} from '@imtbl/auth';
import type { ZkEvmInfo } from './types';
import {
loginWithPopup as rawLoginWithPopup,
loginWithEmbedded as rawLoginWithEmbedded,
loginWithRedirect as rawLoginWithRedirect,
logoutWithRedirect as rawLogoutWithRedirect,
} from '@imtbl/auth';
import { deriveDefaultRedirectUri } from './defaultConfig';
import {
IMMUTABLE_PROVIDER_ID,
TOKEN_EXPIRY_BUFFER_MS,
DEFAULT_SANDBOX_CLIENT_ID,
DEFAULT_LOGOUT_REDIRECT_URI_PATH,
DEFAULT_AUTH_DOMAIN,
DEFAULT_SCOPE,
DEFAULT_AUDIENCE,
} from './constants';
import { storeIdToken, getStoredIdToken, clearStoredIdToken } from './idTokenStorage';
import { useStableValue } from './useStableValue';
// ---------------------------------------------------------------------------
// Module-level deduplication for session refresh
// ---------------------------------------------------------------------------
/**
* Deduplicates concurrent session refresh calls.
* Multiple components may mount useImmutableSession simultaneously; without
* deduplication each would trigger its own update() call, which could fail
* if the auth server rotates refresh tokens.
*/
let pendingRefresh: Promise<Session | null | undefined> | null = null;
function deduplicatedUpdate(
update: () => Promise<Session | null | undefined>,
): Promise<Session | null | undefined> {
if (!pendingRefresh) {
pendingRefresh = update().finally(() => { pendingRefresh = null; });
}
return pendingRefresh;
}
// ---------------------------------------------------------------------------
// Sandbox defaults for zero-config (no config or full config - no merge)
// ---------------------------------------------------------------------------
function getSandboxLoginConfig(): LoginConfig {
const redirectUri = deriveDefaultRedirectUri();
return {
clientId: DEFAULT_SANDBOX_CLIENT_ID,
redirectUri,
popupRedirectUri: redirectUri,
scope: DEFAULT_SCOPE,
audience: DEFAULT_AUDIENCE,
authenticationDomain: DEFAULT_AUTH_DOMAIN,
};
}
function getSandboxLogoutConfig(): LogoutConfig {
if (typeof window === 'undefined') {
throw new Error(
'[auth-next-client] getSandboxLogoutConfig requires window. '
+ 'Logout runs in the browser when the user triggers it.',
);
}
const logoutRedirectUri = window.location.origin + DEFAULT_LOGOUT_REDIRECT_URI_PATH;
return {
clientId: DEFAULT_SANDBOX_CLIENT_ID,
logoutRedirectUri,
authenticationDomain: DEFAULT_AUTH_DOMAIN,
};
}
/**
* Internal session type with full token data (not exported).
* Used internally by the hook for token validation, refresh logic, and getUser/getAccessToken.
*/
interface ImmutableSessionInternal extends Session {
accessToken: string;
refreshToken?: string;
idToken?: string;
accessTokenExpires: number;
zkEvm?: ZkEvmInfo;
error?: string;
}
/**
* Public session type exposed to consumers.
*
* Does **not** include `accessToken` -- consumers must use the `getAccessToken()`
* function returned by `useImmutableSession()` to obtain a guaranteed-fresh token.
* This prevents accidental use of stale/expired tokens.
*/
export type ImmutableSession = Omit<ImmutableSessionInternal, 'accessToken'>;
/**
* Return type for useImmutableSession hook
*/
export interface UseImmutableSessionReturn {
/** The session data with tokens, or null if not authenticated */
session: ImmutableSession | null;
/** Authentication status: 'loading' | 'authenticated' | 'unauthenticated' */
status: 'loading' | 'authenticated' | 'unauthenticated';
/** Whether the session is currently loading */
isLoading: boolean;
/** Whether the user is authenticated */
isAuthenticated: boolean;
/** Whether a session refresh is in progress (e.g., after wallet registration) */
isRefreshing: boolean;
/**
* Get user function for wallet integration.
* Returns a User object compatible with @imtbl/wallet's getUser option.
*
* @param forceRefresh - When true, triggers a server-side token refresh to get
* updated claims from the identity provider (e.g., after zkEVM registration).
* The refreshed session will include updated zkEvm data if available.
*/
getUser: (forceRefresh?: boolean) => Promise<User | null>;
/**
* Get a guaranteed-fresh access token.
* Returns immediately if the current token is valid.
* If expired, triggers a refresh and blocks (awaits) until the fresh token is available.
* Throws if the user is not authenticated or if refresh fails.
*/
getAccessToken: () => Promise<string>;
}
/**
* Hook to access Immutable session with a getUser function for wallet integration.
*
* This is a convenience wrapper around next-auth/react's useSession that:
* 1. Provides typed access to Immutable token data in the session
* 2. Provides a `getUser` function compatible with @imtbl/wallet's getUser option
*
* Must be used within a SessionProvider from next-auth/react.
*
* @example
* ```tsx
* import { useImmutableSession } from '@imtbl/auth-next-client';
* import { connectWallet } from '@imtbl/wallet';
*
* function WalletComponent() {
* const { session, isAuthenticated, getUser } = useImmutableSession();
*
* const connect = async () => {
* const provider = await connectWallet({
* getUser, // Pass directly to wallet
* });
* };
*
* if (!isAuthenticated) {
* return <p>Please log in</p>;
* }
*
* return <button onClick={connect}>Connect Wallet</button>;
* }
* ```
*/
export function useImmutableSession(): UseImmutableSessionReturn {
const { data: sessionData, status, update } = useSession();
// Track when a manual refresh is in progress (via getUser(true))
const [isRefreshing, setIsRefreshing] = useState(false);
// Cast session to our internal type (includes accessToken for internal logic)
const session = sessionData as ImmutableSessionInternal | null;
const isLoading = status === 'loading';
// Core authentication check - user has a valid session with usable access token.
// A session can exist but be unusable if the access token is missing or refresh failed.
const hasValidSession = status === 'authenticated'
&& !!session
&& !!session.accessToken
&& !session.error;
// During loading/refreshing, keep showing authenticated if we had a valid session (avoids UI flicker
// when NextAuth refetches on window focus or after getUser(forceRefresh)).
const hadSessionRef = useRef(false);
if (hasValidSession) hadSessionRef.current = true;
if (!hasValidSession && !isLoading && !isRefreshing) hadSessionRef.current = false;
const isAuthenticated = hasValidSession || ((isLoading || isRefreshing) && hadSessionRef.current);
// Use a ref to always have access to the latest session.
// This avoids stale closure issues when the wallet stores the getUser function
// and calls it later - the ref always points to the current session.
const sessionRef = useRef<ImmutableSessionInternal | null>(session);
sessionRef.current = session;
// Also store update in a ref so the callback is stable
const updateRef = useRef(update);
updateRef.current = update;
// Store setIsRefreshing in a ref for stable callback
const setIsRefreshingRef = useRef(setIsRefreshing);
setIsRefreshingRef.current = setIsRefreshing;
// ---------------------------------------------------------------------------
// Proactive token refresh
// ---------------------------------------------------------------------------
// Reactive refresh: when the effect runs and the token is already expired
// (e.g., after tab regains focus), trigger an immediate silent refresh.
// For tokens that are still valid, getAccessToken() handles refresh on demand.
//
// NOTE: This intentionally does NOT set isRefreshing. isRefreshing is reserved
// for explicit user-triggered refreshes (e.g., getUser(true) after wallet
// registration). Background token refreshes must be invisible to consumers --
// setting isRefreshing would cause downstream hooks that gate SWR keys on
// `!isRefreshing` to briefly lose their cached data, resulting in UI flicker.
useEffect(() => {
if (!session?.accessTokenExpires) return;
// Don't retry if the last refresh already failed - prevents infinite loops
if (session?.error) return;
const timeUntilExpiry = session.accessTokenExpires - Date.now() - TOKEN_EXPIRY_BUFFER_MS;
if (timeUntilExpiry <= 0) {
// Already expired -- refresh silently
deduplicatedUpdate(() => updateRef.current());
}
}, [session?.accessTokenExpires, session?.error]);
// ---------------------------------------------------------------------------
// Sync idToken to localStorage
// ---------------------------------------------------------------------------
// The idToken is stripped from the cookie by jwt.encode on the server to avoid
// CloudFront 413 errors. It is only present in the session response transiently
// after sign-in or token refresh. When present, persist it in localStorage so
// that getUser() can always return it (used by wallet's MagicTEESigner).
useEffect(() => {
if (session?.idToken) {
storeIdToken(session.idToken);
}
}, [session?.idToken]);
/**
* Get user function for wallet integration.
* Returns a User object compatible with @imtbl/wallet's getUser option.
*
* Uses a ref to access the latest session instantly without network calls.
* When forceRefresh is true, triggers a server-side token refresh.
*
* @param forceRefresh - When true, triggers a server-side token refresh
*/
const getUser = useCallback(async (forceRefresh?: boolean): Promise<User | null> => {
let currentSession: ImmutableSessionInternal | null;
// If forceRefresh is requested, trigger server-side refresh via NextAuth
// This calls the jwt callback with trigger='update' and sessionUpdate.forceRefresh=true
if (forceRefresh) {
// Set refreshing state to prevent isAuthenticated from going false
setIsRefreshingRef.current(true);
try {
// update() returns the refreshed session
const updatedSession = await updateRef.current({ forceRefresh: true });
currentSession = updatedSession as ImmutableSessionInternal | null;
// Also update the ref so subsequent calls get the fresh data
if (currentSession) {
sessionRef.current = currentSession;
// Immediately persist fresh idToken to localStorage (avoids race with useEffect)
if (currentSession.idToken) {
storeIdToken(currentSession.idToken);
}
}
} catch (error) {
// eslint-disable-next-line no-console
console.error('[auth-next-client] Force refresh failed:', error);
// Fall back to current session from ref
currentSession = sessionRef.current;
} finally {
setIsRefreshingRef.current(false);
}
} else if (pendingRefresh) {
// If a refresh is in-flight (proactive timer or another getAccessToken call),
// wait for it and use the refreshed session rather than returning a stale token.
const refreshed = await pendingRefresh;
if (refreshed) {
currentSession = refreshed as ImmutableSessionInternal;
sessionRef.current = currentSession;
// Persist fresh idToken to localStorage immediately
if (currentSession.idToken) {
storeIdToken(currentSession.idToken);
}
} else {
currentSession = sessionRef.current;
}
} else {
// Read from ref - instant, no network call
// The ref is always updated on each render with the latest session
currentSession = sessionRef.current;
}
if (!currentSession?.accessToken) {
return null;
}
// Check for session errors
if (currentSession.error) {
// eslint-disable-next-line no-console
console.warn('[auth-next-client] Session has error:', currentSession.error);
return null;
}
return {
accessToken: currentSession.accessToken,
refreshToken: currentSession.refreshToken,
// Prefer session idToken (fresh after sign-in or refresh, before useEffect
// stores it), fall back to localStorage for normal reads (cookie has no idToken).
idToken: currentSession.idToken || getStoredIdToken(),
profile: {
sub: currentSession.user?.sub ?? '',
email: currentSession.user?.email ?? undefined,
nickname: currentSession.user?.nickname ?? undefined,
},
zkEvm: currentSession.zkEvm,
};
}, []); // Empty deps - uses refs for latest values
/**
* Get a guaranteed-fresh access token.
* Returns immediately if the current token is valid (fast path, no network call).
* If expired, triggers a server-side refresh and blocks (awaits) until the fresh
* token is available. Piggybacks on any in-flight refresh to avoid duplicate calls.
*
* @throws Error if the user is not authenticated or if the refresh fails.
*/
const getAccessToken = useCallback(async (): Promise<string> => {
const currentSession = sessionRef.current;
// Fast path: token is valid -- return immediately
if (
currentSession?.accessToken
&& currentSession.accessTokenExpires
&& Date.now() < currentSession.accessTokenExpires - TOKEN_EXPIRY_BUFFER_MS
&& !currentSession.error
) {
return currentSession.accessToken;
}
// Token is expired or missing -- wait for in-flight refresh or trigger one
const refreshed = await deduplicatedUpdate(
() => updateRef.current(),
) as ImmutableSessionInternal | null;
if (!refreshed?.accessToken || refreshed.error) {
throw new Error(
`[auth-next-client] Failed to get access token: ${refreshed?.error || 'no session'}`,
);
}
// Update ref so subsequent sync reads get the fresh data
sessionRef.current = refreshed;
return refreshed.accessToken;
}, []); // Empty deps -- uses refs for latest values
// Stable session reference for consumers.
//
// next-auth's SessionProvider refetches the session on every window focus
// (refetchOnWindowFocus defaults to true). Each refetch returns a new object
// even when nothing has changed, which causes unnecessary re-renders and
// effect re-runs for any consumer using session in deps or as a prop.
// See: https://github.com/nextauthjs/next-auth/issues/3405
//
// Cast to public type (omits accessToken) to prevent consumers from
// accidentally using a potentially stale token. Use getAccessToken() instead.
// sessionRef (above) still tracks the raw latest for imperative use by
// getUser/getAccessToken.
const publicSession = useStableValue(session as ImmutableSession | null);
return {
session: publicSession,
status,
isLoading,
isAuthenticated,
isRefreshing,
getUser,
getAccessToken,
};
}
/**
* Return type for useLogin hook
*
* Config is optional - when omitted, defaults are auto-derived (clientId, redirectUri, etc.).
* When provided, must be a complete LoginConfig.
*/
export interface UseLoginReturn {
/** Start login with popup flow */
loginWithPopup: (config?: LoginConfig, options?: StandaloneLoginOptions) => Promise<void>;
/** Start login with embedded modal flow */
loginWithEmbedded: (config?: LoginConfig) => Promise<void>;
/** Start login with redirect flow (navigates away from page) */
loginWithRedirect: (config?: LoginConfig, options?: StandaloneLoginOptions) => Promise<void>;
/** Whether login is currently in progress */
isLoggingIn: boolean;
/** Error message from the last login attempt, or null if none */
error: string | null;
}
/**
* Hook to handle Immutable authentication login flows with automatic defaults.
*
* Provides login functions that:
* 1. Handle OAuth authentication via popup, embedded modal, or redirect
* 2. Automatically sign in to NextAuth after successful authentication
* 3. Track loading and error states
* 4. Auto-detect clientId and redirectUri if not provided (uses defaults)
*
* Config can be passed at call time or omitted to use sensible defaults:
* - `clientId`: Auto-detected based on environment (sandbox vs production)
* - `redirectUri`: Auto-derived from `window.location.origin + '/callback'`
* - `popupRedirectUri`: Auto-derived from `window.location.origin + '/callback'` (same as redirectUri)
* - `logoutRedirectUri`: Auto-derived from `window.location.origin`
* - `scope`: `'openid profile email offline_access transact'`
* - `audience`: `'platform_api'`
* - `authenticationDomain`: `'https://auth.immutable.com'`
*
* Must be used within a SessionProvider from next-auth/react.
*
* @example Minimal usage (uses all defaults)
* ```tsx
* import { useLogin, useImmutableSession } from '@imtbl/auth-next-client';
*
* function LoginButton() {
* const { isAuthenticated } = useImmutableSession();
* const { loginWithPopup, isLoggingIn, error } = useLogin();
*
* if (isAuthenticated) {
* return <p>You are logged in!</p>;
* }
*
* return (
* <>
* <button onClick={() => loginWithPopup()} disabled={isLoggingIn}>
* {isLoggingIn ? 'Signing in...' : 'Sign In'}
* </button>
* {error && <p style={{ color: 'red' }}>{error}</p>}
* </>
* );
* }
* ```
*
* @example With custom configuration
* ```tsx
* import { useLogin, useImmutableSession } from '@imtbl/auth-next-client';
*
* function LoginButton() {
* const { isAuthenticated } = useImmutableSession();
* const { loginWithPopup, isLoggingIn, error } = useLogin();
*
* const handleLogin = () => {
* loginWithPopup({
* clientId: process.env.NEXT_PUBLIC_IMMUTABLE_CLIENT_ID,
* redirectUri: `${window.location.origin}/callback`,
* });
* };
*
* if (isAuthenticated) {
* return <p>You are logged in!</p>;
* }
*
* return (
* <>
* <button onClick={handleLogin} disabled={isLoggingIn}>
* {isLoggingIn ? 'Signing in...' : 'Sign In'}
* </button>
* {error && <p style={{ color: 'red' }}>{error}</p>}
* </>
* );
* }
* ```
*/
export function useLogin(): UseLoginReturn {
const [isLoggingIn, setIsLoggingIn] = useState(false);
const [error, setError] = useState<string | null>(null);
/**
* Sign in to NextAuth with tokens from OAuth flow
*/
const signInWithTokens = useCallback(async (tokens: {
accessToken: string;
refreshToken?: string;
idToken?: string;
accessTokenExpires: number;
profile: { sub: string; email?: string; nickname?: string };
zkEvm?: ZkEvmInfo;
}) => {
// Persist idToken to localStorage before signIn so it's available immediately.
// The cookie won't contain idToken (stripped by jwt.encode on the server).
if (tokens.idToken) {
storeIdToken(tokens.idToken);
}
const result = await signIn(IMMUTABLE_PROVIDER_ID, {
tokens: JSON.stringify(tokens),
redirect: false,
});
if (result?.error) {
throw new Error(`NextAuth sign-in failed: ${result.error}`);
}
if (!result?.ok) {
throw new Error('NextAuth sign-in failed: unknown error');
}
}, []);
/**
* Login with a popup window.
* Opens a popup for OAuth authentication, then signs in to NextAuth.
* Config is optional - defaults will be auto-derived if not provided.
*/
const loginWithPopup = useCallback(async (
config?: LoginConfig,
options?: StandaloneLoginOptions,
): Promise<void> => {
setIsLoggingIn(true);
setError(null);
try {
const fullConfig = config ?? getSandboxLoginConfig();
const tokens = await rawLoginWithPopup(fullConfig, options);
await signInWithTokens(tokens);
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Login failed';
setError(errorMessage);
throw err;
} finally {
setIsLoggingIn(false);
}
}, [signInWithTokens]);
/**
* Login with an embedded modal.
* Shows a modal for login method selection, then opens a popup for OAuth.
* Config is optional - defaults will be auto-derived if not provided.
*/
const loginWithEmbedded = useCallback(async (config?: LoginConfig): Promise<void> => {
setIsLoggingIn(true);
setError(null);
try {
const fullConfig = config ?? getSandboxLoginConfig();
const tokens = await rawLoginWithEmbedded(fullConfig);
await signInWithTokens(tokens);
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Login failed';
setError(errorMessage);
throw err;
} finally {
setIsLoggingIn(false);
}
}, [signInWithTokens]);
/**
* Login with redirect.
* Redirects the page to OAuth authentication.
* After authentication, the user will be redirected to your callback page.
* Use the CallbackPage component to complete the flow.
* Config is optional - defaults will be auto-derived if not provided.
*/
const loginWithRedirect = useCallback(async (
config?: LoginConfig,
options?: StandaloneLoginOptions,
): Promise<void> => {
setIsLoggingIn(true);
setError(null);
try {
const fullConfig = config ?? getSandboxLoginConfig();
await rawLoginWithRedirect(fullConfig, options);
// Note: The page will redirect, so this code may not run
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Login failed';
setError(errorMessage);
setIsLoggingIn(false);
throw err;
}
// Don't set isLoggingIn to false here - page is redirecting
}, []);
return {
loginWithPopup,
loginWithEmbedded,
loginWithRedirect,
isLoggingIn,
error,
};
}
/**
* Return type for useLogout hook
*/
export interface UseLogoutReturn {
/**
* Logout with federated logout support.
* Clears both the local NextAuth session AND the upstream Immutable/Auth0 session.
* This ensures that when the user logs in again, they will be prompted to select
* an account instead of being automatically logged in with the previous account.
*
* Config is optional - defaults will be auto-derived if not provided.
*
* @param config - Optional logout configuration with clientId and optional redirectUri
*/
logout: (config?: LogoutConfig) => Promise<void>;
/** Whether logout is currently in progress */
isLoggingOut: boolean;
/** Error message from the last logout attempt, or null if none */
error: string | null;
}
/**
* Hook to handle Immutable authentication logout with federated logout support.
*
* This hook provides a `logout` function that performs federated logout:
* 1. Clears the local NextAuth session (JWT cookie)
* 2. Redirects to the Immutable auth domain's logout endpoint to clear the upstream session
*
* This ensures that when the user logs in again, they will be prompted to select
* an account (for social logins like Google) instead of being automatically logged
* in with the previous account.
*
* Config is optional - defaults will be auto-derived if not provided:
* - `clientId`: Auto-detected based on environment (sandbox vs production)
* - `logoutRedirectUri`: Auto-derived from `window.location.origin`
*
* Must be used within a SessionProvider from next-auth/react.
*
* @example Minimal usage (uses all defaults)
* ```tsx
* import { useLogout, useImmutableSession } from '@imtbl/auth-next-client';
*
* function LogoutButton() {
* const { isAuthenticated } = useImmutableSession();
* const { logout, isLoggingOut, error } = useLogout();
*
* if (!isAuthenticated) {
* return null;
* }
*
* return (
* <>
* <button onClick={() => logout()} disabled={isLoggingOut}>
* {isLoggingOut ? 'Signing out...' : 'Sign Out'}
* </button>
* {error && <p style={{ color: 'red' }}>{error}</p>}
* </>
* );
* }
* ```
*
* @example With custom configuration
* ```tsx
* import { useLogout, useImmutableSession } from '@imtbl/auth-next-client';
*
* function LogoutButton() {
* const { isAuthenticated } = useImmutableSession();
* const { logout, isLoggingOut, error } = useLogout();
*
* if (!isAuthenticated) {
* return null;
* }
*
* return (
* <>
* <button
* onClick={() => logout({
* clientId: process.env.NEXT_PUBLIC_IMMUTABLE_CLIENT_ID,
* logoutRedirectUri: `${window.location.origin}/custom-logout`,
* })}
* disabled={isLoggingOut}
* >
* {isLoggingOut ? 'Signing out...' : 'Sign Out'}
* </button>
* {error && <p style={{ color: 'red' }}>{error}</p>}
* </>
* );
* }
* ```
*/
export function useLogout(): UseLogoutReturn {
const [isLoggingOut, setIsLoggingOut] = useState(false);
const [error, setError] = useState<string | null>(null);
/**
* Logout with federated logout.
* First clears the NextAuth session, then redirects to the auth domain's logout endpoint.
* Config is optional - defaults will be auto-derived if not provided.
*/
const logout = useCallback(async (config?: LogoutConfig): Promise<void> => {
setIsLoggingOut(true);
setError(null);
try {
// Clear idToken from localStorage before clearing session
clearStoredIdToken();
// First, clear the NextAuth session (this clears the JWT cookie)
// We use redirect: false to handle the redirect ourselves for federated logout
await signOut({ redirect: false });
// Create full config with defaults
const fullConfig = config ?? getSandboxLogoutConfig();
// Redirect to the auth domain's logout endpoint using the standalone function
// This clears the upstream session (Auth0/Immutable) so that on next login,
// the user will be prompted to select an account instead of auto-logging in
rawLogoutWithRedirect(fullConfig);
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Logout failed';
setError(errorMessage);
setIsLoggingOut(false);
throw err;
}
// Don't set isLoggingOut to false here - page is redirecting
}, []);
return {
logout,
isLoggingOut,
error,
};
}