@imtbl/auth-next-client 2.12.7-alpha.1 → 2.12.7-alpha.11

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/src/constants.ts CHANGED
@@ -1,39 +1,16 @@
1
1
  /**
2
- * Shared constants for @imtbl/auth-next-client
2
+ * Client-side constants for @imtbl/auth-next-client.
3
+ * Defined locally to avoid importing from auth-next-server (which uses next/server).
4
+ * Values must stay in sync with auth-next-server constants.
3
5
  */
4
6
 
5
- /**
6
- * Default Immutable authentication domain
7
- */
8
7
  export const DEFAULT_AUTH_DOMAIN = 'https://auth.immutable.com';
9
-
10
- /**
11
- * Default OAuth audience
12
- */
13
8
  export const DEFAULT_AUDIENCE = 'platform_api';
14
-
15
- /**
16
- * Default OAuth scopes
17
- */
18
9
  export const DEFAULT_SCOPE = 'openid profile email offline_access transact';
19
-
20
- /**
21
- * NextAuth credentials provider ID for Immutable
22
- */
23
10
  export const IMMUTABLE_PROVIDER_ID = 'immutable';
24
-
25
- /**
26
- * Default NextAuth API base path
27
- */
28
11
  export const DEFAULT_NEXTAUTH_BASE_PATH = '/api/auth';
29
-
30
- /**
31
- * Default token expiry in seconds (15 minutes)
32
- * Used as fallback when exp claim cannot be extracted from JWT
33
- */
34
- export const DEFAULT_TOKEN_EXPIRY_SECONDS = 900;
35
-
36
- /**
37
- * Default token expiry in milliseconds
38
- */
39
- export const DEFAULT_TOKEN_EXPIRY_MS = DEFAULT_TOKEN_EXPIRY_SECONDS * 1000;
12
+ export const DEFAULT_SANDBOX_CLIENT_ID = 'mjtCL8mt06BtbxSkp2vbrYStKWnXVZfo';
13
+ export const DEFAULT_REDIRECT_URI_PATH = '/callback';
14
+ export const DEFAULT_LOGOUT_REDIRECT_URI_PATH = '/';
15
+ export const DEFAULT_TOKEN_EXPIRY_MS = 900_000;
16
+ export const TOKEN_EXPIRY_BUFFER_MS = 60_000;
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Sandbox default redirect URI for zero-config mode.
3
+ * Defined locally to avoid importing from auth-next-server (which uses next/server).
4
+ * OAuth requires an absolute URL; this runs in the browser when login is invoked.
5
+ *
6
+ * @internal
7
+ */
8
+
9
+ import { DEFAULT_REDIRECT_URI_PATH } from './constants';
10
+
11
+ export function deriveDefaultRedirectUri(): string {
12
+ if (typeof window === 'undefined') {
13
+ throw new Error(
14
+ '[auth-next-client] deriveDefaultRedirectUri requires window. '
15
+ + 'Login hooks run in the browser when the user triggers login.',
16
+ );
17
+ }
18
+ return `${window.location.origin}${DEFAULT_REDIRECT_URI_PATH}`;
19
+ }
@@ -0,0 +1,321 @@
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
+ jest.mock('./defaultConfig', () => ({
27
+ deriveDefaultRedirectUri: jest.fn(() => 'http://localhost:3000/callback'),
28
+ }));
29
+
30
+ import { useImmutableSession } from './hooks';
31
+
32
+ // ---------------------------------------------------------------------------
33
+ // Helpers
34
+ // ---------------------------------------------------------------------------
35
+
36
+ /**
37
+ * Full session shape matching what NextAuth returns at runtime.
38
+ * The public ImmutableSession type intentionally omits accessToken,
39
+ * but the underlying data still has it -- tests need the full shape
40
+ * to set up mock sessions correctly.
41
+ */
42
+ interface TestSession {
43
+ accessToken: string;
44
+ refreshToken?: string;
45
+ idToken?: string;
46
+ accessTokenExpires: number;
47
+ zkEvm?: { ethAddress: string; userAdminAddress: string };
48
+ error?: string;
49
+ user?: any;
50
+ expires: string;
51
+ }
52
+
53
+ function createSession(overrides: Partial<TestSession> = {}): TestSession {
54
+ return {
55
+ accessToken: 'valid-token',
56
+ refreshToken: 'refresh-token',
57
+ idToken: 'id-token',
58
+ accessTokenExpires: Date.now() + 10 * 60 * 1000, // 10 min from now
59
+ user: { sub: 'user-1', email: 'test@test.com' },
60
+ expires: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(),
61
+ ...overrides,
62
+ };
63
+ }
64
+
65
+ function setupUseSession(session: TestSession | null, status: string = 'authenticated') {
66
+ mockUseSession.mockReturnValue({
67
+ data: session,
68
+ status,
69
+ update: mockUpdate,
70
+ });
71
+ }
72
+
73
+ // ---------------------------------------------------------------------------
74
+ // Tests
75
+ // ---------------------------------------------------------------------------
76
+
77
+ describe('useImmutableSession', () => {
78
+ beforeEach(() => {
79
+ jest.clearAllMocks();
80
+ mockUpdate.mockReset();
81
+ });
82
+
83
+ describe('session type safety', () => {
84
+ it('does not expose accessToken on the public session type', () => {
85
+ const session = createSession();
86
+ setupUseSession(session);
87
+ mockUpdate.mockResolvedValue(session);
88
+
89
+ const { result } = renderHook(() => useImmutableSession());
90
+
91
+ // TypeScript prevents accessing accessToken, but at runtime the
92
+ // underlying object still has it. Verify the hook returns a session
93
+ // and that the public type doesn't advertise accessToken.
94
+ expect(result.current.session).toBeDefined();
95
+ expect(result.current.session?.error).toBeUndefined();
96
+ // The property exists at runtime (it's the same object) but the type
97
+ // system hides it -- this is a compile-time guard, not a runtime one.
98
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
99
+ expect((result.current.session as any).accessToken).toBeDefined();
100
+ });
101
+ });
102
+
103
+ describe('getAccessToken()', () => {
104
+ it('returns the token immediately when it is valid (fast path)', async () => {
105
+ const session = createSession();
106
+ setupUseSession(session);
107
+ mockUpdate.mockResolvedValue(session); // in case proactive timer fires
108
+
109
+ const { result } = renderHook(() => useImmutableSession());
110
+
111
+ const token = await result.current.getAccessToken();
112
+ expect(token).toBe('valid-token');
113
+ });
114
+
115
+ it('triggers refresh and returns fresh token when expired', async () => {
116
+ const expiredSession = createSession({
117
+ accessTokenExpires: Date.now() - 1000, // already expired
118
+ });
119
+ setupUseSession(expiredSession);
120
+
121
+ const freshSession = createSession({
122
+ accessToken: 'fresh-token',
123
+ accessTokenExpires: Date.now() + 10 * 60 * 1000,
124
+ });
125
+ mockUpdate.mockResolvedValue(freshSession);
126
+
127
+ const { result } = renderHook(() => useImmutableSession());
128
+
129
+ let token: string = '';
130
+ await act(async () => {
131
+ token = await result.current.getAccessToken();
132
+ });
133
+ expect(token).toBe('fresh-token');
134
+ expect(mockUpdate).toHaveBeenCalled();
135
+ });
136
+
137
+ it('triggers refresh when token is within the buffer window', async () => {
138
+ const almostExpiredSession = createSession({
139
+ accessTokenExpires: Date.now() + TOKEN_EXPIRY_BUFFER_MS - 1000, // within buffer
140
+ });
141
+ setupUseSession(almostExpiredSession);
142
+
143
+ const freshSession = createSession({
144
+ accessToken: 'refreshed-token',
145
+ accessTokenExpires: Date.now() + 10 * 60 * 1000,
146
+ });
147
+ mockUpdate.mockResolvedValue(freshSession);
148
+
149
+ const { result } = renderHook(() => useImmutableSession());
150
+
151
+ let token: string = '';
152
+ await act(async () => {
153
+ token = await result.current.getAccessToken();
154
+ });
155
+ expect(token).toBe('refreshed-token');
156
+ expect(mockUpdate).toHaveBeenCalled();
157
+ });
158
+
159
+ it('throws when refresh fails (no accessToken in response)', async () => {
160
+ const expiredSession = createSession({
161
+ accessTokenExpires: Date.now() - 1000,
162
+ });
163
+ setupUseSession(expiredSession);
164
+
165
+ mockUpdate.mockResolvedValue(null);
166
+
167
+ const { result } = renderHook(() => useImmutableSession());
168
+
169
+ await expect(
170
+ act(async () => {
171
+ await result.current.getAccessToken();
172
+ }),
173
+ ).rejects.toThrow('[auth-next-client] Failed to get access token');
174
+ });
175
+
176
+ it('throws when refresh returns a session with error', async () => {
177
+ const expiredSession = createSession({
178
+ accessTokenExpires: Date.now() - 1000,
179
+ });
180
+ setupUseSession(expiredSession);
181
+
182
+ const errorSession = createSession({ error: 'RefreshTokenError' });
183
+ mockUpdate.mockResolvedValue(errorSession);
184
+
185
+ const { result } = renderHook(() => useImmutableSession());
186
+
187
+ await expect(
188
+ act(async () => {
189
+ await result.current.getAccessToken();
190
+ }),
191
+ ).rejects.toThrow('RefreshTokenError');
192
+ });
193
+
194
+ it('deduplicates concurrent calls (only one update())', async () => {
195
+ const expiredSession = createSession({
196
+ accessTokenExpires: Date.now() - 1000,
197
+ });
198
+ setupUseSession(expiredSession);
199
+
200
+ const freshSession = createSession({
201
+ accessToken: 'deduped-token',
202
+ accessTokenExpires: Date.now() + 10 * 60 * 1000,
203
+ });
204
+
205
+ // Track how many times update is actually invoked
206
+ let updateCallCount = 0;
207
+ mockUpdate.mockImplementation(() => {
208
+ updateCallCount++;
209
+ return Promise.resolve(freshSession);
210
+ });
211
+
212
+ const { result } = renderHook(() => useImmutableSession());
213
+
214
+ let token1: string = '';
215
+ let token2: string = '';
216
+ await act(async () => {
217
+ [token1, token2] = await Promise.all([
218
+ result.current.getAccessToken(),
219
+ result.current.getAccessToken(),
220
+ ]);
221
+ });
222
+
223
+ expect(token1).toBe('deduped-token');
224
+ expect(token2).toBe('deduped-token');
225
+ // The proactive effect may also trigger one call, so we check
226
+ // that concurrent getAccessToken calls are deduped (not doubled)
227
+ // The key invariant: at most 1 update() call per pending refresh cycle
228
+ expect(updateCallCount).toBeLessThanOrEqual(2); // 1 from effect + 1 from getAccessToken (deduped)
229
+ });
230
+ });
231
+
232
+ describe('reactive refresh on mount', () => {
233
+ it('triggers refresh when token is already expired on mount', async () => {
234
+ const session = createSession({
235
+ accessTokenExpires: Date.now() - 1000, // already expired
236
+ });
237
+ setupUseSession(session);
238
+
239
+ const freshSession = createSession({
240
+ accessToken: 'immediate-refresh',
241
+ });
242
+ mockUpdate.mockResolvedValue(freshSession);
243
+
244
+ await act(async () => {
245
+ renderHook(() => useImmutableSession());
246
+ });
247
+
248
+ // The proactive useEffect should have called update
249
+ expect(mockUpdate).toHaveBeenCalled();
250
+ });
251
+
252
+ it('does not set isRefreshing during reactive refresh (prevents UI flicker)', async () => {
253
+ const session = createSession({
254
+ accessTokenExpires: Date.now() - 1000, // already expired
255
+ });
256
+ setupUseSession(session);
257
+
258
+ const freshSession = createSession({
259
+ accessToken: 'immediate-refresh',
260
+ });
261
+ mockUpdate.mockResolvedValue(freshSession);
262
+
263
+ const capturedIsRefreshing: boolean[] = [];
264
+ const { result } = renderHook(() => {
265
+ const hook = useImmutableSession();
266
+ capturedIsRefreshing.push(hook.isRefreshing);
267
+ return hook;
268
+ });
269
+
270
+ await act(async () => {
271
+ // Let the reactive refresh effect run and complete
272
+ await mockUpdate.mock.results[0]?.value;
273
+ });
274
+
275
+ // isRefreshing should NEVER have been true during reactive refresh.
276
+ // It is reserved for explicit user-triggered refreshes (getUser(true)).
277
+ expect(capturedIsRefreshing.every((v) => v === false)).toBe(true);
278
+ expect(result.current.isRefreshing).toBe(false);
279
+ });
280
+
281
+ it('does not trigger refresh when token is valid and far from expiry', async () => {
282
+ const session = createSession({
283
+ accessTokenExpires: Date.now() + 10 * 60 * 1000, // 10 min from now, well beyond buffer
284
+ });
285
+ setupUseSession(session);
286
+
287
+ await act(async () => {
288
+ renderHook(() => useImmutableSession());
289
+ });
290
+
291
+ // Should NOT have called update -- token is still valid
292
+ expect(mockUpdate).not.toHaveBeenCalled();
293
+ });
294
+ });
295
+
296
+ describe('getUser() respects pending refresh', () => {
297
+ it('waits for in-flight refresh before returning user', async () => {
298
+ const expiredSession = createSession({
299
+ accessTokenExpires: Date.now() - 1000,
300
+ });
301
+ setupUseSession(expiredSession);
302
+
303
+ const freshSession = createSession({
304
+ accessToken: 'user-fresh-token',
305
+ accessTokenExpires: Date.now() + 10 * 60 * 1000,
306
+ });
307
+
308
+ mockUpdate.mockResolvedValue(freshSession);
309
+
310
+ const { result } = renderHook(() => useImmutableSession());
311
+
312
+ let user: any;
313
+ await act(async () => {
314
+ user = await result.current.getUser();
315
+ });
316
+
317
+ // getUser() should have waited for the refresh and gotten the fresh token
318
+ expect(user?.accessToken).toBe('user-fresh-token');
319
+ });
320
+ });
321
+ });