@imtbl/auth-next-client 2.12.5-alpha.13
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/.eslintrc.cjs +18 -0
- package/LICENSE.md +176 -0
- package/dist/node/callback.d.ts +56 -0
- package/dist/node/constants.d.ts +32 -0
- package/dist/node/index.cjs +501 -0
- package/dist/node/index.d.ts +15 -0
- package/dist/node/index.js +486 -0
- package/dist/node/provider.d.ts +66 -0
- package/dist/node/types.d.ts +133 -0
- package/dist/node/utils/token.d.ts +8 -0
- package/jest.config.ts +16 -0
- package/package.json +70 -0
- package/src/callback.tsx +281 -0
- package/src/constants.ts +39 -0
- package/src/index.ts +45 -0
- package/src/provider.tsx +547 -0
- package/src/types.ts +148 -0
- package/src/utils/token.ts +39 -0
- package/tsconfig.eslint.json +5 -0
- package/tsconfig.json +19 -0
- package/tsconfig.types.json +8 -0
- package/tsup.config.ts +33 -0
package/src/provider.tsx
ADDED
|
@@ -0,0 +1,547 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React, {
|
|
4
|
+
createContext,
|
|
5
|
+
useContext,
|
|
6
|
+
useEffect,
|
|
7
|
+
useRef,
|
|
8
|
+
useState,
|
|
9
|
+
useCallback,
|
|
10
|
+
useMemo,
|
|
11
|
+
} from 'react';
|
|
12
|
+
import {
|
|
13
|
+
SessionProvider, useSession, signIn, signOut,
|
|
14
|
+
} from 'next-auth/react';
|
|
15
|
+
import type { Session } from 'next-auth';
|
|
16
|
+
import {
|
|
17
|
+
Auth, AuthEvents, type User, type LoginOptions, type UserRemovedReason,
|
|
18
|
+
} from '@imtbl/auth';
|
|
19
|
+
import type {
|
|
20
|
+
ImmutableAuthProviderProps,
|
|
21
|
+
UseImmutableAuthReturn,
|
|
22
|
+
ImmutableUserClient,
|
|
23
|
+
ImmutableTokenDataClient,
|
|
24
|
+
} from './types';
|
|
25
|
+
import { getTokenExpiry } from './utils/token';
|
|
26
|
+
import {
|
|
27
|
+
DEFAULT_AUTH_DOMAIN,
|
|
28
|
+
DEFAULT_AUDIENCE,
|
|
29
|
+
DEFAULT_SCOPE,
|
|
30
|
+
DEFAULT_NEXTAUTH_BASE_PATH,
|
|
31
|
+
IMMUTABLE_PROVIDER_ID,
|
|
32
|
+
} from './constants';
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Internal context for Immutable auth state
|
|
36
|
+
*/
|
|
37
|
+
interface ImmutableAuthContextValue {
|
|
38
|
+
auth: Auth | null;
|
|
39
|
+
config: ImmutableAuthProviderProps['config'];
|
|
40
|
+
basePath: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const ImmutableAuthContext = createContext<ImmutableAuthContextValue | null>(null);
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Internal provider that manages Auth instance
|
|
47
|
+
*/
|
|
48
|
+
function ImmutableAuthInner({
|
|
49
|
+
children,
|
|
50
|
+
config,
|
|
51
|
+
basePath,
|
|
52
|
+
}: {
|
|
53
|
+
children: React.ReactNode;
|
|
54
|
+
config: ImmutableAuthProviderProps['config'];
|
|
55
|
+
basePath: string;
|
|
56
|
+
}) {
|
|
57
|
+
// Use state instead of ref so changes trigger re-renders and update context consumers
|
|
58
|
+
const [auth, setAuth] = useState<Auth | null>(null);
|
|
59
|
+
const prevConfigRef = useRef<string | null>(null);
|
|
60
|
+
// Track auth instance in a ref to check if it's still valid synchronously
|
|
61
|
+
// This is needed for React 18 Strict Mode compatibility
|
|
62
|
+
const authInstanceRef = useRef<Auth | null>(null);
|
|
63
|
+
const [isAuthReady, setIsAuthReady] = useState(false);
|
|
64
|
+
const { data: session, update: updateSession } = useSession();
|
|
65
|
+
|
|
66
|
+
// Initialize/reinitialize Auth instance when config changes (e.g., environment switch)
|
|
67
|
+
useEffect(() => {
|
|
68
|
+
if (typeof window === 'undefined') return undefined;
|
|
69
|
+
|
|
70
|
+
// Create a config key to detect changes - include all properties used in Auth constructor
|
|
71
|
+
// to ensure the Auth instance is recreated when any config property changes
|
|
72
|
+
const configKey = [
|
|
73
|
+
config.clientId,
|
|
74
|
+
config.redirectUri,
|
|
75
|
+
config.popupRedirectUri || '',
|
|
76
|
+
config.logoutRedirectUri || '',
|
|
77
|
+
config.audience || DEFAULT_AUDIENCE,
|
|
78
|
+
config.scope || DEFAULT_SCOPE,
|
|
79
|
+
config.authenticationDomain || DEFAULT_AUTH_DOMAIN,
|
|
80
|
+
config.passportDomain || '',
|
|
81
|
+
].join(':');
|
|
82
|
+
|
|
83
|
+
// Only skip recreation if BOTH:
|
|
84
|
+
// 1. Config hasn't changed (same configKey)
|
|
85
|
+
// 2. Auth instance still exists (wasn't nullified by cleanup)
|
|
86
|
+
// This handles React 18 Strict Mode where effects run twice:
|
|
87
|
+
// setup → cleanup → setup. After cleanup, authInstanceRef is null,
|
|
88
|
+
// so we correctly recreate Auth on the second setup.
|
|
89
|
+
if (prevConfigRef.current === configKey && authInstanceRef.current !== null) {
|
|
90
|
+
return undefined;
|
|
91
|
+
}
|
|
92
|
+
prevConfigRef.current = configKey;
|
|
93
|
+
|
|
94
|
+
// Create new Auth instance with current config
|
|
95
|
+
const newAuth = new Auth({
|
|
96
|
+
clientId: config.clientId,
|
|
97
|
+
redirectUri: config.redirectUri,
|
|
98
|
+
popupRedirectUri: config.popupRedirectUri,
|
|
99
|
+
logoutRedirectUri: config.logoutRedirectUri,
|
|
100
|
+
audience: config.audience || DEFAULT_AUDIENCE,
|
|
101
|
+
scope: config.scope || DEFAULT_SCOPE,
|
|
102
|
+
authenticationDomain: config.authenticationDomain || DEFAULT_AUTH_DOMAIN,
|
|
103
|
+
passportDomain: config.passportDomain,
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
authInstanceRef.current = newAuth;
|
|
107
|
+
setAuth(newAuth);
|
|
108
|
+
setIsAuthReady(true);
|
|
109
|
+
|
|
110
|
+
// Cleanup function: When config changes or component unmounts,
|
|
111
|
+
// clear the Auth instance to prevent memory leaks.
|
|
112
|
+
// The Auth class holds a UserManager from oidc-client-ts which may register
|
|
113
|
+
// window event listeners (storage, message). By setting auth to null,
|
|
114
|
+
// we allow garbage collection.
|
|
115
|
+
return () => {
|
|
116
|
+
authInstanceRef.current = null;
|
|
117
|
+
setAuth(null);
|
|
118
|
+
setIsAuthReady(false);
|
|
119
|
+
};
|
|
120
|
+
}, [config]);
|
|
121
|
+
|
|
122
|
+
// Listen for Auth events to sync tokens to NextAuth
|
|
123
|
+
useEffect(() => {
|
|
124
|
+
if (!auth || !isAuthReady) return undefined;
|
|
125
|
+
|
|
126
|
+
const handleLoggedIn = async (authUser: User) => {
|
|
127
|
+
// When Auth refreshes tokens, sync to NextAuth session
|
|
128
|
+
if (session?.accessToken && authUser.accessToken !== session.accessToken) {
|
|
129
|
+
await updateSession({
|
|
130
|
+
accessToken: authUser.accessToken,
|
|
131
|
+
refreshToken: authUser.refreshToken,
|
|
132
|
+
idToken: authUser.idToken,
|
|
133
|
+
accessTokenExpires: getTokenExpiry(authUser.accessToken),
|
|
134
|
+
zkEvm: authUser.zkEvm,
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
// Handle client-side token refresh - critical for refresh token rotation.
|
|
140
|
+
// When Auth refreshes tokens via signinSilent(), we must sync the new tokens
|
|
141
|
+
// (especially the new refresh token) to the NextAuth session. Without this,
|
|
142
|
+
// the server-side JWT callback may use a stale refresh token that Auth0 has
|
|
143
|
+
// already invalidated, causing "Unknown or invalid refresh token" errors.
|
|
144
|
+
const handleTokenRefreshed = async (authUser: User) => {
|
|
145
|
+
await updateSession({
|
|
146
|
+
accessToken: authUser.accessToken,
|
|
147
|
+
refreshToken: authUser.refreshToken,
|
|
148
|
+
idToken: authUser.idToken,
|
|
149
|
+
accessTokenExpires: getTokenExpiry(authUser.accessToken),
|
|
150
|
+
zkEvm: authUser.zkEvm,
|
|
151
|
+
});
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
// Handle user removal from Auth due to permanent auth errors
|
|
155
|
+
// (e.g., invalid_grant, login_required - refresh token is truly invalid).
|
|
156
|
+
// Transient errors (network, timeout, server errors) do NOT trigger this.
|
|
157
|
+
// When this happens, we must clear the NextAuth session to keep them in sync.
|
|
158
|
+
const handleUserRemoved = async (payload: { reason: UserRemovedReason; error?: string }) => {
|
|
159
|
+
// eslint-disable-next-line no-console
|
|
160
|
+
console.warn('[auth-next-client] User removed from Auth SDK:', payload.reason, payload.error);
|
|
161
|
+
// Sign out from NextAuth to clear the session cookie
|
|
162
|
+
// This prevents the state mismatch where session exists but Auth has no user
|
|
163
|
+
await signOut({ redirect: false });
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
// Handle explicit logout from Auth SDK (e.g., via auth.logout() or auth.getLogoutUrl())
|
|
167
|
+
// This ensures NextAuth session is always in sync with Auth SDK state
|
|
168
|
+
const handleLoggedOut = async () => {
|
|
169
|
+
// Sign out from NextAuth to clear the session cookie
|
|
170
|
+
await signOut({ redirect: false });
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
auth.eventEmitter.on(AuthEvents.LOGGED_IN, handleLoggedIn);
|
|
174
|
+
auth.eventEmitter.on(AuthEvents.TOKEN_REFRESHED, handleTokenRefreshed);
|
|
175
|
+
auth.eventEmitter.on(AuthEvents.USER_REMOVED, handleUserRemoved);
|
|
176
|
+
auth.eventEmitter.on(AuthEvents.LOGGED_OUT, handleLoggedOut);
|
|
177
|
+
|
|
178
|
+
return () => {
|
|
179
|
+
auth.eventEmitter.removeListener(AuthEvents.LOGGED_IN, handleLoggedIn);
|
|
180
|
+
auth.eventEmitter.removeListener(AuthEvents.TOKEN_REFRESHED, handleTokenRefreshed);
|
|
181
|
+
auth.eventEmitter.removeListener(AuthEvents.USER_REMOVED, handleUserRemoved);
|
|
182
|
+
auth.eventEmitter.removeListener(AuthEvents.LOGGED_OUT, handleLoggedOut);
|
|
183
|
+
};
|
|
184
|
+
}, [auth, isAuthReady, session, updateSession]);
|
|
185
|
+
|
|
186
|
+
const contextValue = useMemo(
|
|
187
|
+
() => ({ auth, config, basePath }),
|
|
188
|
+
[auth, config, basePath],
|
|
189
|
+
);
|
|
190
|
+
|
|
191
|
+
return (
|
|
192
|
+
<ImmutableAuthContext.Provider value={contextValue}>
|
|
193
|
+
{children}
|
|
194
|
+
</ImmutableAuthContext.Provider>
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Provider component for Immutable authentication with Auth.js v5
|
|
200
|
+
*
|
|
201
|
+
* Wraps your app to provide authentication state via useImmutableAuth hook.
|
|
202
|
+
*
|
|
203
|
+
* @example App Router (recommended)
|
|
204
|
+
* ```tsx
|
|
205
|
+
* // app/providers.tsx
|
|
206
|
+
* "use client";
|
|
207
|
+
* import { ImmutableAuthProvider } from "@imtbl/auth-next-client";
|
|
208
|
+
*
|
|
209
|
+
* const config = {
|
|
210
|
+
* clientId: process.env.NEXT_PUBLIC_IMMUTABLE_CLIENT_ID!,
|
|
211
|
+
* redirectUri: `${process.env.NEXT_PUBLIC_BASE_URL}/callback`,
|
|
212
|
+
* };
|
|
213
|
+
*
|
|
214
|
+
* export function Providers({ children }: { children: React.ReactNode }) {
|
|
215
|
+
* return (
|
|
216
|
+
* <ImmutableAuthProvider config={config}>
|
|
217
|
+
* {children}
|
|
218
|
+
* </ImmutableAuthProvider>
|
|
219
|
+
* );
|
|
220
|
+
* }
|
|
221
|
+
* ```
|
|
222
|
+
*/
|
|
223
|
+
export function ImmutableAuthProvider({
|
|
224
|
+
children,
|
|
225
|
+
config,
|
|
226
|
+
session,
|
|
227
|
+
basePath = DEFAULT_NEXTAUTH_BASE_PATH,
|
|
228
|
+
}: ImmutableAuthProviderProps) {
|
|
229
|
+
return (
|
|
230
|
+
<SessionProvider session={session as Session | null | undefined} basePath={basePath}>
|
|
231
|
+
<ImmutableAuthInner config={config} basePath={basePath}>{children}</ImmutableAuthInner>
|
|
232
|
+
</SessionProvider>
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Hook to access Immutable authentication state and methods
|
|
238
|
+
*
|
|
239
|
+
* Must be used within an ImmutableAuthProvider.
|
|
240
|
+
*/
|
|
241
|
+
export function useImmutableAuth(): UseImmutableAuthReturn {
|
|
242
|
+
const context = useContext(ImmutableAuthContext);
|
|
243
|
+
const { data: sessionData, status } = useSession();
|
|
244
|
+
const [isLoggingIn, setIsLoggingIn] = useState(false);
|
|
245
|
+
|
|
246
|
+
if (!context) {
|
|
247
|
+
throw new Error('useImmutableAuth must be used within ImmutableAuthProvider');
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// Cast session to our augmented Session type
|
|
251
|
+
const session = sessionData as Session | null;
|
|
252
|
+
|
|
253
|
+
const { auth } = context;
|
|
254
|
+
const isLoading = status === 'loading';
|
|
255
|
+
const isAuthenticated = status === 'authenticated' && !!session;
|
|
256
|
+
|
|
257
|
+
// Extract user from session
|
|
258
|
+
const user: ImmutableUserClient | null = session?.user
|
|
259
|
+
? {
|
|
260
|
+
sub: session.user.sub,
|
|
261
|
+
email: session.user.email,
|
|
262
|
+
nickname: session.user.nickname,
|
|
263
|
+
}
|
|
264
|
+
: null;
|
|
265
|
+
|
|
266
|
+
// Sign in with Immutable popup
|
|
267
|
+
const handleSignIn = useCallback(async (options?: LoginOptions) => {
|
|
268
|
+
if (!auth) {
|
|
269
|
+
throw new Error('Auth not initialized');
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
setIsLoggingIn(true);
|
|
273
|
+
try {
|
|
274
|
+
// Open popup login with optional login options
|
|
275
|
+
const authUser = await auth.login(options);
|
|
276
|
+
if (!authUser) {
|
|
277
|
+
throw new Error('Login failed');
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Build token data for NextAuth
|
|
281
|
+
const tokenData: ImmutableTokenDataClient = {
|
|
282
|
+
accessToken: authUser.accessToken,
|
|
283
|
+
refreshToken: authUser.refreshToken,
|
|
284
|
+
idToken: authUser.idToken,
|
|
285
|
+
accessTokenExpires: getTokenExpiry(authUser.accessToken),
|
|
286
|
+
profile: {
|
|
287
|
+
sub: authUser.profile.sub,
|
|
288
|
+
email: authUser.profile.email,
|
|
289
|
+
nickname: authUser.profile.nickname,
|
|
290
|
+
},
|
|
291
|
+
zkEvm: authUser.zkEvm,
|
|
292
|
+
};
|
|
293
|
+
|
|
294
|
+
// Sign in to NextAuth with the tokens
|
|
295
|
+
const result = await signIn(IMMUTABLE_PROVIDER_ID, {
|
|
296
|
+
tokens: JSON.stringify(tokenData),
|
|
297
|
+
redirect: false,
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
// signIn with redirect: false returns a result object instead of throwing
|
|
301
|
+
if (result?.error) {
|
|
302
|
+
throw new Error(`NextAuth sign-in failed: ${result.error}`);
|
|
303
|
+
}
|
|
304
|
+
if (!result?.ok) {
|
|
305
|
+
throw new Error('NextAuth sign-in failed: unknown error');
|
|
306
|
+
}
|
|
307
|
+
} finally {
|
|
308
|
+
setIsLoggingIn(false);
|
|
309
|
+
}
|
|
310
|
+
}, [auth]);
|
|
311
|
+
|
|
312
|
+
// Sign out from both NextAuth and Immutable
|
|
313
|
+
const handleSignOut = useCallback(async () => {
|
|
314
|
+
if (auth) {
|
|
315
|
+
try {
|
|
316
|
+
// Clear local Auth state - this emits LOGGED_OUT event which triggers
|
|
317
|
+
// handleLoggedOut listener to call signOut() from NextAuth
|
|
318
|
+
await auth.getLogoutUrl();
|
|
319
|
+
} catch (error) {
|
|
320
|
+
// If getLogoutUrl fails, fall back to direct signOut
|
|
321
|
+
// eslint-disable-next-line no-console
|
|
322
|
+
console.warn('[auth-next-client] Logout cleanup error:', error);
|
|
323
|
+
await signOut({ redirect: false });
|
|
324
|
+
}
|
|
325
|
+
} else {
|
|
326
|
+
// No auth instance, just sign out from NextAuth directly
|
|
327
|
+
await signOut({ redirect: false });
|
|
328
|
+
}
|
|
329
|
+
}, [auth]);
|
|
330
|
+
|
|
331
|
+
// Get access token (refreshes if needed)
|
|
332
|
+
const getAccessToken = useCallback(async (): Promise<string> => {
|
|
333
|
+
// First try to get from Auth instance (most up-to-date)
|
|
334
|
+
if (auth) {
|
|
335
|
+
try {
|
|
336
|
+
const token = await auth.getAccessToken();
|
|
337
|
+
if (token) {
|
|
338
|
+
return token;
|
|
339
|
+
}
|
|
340
|
+
} catch {
|
|
341
|
+
// Fall through to session
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// Fall back to session token, but check for errors first.
|
|
346
|
+
// Session errors indicate authentication issues that require user action:
|
|
347
|
+
// - "TokenExpired": Access token expired and Auth instance couldn't refresh
|
|
348
|
+
// (this happens if localStorage was cleared but session cookie remains)
|
|
349
|
+
// - "RefreshTokenError": Refresh token is invalid/expired, need re-login
|
|
350
|
+
if (session?.error) {
|
|
351
|
+
throw new Error(
|
|
352
|
+
session.error === 'TokenExpired'
|
|
353
|
+
? 'Session expired. Please log in again.'
|
|
354
|
+
: `Authentication error: ${session.error}`,
|
|
355
|
+
);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
if (session?.accessToken) {
|
|
359
|
+
return session.accessToken;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
throw new Error('No access token available');
|
|
363
|
+
}, [auth, session]);
|
|
364
|
+
|
|
365
|
+
return {
|
|
366
|
+
user,
|
|
367
|
+
session,
|
|
368
|
+
isLoading,
|
|
369
|
+
isLoggingIn,
|
|
370
|
+
isAuthenticated,
|
|
371
|
+
signIn: handleSignIn,
|
|
372
|
+
signOut: handleSignOut,
|
|
373
|
+
getAccessToken,
|
|
374
|
+
auth,
|
|
375
|
+
};
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* Hook to get a function that returns a valid access token
|
|
380
|
+
*/
|
|
381
|
+
export function useAccessToken(): () => Promise<string> {
|
|
382
|
+
const { getAccessToken } = useImmutableAuth();
|
|
383
|
+
return getAccessToken;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* Result from useHydratedData hook
|
|
388
|
+
*/
|
|
389
|
+
export interface UseHydratedDataResult<T> {
|
|
390
|
+
data: T | null;
|
|
391
|
+
isLoading: boolean;
|
|
392
|
+
error: Error | null;
|
|
393
|
+
refetch: () => Promise<void>;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* Props for useHydratedData hook - matches AuthPropsWithData from server
|
|
398
|
+
*/
|
|
399
|
+
export interface HydratedDataProps<T> {
|
|
400
|
+
session: Session | null;
|
|
401
|
+
ssr: boolean;
|
|
402
|
+
data: T | null;
|
|
403
|
+
fetchError?: string;
|
|
404
|
+
authError?: string;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* Hook for hydrating server-fetched data with automatic client-side fallback.
|
|
409
|
+
*
|
|
410
|
+
* This is the recommended pattern for components that receive data from `getAuthenticatedData`:
|
|
411
|
+
* - When `ssr: true` and `data` exists: Uses pre-fetched server data immediately (no loading state)
|
|
412
|
+
* - When `ssr: false`: Refreshes token client-side and fetches data
|
|
413
|
+
* - When `fetchError` exists: Retries fetch client-side
|
|
414
|
+
*/
|
|
415
|
+
export function useHydratedData<T>(
|
|
416
|
+
props: HydratedDataProps<T>,
|
|
417
|
+
fetcher: (accessToken: string) => Promise<T>,
|
|
418
|
+
): UseHydratedDataResult<T> {
|
|
419
|
+
const { getAccessToken, auth } = useImmutableAuth();
|
|
420
|
+
const {
|
|
421
|
+
ssr,
|
|
422
|
+
data: serverData,
|
|
423
|
+
fetchError,
|
|
424
|
+
} = props;
|
|
425
|
+
|
|
426
|
+
// Determine if we need to fetch client-side:
|
|
427
|
+
// 1. SSR was skipped (token expired) - need to refresh token and fetch
|
|
428
|
+
// 2. Server fetch failed - retry on client
|
|
429
|
+
// Note: We intentionally do NOT check serverData === null here.
|
|
430
|
+
// When ssr=true and no fetchError, null is a valid response (e.g., "no results found")
|
|
431
|
+
// and should not trigger a client-side refetch.
|
|
432
|
+
const needsClientFetch = !ssr || Boolean(fetchError);
|
|
433
|
+
|
|
434
|
+
// Initialize state with server data if available
|
|
435
|
+
const [data, setData] = useState<T | null>(serverData);
|
|
436
|
+
const [isLoading, setIsLoading] = useState(needsClientFetch);
|
|
437
|
+
const [error, setError] = useState<Error | null>(
|
|
438
|
+
fetchError ? new Error(fetchError) : null,
|
|
439
|
+
);
|
|
440
|
+
|
|
441
|
+
// Track if we've already started fetching to prevent duplicate calls
|
|
442
|
+
const hasFetchedRef = useRef(false);
|
|
443
|
+
|
|
444
|
+
// Fetch ID counter to detect stale fetches.
|
|
445
|
+
// When props change, we increment this counter. In-flight fetches compare their
|
|
446
|
+
// captured ID against the current counter and ignore results if they don't match.
|
|
447
|
+
// This prevents race conditions where a slow client-side fetch overwrites
|
|
448
|
+
// fresh server data that arrived via prop changes (e.g., during soft navigation).
|
|
449
|
+
const fetchIdRef = useRef(0);
|
|
450
|
+
|
|
451
|
+
// Track previous props to detect changes (for navigation between routes)
|
|
452
|
+
const prevPropsRef = useRef({ serverData, ssr, fetchError });
|
|
453
|
+
|
|
454
|
+
// Sync state when props change (e.g., navigating between routes with same component).
|
|
455
|
+
// useState only uses initial value on mount - subsequent prop changes are ignored
|
|
456
|
+
// unless we explicitly sync them.
|
|
457
|
+
useEffect(() => {
|
|
458
|
+
const prevProps = prevPropsRef.current;
|
|
459
|
+
const propsChanged = prevProps.serverData !== serverData
|
|
460
|
+
|| prevProps.ssr !== ssr
|
|
461
|
+
|| prevProps.fetchError !== fetchError;
|
|
462
|
+
|
|
463
|
+
if (propsChanged) {
|
|
464
|
+
prevPropsRef.current = { serverData, ssr, fetchError };
|
|
465
|
+
// Reset fetch guard to allow fetching with new props
|
|
466
|
+
hasFetchedRef.current = false;
|
|
467
|
+
// Increment fetch ID to invalidate any in-flight fetches
|
|
468
|
+
fetchIdRef.current += 1;
|
|
469
|
+
|
|
470
|
+
// Sync state from new props
|
|
471
|
+
if (ssr && !fetchError) {
|
|
472
|
+
// SSR succeeded: use server data directly (even if null - that's valid)
|
|
473
|
+
setData(serverData);
|
|
474
|
+
setIsLoading(false);
|
|
475
|
+
setError(null);
|
|
476
|
+
} else {
|
|
477
|
+
// Need client-side fetch: reset state to trigger fetch
|
|
478
|
+
setData(null);
|
|
479
|
+
setIsLoading(true);
|
|
480
|
+
setError(fetchError ? new Error(fetchError) : null);
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
}, [serverData, ssr, fetchError]);
|
|
484
|
+
|
|
485
|
+
const fetchData = useCallback(async () => {
|
|
486
|
+
// Capture current fetch ID to detect staleness after async operations
|
|
487
|
+
const currentFetchId = fetchIdRef.current;
|
|
488
|
+
|
|
489
|
+
setIsLoading(true);
|
|
490
|
+
setError(null);
|
|
491
|
+
|
|
492
|
+
try {
|
|
493
|
+
// Always get the current valid token via getAccessToken()
|
|
494
|
+
// This handles token refresh and uses the live session from useSession()
|
|
495
|
+
// rather than stale props.session which doesn't update after client-side refresh
|
|
496
|
+
const token = await getAccessToken();
|
|
497
|
+
const result = await fetcher(token);
|
|
498
|
+
|
|
499
|
+
// Only update state if this fetch is still current.
|
|
500
|
+
// If props changed while we were fetching, fetchIdRef will have been incremented
|
|
501
|
+
// and our captured ID will be stale - discard results to avoid overwriting
|
|
502
|
+
// fresh server data with stale client-fetched results.
|
|
503
|
+
if (fetchIdRef.current === currentFetchId) {
|
|
504
|
+
setData(result);
|
|
505
|
+
}
|
|
506
|
+
} catch (err) {
|
|
507
|
+
// Only update error state if this fetch is still current
|
|
508
|
+
if (fetchIdRef.current === currentFetchId) {
|
|
509
|
+
setError(err instanceof Error ? err : new Error(String(err)));
|
|
510
|
+
}
|
|
511
|
+
} finally {
|
|
512
|
+
// Only update loading state if this fetch is still current
|
|
513
|
+
if (fetchIdRef.current === currentFetchId) {
|
|
514
|
+
setIsLoading(false);
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
}, [fetcher, getAccessToken]);
|
|
518
|
+
|
|
519
|
+
// Fetch client-side data when needed
|
|
520
|
+
// When ssr is false (token expired server-side), we must wait for the Auth instance
|
|
521
|
+
// to be initialized before fetching. Auth is created in a parent component's effect
|
|
522
|
+
// which runs AFTER this (child) effect due to React's bottom-up effect execution.
|
|
523
|
+
// Without waiting for auth, getAccessToken() would find auth === null, fall through
|
|
524
|
+
// to check the session (which has an error because token is expired), and throw
|
|
525
|
+
// "Session expired" instead of properly refreshing the token via Auth.
|
|
526
|
+
useEffect(() => {
|
|
527
|
+
// Already fetched, don't fetch again
|
|
528
|
+
if (hasFetchedRef.current) return;
|
|
529
|
+
|
|
530
|
+
// Don't need client fetch
|
|
531
|
+
if (!needsClientFetch) return;
|
|
532
|
+
|
|
533
|
+
// When ssr is false, we need Auth to refresh the expired token.
|
|
534
|
+
// Wait for it to initialize before attempting to fetch.
|
|
535
|
+
if (!ssr && !auth) return;
|
|
536
|
+
|
|
537
|
+
hasFetchedRef.current = true;
|
|
538
|
+
fetchData();
|
|
539
|
+
}, [needsClientFetch, ssr, auth, fetchData]);
|
|
540
|
+
|
|
541
|
+
return {
|
|
542
|
+
data,
|
|
543
|
+
isLoading,
|
|
544
|
+
error,
|
|
545
|
+
refetch: fetchData,
|
|
546
|
+
};
|
|
547
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import type { DefaultSession, Session } from 'next-auth';
|
|
2
|
+
|
|
3
|
+
// Re-export types from auth-next-server for convenience
|
|
4
|
+
export type {
|
|
5
|
+
ImmutableAuthConfig,
|
|
6
|
+
ImmutableTokenData,
|
|
7
|
+
ZkEvmUser,
|
|
8
|
+
ImmutableUser,
|
|
9
|
+
} from '@imtbl/auth-next-server';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* zkEVM wallet information
|
|
13
|
+
*/
|
|
14
|
+
export interface ZkEvmInfo {
|
|
15
|
+
ethAddress: string;
|
|
16
|
+
userAdminAddress: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Auth.js v5 module augmentation to add Immutable-specific fields
|
|
21
|
+
*/
|
|
22
|
+
declare module 'next-auth' {
|
|
23
|
+
// eslint-disable-next-line @typescript-eslint/no-shadow
|
|
24
|
+
interface Session extends DefaultSession {
|
|
25
|
+
user: {
|
|
26
|
+
sub: string;
|
|
27
|
+
email?: string;
|
|
28
|
+
nickname?: string;
|
|
29
|
+
} & DefaultSession['user'];
|
|
30
|
+
accessToken: string;
|
|
31
|
+
refreshToken?: string;
|
|
32
|
+
idToken?: string;
|
|
33
|
+
accessTokenExpires: number;
|
|
34
|
+
zkEvm?: ZkEvmInfo;
|
|
35
|
+
error?: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface User {
|
|
39
|
+
id: string;
|
|
40
|
+
sub: string;
|
|
41
|
+
email?: string | null;
|
|
42
|
+
nickname?: string;
|
|
43
|
+
accessToken: string;
|
|
44
|
+
refreshToken?: string;
|
|
45
|
+
idToken?: string;
|
|
46
|
+
accessTokenExpires: number;
|
|
47
|
+
zkEvm?: ZkEvmInfo;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Props for ImmutableAuthProvider
|
|
53
|
+
*/
|
|
54
|
+
export interface ImmutableAuthProviderProps {
|
|
55
|
+
children: React.ReactNode;
|
|
56
|
+
/**
|
|
57
|
+
* Immutable auth configuration
|
|
58
|
+
*/
|
|
59
|
+
config: {
|
|
60
|
+
clientId: string;
|
|
61
|
+
redirectUri: string;
|
|
62
|
+
popupRedirectUri?: string;
|
|
63
|
+
logoutRedirectUri?: string;
|
|
64
|
+
audience?: string;
|
|
65
|
+
scope?: string;
|
|
66
|
+
authenticationDomain?: string;
|
|
67
|
+
passportDomain?: string;
|
|
68
|
+
};
|
|
69
|
+
/**
|
|
70
|
+
* Initial session from server (for SSR hydration)
|
|
71
|
+
* Can be Session from auth() or any compatible session object
|
|
72
|
+
*/
|
|
73
|
+
session?: Session | DefaultSession | null;
|
|
74
|
+
/**
|
|
75
|
+
* Custom base path for Auth.js API routes
|
|
76
|
+
* Use this when you have multiple auth endpoints (e.g., per environment)
|
|
77
|
+
* @default "/api/auth"
|
|
78
|
+
*/
|
|
79
|
+
basePath?: string;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* User profile from Immutable (local definition for client)
|
|
84
|
+
*/
|
|
85
|
+
export interface ImmutableUserClient {
|
|
86
|
+
sub: string;
|
|
87
|
+
email?: string;
|
|
88
|
+
nickname?: string;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Token data passed from client to Auth.js credentials provider
|
|
93
|
+
*/
|
|
94
|
+
export interface ImmutableTokenDataClient {
|
|
95
|
+
accessToken: string;
|
|
96
|
+
refreshToken?: string;
|
|
97
|
+
idToken?: string;
|
|
98
|
+
accessTokenExpires: number;
|
|
99
|
+
profile: {
|
|
100
|
+
sub: string;
|
|
101
|
+
email?: string;
|
|
102
|
+
nickname?: string;
|
|
103
|
+
};
|
|
104
|
+
zkEvm?: ZkEvmInfo;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Return type of useImmutableAuth hook
|
|
109
|
+
*/
|
|
110
|
+
export interface UseImmutableAuthReturn {
|
|
111
|
+
/**
|
|
112
|
+
* Current user profile (null if not authenticated)
|
|
113
|
+
*/
|
|
114
|
+
user: ImmutableUserClient | null;
|
|
115
|
+
/**
|
|
116
|
+
* Full Auth.js session with tokens
|
|
117
|
+
*/
|
|
118
|
+
session: Session | null;
|
|
119
|
+
/**
|
|
120
|
+
* Whether authentication state is loading (initial session fetch)
|
|
121
|
+
*/
|
|
122
|
+
isLoading: boolean;
|
|
123
|
+
/**
|
|
124
|
+
* Whether a login flow is in progress (popup open, waiting for OAuth callback)
|
|
125
|
+
*/
|
|
126
|
+
isLoggingIn: boolean;
|
|
127
|
+
/**
|
|
128
|
+
* Whether user is authenticated
|
|
129
|
+
*/
|
|
130
|
+
isAuthenticated: boolean;
|
|
131
|
+
/**
|
|
132
|
+
* Sign in with Immutable (opens popup)
|
|
133
|
+
* @param options - Optional login options (cached session, silent login, redirect flow, direct login)
|
|
134
|
+
*/
|
|
135
|
+
signIn: (options?: import('@imtbl/auth').LoginOptions) => Promise<void>;
|
|
136
|
+
/**
|
|
137
|
+
* Sign out from both Auth.js and Immutable
|
|
138
|
+
*/
|
|
139
|
+
signOut: () => Promise<void>;
|
|
140
|
+
/**
|
|
141
|
+
* Get a valid access token (refreshes if needed)
|
|
142
|
+
*/
|
|
143
|
+
getAccessToken: () => Promise<string>;
|
|
144
|
+
/**
|
|
145
|
+
* The underlying Auth instance (for advanced use)
|
|
146
|
+
*/
|
|
147
|
+
auth: import('@imtbl/auth').Auth | null;
|
|
148
|
+
}
|