@imtbl/auth-next-client 2.12.7-alpha.1 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@imtbl/auth-next-client",
3
- "version": "2.12.7-alpha.1",
3
+ "version": "2.12.7-alpha.10",
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.1",
31
- "@imtbl/auth-next-server": "2.12.7-alpha.1"
30
+ "@imtbl/auth": "2.12.7-alpha.10",
31
+ "@imtbl/auth-next-server": "2.12.7-alpha.10"
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/callback.tsx CHANGED
@@ -6,6 +6,7 @@ import { signIn } from 'next-auth/react';
6
6
  import { handleLoginCallback as handleAuthCallback, type TokenResponse } from '@imtbl/auth';
7
7
  import type { ImmutableUserClient } from './types';
8
8
  import { IMMUTABLE_PROVIDER_ID } from './constants';
9
+ import { storeIdToken } from './idTokenStorage';
9
10
 
10
11
  /**
11
12
  * Config for CallbackPage - matches LoginConfig from @imtbl/auth
@@ -159,6 +160,12 @@ export function CallbackPage({
159
160
  // Not in a popup - sign in to NextAuth with the tokens
160
161
  const tokenData = mapTokensToSignInData(tokens);
161
162
 
163
+ // Persist idToken to localStorage before signIn so it's available
164
+ // immediately. The cookie won't contain idToken (stripped by jwt.encode).
165
+ if (tokens.idToken) {
166
+ storeIdToken(tokens.idToken);
167
+ }
168
+
162
169
  const result = await signIn(IMMUTABLE_PROVIDER_ID, {
163
170
  tokens: JSON.stringify(tokenData),
164
171
  redirect: false,
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,35 @@ import {
16
18
  loginWithRedirect as rawLoginWithRedirect,
17
19
  logoutWithRedirect as rawLogoutWithRedirect,
18
20
  } from '@imtbl/auth';
19
- import { IMMUTABLE_PROVIDER_ID } from './constants';
21
+ import { IMMUTABLE_PROVIDER_ID, TOKEN_EXPIRY_BUFFER_MS } from './constants';
22
+ import { storeIdToken, getStoredIdToken, clearStoredIdToken } from './idTokenStorage';
23
+
24
+ // ---------------------------------------------------------------------------
25
+ // Module-level deduplication for session refresh
26
+ // ---------------------------------------------------------------------------
27
+
28
+ /**
29
+ * Deduplicates concurrent session refresh calls.
30
+ * Multiple components may mount useImmutableSession simultaneously; without
31
+ * deduplication each would trigger its own update() call, which could fail
32
+ * if the auth server rotates refresh tokens.
33
+ */
34
+ let pendingRefresh: Promise<Session | null | undefined> | null = null;
35
+
36
+ function deduplicatedUpdate(
37
+ update: () => Promise<Session | null | undefined>,
38
+ ): Promise<Session | null | undefined> {
39
+ if (!pendingRefresh) {
40
+ pendingRefresh = update().finally(() => { pendingRefresh = null; });
41
+ }
42
+ return pendingRefresh;
43
+ }
20
44
 
21
45
  /**
22
- * Extended session type with Immutable token data
46
+ * Internal session type with full token data (not exported).
47
+ * Used internally by the hook for token validation, refresh logic, and getUser/getAccessToken.
23
48
  */
24
- export interface ImmutableSession extends Session {
49
+ interface ImmutableSessionInternal extends Session {
25
50
  accessToken: string;
26
51
  refreshToken?: string;
27
52
  idToken?: string;
@@ -30,6 +55,15 @@ export interface ImmutableSession extends Session {
30
55
  error?: string;
31
56
  }
32
57
 
58
+ /**
59
+ * Public session type exposed to consumers.
60
+ *
61
+ * Does **not** include `accessToken` -- consumers must use the `getAccessToken()`
62
+ * function returned by `useImmutableSession()` to obtain a guaranteed-fresh token.
63
+ * This prevents accidental use of stale/expired tokens.
64
+ */
65
+ export type ImmutableSession = Omit<ImmutableSessionInternal, 'accessToken'>;
66
+
33
67
  /**
34
68
  * Return type for useImmutableSession hook
35
69
  */
@@ -53,6 +87,13 @@ export interface UseImmutableSessionReturn {
53
87
  * The refreshed session will include updated zkEvm data if available.
54
88
  */
55
89
  getUser: (forceRefresh?: boolean) => Promise<User | null>;
90
+ /**
91
+ * Get a guaranteed-fresh access token.
92
+ * Returns immediately if the current token is valid.
93
+ * If expired, triggers a refresh and blocks (awaits) until the fresh token is available.
94
+ * Throws if the user is not authenticated or if refresh fails.
95
+ */
96
+ getAccessToken: () => Promise<string>;
56
97
  }
57
98
 
58
99
  /**
@@ -92,8 +133,8 @@ export function useImmutableSession(): UseImmutableSessionReturn {
92
133
  // Track when a manual refresh is in progress (via getUser(true))
93
134
  const [isRefreshing, setIsRefreshing] = useState(false);
94
135
 
95
- // Cast session to our extended type
96
- const session = sessionData as ImmutableSession | null;
136
+ // Cast session to our internal type (includes accessToken for internal logic)
137
+ const session = sessionData as ImmutableSessionInternal | null;
97
138
 
98
139
  const isLoading = status === 'loading';
99
140
 
@@ -114,7 +155,7 @@ export function useImmutableSession(): UseImmutableSessionReturn {
114
155
  // Use a ref to always have access to the latest session.
115
156
  // This avoids stale closure issues when the wallet stores the getUser function
116
157
  // and calls it later - the ref always points to the current session.
117
- const sessionRef = useRef<ImmutableSession | null>(session);
158
+ const sessionRef = useRef<ImmutableSessionInternal | null>(session);
118
159
  sessionRef.current = session;
119
160
 
120
161
  // Also store update in a ref so the callback is stable
@@ -125,6 +166,44 @@ export function useImmutableSession(): UseImmutableSessionReturn {
125
166
  const setIsRefreshingRef = useRef(setIsRefreshing);
126
167
  setIsRefreshingRef.current = setIsRefreshing;
127
168
 
169
+ // ---------------------------------------------------------------------------
170
+ // Proactive token refresh
171
+ // ---------------------------------------------------------------------------
172
+
173
+ // Reactive refresh: when the effect runs and the token is already expired
174
+ // (e.g., after tab regains focus), trigger an immediate silent refresh.
175
+ // For tokens that are still valid, getAccessToken() handles refresh on demand.
176
+ //
177
+ // NOTE: This intentionally does NOT set isRefreshing. isRefreshing is reserved
178
+ // for explicit user-triggered refreshes (e.g., getUser(true) after wallet
179
+ // registration). Background token refreshes must be invisible to consumers --
180
+ // setting isRefreshing would cause downstream hooks that gate SWR keys on
181
+ // `!isRefreshing` to briefly lose their cached data, resulting in UI flicker.
182
+ useEffect(() => {
183
+ if (!session?.accessTokenExpires) return;
184
+
185
+ const timeUntilExpiry = session.accessTokenExpires - Date.now() - TOKEN_EXPIRY_BUFFER_MS;
186
+
187
+ if (timeUntilExpiry <= 0) {
188
+ // Already expired -- refresh silently
189
+ deduplicatedUpdate(() => updateRef.current());
190
+ }
191
+ }, [session?.accessTokenExpires]);
192
+
193
+ // ---------------------------------------------------------------------------
194
+ // Sync idToken to localStorage
195
+ // ---------------------------------------------------------------------------
196
+
197
+ // The idToken is stripped from the cookie by jwt.encode on the server to avoid
198
+ // CloudFront 413 errors. It is only present in the session response transiently
199
+ // after sign-in or token refresh. When present, persist it in localStorage so
200
+ // that getUser() can always return it (used by wallet's MagicTEESigner).
201
+ useEffect(() => {
202
+ if (session?.idToken) {
203
+ storeIdToken(session.idToken);
204
+ }
205
+ }, [session?.idToken]);
206
+
128
207
  /**
129
208
  * Get user function for wallet integration.
130
209
  * Returns a User object compatible with @imtbl/wallet's getUser option.
@@ -135,7 +214,7 @@ export function useImmutableSession(): UseImmutableSessionReturn {
135
214
  * @param forceRefresh - When true, triggers a server-side token refresh
136
215
  */
137
216
  const getUser = useCallback(async (forceRefresh?: boolean): Promise<User | null> => {
138
- let currentSession: ImmutableSession | null;
217
+ let currentSession: ImmutableSessionInternal | null;
139
218
 
140
219
  // If forceRefresh is requested, trigger server-side refresh via NextAuth
141
220
  // This calls the jwt callback with trigger='update' and sessionUpdate.forceRefresh=true
@@ -145,10 +224,14 @@ export function useImmutableSession(): UseImmutableSessionReturn {
145
224
  try {
146
225
  // update() returns the refreshed session
147
226
  const updatedSession = await updateRef.current({ forceRefresh: true });
148
- currentSession = updatedSession as ImmutableSession | null;
227
+ currentSession = updatedSession as ImmutableSessionInternal | null;
149
228
  // Also update the ref so subsequent calls get the fresh data
150
229
  if (currentSession) {
151
230
  sessionRef.current = currentSession;
231
+ // Immediately persist fresh idToken to localStorage (avoids race with useEffect)
232
+ if (currentSession.idToken) {
233
+ storeIdToken(currentSession.idToken);
234
+ }
152
235
  }
153
236
  } catch (error) {
154
237
  // eslint-disable-next-line no-console
@@ -158,6 +241,20 @@ export function useImmutableSession(): UseImmutableSessionReturn {
158
241
  } finally {
159
242
  setIsRefreshingRef.current(false);
160
243
  }
244
+ } else if (pendingRefresh) {
245
+ // If a refresh is in-flight (proactive timer or another getAccessToken call),
246
+ // wait for it and use the refreshed session rather than returning a stale token.
247
+ const refreshed = await pendingRefresh;
248
+ if (refreshed) {
249
+ currentSession = refreshed as ImmutableSessionInternal;
250
+ sessionRef.current = currentSession;
251
+ // Persist fresh idToken to localStorage immediately
252
+ if (currentSession.idToken) {
253
+ storeIdToken(currentSession.idToken);
254
+ }
255
+ } else {
256
+ currentSession = sessionRef.current;
257
+ }
161
258
  } else {
162
259
  // Read from ref - instant, no network call
163
260
  // The ref is always updated on each render with the latest session
@@ -178,7 +275,9 @@ export function useImmutableSession(): UseImmutableSessionReturn {
178
275
  return {
179
276
  accessToken: currentSession.accessToken,
180
277
  refreshToken: currentSession.refreshToken,
181
- idToken: currentSession.idToken,
278
+ // Prefer session idToken (fresh after sign-in or refresh, before useEffect
279
+ // stores it), fall back to localStorage for normal reads (cookie has no idToken).
280
+ idToken: currentSession.idToken || getStoredIdToken(),
182
281
  profile: {
183
282
  sub: currentSession.user?.sub ?? '',
184
283
  email: currentSession.user?.email ?? undefined,
@@ -188,13 +287,55 @@ export function useImmutableSession(): UseImmutableSessionReturn {
188
287
  };
189
288
  }, []); // Empty deps - uses refs for latest values
190
289
 
290
+ /**
291
+ * Get a guaranteed-fresh access token.
292
+ * Returns immediately if the current token is valid (fast path, no network call).
293
+ * If expired, triggers a server-side refresh and blocks (awaits) until the fresh
294
+ * token is available. Piggybacks on any in-flight refresh to avoid duplicate calls.
295
+ *
296
+ * @throws Error if the user is not authenticated or if the refresh fails.
297
+ */
298
+ const getAccessToken = useCallback(async (): Promise<string> => {
299
+ const currentSession = sessionRef.current;
300
+
301
+ // Fast path: token is valid -- return immediately
302
+ if (
303
+ currentSession?.accessToken
304
+ && currentSession.accessTokenExpires
305
+ && Date.now() < currentSession.accessTokenExpires - TOKEN_EXPIRY_BUFFER_MS
306
+ && !currentSession.error
307
+ ) {
308
+ return currentSession.accessToken;
309
+ }
310
+
311
+ // Token is expired or missing -- wait for in-flight refresh or trigger one
312
+ const refreshed = await deduplicatedUpdate(
313
+ () => updateRef.current(),
314
+ ) as ImmutableSessionInternal | null;
315
+
316
+ if (!refreshed?.accessToken || refreshed.error) {
317
+ throw new Error(
318
+ `[auth-next-client] Failed to get access token: ${refreshed?.error || 'no session'}`,
319
+ );
320
+ }
321
+
322
+ // Update ref so subsequent sync reads get the fresh data
323
+ sessionRef.current = refreshed;
324
+ return refreshed.accessToken;
325
+ }, []); // Empty deps -- uses refs for latest values
326
+
327
+ // Cast to public type (omits accessToken) to prevent consumers from
328
+ // accidentally using a potentially stale token. Use getAccessToken() instead.
329
+ const publicSession = session as ImmutableSession | null;
330
+
191
331
  return {
192
- session,
332
+ session: publicSession,
193
333
  status,
194
334
  isLoading,
195
335
  isAuthenticated,
196
336
  isRefreshing,
197
337
  getUser,
338
+ getAccessToken,
198
339
  };
199
340
  }
200
341
 
@@ -271,6 +412,12 @@ export function useLogin(): UseLoginReturn {
271
412
  profile: { sub: string; email?: string; nickname?: string };
272
413
  zkEvm?: ZkEvmInfo;
273
414
  }) => {
415
+ // Persist idToken to localStorage before signIn so it's available immediately.
416
+ // The cookie won't contain idToken (stripped by jwt.encode on the server).
417
+ if (tokens.idToken) {
418
+ storeIdToken(tokens.idToken);
419
+ }
420
+
274
421
  const result = await signIn(IMMUTABLE_PROVIDER_ID, {
275
422
  tokens: JSON.stringify(tokens),
276
423
  redirect: false,
@@ -434,6 +581,9 @@ export function useLogout(): UseLogoutReturn {
434
581
  setError(null);
435
582
 
436
583
  try {
584
+ // Clear idToken from localStorage before clearing session
585
+ clearStoredIdToken();
586
+
437
587
  // First, clear the NextAuth session (this clears the JWT cookie)
438
588
  // We use redirect: false to handle the redirect ourselves for federated logout
439
589
  await signOut({ redirect: false });