@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
|
@@ -0,0 +1,486 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
// src/provider.tsx
|
|
4
|
+
import {
|
|
5
|
+
createContext,
|
|
6
|
+
useContext,
|
|
7
|
+
useEffect,
|
|
8
|
+
useRef,
|
|
9
|
+
useState,
|
|
10
|
+
useCallback,
|
|
11
|
+
useMemo
|
|
12
|
+
} from "react";
|
|
13
|
+
import {
|
|
14
|
+
SessionProvider,
|
|
15
|
+
useSession,
|
|
16
|
+
signIn,
|
|
17
|
+
signOut
|
|
18
|
+
} from "next-auth/react";
|
|
19
|
+
import {
|
|
20
|
+
Auth,
|
|
21
|
+
AuthEvents
|
|
22
|
+
} from "@imtbl/auth";
|
|
23
|
+
|
|
24
|
+
// src/utils/token.ts
|
|
25
|
+
import { decodeJwtPayload } from "@imtbl/auth";
|
|
26
|
+
|
|
27
|
+
// src/constants.ts
|
|
28
|
+
var DEFAULT_AUTH_DOMAIN = "https://auth.immutable.com";
|
|
29
|
+
var DEFAULT_AUDIENCE = "platform_api";
|
|
30
|
+
var DEFAULT_SCOPE = "openid profile email offline_access transact";
|
|
31
|
+
var IMMUTABLE_PROVIDER_ID = "immutable";
|
|
32
|
+
var DEFAULT_NEXTAUTH_BASE_PATH = "/api/auth";
|
|
33
|
+
var DEFAULT_TOKEN_EXPIRY_SECONDS = 900;
|
|
34
|
+
var DEFAULT_TOKEN_EXPIRY_MS = DEFAULT_TOKEN_EXPIRY_SECONDS * 1e3;
|
|
35
|
+
|
|
36
|
+
// src/utils/token.ts
|
|
37
|
+
function getTokenExpiry(accessToken) {
|
|
38
|
+
if (!accessToken) {
|
|
39
|
+
return Date.now() + DEFAULT_TOKEN_EXPIRY_MS;
|
|
40
|
+
}
|
|
41
|
+
try {
|
|
42
|
+
const payload = decodeJwtPayload(accessToken);
|
|
43
|
+
if (payload.exp && typeof payload.exp === "number") {
|
|
44
|
+
return payload.exp * 1e3;
|
|
45
|
+
}
|
|
46
|
+
return Date.now() + DEFAULT_TOKEN_EXPIRY_MS;
|
|
47
|
+
} catch {
|
|
48
|
+
return Date.now() + DEFAULT_TOKEN_EXPIRY_MS;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// src/provider.tsx
|
|
53
|
+
import { jsx } from "react/jsx-runtime";
|
|
54
|
+
var ImmutableAuthContext = createContext(null);
|
|
55
|
+
function ImmutableAuthInner({
|
|
56
|
+
children,
|
|
57
|
+
config,
|
|
58
|
+
basePath
|
|
59
|
+
}) {
|
|
60
|
+
const [auth, setAuth] = useState(null);
|
|
61
|
+
const prevConfigRef = useRef(null);
|
|
62
|
+
const authInstanceRef = useRef(null);
|
|
63
|
+
const [isAuthReady, setIsAuthReady] = useState(false);
|
|
64
|
+
const { data: session, update: updateSession } = useSession();
|
|
65
|
+
useEffect(() => {
|
|
66
|
+
if (typeof window === "undefined") return void 0;
|
|
67
|
+
const configKey = [
|
|
68
|
+
config.clientId,
|
|
69
|
+
config.redirectUri,
|
|
70
|
+
config.popupRedirectUri || "",
|
|
71
|
+
config.logoutRedirectUri || "",
|
|
72
|
+
config.audience || DEFAULT_AUDIENCE,
|
|
73
|
+
config.scope || DEFAULT_SCOPE,
|
|
74
|
+
config.authenticationDomain || DEFAULT_AUTH_DOMAIN,
|
|
75
|
+
config.passportDomain || ""
|
|
76
|
+
].join(":");
|
|
77
|
+
if (prevConfigRef.current === configKey && authInstanceRef.current !== null) {
|
|
78
|
+
return void 0;
|
|
79
|
+
}
|
|
80
|
+
prevConfigRef.current = configKey;
|
|
81
|
+
const newAuth = new Auth({
|
|
82
|
+
clientId: config.clientId,
|
|
83
|
+
redirectUri: config.redirectUri,
|
|
84
|
+
popupRedirectUri: config.popupRedirectUri,
|
|
85
|
+
logoutRedirectUri: config.logoutRedirectUri,
|
|
86
|
+
audience: config.audience || DEFAULT_AUDIENCE,
|
|
87
|
+
scope: config.scope || DEFAULT_SCOPE,
|
|
88
|
+
authenticationDomain: config.authenticationDomain || DEFAULT_AUTH_DOMAIN,
|
|
89
|
+
passportDomain: config.passportDomain
|
|
90
|
+
});
|
|
91
|
+
authInstanceRef.current = newAuth;
|
|
92
|
+
setAuth(newAuth);
|
|
93
|
+
setIsAuthReady(true);
|
|
94
|
+
return () => {
|
|
95
|
+
authInstanceRef.current = null;
|
|
96
|
+
setAuth(null);
|
|
97
|
+
setIsAuthReady(false);
|
|
98
|
+
};
|
|
99
|
+
}, [config]);
|
|
100
|
+
useEffect(() => {
|
|
101
|
+
if (!auth || !isAuthReady) return void 0;
|
|
102
|
+
const handleLoggedIn = async (authUser) => {
|
|
103
|
+
if (session?.accessToken && authUser.accessToken !== session.accessToken) {
|
|
104
|
+
await updateSession({
|
|
105
|
+
accessToken: authUser.accessToken,
|
|
106
|
+
refreshToken: authUser.refreshToken,
|
|
107
|
+
idToken: authUser.idToken,
|
|
108
|
+
accessTokenExpires: getTokenExpiry(authUser.accessToken),
|
|
109
|
+
zkEvm: authUser.zkEvm
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
const handleTokenRefreshed = async (authUser) => {
|
|
114
|
+
await updateSession({
|
|
115
|
+
accessToken: authUser.accessToken,
|
|
116
|
+
refreshToken: authUser.refreshToken,
|
|
117
|
+
idToken: authUser.idToken,
|
|
118
|
+
accessTokenExpires: getTokenExpiry(authUser.accessToken),
|
|
119
|
+
zkEvm: authUser.zkEvm
|
|
120
|
+
});
|
|
121
|
+
};
|
|
122
|
+
const handleUserRemoved = async (payload) => {
|
|
123
|
+
console.warn("[auth-next-client] User removed from Auth SDK:", payload.reason, payload.error);
|
|
124
|
+
await signOut({ redirect: false });
|
|
125
|
+
};
|
|
126
|
+
const handleLoggedOut = async () => {
|
|
127
|
+
await signOut({ redirect: false });
|
|
128
|
+
};
|
|
129
|
+
auth.eventEmitter.on(AuthEvents.LOGGED_IN, handleLoggedIn);
|
|
130
|
+
auth.eventEmitter.on(AuthEvents.TOKEN_REFRESHED, handleTokenRefreshed);
|
|
131
|
+
auth.eventEmitter.on(AuthEvents.USER_REMOVED, handleUserRemoved);
|
|
132
|
+
auth.eventEmitter.on(AuthEvents.LOGGED_OUT, handleLoggedOut);
|
|
133
|
+
return () => {
|
|
134
|
+
auth.eventEmitter.removeListener(AuthEvents.LOGGED_IN, handleLoggedIn);
|
|
135
|
+
auth.eventEmitter.removeListener(AuthEvents.TOKEN_REFRESHED, handleTokenRefreshed);
|
|
136
|
+
auth.eventEmitter.removeListener(AuthEvents.USER_REMOVED, handleUserRemoved);
|
|
137
|
+
auth.eventEmitter.removeListener(AuthEvents.LOGGED_OUT, handleLoggedOut);
|
|
138
|
+
};
|
|
139
|
+
}, [auth, isAuthReady, session, updateSession]);
|
|
140
|
+
const contextValue = useMemo(
|
|
141
|
+
() => ({ auth, config, basePath }),
|
|
142
|
+
[auth, config, basePath]
|
|
143
|
+
);
|
|
144
|
+
return /* @__PURE__ */ jsx(ImmutableAuthContext.Provider, { value: contextValue, children });
|
|
145
|
+
}
|
|
146
|
+
function ImmutableAuthProvider({
|
|
147
|
+
children,
|
|
148
|
+
config,
|
|
149
|
+
session,
|
|
150
|
+
basePath = DEFAULT_NEXTAUTH_BASE_PATH
|
|
151
|
+
}) {
|
|
152
|
+
return /* @__PURE__ */ jsx(SessionProvider, { session, basePath, children: /* @__PURE__ */ jsx(ImmutableAuthInner, { config, basePath, children }) });
|
|
153
|
+
}
|
|
154
|
+
function useImmutableAuth() {
|
|
155
|
+
const context = useContext(ImmutableAuthContext);
|
|
156
|
+
const { data: sessionData, status } = useSession();
|
|
157
|
+
const [isLoggingIn, setIsLoggingIn] = useState(false);
|
|
158
|
+
if (!context) {
|
|
159
|
+
throw new Error("useImmutableAuth must be used within ImmutableAuthProvider");
|
|
160
|
+
}
|
|
161
|
+
const session = sessionData;
|
|
162
|
+
const { auth } = context;
|
|
163
|
+
const isLoading = status === "loading";
|
|
164
|
+
const isAuthenticated = status === "authenticated" && !!session;
|
|
165
|
+
const user = session?.user ? {
|
|
166
|
+
sub: session.user.sub,
|
|
167
|
+
email: session.user.email,
|
|
168
|
+
nickname: session.user.nickname
|
|
169
|
+
} : null;
|
|
170
|
+
const handleSignIn = useCallback(async (options) => {
|
|
171
|
+
if (!auth) {
|
|
172
|
+
throw new Error("Auth not initialized");
|
|
173
|
+
}
|
|
174
|
+
setIsLoggingIn(true);
|
|
175
|
+
try {
|
|
176
|
+
const authUser = await auth.login(options);
|
|
177
|
+
if (!authUser) {
|
|
178
|
+
throw new Error("Login failed");
|
|
179
|
+
}
|
|
180
|
+
const tokenData = {
|
|
181
|
+
accessToken: authUser.accessToken,
|
|
182
|
+
refreshToken: authUser.refreshToken,
|
|
183
|
+
idToken: authUser.idToken,
|
|
184
|
+
accessTokenExpires: getTokenExpiry(authUser.accessToken),
|
|
185
|
+
profile: {
|
|
186
|
+
sub: authUser.profile.sub,
|
|
187
|
+
email: authUser.profile.email,
|
|
188
|
+
nickname: authUser.profile.nickname
|
|
189
|
+
},
|
|
190
|
+
zkEvm: authUser.zkEvm
|
|
191
|
+
};
|
|
192
|
+
const result = await signIn(IMMUTABLE_PROVIDER_ID, {
|
|
193
|
+
tokens: JSON.stringify(tokenData),
|
|
194
|
+
redirect: false
|
|
195
|
+
});
|
|
196
|
+
if (result?.error) {
|
|
197
|
+
throw new Error(`NextAuth sign-in failed: ${result.error}`);
|
|
198
|
+
}
|
|
199
|
+
if (!result?.ok) {
|
|
200
|
+
throw new Error("NextAuth sign-in failed: unknown error");
|
|
201
|
+
}
|
|
202
|
+
} finally {
|
|
203
|
+
setIsLoggingIn(false);
|
|
204
|
+
}
|
|
205
|
+
}, [auth]);
|
|
206
|
+
const handleSignOut = useCallback(async () => {
|
|
207
|
+
if (auth) {
|
|
208
|
+
try {
|
|
209
|
+
await auth.getLogoutUrl();
|
|
210
|
+
} catch (error) {
|
|
211
|
+
console.warn("[auth-next-client] Logout cleanup error:", error);
|
|
212
|
+
await signOut({ redirect: false });
|
|
213
|
+
}
|
|
214
|
+
} else {
|
|
215
|
+
await signOut({ redirect: false });
|
|
216
|
+
}
|
|
217
|
+
}, [auth]);
|
|
218
|
+
const getAccessToken = useCallback(async () => {
|
|
219
|
+
if (auth) {
|
|
220
|
+
try {
|
|
221
|
+
const token = await auth.getAccessToken();
|
|
222
|
+
if (token) {
|
|
223
|
+
return token;
|
|
224
|
+
}
|
|
225
|
+
} catch {
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
if (session?.error) {
|
|
229
|
+
throw new Error(
|
|
230
|
+
session.error === "TokenExpired" ? "Session expired. Please log in again." : `Authentication error: ${session.error}`
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
if (session?.accessToken) {
|
|
234
|
+
return session.accessToken;
|
|
235
|
+
}
|
|
236
|
+
throw new Error("No access token available");
|
|
237
|
+
}, [auth, session]);
|
|
238
|
+
return {
|
|
239
|
+
user,
|
|
240
|
+
session,
|
|
241
|
+
isLoading,
|
|
242
|
+
isLoggingIn,
|
|
243
|
+
isAuthenticated,
|
|
244
|
+
signIn: handleSignIn,
|
|
245
|
+
signOut: handleSignOut,
|
|
246
|
+
getAccessToken,
|
|
247
|
+
auth
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
function useAccessToken() {
|
|
251
|
+
const { getAccessToken } = useImmutableAuth();
|
|
252
|
+
return getAccessToken;
|
|
253
|
+
}
|
|
254
|
+
function useHydratedData(props, fetcher) {
|
|
255
|
+
const { getAccessToken, auth } = useImmutableAuth();
|
|
256
|
+
const {
|
|
257
|
+
ssr,
|
|
258
|
+
data: serverData,
|
|
259
|
+
fetchError
|
|
260
|
+
} = props;
|
|
261
|
+
const needsClientFetch = !ssr || Boolean(fetchError);
|
|
262
|
+
const [data, setData] = useState(serverData);
|
|
263
|
+
const [isLoading, setIsLoading] = useState(needsClientFetch);
|
|
264
|
+
const [error, setError] = useState(
|
|
265
|
+
fetchError ? new Error(fetchError) : null
|
|
266
|
+
);
|
|
267
|
+
const hasFetchedRef = useRef(false);
|
|
268
|
+
const fetchIdRef = useRef(0);
|
|
269
|
+
const prevPropsRef = useRef({ serverData, ssr, fetchError });
|
|
270
|
+
useEffect(() => {
|
|
271
|
+
const prevProps = prevPropsRef.current;
|
|
272
|
+
const propsChanged = prevProps.serverData !== serverData || prevProps.ssr !== ssr || prevProps.fetchError !== fetchError;
|
|
273
|
+
if (propsChanged) {
|
|
274
|
+
prevPropsRef.current = { serverData, ssr, fetchError };
|
|
275
|
+
hasFetchedRef.current = false;
|
|
276
|
+
fetchIdRef.current += 1;
|
|
277
|
+
if (ssr && !fetchError) {
|
|
278
|
+
setData(serverData);
|
|
279
|
+
setIsLoading(false);
|
|
280
|
+
setError(null);
|
|
281
|
+
} else {
|
|
282
|
+
setData(null);
|
|
283
|
+
setIsLoading(true);
|
|
284
|
+
setError(fetchError ? new Error(fetchError) : null);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
}, [serverData, ssr, fetchError]);
|
|
288
|
+
const fetchData = useCallback(async () => {
|
|
289
|
+
const currentFetchId = fetchIdRef.current;
|
|
290
|
+
setIsLoading(true);
|
|
291
|
+
setError(null);
|
|
292
|
+
try {
|
|
293
|
+
const token = await getAccessToken();
|
|
294
|
+
const result = await fetcher(token);
|
|
295
|
+
if (fetchIdRef.current === currentFetchId) {
|
|
296
|
+
setData(result);
|
|
297
|
+
}
|
|
298
|
+
} catch (err) {
|
|
299
|
+
if (fetchIdRef.current === currentFetchId) {
|
|
300
|
+
setError(err instanceof Error ? err : new Error(String(err)));
|
|
301
|
+
}
|
|
302
|
+
} finally {
|
|
303
|
+
if (fetchIdRef.current === currentFetchId) {
|
|
304
|
+
setIsLoading(false);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
}, [fetcher, getAccessToken]);
|
|
308
|
+
useEffect(() => {
|
|
309
|
+
if (hasFetchedRef.current) return;
|
|
310
|
+
if (!needsClientFetch) return;
|
|
311
|
+
if (!ssr && !auth) return;
|
|
312
|
+
hasFetchedRef.current = true;
|
|
313
|
+
fetchData();
|
|
314
|
+
}, [needsClientFetch, ssr, auth, fetchData]);
|
|
315
|
+
return {
|
|
316
|
+
data,
|
|
317
|
+
isLoading,
|
|
318
|
+
error,
|
|
319
|
+
refetch: fetchData
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// src/callback.tsx
|
|
324
|
+
import { useEffect as useEffect2, useState as useState2, useRef as useRef2 } from "react";
|
|
325
|
+
import { useRouter } from "next/navigation";
|
|
326
|
+
import { signIn as signIn2 } from "next-auth/react";
|
|
327
|
+
import { Auth as Auth2 } from "@imtbl/auth";
|
|
328
|
+
import { jsx as jsx2, jsxs } from "react/jsx-runtime";
|
|
329
|
+
function getSearchParams() {
|
|
330
|
+
if (typeof window === "undefined") {
|
|
331
|
+
return new URLSearchParams();
|
|
332
|
+
}
|
|
333
|
+
return new URLSearchParams(window.location.search);
|
|
334
|
+
}
|
|
335
|
+
function CallbackPage({
|
|
336
|
+
config,
|
|
337
|
+
redirectTo = "/",
|
|
338
|
+
loadingComponent = null,
|
|
339
|
+
errorComponent,
|
|
340
|
+
onSuccess,
|
|
341
|
+
onError
|
|
342
|
+
}) {
|
|
343
|
+
const router = useRouter();
|
|
344
|
+
const [error, setError] = useState2(null);
|
|
345
|
+
const callbackProcessedRef = useRef2(false);
|
|
346
|
+
useEffect2(() => {
|
|
347
|
+
const searchParams = getSearchParams();
|
|
348
|
+
const handleCallback = async () => {
|
|
349
|
+
try {
|
|
350
|
+
const auth = new Auth2({
|
|
351
|
+
clientId: config.clientId,
|
|
352
|
+
redirectUri: config.redirectUri,
|
|
353
|
+
popupRedirectUri: config.popupRedirectUri,
|
|
354
|
+
logoutRedirectUri: config.logoutRedirectUri,
|
|
355
|
+
audience: config.audience || DEFAULT_AUDIENCE,
|
|
356
|
+
scope: config.scope || DEFAULT_SCOPE,
|
|
357
|
+
authenticationDomain: config.authenticationDomain || DEFAULT_AUTH_DOMAIN,
|
|
358
|
+
passportDomain: config.passportDomain
|
|
359
|
+
});
|
|
360
|
+
const authUser = await auth.loginCallback();
|
|
361
|
+
if (window.opener) {
|
|
362
|
+
if (!authUser) {
|
|
363
|
+
throw new Error("Authentication failed: no user data received from login callback");
|
|
364
|
+
}
|
|
365
|
+
const user = {
|
|
366
|
+
sub: authUser.profile.sub,
|
|
367
|
+
email: authUser.profile.email,
|
|
368
|
+
nickname: authUser.profile.nickname
|
|
369
|
+
};
|
|
370
|
+
if (onSuccess) {
|
|
371
|
+
await onSuccess(user);
|
|
372
|
+
}
|
|
373
|
+
window.close();
|
|
374
|
+
} else if (authUser) {
|
|
375
|
+
const tokenData = {
|
|
376
|
+
accessToken: authUser.accessToken,
|
|
377
|
+
refreshToken: authUser.refreshToken,
|
|
378
|
+
idToken: authUser.idToken,
|
|
379
|
+
accessTokenExpires: getTokenExpiry(authUser.accessToken),
|
|
380
|
+
profile: {
|
|
381
|
+
sub: authUser.profile.sub,
|
|
382
|
+
email: authUser.profile.email,
|
|
383
|
+
nickname: authUser.profile.nickname
|
|
384
|
+
},
|
|
385
|
+
zkEvm: authUser.zkEvm
|
|
386
|
+
};
|
|
387
|
+
const result = await signIn2(IMMUTABLE_PROVIDER_ID, {
|
|
388
|
+
tokens: JSON.stringify(tokenData),
|
|
389
|
+
redirect: false
|
|
390
|
+
});
|
|
391
|
+
if (result?.error) {
|
|
392
|
+
throw new Error(`NextAuth sign-in failed: ${result.error}`);
|
|
393
|
+
}
|
|
394
|
+
if (!result?.ok) {
|
|
395
|
+
throw new Error("NextAuth sign-in failed: unknown error");
|
|
396
|
+
}
|
|
397
|
+
const user = {
|
|
398
|
+
sub: authUser.profile.sub,
|
|
399
|
+
email: authUser.profile.email,
|
|
400
|
+
nickname: authUser.profile.nickname
|
|
401
|
+
};
|
|
402
|
+
if (onSuccess) {
|
|
403
|
+
await onSuccess(user);
|
|
404
|
+
}
|
|
405
|
+
const resolvedRedirectTo = typeof redirectTo === "function" ? redirectTo(user) || "/" : redirectTo;
|
|
406
|
+
router.replace(resolvedRedirectTo);
|
|
407
|
+
} else {
|
|
408
|
+
throw new Error("Authentication failed: no user data received from login callback");
|
|
409
|
+
}
|
|
410
|
+
} catch (err) {
|
|
411
|
+
const errorMessage2 = err instanceof Error ? err.message : "Authentication failed";
|
|
412
|
+
if (onError) {
|
|
413
|
+
onError(errorMessage2);
|
|
414
|
+
}
|
|
415
|
+
setError(errorMessage2);
|
|
416
|
+
}
|
|
417
|
+
};
|
|
418
|
+
const handleOAuthError = () => {
|
|
419
|
+
const errorCode = searchParams.get("error");
|
|
420
|
+
const errorDescription = searchParams.get("error_description");
|
|
421
|
+
const errorMessage2 = errorDescription || errorCode || "Authentication failed";
|
|
422
|
+
if (onError) {
|
|
423
|
+
onError(errorMessage2);
|
|
424
|
+
}
|
|
425
|
+
setError(errorMessage2);
|
|
426
|
+
};
|
|
427
|
+
if (callbackProcessedRef.current) {
|
|
428
|
+
return;
|
|
429
|
+
}
|
|
430
|
+
const hasError = searchParams.get("error");
|
|
431
|
+
const hasCode = searchParams.get("code");
|
|
432
|
+
if (hasError) {
|
|
433
|
+
callbackProcessedRef.current = true;
|
|
434
|
+
handleOAuthError();
|
|
435
|
+
return;
|
|
436
|
+
}
|
|
437
|
+
if (hasCode) {
|
|
438
|
+
callbackProcessedRef.current = true;
|
|
439
|
+
handleCallback();
|
|
440
|
+
return;
|
|
441
|
+
}
|
|
442
|
+
callbackProcessedRef.current = true;
|
|
443
|
+
const errorMessage = "Invalid callback: missing OAuth parameters. Please try logging in again.";
|
|
444
|
+
if (onError) {
|
|
445
|
+
onError(errorMessage);
|
|
446
|
+
}
|
|
447
|
+
setError(errorMessage);
|
|
448
|
+
}, [router, config, redirectTo, onSuccess, onError]);
|
|
449
|
+
if (error) {
|
|
450
|
+
if (errorComponent) {
|
|
451
|
+
return errorComponent(error);
|
|
452
|
+
}
|
|
453
|
+
return /* @__PURE__ */ jsxs("div", { style: { padding: "2rem", textAlign: "center" }, children: [
|
|
454
|
+
/* @__PURE__ */ jsx2("h2", { style: { color: "#dc3545" }, children: "Authentication Error" }),
|
|
455
|
+
/* @__PURE__ */ jsx2("p", { children: error }),
|
|
456
|
+
/* @__PURE__ */ jsx2(
|
|
457
|
+
"button",
|
|
458
|
+
{
|
|
459
|
+
onClick: () => router.push("/"),
|
|
460
|
+
type: "button",
|
|
461
|
+
style: {
|
|
462
|
+
padding: "0.5rem 1rem",
|
|
463
|
+
marginTop: "1rem",
|
|
464
|
+
cursor: "pointer"
|
|
465
|
+
},
|
|
466
|
+
children: "Return to Home"
|
|
467
|
+
}
|
|
468
|
+
)
|
|
469
|
+
] });
|
|
470
|
+
}
|
|
471
|
+
if (loadingComponent) {
|
|
472
|
+
return loadingComponent;
|
|
473
|
+
}
|
|
474
|
+
return /* @__PURE__ */ jsx2("div", { style: { padding: "2rem", textAlign: "center" }, children: /* @__PURE__ */ jsx2("p", { children: "Completing authentication..." }) });
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
// src/index.ts
|
|
478
|
+
import { MarketingConsentStatus } from "@imtbl/auth";
|
|
479
|
+
export {
|
|
480
|
+
CallbackPage,
|
|
481
|
+
ImmutableAuthProvider,
|
|
482
|
+
MarketingConsentStatus,
|
|
483
|
+
useAccessToken,
|
|
484
|
+
useHydratedData,
|
|
485
|
+
useImmutableAuth
|
|
486
|
+
};
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import type { Session } from 'next-auth';
|
|
2
|
+
import type { ImmutableAuthProviderProps, UseImmutableAuthReturn } from './types';
|
|
3
|
+
/**
|
|
4
|
+
* Provider component for Immutable authentication with Auth.js v5
|
|
5
|
+
*
|
|
6
|
+
* Wraps your app to provide authentication state via useImmutableAuth hook.
|
|
7
|
+
*
|
|
8
|
+
* @example App Router (recommended)
|
|
9
|
+
* ```tsx
|
|
10
|
+
* // app/providers.tsx
|
|
11
|
+
* "use client";
|
|
12
|
+
* import { ImmutableAuthProvider } from "@imtbl/auth-next-client";
|
|
13
|
+
*
|
|
14
|
+
* const config = {
|
|
15
|
+
* clientId: process.env.NEXT_PUBLIC_IMMUTABLE_CLIENT_ID!,
|
|
16
|
+
* redirectUri: `${process.env.NEXT_PUBLIC_BASE_URL}/callback`,
|
|
17
|
+
* };
|
|
18
|
+
*
|
|
19
|
+
* export function Providers({ children }: { children: React.ReactNode }) {
|
|
20
|
+
* return (
|
|
21
|
+
* <ImmutableAuthProvider config={config}>
|
|
22
|
+
* {children}
|
|
23
|
+
* </ImmutableAuthProvider>
|
|
24
|
+
* );
|
|
25
|
+
* }
|
|
26
|
+
* ```
|
|
27
|
+
*/
|
|
28
|
+
export declare function ImmutableAuthProvider({ children, config, session, basePath, }: ImmutableAuthProviderProps): import("react/jsx-runtime").JSX.Element;
|
|
29
|
+
/**
|
|
30
|
+
* Hook to access Immutable authentication state and methods
|
|
31
|
+
*
|
|
32
|
+
* Must be used within an ImmutableAuthProvider.
|
|
33
|
+
*/
|
|
34
|
+
export declare function useImmutableAuth(): UseImmutableAuthReturn;
|
|
35
|
+
/**
|
|
36
|
+
* Hook to get a function that returns a valid access token
|
|
37
|
+
*/
|
|
38
|
+
export declare function useAccessToken(): () => Promise<string>;
|
|
39
|
+
/**
|
|
40
|
+
* Result from useHydratedData hook
|
|
41
|
+
*/
|
|
42
|
+
export interface UseHydratedDataResult<T> {
|
|
43
|
+
data: T | null;
|
|
44
|
+
isLoading: boolean;
|
|
45
|
+
error: Error | null;
|
|
46
|
+
refetch: () => Promise<void>;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Props for useHydratedData hook - matches AuthPropsWithData from server
|
|
50
|
+
*/
|
|
51
|
+
export interface HydratedDataProps<T> {
|
|
52
|
+
session: Session | null;
|
|
53
|
+
ssr: boolean;
|
|
54
|
+
data: T | null;
|
|
55
|
+
fetchError?: string;
|
|
56
|
+
authError?: string;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Hook for hydrating server-fetched data with automatic client-side fallback.
|
|
60
|
+
*
|
|
61
|
+
* This is the recommended pattern for components that receive data from `getAuthenticatedData`:
|
|
62
|
+
* - When `ssr: true` and `data` exists: Uses pre-fetched server data immediately (no loading state)
|
|
63
|
+
* - When `ssr: false`: Refreshes token client-side and fetches data
|
|
64
|
+
* - When `fetchError` exists: Retries fetch client-side
|
|
65
|
+
*/
|
|
66
|
+
export declare function useHydratedData<T>(props: HydratedDataProps<T>, fetcher: (accessToken: string) => Promise<T>): UseHydratedDataResult<T>;
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import type { DefaultSession, Session } from 'next-auth';
|
|
2
|
+
export type { ImmutableAuthConfig, ImmutableTokenData, ZkEvmUser, ImmutableUser, } from '@imtbl/auth-next-server';
|
|
3
|
+
/**
|
|
4
|
+
* zkEVM wallet information
|
|
5
|
+
*/
|
|
6
|
+
export interface ZkEvmInfo {
|
|
7
|
+
ethAddress: string;
|
|
8
|
+
userAdminAddress: string;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Auth.js v5 module augmentation to add Immutable-specific fields
|
|
12
|
+
*/
|
|
13
|
+
declare module 'next-auth' {
|
|
14
|
+
interface Session extends DefaultSession {
|
|
15
|
+
user: {
|
|
16
|
+
sub: string;
|
|
17
|
+
email?: string;
|
|
18
|
+
nickname?: string;
|
|
19
|
+
} & DefaultSession['user'];
|
|
20
|
+
accessToken: string;
|
|
21
|
+
refreshToken?: string;
|
|
22
|
+
idToken?: string;
|
|
23
|
+
accessTokenExpires: number;
|
|
24
|
+
zkEvm?: ZkEvmInfo;
|
|
25
|
+
error?: string;
|
|
26
|
+
}
|
|
27
|
+
interface User {
|
|
28
|
+
id: string;
|
|
29
|
+
sub: string;
|
|
30
|
+
email?: string | null;
|
|
31
|
+
nickname?: string;
|
|
32
|
+
accessToken: string;
|
|
33
|
+
refreshToken?: string;
|
|
34
|
+
idToken?: string;
|
|
35
|
+
accessTokenExpires: number;
|
|
36
|
+
zkEvm?: ZkEvmInfo;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Props for ImmutableAuthProvider
|
|
41
|
+
*/
|
|
42
|
+
export interface ImmutableAuthProviderProps {
|
|
43
|
+
children: React.ReactNode;
|
|
44
|
+
/**
|
|
45
|
+
* Immutable auth configuration
|
|
46
|
+
*/
|
|
47
|
+
config: {
|
|
48
|
+
clientId: string;
|
|
49
|
+
redirectUri: string;
|
|
50
|
+
popupRedirectUri?: string;
|
|
51
|
+
logoutRedirectUri?: string;
|
|
52
|
+
audience?: string;
|
|
53
|
+
scope?: string;
|
|
54
|
+
authenticationDomain?: string;
|
|
55
|
+
passportDomain?: string;
|
|
56
|
+
};
|
|
57
|
+
/**
|
|
58
|
+
* Initial session from server (for SSR hydration)
|
|
59
|
+
* Can be Session from auth() or any compatible session object
|
|
60
|
+
*/
|
|
61
|
+
session?: Session | DefaultSession | null;
|
|
62
|
+
/**
|
|
63
|
+
* Custom base path for Auth.js API routes
|
|
64
|
+
* Use this when you have multiple auth endpoints (e.g., per environment)
|
|
65
|
+
* @default "/api/auth"
|
|
66
|
+
*/
|
|
67
|
+
basePath?: string;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* User profile from Immutable (local definition for client)
|
|
71
|
+
*/
|
|
72
|
+
export interface ImmutableUserClient {
|
|
73
|
+
sub: string;
|
|
74
|
+
email?: string;
|
|
75
|
+
nickname?: string;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Token data passed from client to Auth.js credentials provider
|
|
79
|
+
*/
|
|
80
|
+
export interface ImmutableTokenDataClient {
|
|
81
|
+
accessToken: string;
|
|
82
|
+
refreshToken?: string;
|
|
83
|
+
idToken?: string;
|
|
84
|
+
accessTokenExpires: number;
|
|
85
|
+
profile: {
|
|
86
|
+
sub: string;
|
|
87
|
+
email?: string;
|
|
88
|
+
nickname?: string;
|
|
89
|
+
};
|
|
90
|
+
zkEvm?: ZkEvmInfo;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Return type of useImmutableAuth hook
|
|
94
|
+
*/
|
|
95
|
+
export interface UseImmutableAuthReturn {
|
|
96
|
+
/**
|
|
97
|
+
* Current user profile (null if not authenticated)
|
|
98
|
+
*/
|
|
99
|
+
user: ImmutableUserClient | null;
|
|
100
|
+
/**
|
|
101
|
+
* Full Auth.js session with tokens
|
|
102
|
+
*/
|
|
103
|
+
session: Session | null;
|
|
104
|
+
/**
|
|
105
|
+
* Whether authentication state is loading (initial session fetch)
|
|
106
|
+
*/
|
|
107
|
+
isLoading: boolean;
|
|
108
|
+
/**
|
|
109
|
+
* Whether a login flow is in progress (popup open, waiting for OAuth callback)
|
|
110
|
+
*/
|
|
111
|
+
isLoggingIn: boolean;
|
|
112
|
+
/**
|
|
113
|
+
* Whether user is authenticated
|
|
114
|
+
*/
|
|
115
|
+
isAuthenticated: boolean;
|
|
116
|
+
/**
|
|
117
|
+
* Sign in with Immutable (opens popup)
|
|
118
|
+
* @param options - Optional login options (cached session, silent login, redirect flow, direct login)
|
|
119
|
+
*/
|
|
120
|
+
signIn: (options?: import('@imtbl/auth').LoginOptions) => Promise<void>;
|
|
121
|
+
/**
|
|
122
|
+
* Sign out from both Auth.js and Immutable
|
|
123
|
+
*/
|
|
124
|
+
signOut: () => Promise<void>;
|
|
125
|
+
/**
|
|
126
|
+
* Get a valid access token (refreshes if needed)
|
|
127
|
+
*/
|
|
128
|
+
getAccessToken: () => Promise<string>;
|
|
129
|
+
/**
|
|
130
|
+
* The underlying Auth instance (for advanced use)
|
|
131
|
+
*/
|
|
132
|
+
auth: import('@imtbl/auth').Auth | null;
|
|
133
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Extract the expiry timestamp from a JWT access token.
|
|
3
|
+
* Returns the expiry as a Unix timestamp in milliseconds.
|
|
4
|
+
*
|
|
5
|
+
* @param accessToken - JWT access token
|
|
6
|
+
* @returns Expiry timestamp in milliseconds, or a default 15-minute expiry if extraction fails
|
|
7
|
+
*/
|
|
8
|
+
export declare function getTokenExpiry(accessToken: string | undefined): number;
|
package/jest.config.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { Config } from 'jest';
|
|
2
|
+
|
|
3
|
+
const config: Config = {
|
|
4
|
+
clearMocks: true,
|
|
5
|
+
coverageProvider: 'v8',
|
|
6
|
+
moduleDirectories: ['node_modules', 'src'],
|
|
7
|
+
testEnvironment: 'jsdom',
|
|
8
|
+
transform: {
|
|
9
|
+
'^.+\\.(t|j)sx?$': '@swc/jest',
|
|
10
|
+
},
|
|
11
|
+
transformIgnorePatterns: [],
|
|
12
|
+
restoreMocks: true,
|
|
13
|
+
roots: ['<rootDir>/src'],
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export default config;
|