@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/README.md +157 -20
- package/dist/node/index.cjs +94 -3
- package/dist/node/index.js +100 -4
- 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 +161 -11
- package/src/idTokenStorage.ts +56 -0
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
|
+
});
|
package/src/hooks.tsx
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import {
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
|
96
|
-
const session = sessionData as
|
|
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<
|
|
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:
|
|
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
|
|
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
|
|
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 });
|