@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/README.md +197 -20
- package/dist/node/index.cjs +160 -9
- package/dist/node/index.js +158 -10
- package/dist/types/constants.d.ts +8 -25
- package/dist/types/defaultConfig.d.ts +8 -0
- package/dist/types/hooks.d.ts +104 -23
- package/dist/types/idTokenStorage.d.ts +26 -0
- package/dist/types/index.d.ts +2 -0
- package/package.json +7 -5
- package/src/callback.tsx +7 -0
- package/src/constants.ts +8 -31
- package/src/defaultConfig.ts +19 -0
- package/src/hooks.test.tsx +321 -0
- package/src/hooks.tsx +304 -40
- package/src/idTokenStorage.ts +56 -0
- package/src/index.ts +12 -0
package/src/hooks.tsx
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import {
|
|
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 {
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
|
96
|
-
const session = sessionData as
|
|
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<
|
|
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:
|
|
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
|
|
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
|
|
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
|
|
390
|
+
loginWithPopup: (config?: LoginConfig, options?: StandaloneLoginOptions) => Promise<void>;
|
|
207
391
|
/** Start login with embedded modal flow */
|
|
208
|
-
loginWithEmbedded: (config
|
|
392
|
+
loginWithEmbedded: (config?: LoginConfig) => Promise<void>;
|
|
209
393
|
/** Start login with redirect flow (navigates away from page) */
|
|
210
|
-
loginWithRedirect: (config
|
|
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
|
|
226
|
-
*
|
|
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
|
-
*
|
|
235
|
-
*
|
|
236
|
-
*
|
|
237
|
-
*
|
|
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={
|
|
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
|
|
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
|
|
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
|
|
538
|
+
const loginWithEmbedded = useCallback(async (config?: LoginConfig): Promise<void> => {
|
|
315
539
|
setIsLoggingIn(true);
|
|
316
540
|
setError(null);
|
|
317
541
|
|
|
318
542
|
try {
|
|
319
|
-
const
|
|
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
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
|
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
|
-
*
|
|
401
|
-
*
|
|
402
|
-
*
|
|
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
|
|
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
|
|
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(
|
|
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';
|