@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/README.md +197 -20
- package/dist/node/index.cjs +160 -9
- package/dist/node/index.js +158 -10
- package/dist/types/constants.d.ts +8 -25
- package/dist/types/defaultConfig.d.ts +8 -0
- package/dist/types/hooks.d.ts +104 -23
- package/dist/types/idTokenStorage.d.ts +26 -0
- package/dist/types/index.d.ts +2 -0
- package/package.json +7 -5
- package/src/callback.tsx +7 -0
- package/src/constants.ts +8 -31
- package/src/defaultConfig.ts +19 -0
- package/src/hooks.test.tsx +321 -0
- package/src/hooks.tsx +304 -40
- package/src/idTokenStorage.ts +56 -0
- package/src/index.ts +12 -0
package/src/constants.ts
CHANGED
|
@@ -1,39 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
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
|
-
|
|
32
|
-
|
|
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
|
+
});
|