@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.
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Utility for persisting idToken in localStorage.
3
+ *
4
+ * The idToken is stripped from the NextAuth session cookie (via a custom
5
+ * jwt.encode in @imtbl/auth-next-server) to keep cookie size under CDN header
6
+ * limits (CloudFront 20 KB). Instead, the client stores idToken in
7
+ * localStorage so that wallet operations (e.g., MagicTEESigner) can still
8
+ * access it via getUser().
9
+ *
10
+ * All functions are safe to call during SSR or in restricted environments
11
+ * (e.g., incognito mode with localStorage disabled) -- they silently no-op.
12
+ */
13
+ /**
14
+ * Store the idToken in localStorage.
15
+ * @param idToken - The raw ID token JWT string
16
+ */
17
+ export declare function storeIdToken(idToken: string): void;
18
+ /**
19
+ * Retrieve the idToken from localStorage.
20
+ * @returns The stored idToken, or undefined if not available.
21
+ */
22
+ export declare function getStoredIdToken(): string | undefined;
23
+ /**
24
+ * Remove the idToken from localStorage (e.g., on logout).
25
+ */
26
+ export declare function clearStoredIdToken(): void;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@imtbl/auth-next-client",
3
- "version": "2.12.7-alpha.0",
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.0",
31
- "@imtbl/auth-next-server": "2.12.7-alpha.0"
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
+ });