@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/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,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
- * Extended session type with Immutable token data
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
- export interface ImmutableSession extends Session {
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 extended type
96
- const session = sessionData as ImmutableSession | null;
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 from NextAuth
101
- const hasSession = status === 'authenticated' && !!session;
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 (hasSession) hadSessionRef.current = true;
107
- if (!hasSession && !isLoading && !isRefreshing) hadSessionRef.current = false;
108
- const isAuthenticated = hasSession || ((isLoading || isRefreshing) && hadSessionRef.current);
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<ImmutableSession | null>(session);
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: ImmutableSession | null;
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 ImmutableSession | null;
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: currentSession.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
+ }