@imtbl/auth-next-client 2.12.7-alpha.5 → 2.12.7-alpha.7

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,36 @@ 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.
430
459
 
431
460
  #### Checking Authentication Status
432
461
 
@@ -465,6 +494,64 @@ const { status } = useImmutableSession();
465
494
  if (status !== "authenticated") return <div>Please log in</div>;
466
495
  ```
467
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
+ ```
554
+
468
555
  #### The `getUser` Function
469
556
 
470
557
  The `getUser` function returns fresh tokens from the session. It accepts an optional `forceRefresh` parameter:
@@ -489,11 +576,11 @@ When `forceRefresh` is `true`:
489
576
 
490
577
  ### ImmutableSession
491
578
 
492
- 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.
493
580
 
494
581
  ```typescript
495
582
  interface ImmutableSession {
496
- accessToken: string;
583
+ // accessToken is NOT exposed -- use getAccessToken() instead
497
584
  refreshToken?: string;
498
585
  idToken?: string;
499
586
  accessTokenExpires: number;
@@ -568,17 +655,19 @@ interface LogoutConfig {
568
655
 
569
656
  The session may contain an `error` field indicating authentication issues:
570
657
 
571
- | Error | Description | Handling |
572
- | --------------------- | --------------------- | --------------------------------------------- |
573
- | `"TokenExpired"` | Access token expired | Server-side refresh will happen automatically |
574
- | `"RefreshTokenError"` | Refresh token invalid | Prompt user to sign in again |
658
+ | Error | Description | Handling |
659
+ | --------------------- | --------------------- | -------------------------------------------- |
660
+ | `"TokenExpired"` | Access token expired | Proactive refresh handles this automatically |
661
+ | `"RefreshTokenError"` | Refresh token invalid | Prompt user to sign in again |
662
+
663
+ `getAccessToken()` throws an error if the token cannot be obtained (e.g., refresh failure). Handle it with try/catch:
575
664
 
576
665
  ```tsx
577
666
  import { useImmutableSession } from "@imtbl/auth-next-client";
578
- import { signIn, signOut } from "next-auth/react";
667
+ import { signOut } from "next-auth/react";
579
668
 
580
669
  function ProtectedContent() {
581
- const { session, isAuthenticated } = useImmutableSession();
670
+ const { session, isAuthenticated, getAccessToken } = useImmutableSession();
582
671
 
583
672
  if (session?.error === "RefreshTokenError") {
584
673
  return (
@@ -593,11 +682,20 @@ function ProtectedContent() {
593
682
  return (
594
683
  <div>
595
684
  <p>Please sign in to continue.</p>
596
- <button onClick={() => signIn()}>Sign In</button>
597
685
  </div>
598
686
  );
599
687
  }
600
688
 
689
+ const handleFetch = async () => {
690
+ try {
691
+ const token = await getAccessToken();
692
+ // Use token for authenticated requests
693
+ } catch (error) {
694
+ // Token refresh failed -- session may be expired
695
+ console.error("Failed to get access token:", error);
696
+ }
697
+ };
698
+
601
699
  return <div>Protected content here</div>;
602
700
  }
603
701
  ```
@@ -39,6 +39,7 @@ 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;
42
43
 
43
44
  // src/callback.tsx
44
45
  var import_jsx_runtime = require("react/jsx-runtime");
@@ -181,6 +182,15 @@ function CallbackPage({
181
182
  var import_react3 = require("react");
182
183
  var import_react4 = require("next-auth/react");
183
184
  var import_auth2 = require("@imtbl/auth");
185
+ var pendingRefresh = null;
186
+ function deduplicatedUpdate(update) {
187
+ if (!pendingRefresh) {
188
+ pendingRefresh = update().finally(() => {
189
+ pendingRefresh = null;
190
+ });
191
+ }
192
+ return pendingRefresh;
193
+ }
184
194
  function useImmutableSession() {
185
195
  const { data: sessionData, status, update } = (0, import_react4.useSession)();
186
196
  const [isRefreshing, setIsRefreshing] = (0, import_react3.useState)(false);
@@ -197,6 +207,13 @@ function useImmutableSession() {
197
207
  updateRef.current = update;
198
208
  const setIsRefreshingRef = (0, import_react3.useRef)(setIsRefreshing);
199
209
  setIsRefreshingRef.current = setIsRefreshing;
210
+ (0, import_react3.useEffect)(() => {
211
+ if (!session?.accessTokenExpires) return;
212
+ const timeUntilExpiry = session.accessTokenExpires - Date.now() - TOKEN_EXPIRY_BUFFER_MS;
213
+ if (timeUntilExpiry <= 0) {
214
+ deduplicatedUpdate(() => updateRef.current());
215
+ }
216
+ }, [session?.accessTokenExpires]);
200
217
  const getUser = (0, import_react3.useCallback)(async (forceRefresh) => {
201
218
  let currentSession;
202
219
  if (forceRefresh) {
@@ -213,6 +230,14 @@ function useImmutableSession() {
213
230
  } finally {
214
231
  setIsRefreshingRef.current(false);
215
232
  }
233
+ } else if (pendingRefresh) {
234
+ const refreshed = await pendingRefresh;
235
+ if (refreshed) {
236
+ currentSession = refreshed;
237
+ sessionRef.current = currentSession;
238
+ } else {
239
+ currentSession = sessionRef.current;
240
+ }
216
241
  } else {
217
242
  currentSession = sessionRef.current;
218
243
  }
@@ -235,13 +260,31 @@ function useImmutableSession() {
235
260
  zkEvm: currentSession.zkEvm
236
261
  };
237
262
  }, []);
263
+ const getAccessToken = (0, import_react3.useCallback)(async () => {
264
+ const currentSession = sessionRef.current;
265
+ if (currentSession?.accessToken && currentSession.accessTokenExpires && Date.now() < currentSession.accessTokenExpires - TOKEN_EXPIRY_BUFFER_MS && !currentSession.error) {
266
+ return currentSession.accessToken;
267
+ }
268
+ const refreshed = await deduplicatedUpdate(
269
+ () => updateRef.current()
270
+ );
271
+ if (!refreshed?.accessToken || refreshed.error) {
272
+ throw new Error(
273
+ `[auth-next-client] Failed to get access token: ${refreshed?.error || "no session"}`
274
+ );
275
+ }
276
+ sessionRef.current = refreshed;
277
+ return refreshed.accessToken;
278
+ }, []);
279
+ const publicSession = session;
238
280
  return {
239
- session,
281
+ session: publicSession,
240
282
  status,
241
283
  isLoading,
242
284
  isAuthenticated,
243
285
  isRefreshing,
244
- getUser
286
+ getUser,
287
+ getAccessToken
245
288
  };
246
289
  }
247
290
  function useLogin() {
@@ -10,6 +10,7 @@ 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;
13
14
 
14
15
  // src/callback.tsx
15
16
  import { jsx, jsxs } from "react/jsx-runtime";
@@ -149,7 +150,12 @@ function CallbackPage({
149
150
  }
150
151
 
151
152
  // src/hooks.tsx
152
- import { useCallback, useRef as useRef2, useState as useState2 } from "react";
153
+ import {
154
+ useCallback,
155
+ useEffect as useEffect2,
156
+ useRef as useRef2,
157
+ useState as useState2
158
+ } from "react";
153
159
  import { useSession, signIn as signIn2, signOut } from "next-auth/react";
154
160
  import {
155
161
  loginWithPopup as rawLoginWithPopup,
@@ -157,6 +163,15 @@ import {
157
163
  loginWithRedirect as rawLoginWithRedirect,
158
164
  logoutWithRedirect as rawLogoutWithRedirect
159
165
  } from "@imtbl/auth";
166
+ var pendingRefresh = null;
167
+ function deduplicatedUpdate(update) {
168
+ if (!pendingRefresh) {
169
+ pendingRefresh = update().finally(() => {
170
+ pendingRefresh = null;
171
+ });
172
+ }
173
+ return pendingRefresh;
174
+ }
160
175
  function useImmutableSession() {
161
176
  const { data: sessionData, status, update } = useSession();
162
177
  const [isRefreshing, setIsRefreshing] = useState2(false);
@@ -173,6 +188,13 @@ function useImmutableSession() {
173
188
  updateRef.current = update;
174
189
  const setIsRefreshingRef = useRef2(setIsRefreshing);
175
190
  setIsRefreshingRef.current = setIsRefreshing;
191
+ useEffect2(() => {
192
+ if (!session?.accessTokenExpires) return;
193
+ const timeUntilExpiry = session.accessTokenExpires - Date.now() - TOKEN_EXPIRY_BUFFER_MS;
194
+ if (timeUntilExpiry <= 0) {
195
+ deduplicatedUpdate(() => updateRef.current());
196
+ }
197
+ }, [session?.accessTokenExpires]);
176
198
  const getUser = useCallback(async (forceRefresh) => {
177
199
  let currentSession;
178
200
  if (forceRefresh) {
@@ -189,6 +211,14 @@ function useImmutableSession() {
189
211
  } finally {
190
212
  setIsRefreshingRef.current(false);
191
213
  }
214
+ } else if (pendingRefresh) {
215
+ const refreshed = await pendingRefresh;
216
+ if (refreshed) {
217
+ currentSession = refreshed;
218
+ sessionRef.current = currentSession;
219
+ } else {
220
+ currentSession = sessionRef.current;
221
+ }
192
222
  } else {
193
223
  currentSession = sessionRef.current;
194
224
  }
@@ -211,13 +241,31 @@ function useImmutableSession() {
211
241
  zkEvm: currentSession.zkEvm
212
242
  };
213
243
  }, []);
244
+ const getAccessToken = useCallback(async () => {
245
+ const currentSession = sessionRef.current;
246
+ if (currentSession?.accessToken && currentSession.accessTokenExpires && Date.now() < currentSession.accessTokenExpires - TOKEN_EXPIRY_BUFFER_MS && !currentSession.error) {
247
+ return currentSession.accessToken;
248
+ }
249
+ const refreshed = await deduplicatedUpdate(
250
+ () => updateRef.current()
251
+ );
252
+ if (!refreshed?.accessToken || refreshed.error) {
253
+ throw new Error(
254
+ `[auth-next-client] Failed to get access token: ${refreshed?.error || "no session"}`
255
+ );
256
+ }
257
+ sessionRef.current = refreshed;
258
+ return refreshed.accessToken;
259
+ }, []);
260
+ const publicSession = session;
214
261
  return {
215
- session,
262
+ session: publicSession,
216
263
  status,
217
264
  isLoading,
218
265
  isAuthenticated,
219
266
  isRefreshing,
220
- getUser
267
+ getUser,
268
+ getAccessToken
221
269
  };
222
270
  }
223
271
  function useLogin() {
@@ -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 {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@imtbl/auth-next-client",
3
- "version": "2.12.7-alpha.5",
3
+ "version": "2.12.7-alpha.7",
4
4
  "description": "Immutable Auth.js v5 integration for Next.js - Client-side components",
5
5
  "author": "Immutable",
6
6
  "license": "Apache-2.0",
@@ -27,8 +27,8 @@
27
27
  }
28
28
  },
29
29
  "dependencies": {
30
- "@imtbl/auth": "2.12.7-alpha.5",
31
- "@imtbl/auth-next-server": "2.12.7-alpha.5"
30
+ "@imtbl/auth": "2.12.7-alpha.7",
31
+ "@imtbl/auth-next-server": "2.12.7-alpha.7"
32
32
  },
33
33
  "peerDependencies": {
34
34
  "next": "^15.0.0",
@@ -49,6 +49,8 @@
49
49
  "devDependencies": {
50
50
  "@swc/core": "^1.4.2",
51
51
  "@swc/jest": "^0.2.37",
52
+ "@testing-library/jest-dom": "^5.16.5",
53
+ "@testing-library/react": "^13.4.0",
52
54
  "@types/jest": "^29.5.12",
53
55
  "@types/node": "^22.10.7",
54
56
  "@types/react": "^18.3.5",
package/src/constants.ts CHANGED
@@ -37,3 +37,9 @@ export const DEFAULT_TOKEN_EXPIRY_SECONDS = 900;
37
37
  * Default token expiry in milliseconds
38
38
  */
39
39
  export const DEFAULT_TOKEN_EXPIRY_MS = DEFAULT_TOKEN_EXPIRY_SECONDS * 1000;
40
+
41
+ /**
42
+ * Buffer time in milliseconds before token expiry to trigger refresh.
43
+ * Matches TOKEN_EXPIRY_BUFFER_SECONDS (60s) in @imtbl/auth-next-server.
44
+ */
45
+ export const TOKEN_EXPIRY_BUFFER_MS = 60 * 1000;
@@ -0,0 +1,317 @@
1
+ /* eslint-disable @typescript-eslint/no-explicit-any */
2
+ /* eslint-disable import/first */
3
+ import { renderHook, act } from '@testing-library/react';
4
+ import { TOKEN_EXPIRY_BUFFER_MS } from './constants';
5
+
6
+ // ---------------------------------------------------------------------------
7
+ // Mocks -- must be declared before importing the modules that use them
8
+ // ---------------------------------------------------------------------------
9
+
10
+ const mockUpdate = jest.fn();
11
+ const mockUseSession = jest.fn();
12
+
13
+ jest.mock('next-auth/react', () => ({
14
+ useSession: () => mockUseSession(),
15
+ signIn: jest.fn(),
16
+ signOut: jest.fn(),
17
+ }));
18
+
19
+ jest.mock('@imtbl/auth', () => ({
20
+ loginWithPopup: jest.fn(),
21
+ loginWithEmbedded: jest.fn(),
22
+ loginWithRedirect: jest.fn(),
23
+ logoutWithRedirect: jest.fn(),
24
+ }));
25
+
26
+ import { useImmutableSession } from './hooks';
27
+
28
+ // ---------------------------------------------------------------------------
29
+ // Helpers
30
+ // ---------------------------------------------------------------------------
31
+
32
+ /**
33
+ * Full session shape matching what NextAuth returns at runtime.
34
+ * The public ImmutableSession type intentionally omits accessToken,
35
+ * but the underlying data still has it -- tests need the full shape
36
+ * to set up mock sessions correctly.
37
+ */
38
+ interface TestSession {
39
+ accessToken: string;
40
+ refreshToken?: string;
41
+ idToken?: string;
42
+ accessTokenExpires: number;
43
+ zkEvm?: { ethAddress: string; userAdminAddress: string };
44
+ error?: string;
45
+ user?: any;
46
+ expires: string;
47
+ }
48
+
49
+ function createSession(overrides: Partial<TestSession> = {}): TestSession {
50
+ return {
51
+ accessToken: 'valid-token',
52
+ refreshToken: 'refresh-token',
53
+ idToken: 'id-token',
54
+ accessTokenExpires: Date.now() + 10 * 60 * 1000, // 10 min from now
55
+ user: { sub: 'user-1', email: 'test@test.com' },
56
+ expires: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(),
57
+ ...overrides,
58
+ };
59
+ }
60
+
61
+ function setupUseSession(session: TestSession | null, status: string = 'authenticated') {
62
+ mockUseSession.mockReturnValue({
63
+ data: session,
64
+ status,
65
+ update: mockUpdate,
66
+ });
67
+ }
68
+
69
+ // ---------------------------------------------------------------------------
70
+ // Tests
71
+ // ---------------------------------------------------------------------------
72
+
73
+ describe('useImmutableSession', () => {
74
+ beforeEach(() => {
75
+ jest.clearAllMocks();
76
+ mockUpdate.mockReset();
77
+ });
78
+
79
+ describe('session type safety', () => {
80
+ it('does not expose accessToken on the public session type', () => {
81
+ const session = createSession();
82
+ setupUseSession(session);
83
+ mockUpdate.mockResolvedValue(session);
84
+
85
+ const { result } = renderHook(() => useImmutableSession());
86
+
87
+ // TypeScript prevents accessing accessToken, but at runtime the
88
+ // underlying object still has it. Verify the hook returns a session
89
+ // and that the public type doesn't advertise accessToken.
90
+ expect(result.current.session).toBeDefined();
91
+ expect(result.current.session?.error).toBeUndefined();
92
+ // The property exists at runtime (it's the same object) but the type
93
+ // system hides it -- this is a compile-time guard, not a runtime one.
94
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
95
+ expect((result.current.session as any).accessToken).toBeDefined();
96
+ });
97
+ });
98
+
99
+ describe('getAccessToken()', () => {
100
+ it('returns the token immediately when it is valid (fast path)', async () => {
101
+ const session = createSession();
102
+ setupUseSession(session);
103
+ mockUpdate.mockResolvedValue(session); // in case proactive timer fires
104
+
105
+ const { result } = renderHook(() => useImmutableSession());
106
+
107
+ const token = await result.current.getAccessToken();
108
+ expect(token).toBe('valid-token');
109
+ });
110
+
111
+ it('triggers refresh and returns fresh token when expired', async () => {
112
+ const expiredSession = createSession({
113
+ accessTokenExpires: Date.now() - 1000, // already expired
114
+ });
115
+ setupUseSession(expiredSession);
116
+
117
+ const freshSession = createSession({
118
+ accessToken: 'fresh-token',
119
+ accessTokenExpires: Date.now() + 10 * 60 * 1000,
120
+ });
121
+ mockUpdate.mockResolvedValue(freshSession);
122
+
123
+ const { result } = renderHook(() => useImmutableSession());
124
+
125
+ let token: string = '';
126
+ await act(async () => {
127
+ token = await result.current.getAccessToken();
128
+ });
129
+ expect(token).toBe('fresh-token');
130
+ expect(mockUpdate).toHaveBeenCalled();
131
+ });
132
+
133
+ it('triggers refresh when token is within the buffer window', async () => {
134
+ const almostExpiredSession = createSession({
135
+ accessTokenExpires: Date.now() + TOKEN_EXPIRY_BUFFER_MS - 1000, // within buffer
136
+ });
137
+ setupUseSession(almostExpiredSession);
138
+
139
+ const freshSession = createSession({
140
+ accessToken: 'refreshed-token',
141
+ accessTokenExpires: Date.now() + 10 * 60 * 1000,
142
+ });
143
+ mockUpdate.mockResolvedValue(freshSession);
144
+
145
+ const { result } = renderHook(() => useImmutableSession());
146
+
147
+ let token: string = '';
148
+ await act(async () => {
149
+ token = await result.current.getAccessToken();
150
+ });
151
+ expect(token).toBe('refreshed-token');
152
+ expect(mockUpdate).toHaveBeenCalled();
153
+ });
154
+
155
+ it('throws when refresh fails (no accessToken in response)', async () => {
156
+ const expiredSession = createSession({
157
+ accessTokenExpires: Date.now() - 1000,
158
+ });
159
+ setupUseSession(expiredSession);
160
+
161
+ mockUpdate.mockResolvedValue(null);
162
+
163
+ const { result } = renderHook(() => useImmutableSession());
164
+
165
+ await expect(
166
+ act(async () => {
167
+ await result.current.getAccessToken();
168
+ }),
169
+ ).rejects.toThrow('[auth-next-client] Failed to get access token');
170
+ });
171
+
172
+ it('throws when refresh returns a session with error', async () => {
173
+ const expiredSession = createSession({
174
+ accessTokenExpires: Date.now() - 1000,
175
+ });
176
+ setupUseSession(expiredSession);
177
+
178
+ const errorSession = createSession({ error: 'RefreshTokenError' });
179
+ mockUpdate.mockResolvedValue(errorSession);
180
+
181
+ const { result } = renderHook(() => useImmutableSession());
182
+
183
+ await expect(
184
+ act(async () => {
185
+ await result.current.getAccessToken();
186
+ }),
187
+ ).rejects.toThrow('RefreshTokenError');
188
+ });
189
+
190
+ it('deduplicates concurrent calls (only one update())', async () => {
191
+ const expiredSession = createSession({
192
+ accessTokenExpires: Date.now() - 1000,
193
+ });
194
+ setupUseSession(expiredSession);
195
+
196
+ const freshSession = createSession({
197
+ accessToken: 'deduped-token',
198
+ accessTokenExpires: Date.now() + 10 * 60 * 1000,
199
+ });
200
+
201
+ // Track how many times update is actually invoked
202
+ let updateCallCount = 0;
203
+ mockUpdate.mockImplementation(() => {
204
+ updateCallCount++;
205
+ return Promise.resolve(freshSession);
206
+ });
207
+
208
+ const { result } = renderHook(() => useImmutableSession());
209
+
210
+ let token1: string = '';
211
+ let token2: string = '';
212
+ await act(async () => {
213
+ [token1, token2] = await Promise.all([
214
+ result.current.getAccessToken(),
215
+ result.current.getAccessToken(),
216
+ ]);
217
+ });
218
+
219
+ expect(token1).toBe('deduped-token');
220
+ expect(token2).toBe('deduped-token');
221
+ // The proactive effect may also trigger one call, so we check
222
+ // that concurrent getAccessToken calls are deduped (not doubled)
223
+ // The key invariant: at most 1 update() call per pending refresh cycle
224
+ expect(updateCallCount).toBeLessThanOrEqual(2); // 1 from effect + 1 from getAccessToken (deduped)
225
+ });
226
+ });
227
+
228
+ describe('reactive refresh on mount', () => {
229
+ it('triggers refresh when token is already expired on mount', async () => {
230
+ const session = createSession({
231
+ accessTokenExpires: Date.now() - 1000, // already expired
232
+ });
233
+ setupUseSession(session);
234
+
235
+ const freshSession = createSession({
236
+ accessToken: 'immediate-refresh',
237
+ });
238
+ mockUpdate.mockResolvedValue(freshSession);
239
+
240
+ await act(async () => {
241
+ renderHook(() => useImmutableSession());
242
+ });
243
+
244
+ // The proactive useEffect should have called update
245
+ expect(mockUpdate).toHaveBeenCalled();
246
+ });
247
+
248
+ it('does not set isRefreshing during reactive refresh (prevents UI flicker)', async () => {
249
+ const session = createSession({
250
+ accessTokenExpires: Date.now() - 1000, // already expired
251
+ });
252
+ setupUseSession(session);
253
+
254
+ const freshSession = createSession({
255
+ accessToken: 'immediate-refresh',
256
+ });
257
+ mockUpdate.mockResolvedValue(freshSession);
258
+
259
+ const capturedIsRefreshing: boolean[] = [];
260
+ const { result } = renderHook(() => {
261
+ const hook = useImmutableSession();
262
+ capturedIsRefreshing.push(hook.isRefreshing);
263
+ return hook;
264
+ });
265
+
266
+ await act(async () => {
267
+ // Let the reactive refresh effect run and complete
268
+ await mockUpdate.mock.results[0]?.value;
269
+ });
270
+
271
+ // isRefreshing should NEVER have been true during reactive refresh.
272
+ // It is reserved for explicit user-triggered refreshes (getUser(true)).
273
+ expect(capturedIsRefreshing.every((v) => v === false)).toBe(true);
274
+ expect(result.current.isRefreshing).toBe(false);
275
+ });
276
+
277
+ it('does not trigger refresh when token is valid and far from expiry', async () => {
278
+ const session = createSession({
279
+ accessTokenExpires: Date.now() + 10 * 60 * 1000, // 10 min from now, well beyond buffer
280
+ });
281
+ setupUseSession(session);
282
+
283
+ await act(async () => {
284
+ renderHook(() => useImmutableSession());
285
+ });
286
+
287
+ // Should NOT have called update -- token is still valid
288
+ expect(mockUpdate).not.toHaveBeenCalled();
289
+ });
290
+ });
291
+
292
+ describe('getUser() respects pending refresh', () => {
293
+ it('waits for in-flight refresh before returning user', async () => {
294
+ const expiredSession = createSession({
295
+ accessTokenExpires: Date.now() - 1000,
296
+ });
297
+ setupUseSession(expiredSession);
298
+
299
+ const freshSession = createSession({
300
+ accessToken: 'user-fresh-token',
301
+ accessTokenExpires: Date.now() + 10 * 60 * 1000,
302
+ });
303
+
304
+ mockUpdate.mockResolvedValue(freshSession);
305
+
306
+ const { result } = renderHook(() => useImmutableSession());
307
+
308
+ let user: any;
309
+ await act(async () => {
310
+ user = await result.current.getUser();
311
+ });
312
+
313
+ // getUser() should have waited for the refresh and gotten the fresh token
314
+ expect(user?.accessToken).toBe('user-fresh-token');
315
+ });
316
+ });
317
+ });
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,34 @@ 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
+
23
+ // ---------------------------------------------------------------------------
24
+ // Module-level deduplication for session refresh
25
+ // ---------------------------------------------------------------------------
20
26
 
21
27
  /**
22
- * Extended session type with Immutable token data
28
+ * Deduplicates concurrent session refresh calls.
29
+ * Multiple components may mount useImmutableSession simultaneously; without
30
+ * deduplication each would trigger its own update() call, which could fail
31
+ * if the auth server rotates refresh tokens.
23
32
  */
24
- export interface ImmutableSession extends Session {
33
+ let pendingRefresh: Promise<Session | null | undefined> | null = null;
34
+
35
+ function deduplicatedUpdate(
36
+ update: () => Promise<Session | null | undefined>,
37
+ ): Promise<Session | null | undefined> {
38
+ if (!pendingRefresh) {
39
+ pendingRefresh = update().finally(() => { pendingRefresh = null; });
40
+ }
41
+ return pendingRefresh;
42
+ }
43
+
44
+ /**
45
+ * Internal session type with full token data (not exported).
46
+ * Used internally by the hook for token validation, refresh logic, and getUser/getAccessToken.
47
+ */
48
+ interface ImmutableSessionInternal extends Session {
25
49
  accessToken: string;
26
50
  refreshToken?: string;
27
51
  idToken?: string;
@@ -30,6 +54,15 @@ export interface ImmutableSession extends Session {
30
54
  error?: string;
31
55
  }
32
56
 
57
+ /**
58
+ * Public session type exposed to consumers.
59
+ *
60
+ * Does **not** include `accessToken` -- consumers must use the `getAccessToken()`
61
+ * function returned by `useImmutableSession()` to obtain a guaranteed-fresh token.
62
+ * This prevents accidental use of stale/expired tokens.
63
+ */
64
+ export type ImmutableSession = Omit<ImmutableSessionInternal, 'accessToken'>;
65
+
33
66
  /**
34
67
  * Return type for useImmutableSession hook
35
68
  */
@@ -53,6 +86,13 @@ export interface UseImmutableSessionReturn {
53
86
  * The refreshed session will include updated zkEvm data if available.
54
87
  */
55
88
  getUser: (forceRefresh?: boolean) => Promise<User | null>;
89
+ /**
90
+ * Get a guaranteed-fresh access token.
91
+ * Returns immediately if the current token is valid.
92
+ * If expired, triggers a refresh and blocks (awaits) until the fresh token is available.
93
+ * Throws if the user is not authenticated or if refresh fails.
94
+ */
95
+ getAccessToken: () => Promise<string>;
56
96
  }
57
97
 
58
98
  /**
@@ -92,8 +132,8 @@ export function useImmutableSession(): UseImmutableSessionReturn {
92
132
  // Track when a manual refresh is in progress (via getUser(true))
93
133
  const [isRefreshing, setIsRefreshing] = useState(false);
94
134
 
95
- // Cast session to our extended type
96
- const session = sessionData as ImmutableSession | null;
135
+ // Cast session to our internal type (includes accessToken for internal logic)
136
+ const session = sessionData as ImmutableSessionInternal | null;
97
137
 
98
138
  const isLoading = status === 'loading';
99
139
 
@@ -114,7 +154,7 @@ export function useImmutableSession(): UseImmutableSessionReturn {
114
154
  // Use a ref to always have access to the latest session.
115
155
  // This avoids stale closure issues when the wallet stores the getUser function
116
156
  // and calls it later - the ref always points to the current session.
117
- const sessionRef = useRef<ImmutableSession | null>(session);
157
+ const sessionRef = useRef<ImmutableSessionInternal | null>(session);
118
158
  sessionRef.current = session;
119
159
 
120
160
  // Also store update in a ref so the callback is stable
@@ -125,6 +165,30 @@ export function useImmutableSession(): UseImmutableSessionReturn {
125
165
  const setIsRefreshingRef = useRef(setIsRefreshing);
126
166
  setIsRefreshingRef.current = setIsRefreshing;
127
167
 
168
+ // ---------------------------------------------------------------------------
169
+ // Proactive token refresh
170
+ // ---------------------------------------------------------------------------
171
+
172
+ // Reactive refresh: when the effect runs and the token is already expired
173
+ // (e.g., after tab regains focus), trigger an immediate silent refresh.
174
+ // For tokens that are still valid, getAccessToken() handles refresh on demand.
175
+ //
176
+ // NOTE: This intentionally does NOT set isRefreshing. isRefreshing is reserved
177
+ // for explicit user-triggered refreshes (e.g., getUser(true) after wallet
178
+ // registration). Background token refreshes must be invisible to consumers --
179
+ // setting isRefreshing would cause downstream hooks that gate SWR keys on
180
+ // `!isRefreshing` to briefly lose their cached data, resulting in UI flicker.
181
+ useEffect(() => {
182
+ if (!session?.accessTokenExpires) return;
183
+
184
+ const timeUntilExpiry = session.accessTokenExpires - Date.now() - TOKEN_EXPIRY_BUFFER_MS;
185
+
186
+ if (timeUntilExpiry <= 0) {
187
+ // Already expired -- refresh silently
188
+ deduplicatedUpdate(() => updateRef.current());
189
+ }
190
+ }, [session?.accessTokenExpires]);
191
+
128
192
  /**
129
193
  * Get user function for wallet integration.
130
194
  * Returns a User object compatible with @imtbl/wallet's getUser option.
@@ -135,7 +199,7 @@ export function useImmutableSession(): UseImmutableSessionReturn {
135
199
  * @param forceRefresh - When true, triggers a server-side token refresh
136
200
  */
137
201
  const getUser = useCallback(async (forceRefresh?: boolean): Promise<User | null> => {
138
- let currentSession: ImmutableSession | null;
202
+ let currentSession: ImmutableSessionInternal | null;
139
203
 
140
204
  // If forceRefresh is requested, trigger server-side refresh via NextAuth
141
205
  // This calls the jwt callback with trigger='update' and sessionUpdate.forceRefresh=true
@@ -145,7 +209,7 @@ export function useImmutableSession(): UseImmutableSessionReturn {
145
209
  try {
146
210
  // update() returns the refreshed session
147
211
  const updatedSession = await updateRef.current({ forceRefresh: true });
148
- currentSession = updatedSession as ImmutableSession | null;
212
+ currentSession = updatedSession as ImmutableSessionInternal | null;
149
213
  // Also update the ref so subsequent calls get the fresh data
150
214
  if (currentSession) {
151
215
  sessionRef.current = currentSession;
@@ -158,6 +222,16 @@ export function useImmutableSession(): UseImmutableSessionReturn {
158
222
  } finally {
159
223
  setIsRefreshingRef.current(false);
160
224
  }
225
+ } else if (pendingRefresh) {
226
+ // If a refresh is in-flight (proactive timer or another getAccessToken call),
227
+ // wait for it and use the refreshed session rather than returning a stale token.
228
+ const refreshed = await pendingRefresh;
229
+ if (refreshed) {
230
+ currentSession = refreshed as ImmutableSessionInternal;
231
+ sessionRef.current = currentSession;
232
+ } else {
233
+ currentSession = sessionRef.current;
234
+ }
161
235
  } else {
162
236
  // Read from ref - instant, no network call
163
237
  // The ref is always updated on each render with the latest session
@@ -188,13 +262,55 @@ export function useImmutableSession(): UseImmutableSessionReturn {
188
262
  };
189
263
  }, []); // Empty deps - uses refs for latest values
190
264
 
265
+ /**
266
+ * Get a guaranteed-fresh access token.
267
+ * Returns immediately if the current token is valid (fast path, no network call).
268
+ * If expired, triggers a server-side refresh and blocks (awaits) until the fresh
269
+ * token is available. Piggybacks on any in-flight refresh to avoid duplicate calls.
270
+ *
271
+ * @throws Error if the user is not authenticated or if the refresh fails.
272
+ */
273
+ const getAccessToken = useCallback(async (): Promise<string> => {
274
+ const currentSession = sessionRef.current;
275
+
276
+ // Fast path: token is valid -- return immediately
277
+ if (
278
+ currentSession?.accessToken
279
+ && currentSession.accessTokenExpires
280
+ && Date.now() < currentSession.accessTokenExpires - TOKEN_EXPIRY_BUFFER_MS
281
+ && !currentSession.error
282
+ ) {
283
+ return currentSession.accessToken;
284
+ }
285
+
286
+ // Token is expired or missing -- wait for in-flight refresh or trigger one
287
+ const refreshed = await deduplicatedUpdate(
288
+ () => updateRef.current(),
289
+ ) as ImmutableSessionInternal | null;
290
+
291
+ if (!refreshed?.accessToken || refreshed.error) {
292
+ throw new Error(
293
+ `[auth-next-client] Failed to get access token: ${refreshed?.error || 'no session'}`,
294
+ );
295
+ }
296
+
297
+ // Update ref so subsequent sync reads get the fresh data
298
+ sessionRef.current = refreshed;
299
+ return refreshed.accessToken;
300
+ }, []); // Empty deps -- uses refs for latest values
301
+
302
+ // Cast to public type (omits accessToken) to prevent consumers from
303
+ // accidentally using a potentially stale token. Use getAccessToken() instead.
304
+ const publicSession = session as ImmutableSession | null;
305
+
191
306
  return {
192
- session,
307
+ session: publicSession,
193
308
  status,
194
309
  isLoading,
195
310
  isAuthenticated,
196
311
  isRefreshing,
197
312
  getUser,
313
+ getAccessToken,
198
314
  };
199
315
  }
200
316