@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 CHANGED
@@ -10,7 +10,7 @@ This package provides minimal client-side utilities for Next.js applications usi
10
10
 
11
11
  - `useLogin` - Hook for login flows with state management (loading, error)
12
12
  - `useLogout` - Hook for logout with federated logout support (clears both local and upstream sessions)
13
- - `useImmutableSession` - Hook that provides session state and a `getUser` function for wallet integration
13
+ - `useImmutableSession` - Hook that provides session state, `getAccessToken()` for guaranteed-fresh tokens, and `getUser` for wallet integration
14
14
  - `CallbackPage` - OAuth callback handler component
15
15
 
16
16
  For server-side utilities, use [`@imtbl/auth-next-server`](../auth-next-server).
@@ -395,7 +395,11 @@ With federated logout, the auth server's session is also cleared, so users can s
395
395
 
396
396
  ### `useImmutableSession()`
397
397
 
398
- A convenience hook that wraps `next-auth/react`'s `useSession` with a `getUser` function for wallet integration.
398
+ A convenience hook that wraps `next-auth/react`'s `useSession` with:
399
+
400
+ - `getAccessToken()` -- async function that returns a **guaranteed-fresh** access token
401
+ - `getUser()` -- function for wallet integration
402
+ - Automatic token refresh -- detects expired tokens and refreshes on demand
399
403
 
400
404
  ```tsx
401
405
  "use client";
@@ -404,10 +408,12 @@ import { useImmutableSession } from "@imtbl/auth-next-client";
404
408
 
405
409
  function MyComponent() {
406
410
  const {
407
- session, // Session with tokens
411
+ session, // Session metadata (user info, zkEvm, error) -- does NOT include accessToken
408
412
  status, // 'loading' | 'authenticated' | 'unauthenticated'
409
413
  isLoading, // True during initial load
410
414
  isAuthenticated, // True when logged in
415
+ isRefreshing, // True during token refresh
416
+ getAccessToken, // Async function: returns a guaranteed-fresh access token
411
417
  getUser, // Function for wallet integration
412
418
  } = useImmutableSession();
413
419
 
@@ -420,13 +426,131 @@ function MyComponent() {
420
426
 
421
427
  #### Return Value
422
428
 
423
- | Property | Type | Description |
424
- | ----------------- | --------------------------------------------------- | ---------------------------------------------------------------- |
425
- | `session` | `ImmutableSession \| null` | Session with access/refresh tokens |
426
- | `status` | `string` | Auth status: `'loading'`, `'authenticated'`, `'unauthenticated'` |
427
- | `isLoading` | `boolean` | Whether initial auth state is loading |
428
- | `isAuthenticated` | `boolean` | Whether user is authenticated |
429
- | `getUser` | `(forceRefresh?: boolean) => Promise<User \| null>` | Get user function for wallet integration |
429
+ | Property | Type | Description |
430
+ | ----------------- | --------------------------------------------------- | --------------------------------------------------------------------------------------- |
431
+ | `session` | `ImmutableSession \| null` | Session metadata (user, zkEvm, error). Does **not** include `accessToken` -- see below. |
432
+ | `status` | `string` | Auth status: `'loading'`, `'authenticated'`, `'unauthenticated'` |
433
+ | `isLoading` | `boolean` | Whether initial auth state is loading |
434
+ | `isAuthenticated` | `boolean` | Whether user is authenticated |
435
+ | `isRefreshing` | `boolean` | Whether a token refresh is in progress |
436
+ | `getAccessToken` | `() => Promise<string>` | Get a guaranteed-fresh access token. Throws if not authenticated or refresh fails. |
437
+ | `getUser` | `(forceRefresh?: boolean) => Promise<User \| null>` | Get user function for wallet integration |
438
+
439
+ #### Why no `accessToken` on `session`?
440
+
441
+ The `session` object intentionally does **not** expose `accessToken`. This is a deliberate design choice to prevent consumers from accidentally using a stale/expired token.
442
+
443
+ **Always use `getAccessToken()`** to obtain a token for authenticated requests:
444
+
445
+ ```tsx
446
+ // ✅ Correct - always fresh
447
+ const token = await getAccessToken();
448
+ await authenticatedGet("/api/data", token);
449
+
450
+ // ❌ Incorrect - session.accessToken does not exist on the type
451
+ const token = session?.accessToken; // TypeScript error
452
+ ```
453
+
454
+ `getAccessToken()` guarantees freshness:
455
+
456
+ - **Fast path**: If the current token is valid, returns immediately (no network call).
457
+ - **Slow path**: If the token is expired, triggers a server-side refresh and **blocks** (awaits) until the fresh token is available.
458
+ - **Deduplication**: Multiple concurrent calls share a single refresh request.
459
+
460
+ #### Checking Authentication Status
461
+
462
+ **Always use `isAuthenticated` to determine if a user is logged in.** Do not use the `session` object or `status` field directly for this purpose.
463
+
464
+ **Why not `!!session`?**
465
+
466
+ A `session` object can exist but be **unusable**. For example, the session may be present but the access token is missing, or a token refresh may have failed (indicated by `session.error === "RefreshTokenError"`). Checking `!!session` would incorrectly treat these broken sessions as authenticated.
467
+
468
+ **Why not `status === 'authenticated'`?**
469
+
470
+ The `status` field comes directly from NextAuth's `useSession` and only reflects whether NextAuth considers the session valid at the cookie/JWT level. It does **not** account for whether the access token is actually present or whether a token refresh has failed. A session can have `status === 'authenticated'` while `session.error` is set to `"RefreshTokenError"`, meaning the tokens are no longer usable.
471
+
472
+ **What `isAuthenticated` checks:**
473
+
474
+ The `isAuthenticated` boolean validates all of the following:
475
+
476
+ 1. NextAuth reports `'authenticated'` status
477
+ 2. The session object exists
478
+ 3. A valid access token is present in the session
479
+ 4. No session-level error exists (e.g., `RefreshTokenError`)
480
+
481
+ It also handles transient states gracefully — during session refetches (e.g., window focus) or manual refreshes (e.g., after wallet registration via `getUser(true)`), `isAuthenticated` remains `true` if the user was previously authenticated, preventing UI flicker.
482
+
483
+ ```tsx
484
+ // ✅ Correct - uses isAuthenticated
485
+ const { isAuthenticated } = useImmutableSession();
486
+ if (!isAuthenticated) return <div>Please log in</div>;
487
+
488
+ // ❌ Incorrect - session can exist with expired/invalid tokens
489
+ const { session } = useImmutableSession();
490
+ if (!session) return <div>Please log in</div>;
491
+
492
+ // ❌ Incorrect - status doesn't account for token errors
493
+ const { status } = useImmutableSession();
494
+ if (status !== "authenticated") return <div>Please log in</div>;
495
+ ```
496
+
497
+ #### Using `getAccessToken()` in Practice
498
+
499
+ **SWR fetcher:**
500
+
501
+ ```tsx
502
+ import useSWR from "swr";
503
+ import { useImmutableSession } from "@imtbl/auth-next-client";
504
+
505
+ function useProfile() {
506
+ const { getAccessToken, isAuthenticated } = useImmutableSession();
507
+
508
+ return useSWR(
509
+ isAuthenticated ? "/passport-profile/v1/profile" : null,
510
+ async (path) => {
511
+ const token = await getAccessToken(); // blocks until fresh
512
+ return authenticatedGet(path, token);
513
+ },
514
+ );
515
+ }
516
+ ```
517
+
518
+ **Event handler:**
519
+
520
+ ```tsx
521
+ import { useImmutableSession } from "@imtbl/auth-next-client";
522
+
523
+ function ClaimRewardButton({ questId }: { questId: string }) {
524
+ const { getAccessToken } = useImmutableSession();
525
+
526
+ const handleClaim = async () => {
527
+ const token = await getAccessToken(); // blocks until fresh
528
+ await authenticatedPost("/v1/quests/claim", token, { questId });
529
+ };
530
+
531
+ return <button onClick={handleClaim}>Claim</button>;
532
+ }
533
+ ```
534
+
535
+ **Periodic polling:**
536
+
537
+ ```tsx
538
+ import useSWR from "swr";
539
+ import { useImmutableSession } from "@imtbl/auth-next-client";
540
+
541
+ function ActivityFeed() {
542
+ const { getAccessToken, isAuthenticated } = useImmutableSession();
543
+
544
+ return useSWR(
545
+ isAuthenticated ? "/v1/activities" : null,
546
+ async (path) => {
547
+ const token = await getAccessToken();
548
+ return authenticatedGet(path, token);
549
+ },
550
+ { refreshInterval: 10000 }, // polls every 10s, always gets a fresh token
551
+ );
552
+ }
553
+ ```
430
554
 
431
555
  #### The `getUser` Function
432
556
 
@@ -452,13 +576,13 @@ When `forceRefresh` is `true`:
452
576
 
453
577
  ### ImmutableSession
454
578
 
455
- The session type returned by `useImmutableSession`:
579
+ The session type returned by `useImmutableSession`. Note that `accessToken` is intentionally **not** included -- use `getAccessToken()` instead to obtain a guaranteed-fresh token.
456
580
 
457
581
  ```typescript
458
582
  interface ImmutableSession {
459
- accessToken: string;
583
+ // accessToken is NOT exposed -- use getAccessToken() instead
460
584
  refreshToken?: string;
461
- idToken?: string;
585
+ idToken?: string; // Only present transiently after sign-in or token refresh (not stored in cookie)
462
586
  accessTokenExpires: number;
463
587
  zkEvm?: {
464
588
  ethAddress: string;
@@ -473,6 +597,8 @@ interface ImmutableSession {
473
597
  }
474
598
  ```
475
599
 
600
+ > **Note:** The `idToken` is **not** stored in the session cookie (to avoid CloudFront 413 errors from oversized headers). It is only present in the session response transiently after sign-in or token refresh. `@imtbl/auth-next-client` automatically persists it in `localStorage` so that `getUser()` always returns a valid `idToken` for wallet operations. All data extracted from the idToken (`email`, `nickname`, `zkEvm`) remains in the cookie as separate fields and is always available in the session.
601
+
476
602
  ### LoginConfig
477
603
 
478
604
  Configuration for the `useLogin` hook's login functions:
@@ -531,17 +657,19 @@ interface LogoutConfig {
531
657
 
532
658
  The session may contain an `error` field indicating authentication issues:
533
659
 
534
- | Error | Description | Handling |
535
- | --------------------- | --------------------- | --------------------------------------------- |
536
- | `"TokenExpired"` | Access token expired | Server-side refresh will happen automatically |
537
- | `"RefreshTokenError"` | Refresh token invalid | Prompt user to sign in again |
660
+ | Error | Description | Handling |
661
+ | --------------------- | --------------------- | -------------------------------------------- |
662
+ | `"TokenExpired"` | Access token expired | Proactive refresh handles this automatically |
663
+ | `"RefreshTokenError"` | Refresh token invalid | Prompt user to sign in again |
664
+
665
+ `getAccessToken()` throws an error if the token cannot be obtained (e.g., refresh failure). Handle it with try/catch:
538
666
 
539
667
  ```tsx
540
668
  import { useImmutableSession } from "@imtbl/auth-next-client";
541
- import { signIn, signOut } from "next-auth/react";
669
+ import { signOut } from "next-auth/react";
542
670
 
543
671
  function ProtectedContent() {
544
- const { session, isAuthenticated } = useImmutableSession();
672
+ const { session, isAuthenticated, getAccessToken } = useImmutableSession();
545
673
 
546
674
  if (session?.error === "RefreshTokenError") {
547
675
  return (
@@ -556,11 +684,20 @@ function ProtectedContent() {
556
684
  return (
557
685
  <div>
558
686
  <p>Please sign in to continue.</p>
559
- <button onClick={() => signIn()}>Sign In</button>
560
687
  </div>
561
688
  );
562
689
  }
563
690
 
691
+ const handleFetch = async () => {
692
+ try {
693
+ const token = await getAccessToken();
694
+ // Use token for authenticated requests
695
+ } catch (error) {
696
+ // Token refresh failed -- session may be expired
697
+ console.error("Failed to get access token:", error);
698
+ }
699
+ };
700
+
564
701
  return <div>Protected content here</div>;
565
702
  }
566
703
  ```
@@ -39,6 +39,35 @@ var import_auth = require("@imtbl/auth");
39
39
  var IMMUTABLE_PROVIDER_ID = "immutable";
40
40
  var DEFAULT_TOKEN_EXPIRY_SECONDS = 900;
41
41
  var DEFAULT_TOKEN_EXPIRY_MS = DEFAULT_TOKEN_EXPIRY_SECONDS * 1e3;
42
+ var TOKEN_EXPIRY_BUFFER_MS = 60 * 1e3;
43
+
44
+ // src/idTokenStorage.ts
45
+ var ID_TOKEN_STORAGE_KEY = "imtbl_id_token";
46
+ function storeIdToken(idToken) {
47
+ try {
48
+ if (typeof window !== "undefined" && window.localStorage) {
49
+ window.localStorage.setItem(ID_TOKEN_STORAGE_KEY, idToken);
50
+ }
51
+ } catch {
52
+ }
53
+ }
54
+ function getStoredIdToken() {
55
+ try {
56
+ if (typeof window !== "undefined" && window.localStorage) {
57
+ return window.localStorage.getItem(ID_TOKEN_STORAGE_KEY) ?? void 0;
58
+ }
59
+ } catch {
60
+ }
61
+ return void 0;
62
+ }
63
+ function clearStoredIdToken() {
64
+ try {
65
+ if (typeof window !== "undefined" && window.localStorage) {
66
+ window.localStorage.removeItem(ID_TOKEN_STORAGE_KEY);
67
+ }
68
+ } catch {
69
+ }
70
+ }
42
71
 
43
72
  // src/callback.tsx
44
73
  var import_jsx_runtime = require("react/jsx-runtime");
@@ -89,6 +118,9 @@ function CallbackPage({
89
118
  window.close();
90
119
  } else {
91
120
  const tokenData = mapTokensToSignInData(tokens);
121
+ if (tokens.idToken) {
122
+ storeIdToken(tokens.idToken);
123
+ }
92
124
  const result = await (0, import_react2.signIn)(IMMUTABLE_PROVIDER_ID, {
93
125
  tokens: JSON.stringify(tokenData),
94
126
  redirect: false
@@ -181,22 +213,43 @@ function CallbackPage({
181
213
  var import_react3 = require("react");
182
214
  var import_react4 = require("next-auth/react");
183
215
  var import_auth2 = require("@imtbl/auth");
216
+ var pendingRefresh = null;
217
+ function deduplicatedUpdate(update) {
218
+ if (!pendingRefresh) {
219
+ pendingRefresh = update().finally(() => {
220
+ pendingRefresh = null;
221
+ });
222
+ }
223
+ return pendingRefresh;
224
+ }
184
225
  function useImmutableSession() {
185
226
  const { data: sessionData, status, update } = (0, import_react4.useSession)();
186
227
  const [isRefreshing, setIsRefreshing] = (0, import_react3.useState)(false);
187
228
  const session = sessionData;
188
229
  const isLoading = status === "loading";
189
- const hasSession = status === "authenticated" && !!session;
230
+ const hasValidSession = status === "authenticated" && !!session && !!session.accessToken && !session.error;
190
231
  const hadSessionRef = (0, import_react3.useRef)(false);
191
- if (hasSession) hadSessionRef.current = true;
192
- if (!hasSession && !isLoading && !isRefreshing) hadSessionRef.current = false;
193
- const isAuthenticated = hasSession || (isLoading || isRefreshing) && hadSessionRef.current;
232
+ if (hasValidSession) hadSessionRef.current = true;
233
+ if (!hasValidSession && !isLoading && !isRefreshing) hadSessionRef.current = false;
234
+ const isAuthenticated = hasValidSession || (isLoading || isRefreshing) && hadSessionRef.current;
194
235
  const sessionRef = (0, import_react3.useRef)(session);
195
236
  sessionRef.current = session;
196
237
  const updateRef = (0, import_react3.useRef)(update);
197
238
  updateRef.current = update;
198
239
  const setIsRefreshingRef = (0, import_react3.useRef)(setIsRefreshing);
199
240
  setIsRefreshingRef.current = setIsRefreshing;
241
+ (0, import_react3.useEffect)(() => {
242
+ if (!session?.accessTokenExpires) return;
243
+ const timeUntilExpiry = session.accessTokenExpires - Date.now() - TOKEN_EXPIRY_BUFFER_MS;
244
+ if (timeUntilExpiry <= 0) {
245
+ deduplicatedUpdate(() => updateRef.current());
246
+ }
247
+ }, [session?.accessTokenExpires]);
248
+ (0, import_react3.useEffect)(() => {
249
+ if (session?.idToken) {
250
+ storeIdToken(session.idToken);
251
+ }
252
+ }, [session?.idToken]);
200
253
  const getUser = (0, import_react3.useCallback)(async (forceRefresh) => {
201
254
  let currentSession;
202
255
  if (forceRefresh) {
@@ -206,6 +259,9 @@ function useImmutableSession() {
206
259
  currentSession = updatedSession;
207
260
  if (currentSession) {
208
261
  sessionRef.current = currentSession;
262
+ if (currentSession.idToken) {
263
+ storeIdToken(currentSession.idToken);
264
+ }
209
265
  }
210
266
  } catch (error) {
211
267
  console.error("[auth-next-client] Force refresh failed:", error);
@@ -213,6 +269,17 @@ function useImmutableSession() {
213
269
  } finally {
214
270
  setIsRefreshingRef.current(false);
215
271
  }
272
+ } else if (pendingRefresh) {
273
+ const refreshed = await pendingRefresh;
274
+ if (refreshed) {
275
+ currentSession = refreshed;
276
+ sessionRef.current = currentSession;
277
+ if (currentSession.idToken) {
278
+ storeIdToken(currentSession.idToken);
279
+ }
280
+ } else {
281
+ currentSession = sessionRef.current;
282
+ }
216
283
  } else {
217
284
  currentSession = sessionRef.current;
218
285
  }
@@ -226,7 +293,9 @@ function useImmutableSession() {
226
293
  return {
227
294
  accessToken: currentSession.accessToken,
228
295
  refreshToken: currentSession.refreshToken,
229
- idToken: currentSession.idToken,
296
+ // Prefer session idToken (fresh after sign-in or refresh, before useEffect
297
+ // stores it), fall back to localStorage for normal reads (cookie has no idToken).
298
+ idToken: currentSession.idToken || getStoredIdToken(),
230
299
  profile: {
231
300
  sub: currentSession.user?.sub ?? "",
232
301
  email: currentSession.user?.email ?? void 0,
@@ -235,19 +304,40 @@ function useImmutableSession() {
235
304
  zkEvm: currentSession.zkEvm
236
305
  };
237
306
  }, []);
307
+ const getAccessToken = (0, import_react3.useCallback)(async () => {
308
+ const currentSession = sessionRef.current;
309
+ if (currentSession?.accessToken && currentSession.accessTokenExpires && Date.now() < currentSession.accessTokenExpires - TOKEN_EXPIRY_BUFFER_MS && !currentSession.error) {
310
+ return currentSession.accessToken;
311
+ }
312
+ const refreshed = await deduplicatedUpdate(
313
+ () => updateRef.current()
314
+ );
315
+ if (!refreshed?.accessToken || refreshed.error) {
316
+ throw new Error(
317
+ `[auth-next-client] Failed to get access token: ${refreshed?.error || "no session"}`
318
+ );
319
+ }
320
+ sessionRef.current = refreshed;
321
+ return refreshed.accessToken;
322
+ }, []);
323
+ const publicSession = session;
238
324
  return {
239
- session,
325
+ session: publicSession,
240
326
  status,
241
327
  isLoading,
242
328
  isAuthenticated,
243
329
  isRefreshing,
244
- getUser
330
+ getUser,
331
+ getAccessToken
245
332
  };
246
333
  }
247
334
  function useLogin() {
248
335
  const [isLoggingIn, setIsLoggingIn] = (0, import_react3.useState)(false);
249
336
  const [error, setError] = (0, import_react3.useState)(null);
250
337
  const signInWithTokens = (0, import_react3.useCallback)(async (tokens) => {
338
+ if (tokens.idToken) {
339
+ storeIdToken(tokens.idToken);
340
+ }
251
341
  const result = await (0, import_react4.signIn)(IMMUTABLE_PROVIDER_ID, {
252
342
  tokens: JSON.stringify(tokens),
253
343
  redirect: false
@@ -314,6 +404,7 @@ function useLogout() {
314
404
  setIsLoggingOut(true);
315
405
  setError(null);
316
406
  try {
407
+ clearStoredIdToken();
317
408
  await (0, import_react4.signOut)({ redirect: false });
318
409
  (0, import_auth2.logoutWithRedirect)(config);
319
410
  } catch (err) {
@@ -10,6 +10,35 @@ import { handleLoginCallback as handleAuthCallback } from "@imtbl/auth";
10
10
  var IMMUTABLE_PROVIDER_ID = "immutable";
11
11
  var DEFAULT_TOKEN_EXPIRY_SECONDS = 900;
12
12
  var DEFAULT_TOKEN_EXPIRY_MS = DEFAULT_TOKEN_EXPIRY_SECONDS * 1e3;
13
+ var TOKEN_EXPIRY_BUFFER_MS = 60 * 1e3;
14
+
15
+ // src/idTokenStorage.ts
16
+ var ID_TOKEN_STORAGE_KEY = "imtbl_id_token";
17
+ function storeIdToken(idToken) {
18
+ try {
19
+ if (typeof window !== "undefined" && window.localStorage) {
20
+ window.localStorage.setItem(ID_TOKEN_STORAGE_KEY, idToken);
21
+ }
22
+ } catch {
23
+ }
24
+ }
25
+ function getStoredIdToken() {
26
+ try {
27
+ if (typeof window !== "undefined" && window.localStorage) {
28
+ return window.localStorage.getItem(ID_TOKEN_STORAGE_KEY) ?? void 0;
29
+ }
30
+ } catch {
31
+ }
32
+ return void 0;
33
+ }
34
+ function clearStoredIdToken() {
35
+ try {
36
+ if (typeof window !== "undefined" && window.localStorage) {
37
+ window.localStorage.removeItem(ID_TOKEN_STORAGE_KEY);
38
+ }
39
+ } catch {
40
+ }
41
+ }
13
42
 
14
43
  // src/callback.tsx
15
44
  import { jsx, jsxs } from "react/jsx-runtime";
@@ -60,6 +89,9 @@ function CallbackPage({
60
89
  window.close();
61
90
  } else {
62
91
  const tokenData = mapTokensToSignInData(tokens);
92
+ if (tokens.idToken) {
93
+ storeIdToken(tokens.idToken);
94
+ }
63
95
  const result = await signIn(IMMUTABLE_PROVIDER_ID, {
64
96
  tokens: JSON.stringify(tokenData),
65
97
  redirect: false
@@ -149,7 +181,12 @@ function CallbackPage({
149
181
  }
150
182
 
151
183
  // src/hooks.tsx
152
- import { useCallback, useRef as useRef2, useState as useState2 } from "react";
184
+ import {
185
+ useCallback,
186
+ useEffect as useEffect2,
187
+ useRef as useRef2,
188
+ useState as useState2
189
+ } from "react";
153
190
  import { useSession, signIn as signIn2, signOut } from "next-auth/react";
154
191
  import {
155
192
  loginWithPopup as rawLoginWithPopup,
@@ -157,22 +194,43 @@ import {
157
194
  loginWithRedirect as rawLoginWithRedirect,
158
195
  logoutWithRedirect as rawLogoutWithRedirect
159
196
  } from "@imtbl/auth";
197
+ var pendingRefresh = null;
198
+ function deduplicatedUpdate(update) {
199
+ if (!pendingRefresh) {
200
+ pendingRefresh = update().finally(() => {
201
+ pendingRefresh = null;
202
+ });
203
+ }
204
+ return pendingRefresh;
205
+ }
160
206
  function useImmutableSession() {
161
207
  const { data: sessionData, status, update } = useSession();
162
208
  const [isRefreshing, setIsRefreshing] = useState2(false);
163
209
  const session = sessionData;
164
210
  const isLoading = status === "loading";
165
- const hasSession = status === "authenticated" && !!session;
211
+ const hasValidSession = status === "authenticated" && !!session && !!session.accessToken && !session.error;
166
212
  const hadSessionRef = useRef2(false);
167
- if (hasSession) hadSessionRef.current = true;
168
- if (!hasSession && !isLoading && !isRefreshing) hadSessionRef.current = false;
169
- const isAuthenticated = hasSession || (isLoading || isRefreshing) && hadSessionRef.current;
213
+ if (hasValidSession) hadSessionRef.current = true;
214
+ if (!hasValidSession && !isLoading && !isRefreshing) hadSessionRef.current = false;
215
+ const isAuthenticated = hasValidSession || (isLoading || isRefreshing) && hadSessionRef.current;
170
216
  const sessionRef = useRef2(session);
171
217
  sessionRef.current = session;
172
218
  const updateRef = useRef2(update);
173
219
  updateRef.current = update;
174
220
  const setIsRefreshingRef = useRef2(setIsRefreshing);
175
221
  setIsRefreshingRef.current = setIsRefreshing;
222
+ useEffect2(() => {
223
+ if (!session?.accessTokenExpires) return;
224
+ const timeUntilExpiry = session.accessTokenExpires - Date.now() - TOKEN_EXPIRY_BUFFER_MS;
225
+ if (timeUntilExpiry <= 0) {
226
+ deduplicatedUpdate(() => updateRef.current());
227
+ }
228
+ }, [session?.accessTokenExpires]);
229
+ useEffect2(() => {
230
+ if (session?.idToken) {
231
+ storeIdToken(session.idToken);
232
+ }
233
+ }, [session?.idToken]);
176
234
  const getUser = useCallback(async (forceRefresh) => {
177
235
  let currentSession;
178
236
  if (forceRefresh) {
@@ -182,6 +240,9 @@ function useImmutableSession() {
182
240
  currentSession = updatedSession;
183
241
  if (currentSession) {
184
242
  sessionRef.current = currentSession;
243
+ if (currentSession.idToken) {
244
+ storeIdToken(currentSession.idToken);
245
+ }
185
246
  }
186
247
  } catch (error) {
187
248
  console.error("[auth-next-client] Force refresh failed:", error);
@@ -189,6 +250,17 @@ function useImmutableSession() {
189
250
  } finally {
190
251
  setIsRefreshingRef.current(false);
191
252
  }
253
+ } else if (pendingRefresh) {
254
+ const refreshed = await pendingRefresh;
255
+ if (refreshed) {
256
+ currentSession = refreshed;
257
+ sessionRef.current = currentSession;
258
+ if (currentSession.idToken) {
259
+ storeIdToken(currentSession.idToken);
260
+ }
261
+ } else {
262
+ currentSession = sessionRef.current;
263
+ }
192
264
  } else {
193
265
  currentSession = sessionRef.current;
194
266
  }
@@ -202,7 +274,9 @@ function useImmutableSession() {
202
274
  return {
203
275
  accessToken: currentSession.accessToken,
204
276
  refreshToken: currentSession.refreshToken,
205
- idToken: currentSession.idToken,
277
+ // Prefer session idToken (fresh after sign-in or refresh, before useEffect
278
+ // stores it), fall back to localStorage for normal reads (cookie has no idToken).
279
+ idToken: currentSession.idToken || getStoredIdToken(),
206
280
  profile: {
207
281
  sub: currentSession.user?.sub ?? "",
208
282
  email: currentSession.user?.email ?? void 0,
@@ -211,19 +285,40 @@ function useImmutableSession() {
211
285
  zkEvm: currentSession.zkEvm
212
286
  };
213
287
  }, []);
288
+ const getAccessToken = useCallback(async () => {
289
+ const currentSession = sessionRef.current;
290
+ if (currentSession?.accessToken && currentSession.accessTokenExpires && Date.now() < currentSession.accessTokenExpires - TOKEN_EXPIRY_BUFFER_MS && !currentSession.error) {
291
+ return currentSession.accessToken;
292
+ }
293
+ const refreshed = await deduplicatedUpdate(
294
+ () => updateRef.current()
295
+ );
296
+ if (!refreshed?.accessToken || refreshed.error) {
297
+ throw new Error(
298
+ `[auth-next-client] Failed to get access token: ${refreshed?.error || "no session"}`
299
+ );
300
+ }
301
+ sessionRef.current = refreshed;
302
+ return refreshed.accessToken;
303
+ }, []);
304
+ const publicSession = session;
214
305
  return {
215
- session,
306
+ session: publicSession,
216
307
  status,
217
308
  isLoading,
218
309
  isAuthenticated,
219
310
  isRefreshing,
220
- getUser
311
+ getUser,
312
+ getAccessToken
221
313
  };
222
314
  }
223
315
  function useLogin() {
224
316
  const [isLoggingIn, setIsLoggingIn] = useState2(false);
225
317
  const [error, setError] = useState2(null);
226
318
  const signInWithTokens = useCallback(async (tokens) => {
319
+ if (tokens.idToken) {
320
+ storeIdToken(tokens.idToken);
321
+ }
227
322
  const result = await signIn2(IMMUTABLE_PROVIDER_ID, {
228
323
  tokens: JSON.stringify(tokens),
229
324
  redirect: false
@@ -290,6 +385,7 @@ function useLogout() {
290
385
  setIsLoggingOut(true);
291
386
  setError(null);
292
387
  try {
388
+ clearStoredIdToken();
293
389
  await signOut({ redirect: false });
294
390
  rawLogoutWithRedirect(config);
295
391
  } catch (err) {
@@ -30,3 +30,8 @@ export declare const DEFAULT_TOKEN_EXPIRY_SECONDS = 900;
30
30
  * Default token expiry in milliseconds
31
31
  */
32
32
  export declare const DEFAULT_TOKEN_EXPIRY_MS: number;
33
+ /**
34
+ * Buffer time in milliseconds before token expiry to trigger refresh.
35
+ * Matches TOKEN_EXPIRY_BUFFER_SECONDS (60s) in @imtbl/auth-next-server.
36
+ */
37
+ export declare const TOKEN_EXPIRY_BUFFER_MS: number;
@@ -2,9 +2,10 @@ import type { Session } from 'next-auth';
2
2
  import type { User, LoginConfig, StandaloneLoginOptions, LogoutConfig } from '@imtbl/auth';
3
3
  import type { ZkEvmInfo } from './types';
4
4
  /**
5
- * Extended session type with Immutable token data
5
+ * Internal session type with full token data (not exported).
6
+ * Used internally by the hook for token validation, refresh logic, and getUser/getAccessToken.
6
7
  */
7
- export interface ImmutableSession extends Session {
8
+ interface ImmutableSessionInternal extends Session {
8
9
  accessToken: string;
9
10
  refreshToken?: string;
10
11
  idToken?: string;
@@ -12,6 +13,14 @@ export interface ImmutableSession extends Session {
12
13
  zkEvm?: ZkEvmInfo;
13
14
  error?: string;
14
15
  }
16
+ /**
17
+ * Public session type exposed to consumers.
18
+ *
19
+ * Does **not** include `accessToken` -- consumers must use the `getAccessToken()`
20
+ * function returned by `useImmutableSession()` to obtain a guaranteed-fresh token.
21
+ * This prevents accidental use of stale/expired tokens.
22
+ */
23
+ export type ImmutableSession = Omit<ImmutableSessionInternal, 'accessToken'>;
15
24
  /**
16
25
  * Return type for useImmutableSession hook
17
26
  */
@@ -35,6 +44,13 @@ export interface UseImmutableSessionReturn {
35
44
  * The refreshed session will include updated zkEvm data if available.
36
45
  */
37
46
  getUser: (forceRefresh?: boolean) => Promise<User | null>;
47
+ /**
48
+ * Get a guaranteed-fresh access token.
49
+ * Returns immediately if the current token is valid.
50
+ * If expired, triggers a refresh and blocks (awaits) until the fresh token is available.
51
+ * Throws if the user is not authenticated or if refresh fails.
52
+ */
53
+ getAccessToken: () => Promise<string>;
38
54
  }
39
55
  /**
40
56
  * Hook to access Immutable session with a getUser function for wallet integration.
@@ -186,3 +202,4 @@ export interface UseLogoutReturn {
186
202
  * ```
187
203
  */
188
204
  export declare function useLogout(): UseLogoutReturn;
205
+ export {};