@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 +157 -20
- package/dist/node/index.cjs +98 -7
- package/dist/node/index.js +104 -8
- package/dist/types/constants.d.ts +5 -0
- package/dist/types/hooks.d.ts +19 -2
- package/dist/types/idTokenStorage.d.ts +26 -0
- package/package.json +5 -3
- package/src/callback.tsx +7 -0
- package/src/constants.ts +6 -0
- package/src/hooks.test.tsx +317 -0
- package/src/hooks.tsx +171 -17
- package/src/idTokenStorage.ts +56 -0
|
@@ -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.
|
|
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.
|
|
31
|
-
"@imtbl/auth-next-server": "2.12.7-alpha.
|
|
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
|
+
});
|