@imtbl/auth-next-client 2.12.7-alpha.0 → 2.12.7-alpha.10
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 +157 -20
- package/dist/node/index.cjs +98 -7
- package/dist/node/index.js +104 -8
- package/dist/types/constants.d.ts +5 -0
- package/dist/types/hooks.d.ts +19 -2
- package/dist/types/idTokenStorage.d.ts +26 -0
- package/package.json +5 -3
- package/src/callback.tsx +7 -0
- package/src/constants.ts +6 -0
- package/src/hooks.test.tsx +317 -0
- package/src/hooks.tsx +171 -17
- package/src/idTokenStorage.ts +56 -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,35 @@ 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 { IMMUTABLE_PROVIDER_ID, TOKEN_EXPIRY_BUFFER_MS } from './constants';
|
|
22
|
+
import { storeIdToken, getStoredIdToken, clearStoredIdToken } from './idTokenStorage';
|
|
23
|
+
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
// Module-level deduplication for session refresh
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Deduplicates concurrent session refresh calls.
|
|
30
|
+
* Multiple components may mount useImmutableSession simultaneously; without
|
|
31
|
+
* deduplication each would trigger its own update() call, which could fail
|
|
32
|
+
* if the auth server rotates refresh tokens.
|
|
33
|
+
*/
|
|
34
|
+
let pendingRefresh: Promise<Session | null | undefined> | null = null;
|
|
35
|
+
|
|
36
|
+
function deduplicatedUpdate(
|
|
37
|
+
update: () => Promise<Session | null | undefined>,
|
|
38
|
+
): Promise<Session | null | undefined> {
|
|
39
|
+
if (!pendingRefresh) {
|
|
40
|
+
pendingRefresh = update().finally(() => { pendingRefresh = null; });
|
|
41
|
+
}
|
|
42
|
+
return pendingRefresh;
|
|
43
|
+
}
|
|
20
44
|
|
|
21
45
|
/**
|
|
22
|
-
*
|
|
46
|
+
* Internal session type with full token data (not exported).
|
|
47
|
+
* Used internally by the hook for token validation, refresh logic, and getUser/getAccessToken.
|
|
23
48
|
*/
|
|
24
|
-
|
|
49
|
+
interface ImmutableSessionInternal extends Session {
|
|
25
50
|
accessToken: string;
|
|
26
51
|
refreshToken?: string;
|
|
27
52
|
idToken?: string;
|
|
@@ -30,6 +55,15 @@ export interface ImmutableSession extends Session {
|
|
|
30
55
|
error?: string;
|
|
31
56
|
}
|
|
32
57
|
|
|
58
|
+
/**
|
|
59
|
+
* Public session type exposed to consumers.
|
|
60
|
+
*
|
|
61
|
+
* Does **not** include `accessToken` -- consumers must use the `getAccessToken()`
|
|
62
|
+
* function returned by `useImmutableSession()` to obtain a guaranteed-fresh token.
|
|
63
|
+
* This prevents accidental use of stale/expired tokens.
|
|
64
|
+
*/
|
|
65
|
+
export type ImmutableSession = Omit<ImmutableSessionInternal, 'accessToken'>;
|
|
66
|
+
|
|
33
67
|
/**
|
|
34
68
|
* Return type for useImmutableSession hook
|
|
35
69
|
*/
|
|
@@ -53,6 +87,13 @@ export interface UseImmutableSessionReturn {
|
|
|
53
87
|
* The refreshed session will include updated zkEvm data if available.
|
|
54
88
|
*/
|
|
55
89
|
getUser: (forceRefresh?: boolean) => Promise<User | null>;
|
|
90
|
+
/**
|
|
91
|
+
* Get a guaranteed-fresh access token.
|
|
92
|
+
* Returns immediately if the current token is valid.
|
|
93
|
+
* If expired, triggers a refresh and blocks (awaits) until the fresh token is available.
|
|
94
|
+
* Throws if the user is not authenticated or if refresh fails.
|
|
95
|
+
*/
|
|
96
|
+
getAccessToken: () => Promise<string>;
|
|
56
97
|
}
|
|
57
98
|
|
|
58
99
|
/**
|
|
@@ -92,25 +133,29 @@ export function useImmutableSession(): UseImmutableSessionReturn {
|
|
|
92
133
|
// Track when a manual refresh is in progress (via getUser(true))
|
|
93
134
|
const [isRefreshing, setIsRefreshing] = useState(false);
|
|
94
135
|
|
|
95
|
-
// Cast session to our
|
|
96
|
-
const session = sessionData as
|
|
136
|
+
// Cast session to our internal type (includes accessToken for internal logic)
|
|
137
|
+
const session = sessionData as ImmutableSessionInternal | null;
|
|
97
138
|
|
|
98
139
|
const isLoading = status === 'loading';
|
|
99
140
|
|
|
100
|
-
// Core authentication check
|
|
101
|
-
|
|
141
|
+
// Core authentication check - user has a valid session with usable access token.
|
|
142
|
+
// A session can exist but be unusable if the access token is missing or refresh failed.
|
|
143
|
+
const hasValidSession = status === 'authenticated'
|
|
144
|
+
&& !!session
|
|
145
|
+
&& !!session.accessToken
|
|
146
|
+
&& !session.error;
|
|
102
147
|
|
|
103
|
-
// During loading/refreshing, keep showing authenticated if we had a session (avoids UI flicker
|
|
148
|
+
// During loading/refreshing, keep showing authenticated if we had a valid session (avoids UI flicker
|
|
104
149
|
// when NextAuth refetches on window focus or after getUser(forceRefresh)).
|
|
105
150
|
const hadSessionRef = useRef(false);
|
|
106
|
-
if (
|
|
107
|
-
if (!
|
|
108
|
-
const isAuthenticated =
|
|
151
|
+
if (hasValidSession) hadSessionRef.current = true;
|
|
152
|
+
if (!hasValidSession && !isLoading && !isRefreshing) hadSessionRef.current = false;
|
|
153
|
+
const isAuthenticated = hasValidSession || ((isLoading || isRefreshing) && hadSessionRef.current);
|
|
109
154
|
|
|
110
155
|
// Use a ref to always have access to the latest session.
|
|
111
156
|
// This avoids stale closure issues when the wallet stores the getUser function
|
|
112
157
|
// and calls it later - the ref always points to the current session.
|
|
113
|
-
const sessionRef = useRef<
|
|
158
|
+
const sessionRef = useRef<ImmutableSessionInternal | null>(session);
|
|
114
159
|
sessionRef.current = session;
|
|
115
160
|
|
|
116
161
|
// Also store update in a ref so the callback is stable
|
|
@@ -121,6 +166,44 @@ export function useImmutableSession(): UseImmutableSessionReturn {
|
|
|
121
166
|
const setIsRefreshingRef = useRef(setIsRefreshing);
|
|
122
167
|
setIsRefreshingRef.current = setIsRefreshing;
|
|
123
168
|
|
|
169
|
+
// ---------------------------------------------------------------------------
|
|
170
|
+
// Proactive token refresh
|
|
171
|
+
// ---------------------------------------------------------------------------
|
|
172
|
+
|
|
173
|
+
// Reactive refresh: when the effect runs and the token is already expired
|
|
174
|
+
// (e.g., after tab regains focus), trigger an immediate silent refresh.
|
|
175
|
+
// For tokens that are still valid, getAccessToken() handles refresh on demand.
|
|
176
|
+
//
|
|
177
|
+
// NOTE: This intentionally does NOT set isRefreshing. isRefreshing is reserved
|
|
178
|
+
// for explicit user-triggered refreshes (e.g., getUser(true) after wallet
|
|
179
|
+
// registration). Background token refreshes must be invisible to consumers --
|
|
180
|
+
// setting isRefreshing would cause downstream hooks that gate SWR keys on
|
|
181
|
+
// `!isRefreshing` to briefly lose their cached data, resulting in UI flicker.
|
|
182
|
+
useEffect(() => {
|
|
183
|
+
if (!session?.accessTokenExpires) return;
|
|
184
|
+
|
|
185
|
+
const timeUntilExpiry = session.accessTokenExpires - Date.now() - TOKEN_EXPIRY_BUFFER_MS;
|
|
186
|
+
|
|
187
|
+
if (timeUntilExpiry <= 0) {
|
|
188
|
+
// Already expired -- refresh silently
|
|
189
|
+
deduplicatedUpdate(() => updateRef.current());
|
|
190
|
+
}
|
|
191
|
+
}, [session?.accessTokenExpires]);
|
|
192
|
+
|
|
193
|
+
// ---------------------------------------------------------------------------
|
|
194
|
+
// Sync idToken to localStorage
|
|
195
|
+
// ---------------------------------------------------------------------------
|
|
196
|
+
|
|
197
|
+
// The idToken is stripped from the cookie by jwt.encode on the server to avoid
|
|
198
|
+
// CloudFront 413 errors. It is only present in the session response transiently
|
|
199
|
+
// after sign-in or token refresh. When present, persist it in localStorage so
|
|
200
|
+
// that getUser() can always return it (used by wallet's MagicTEESigner).
|
|
201
|
+
useEffect(() => {
|
|
202
|
+
if (session?.idToken) {
|
|
203
|
+
storeIdToken(session.idToken);
|
|
204
|
+
}
|
|
205
|
+
}, [session?.idToken]);
|
|
206
|
+
|
|
124
207
|
/**
|
|
125
208
|
* Get user function for wallet integration.
|
|
126
209
|
* Returns a User object compatible with @imtbl/wallet's getUser option.
|
|
@@ -131,7 +214,7 @@ export function useImmutableSession(): UseImmutableSessionReturn {
|
|
|
131
214
|
* @param forceRefresh - When true, triggers a server-side token refresh
|
|
132
215
|
*/
|
|
133
216
|
const getUser = useCallback(async (forceRefresh?: boolean): Promise<User | null> => {
|
|
134
|
-
let currentSession:
|
|
217
|
+
let currentSession: ImmutableSessionInternal | null;
|
|
135
218
|
|
|
136
219
|
// If forceRefresh is requested, trigger server-side refresh via NextAuth
|
|
137
220
|
// This calls the jwt callback with trigger='update' and sessionUpdate.forceRefresh=true
|
|
@@ -141,10 +224,14 @@ export function useImmutableSession(): UseImmutableSessionReturn {
|
|
|
141
224
|
try {
|
|
142
225
|
// update() returns the refreshed session
|
|
143
226
|
const updatedSession = await updateRef.current({ forceRefresh: true });
|
|
144
|
-
currentSession = updatedSession as
|
|
227
|
+
currentSession = updatedSession as ImmutableSessionInternal | null;
|
|
145
228
|
// Also update the ref so subsequent calls get the fresh data
|
|
146
229
|
if (currentSession) {
|
|
147
230
|
sessionRef.current = currentSession;
|
|
231
|
+
// Immediately persist fresh idToken to localStorage (avoids race with useEffect)
|
|
232
|
+
if (currentSession.idToken) {
|
|
233
|
+
storeIdToken(currentSession.idToken);
|
|
234
|
+
}
|
|
148
235
|
}
|
|
149
236
|
} catch (error) {
|
|
150
237
|
// eslint-disable-next-line no-console
|
|
@@ -154,6 +241,20 @@ export function useImmutableSession(): UseImmutableSessionReturn {
|
|
|
154
241
|
} finally {
|
|
155
242
|
setIsRefreshingRef.current(false);
|
|
156
243
|
}
|
|
244
|
+
} else if (pendingRefresh) {
|
|
245
|
+
// If a refresh is in-flight (proactive timer or another getAccessToken call),
|
|
246
|
+
// wait for it and use the refreshed session rather than returning a stale token.
|
|
247
|
+
const refreshed = await pendingRefresh;
|
|
248
|
+
if (refreshed) {
|
|
249
|
+
currentSession = refreshed as ImmutableSessionInternal;
|
|
250
|
+
sessionRef.current = currentSession;
|
|
251
|
+
// Persist fresh idToken to localStorage immediately
|
|
252
|
+
if (currentSession.idToken) {
|
|
253
|
+
storeIdToken(currentSession.idToken);
|
|
254
|
+
}
|
|
255
|
+
} else {
|
|
256
|
+
currentSession = sessionRef.current;
|
|
257
|
+
}
|
|
157
258
|
} else {
|
|
158
259
|
// Read from ref - instant, no network call
|
|
159
260
|
// The ref is always updated on each render with the latest session
|
|
@@ -174,7 +275,9 @@ export function useImmutableSession(): UseImmutableSessionReturn {
|
|
|
174
275
|
return {
|
|
175
276
|
accessToken: currentSession.accessToken,
|
|
176
277
|
refreshToken: currentSession.refreshToken,
|
|
177
|
-
idToken
|
|
278
|
+
// Prefer session idToken (fresh after sign-in or refresh, before useEffect
|
|
279
|
+
// stores it), fall back to localStorage for normal reads (cookie has no idToken).
|
|
280
|
+
idToken: currentSession.idToken || getStoredIdToken(),
|
|
178
281
|
profile: {
|
|
179
282
|
sub: currentSession.user?.sub ?? '',
|
|
180
283
|
email: currentSession.user?.email ?? undefined,
|
|
@@ -184,13 +287,55 @@ export function useImmutableSession(): UseImmutableSessionReturn {
|
|
|
184
287
|
};
|
|
185
288
|
}, []); // Empty deps - uses refs for latest values
|
|
186
289
|
|
|
290
|
+
/**
|
|
291
|
+
* Get a guaranteed-fresh access token.
|
|
292
|
+
* Returns immediately if the current token is valid (fast path, no network call).
|
|
293
|
+
* If expired, triggers a server-side refresh and blocks (awaits) until the fresh
|
|
294
|
+
* token is available. Piggybacks on any in-flight refresh to avoid duplicate calls.
|
|
295
|
+
*
|
|
296
|
+
* @throws Error if the user is not authenticated or if the refresh fails.
|
|
297
|
+
*/
|
|
298
|
+
const getAccessToken = useCallback(async (): Promise<string> => {
|
|
299
|
+
const currentSession = sessionRef.current;
|
|
300
|
+
|
|
301
|
+
// Fast path: token is valid -- return immediately
|
|
302
|
+
if (
|
|
303
|
+
currentSession?.accessToken
|
|
304
|
+
&& currentSession.accessTokenExpires
|
|
305
|
+
&& Date.now() < currentSession.accessTokenExpires - TOKEN_EXPIRY_BUFFER_MS
|
|
306
|
+
&& !currentSession.error
|
|
307
|
+
) {
|
|
308
|
+
return currentSession.accessToken;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// Token is expired or missing -- wait for in-flight refresh or trigger one
|
|
312
|
+
const refreshed = await deduplicatedUpdate(
|
|
313
|
+
() => updateRef.current(),
|
|
314
|
+
) as ImmutableSessionInternal | null;
|
|
315
|
+
|
|
316
|
+
if (!refreshed?.accessToken || refreshed.error) {
|
|
317
|
+
throw new Error(
|
|
318
|
+
`[auth-next-client] Failed to get access token: ${refreshed?.error || 'no session'}`,
|
|
319
|
+
);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// Update ref so subsequent sync reads get the fresh data
|
|
323
|
+
sessionRef.current = refreshed;
|
|
324
|
+
return refreshed.accessToken;
|
|
325
|
+
}, []); // Empty deps -- uses refs for latest values
|
|
326
|
+
|
|
327
|
+
// Cast to public type (omits accessToken) to prevent consumers from
|
|
328
|
+
// accidentally using a potentially stale token. Use getAccessToken() instead.
|
|
329
|
+
const publicSession = session as ImmutableSession | null;
|
|
330
|
+
|
|
187
331
|
return {
|
|
188
|
-
session,
|
|
332
|
+
session: publicSession,
|
|
189
333
|
status,
|
|
190
334
|
isLoading,
|
|
191
335
|
isAuthenticated,
|
|
192
336
|
isRefreshing,
|
|
193
337
|
getUser,
|
|
338
|
+
getAccessToken,
|
|
194
339
|
};
|
|
195
340
|
}
|
|
196
341
|
|
|
@@ -267,6 +412,12 @@ export function useLogin(): UseLoginReturn {
|
|
|
267
412
|
profile: { sub: string; email?: string; nickname?: string };
|
|
268
413
|
zkEvm?: ZkEvmInfo;
|
|
269
414
|
}) => {
|
|
415
|
+
// Persist idToken to localStorage before signIn so it's available immediately.
|
|
416
|
+
// The cookie won't contain idToken (stripped by jwt.encode on the server).
|
|
417
|
+
if (tokens.idToken) {
|
|
418
|
+
storeIdToken(tokens.idToken);
|
|
419
|
+
}
|
|
420
|
+
|
|
270
421
|
const result = await signIn(IMMUTABLE_PROVIDER_ID, {
|
|
271
422
|
tokens: JSON.stringify(tokens),
|
|
272
423
|
redirect: false,
|
|
@@ -430,6 +581,9 @@ export function useLogout(): UseLogoutReturn {
|
|
|
430
581
|
setError(null);
|
|
431
582
|
|
|
432
583
|
try {
|
|
584
|
+
// Clear idToken from localStorage before clearing session
|
|
585
|
+
clearStoredIdToken();
|
|
586
|
+
|
|
433
587
|
// First, clear the NextAuth session (this clears the JWT cookie)
|
|
434
588
|
// We use redirect: false to handle the redirect ourselves for federated logout
|
|
435
589
|
await signOut({ redirect: false });
|
|
@@ -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
|
+
}
|