@imtbl/auth-next-client 2.12.7-alpha.5 → 2.12.7-alpha.7
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 +117 -19
- package/dist/node/index.cjs +45 -2
- package/dist/node/index.js +51 -3
- package/dist/types/constants.d.ts +5 -0
- package/dist/types/hooks.d.ts +19 -2
- package/package.json +5 -3
- package/src/constants.ts +6 -0
- package/src/hooks.test.tsx +317 -0
- package/src/hooks.tsx +126 -10
package/README.md
CHANGED
|
@@ -10,7 +10,7 @@ This package provides minimal client-side utilities for Next.js applications usi
|
|
|
10
10
|
|
|
11
11
|
- `useLogin` - Hook for login flows with state management (loading, error)
|
|
12
12
|
- `useLogout` - Hook for logout with federated logout support (clears both local and upstream sessions)
|
|
13
|
-
- `useImmutableSession` - Hook that provides session state and
|
|
13
|
+
- `useImmutableSession` - Hook that provides session state, `getAccessToken()` for guaranteed-fresh tokens, and `getUser` for wallet integration
|
|
14
14
|
- `CallbackPage` - OAuth callback handler component
|
|
15
15
|
|
|
16
16
|
For server-side utilities, use [`@imtbl/auth-next-server`](../auth-next-server).
|
|
@@ -395,7 +395,11 @@ With federated logout, the auth server's session is also cleared, so users can s
|
|
|
395
395
|
|
|
396
396
|
### `useImmutableSession()`
|
|
397
397
|
|
|
398
|
-
A convenience hook that wraps `next-auth/react`'s `useSession` with
|
|
398
|
+
A convenience hook that wraps `next-auth/react`'s `useSession` with:
|
|
399
|
+
|
|
400
|
+
- `getAccessToken()` -- async function that returns a **guaranteed-fresh** access token
|
|
401
|
+
- `getUser()` -- function for wallet integration
|
|
402
|
+
- Automatic token refresh -- detects expired tokens and refreshes on demand
|
|
399
403
|
|
|
400
404
|
```tsx
|
|
401
405
|
"use client";
|
|
@@ -404,10 +408,12 @@ import { useImmutableSession } from "@imtbl/auth-next-client";
|
|
|
404
408
|
|
|
405
409
|
function MyComponent() {
|
|
406
410
|
const {
|
|
407
|
-
session, // Session
|
|
411
|
+
session, // Session metadata (user info, zkEvm, error) -- does NOT include accessToken
|
|
408
412
|
status, // 'loading' | 'authenticated' | 'unauthenticated'
|
|
409
413
|
isLoading, // True during initial load
|
|
410
414
|
isAuthenticated, // True when logged in
|
|
415
|
+
isRefreshing, // True during token refresh
|
|
416
|
+
getAccessToken, // Async function: returns a guaranteed-fresh access token
|
|
411
417
|
getUser, // Function for wallet integration
|
|
412
418
|
} = useImmutableSession();
|
|
413
419
|
|
|
@@ -420,13 +426,36 @@ function MyComponent() {
|
|
|
420
426
|
|
|
421
427
|
#### Return Value
|
|
422
428
|
|
|
423
|
-
| Property | Type | Description
|
|
424
|
-
| ----------------- | --------------------------------------------------- |
|
|
425
|
-
| `session` | `ImmutableSession \| null` | Session
|
|
426
|
-
| `status` | `string` | Auth status: `'loading'`, `'authenticated'`, `'unauthenticated'`
|
|
427
|
-
| `isLoading` | `boolean` | Whether initial auth state is loading
|
|
428
|
-
| `isAuthenticated` | `boolean` | Whether user is authenticated
|
|
429
|
-
| `
|
|
429
|
+
| Property | Type | Description |
|
|
430
|
+
| ----------------- | --------------------------------------------------- | --------------------------------------------------------------------------------------- |
|
|
431
|
+
| `session` | `ImmutableSession \| null` | Session metadata (user, zkEvm, error). Does **not** include `accessToken` -- see below. |
|
|
432
|
+
| `status` | `string` | Auth status: `'loading'`, `'authenticated'`, `'unauthenticated'` |
|
|
433
|
+
| `isLoading` | `boolean` | Whether initial auth state is loading |
|
|
434
|
+
| `isAuthenticated` | `boolean` | Whether user is authenticated |
|
|
435
|
+
| `isRefreshing` | `boolean` | Whether a token refresh is in progress |
|
|
436
|
+
| `getAccessToken` | `() => Promise<string>` | Get a guaranteed-fresh access token. Throws if not authenticated or refresh fails. |
|
|
437
|
+
| `getUser` | `(forceRefresh?: boolean) => Promise<User \| null>` | Get user function for wallet integration |
|
|
438
|
+
|
|
439
|
+
#### Why no `accessToken` on `session`?
|
|
440
|
+
|
|
441
|
+
The `session` object intentionally does **not** expose `accessToken`. This is a deliberate design choice to prevent consumers from accidentally using a stale/expired token.
|
|
442
|
+
|
|
443
|
+
**Always use `getAccessToken()`** to obtain a token for authenticated requests:
|
|
444
|
+
|
|
445
|
+
```tsx
|
|
446
|
+
// ✅ Correct - always fresh
|
|
447
|
+
const token = await getAccessToken();
|
|
448
|
+
await authenticatedGet("/api/data", token);
|
|
449
|
+
|
|
450
|
+
// ❌ Incorrect - session.accessToken does not exist on the type
|
|
451
|
+
const token = session?.accessToken; // TypeScript error
|
|
452
|
+
```
|
|
453
|
+
|
|
454
|
+
`getAccessToken()` guarantees freshness:
|
|
455
|
+
|
|
456
|
+
- **Fast path**: If the current token is valid, returns immediately (no network call).
|
|
457
|
+
- **Slow path**: If the token is expired, triggers a server-side refresh and **blocks** (awaits) until the fresh token is available.
|
|
458
|
+
- **Deduplication**: Multiple concurrent calls share a single refresh request.
|
|
430
459
|
|
|
431
460
|
#### Checking Authentication Status
|
|
432
461
|
|
|
@@ -465,6 +494,64 @@ const { status } = useImmutableSession();
|
|
|
465
494
|
if (status !== "authenticated") return <div>Please log in</div>;
|
|
466
495
|
```
|
|
467
496
|
|
|
497
|
+
#### Using `getAccessToken()` in Practice
|
|
498
|
+
|
|
499
|
+
**SWR fetcher:**
|
|
500
|
+
|
|
501
|
+
```tsx
|
|
502
|
+
import useSWR from "swr";
|
|
503
|
+
import { useImmutableSession } from "@imtbl/auth-next-client";
|
|
504
|
+
|
|
505
|
+
function useProfile() {
|
|
506
|
+
const { getAccessToken, isAuthenticated } = useImmutableSession();
|
|
507
|
+
|
|
508
|
+
return useSWR(
|
|
509
|
+
isAuthenticated ? "/passport-profile/v1/profile" : null,
|
|
510
|
+
async (path) => {
|
|
511
|
+
const token = await getAccessToken(); // blocks until fresh
|
|
512
|
+
return authenticatedGet(path, token);
|
|
513
|
+
},
|
|
514
|
+
);
|
|
515
|
+
}
|
|
516
|
+
```
|
|
517
|
+
|
|
518
|
+
**Event handler:**
|
|
519
|
+
|
|
520
|
+
```tsx
|
|
521
|
+
import { useImmutableSession } from "@imtbl/auth-next-client";
|
|
522
|
+
|
|
523
|
+
function ClaimRewardButton({ questId }: { questId: string }) {
|
|
524
|
+
const { getAccessToken } = useImmutableSession();
|
|
525
|
+
|
|
526
|
+
const handleClaim = async () => {
|
|
527
|
+
const token = await getAccessToken(); // blocks until fresh
|
|
528
|
+
await authenticatedPost("/v1/quests/claim", token, { questId });
|
|
529
|
+
};
|
|
530
|
+
|
|
531
|
+
return <button onClick={handleClaim}>Claim</button>;
|
|
532
|
+
}
|
|
533
|
+
```
|
|
534
|
+
|
|
535
|
+
**Periodic polling:**
|
|
536
|
+
|
|
537
|
+
```tsx
|
|
538
|
+
import useSWR from "swr";
|
|
539
|
+
import { useImmutableSession } from "@imtbl/auth-next-client";
|
|
540
|
+
|
|
541
|
+
function ActivityFeed() {
|
|
542
|
+
const { getAccessToken, isAuthenticated } = useImmutableSession();
|
|
543
|
+
|
|
544
|
+
return useSWR(
|
|
545
|
+
isAuthenticated ? "/v1/activities" : null,
|
|
546
|
+
async (path) => {
|
|
547
|
+
const token = await getAccessToken();
|
|
548
|
+
return authenticatedGet(path, token);
|
|
549
|
+
},
|
|
550
|
+
{ refreshInterval: 10000 }, // polls every 10s, always gets a fresh token
|
|
551
|
+
);
|
|
552
|
+
}
|
|
553
|
+
```
|
|
554
|
+
|
|
468
555
|
#### The `getUser` Function
|
|
469
556
|
|
|
470
557
|
The `getUser` function returns fresh tokens from the session. It accepts an optional `forceRefresh` parameter:
|
|
@@ -489,11 +576,11 @@ When `forceRefresh` is `true`:
|
|
|
489
576
|
|
|
490
577
|
### ImmutableSession
|
|
491
578
|
|
|
492
|
-
The session type returned by `useImmutableSession
|
|
579
|
+
The session type returned by `useImmutableSession`. Note that `accessToken` is intentionally **not** included -- use `getAccessToken()` instead to obtain a guaranteed-fresh token.
|
|
493
580
|
|
|
494
581
|
```typescript
|
|
495
582
|
interface ImmutableSession {
|
|
496
|
-
accessToken
|
|
583
|
+
// accessToken is NOT exposed -- use getAccessToken() instead
|
|
497
584
|
refreshToken?: string;
|
|
498
585
|
idToken?: string;
|
|
499
586
|
accessTokenExpires: number;
|
|
@@ -568,17 +655,19 @@ interface LogoutConfig {
|
|
|
568
655
|
|
|
569
656
|
The session may contain an `error` field indicating authentication issues:
|
|
570
657
|
|
|
571
|
-
| Error | Description | Handling
|
|
572
|
-
| --------------------- | --------------------- |
|
|
573
|
-
| `"TokenExpired"` | Access token expired |
|
|
574
|
-
| `"RefreshTokenError"` | Refresh token invalid | Prompt user to sign in again
|
|
658
|
+
| Error | Description | Handling |
|
|
659
|
+
| --------------------- | --------------------- | -------------------------------------------- |
|
|
660
|
+
| `"TokenExpired"` | Access token expired | Proactive refresh handles this automatically |
|
|
661
|
+
| `"RefreshTokenError"` | Refresh token invalid | Prompt user to sign in again |
|
|
662
|
+
|
|
663
|
+
`getAccessToken()` throws an error if the token cannot be obtained (e.g., refresh failure). Handle it with try/catch:
|
|
575
664
|
|
|
576
665
|
```tsx
|
|
577
666
|
import { useImmutableSession } from "@imtbl/auth-next-client";
|
|
578
|
-
import {
|
|
667
|
+
import { signOut } from "next-auth/react";
|
|
579
668
|
|
|
580
669
|
function ProtectedContent() {
|
|
581
|
-
const { session, isAuthenticated } = useImmutableSession();
|
|
670
|
+
const { session, isAuthenticated, getAccessToken } = useImmutableSession();
|
|
582
671
|
|
|
583
672
|
if (session?.error === "RefreshTokenError") {
|
|
584
673
|
return (
|
|
@@ -593,11 +682,20 @@ function ProtectedContent() {
|
|
|
593
682
|
return (
|
|
594
683
|
<div>
|
|
595
684
|
<p>Please sign in to continue.</p>
|
|
596
|
-
<button onClick={() => signIn()}>Sign In</button>
|
|
597
685
|
</div>
|
|
598
686
|
);
|
|
599
687
|
}
|
|
600
688
|
|
|
689
|
+
const handleFetch = async () => {
|
|
690
|
+
try {
|
|
691
|
+
const token = await getAccessToken();
|
|
692
|
+
// Use token for authenticated requests
|
|
693
|
+
} catch (error) {
|
|
694
|
+
// Token refresh failed -- session may be expired
|
|
695
|
+
console.error("Failed to get access token:", error);
|
|
696
|
+
}
|
|
697
|
+
};
|
|
698
|
+
|
|
601
699
|
return <div>Protected content here</div>;
|
|
602
700
|
}
|
|
603
701
|
```
|
package/dist/node/index.cjs
CHANGED
|
@@ -39,6 +39,7 @@ var import_auth = require("@imtbl/auth");
|
|
|
39
39
|
var IMMUTABLE_PROVIDER_ID = "immutable";
|
|
40
40
|
var DEFAULT_TOKEN_EXPIRY_SECONDS = 900;
|
|
41
41
|
var DEFAULT_TOKEN_EXPIRY_MS = DEFAULT_TOKEN_EXPIRY_SECONDS * 1e3;
|
|
42
|
+
var TOKEN_EXPIRY_BUFFER_MS = 60 * 1e3;
|
|
42
43
|
|
|
43
44
|
// src/callback.tsx
|
|
44
45
|
var import_jsx_runtime = require("react/jsx-runtime");
|
|
@@ -181,6 +182,15 @@ function CallbackPage({
|
|
|
181
182
|
var import_react3 = require("react");
|
|
182
183
|
var import_react4 = require("next-auth/react");
|
|
183
184
|
var import_auth2 = require("@imtbl/auth");
|
|
185
|
+
var pendingRefresh = null;
|
|
186
|
+
function deduplicatedUpdate(update) {
|
|
187
|
+
if (!pendingRefresh) {
|
|
188
|
+
pendingRefresh = update().finally(() => {
|
|
189
|
+
pendingRefresh = null;
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
return pendingRefresh;
|
|
193
|
+
}
|
|
184
194
|
function useImmutableSession() {
|
|
185
195
|
const { data: sessionData, status, update } = (0, import_react4.useSession)();
|
|
186
196
|
const [isRefreshing, setIsRefreshing] = (0, import_react3.useState)(false);
|
|
@@ -197,6 +207,13 @@ function useImmutableSession() {
|
|
|
197
207
|
updateRef.current = update;
|
|
198
208
|
const setIsRefreshingRef = (0, import_react3.useRef)(setIsRefreshing);
|
|
199
209
|
setIsRefreshingRef.current = setIsRefreshing;
|
|
210
|
+
(0, import_react3.useEffect)(() => {
|
|
211
|
+
if (!session?.accessTokenExpires) return;
|
|
212
|
+
const timeUntilExpiry = session.accessTokenExpires - Date.now() - TOKEN_EXPIRY_BUFFER_MS;
|
|
213
|
+
if (timeUntilExpiry <= 0) {
|
|
214
|
+
deduplicatedUpdate(() => updateRef.current());
|
|
215
|
+
}
|
|
216
|
+
}, [session?.accessTokenExpires]);
|
|
200
217
|
const getUser = (0, import_react3.useCallback)(async (forceRefresh) => {
|
|
201
218
|
let currentSession;
|
|
202
219
|
if (forceRefresh) {
|
|
@@ -213,6 +230,14 @@ function useImmutableSession() {
|
|
|
213
230
|
} finally {
|
|
214
231
|
setIsRefreshingRef.current(false);
|
|
215
232
|
}
|
|
233
|
+
} else if (pendingRefresh) {
|
|
234
|
+
const refreshed = await pendingRefresh;
|
|
235
|
+
if (refreshed) {
|
|
236
|
+
currentSession = refreshed;
|
|
237
|
+
sessionRef.current = currentSession;
|
|
238
|
+
} else {
|
|
239
|
+
currentSession = sessionRef.current;
|
|
240
|
+
}
|
|
216
241
|
} else {
|
|
217
242
|
currentSession = sessionRef.current;
|
|
218
243
|
}
|
|
@@ -235,13 +260,31 @@ function useImmutableSession() {
|
|
|
235
260
|
zkEvm: currentSession.zkEvm
|
|
236
261
|
};
|
|
237
262
|
}, []);
|
|
263
|
+
const getAccessToken = (0, import_react3.useCallback)(async () => {
|
|
264
|
+
const currentSession = sessionRef.current;
|
|
265
|
+
if (currentSession?.accessToken && currentSession.accessTokenExpires && Date.now() < currentSession.accessTokenExpires - TOKEN_EXPIRY_BUFFER_MS && !currentSession.error) {
|
|
266
|
+
return currentSession.accessToken;
|
|
267
|
+
}
|
|
268
|
+
const refreshed = await deduplicatedUpdate(
|
|
269
|
+
() => updateRef.current()
|
|
270
|
+
);
|
|
271
|
+
if (!refreshed?.accessToken || refreshed.error) {
|
|
272
|
+
throw new Error(
|
|
273
|
+
`[auth-next-client] Failed to get access token: ${refreshed?.error || "no session"}`
|
|
274
|
+
);
|
|
275
|
+
}
|
|
276
|
+
sessionRef.current = refreshed;
|
|
277
|
+
return refreshed.accessToken;
|
|
278
|
+
}, []);
|
|
279
|
+
const publicSession = session;
|
|
238
280
|
return {
|
|
239
|
-
session,
|
|
281
|
+
session: publicSession,
|
|
240
282
|
status,
|
|
241
283
|
isLoading,
|
|
242
284
|
isAuthenticated,
|
|
243
285
|
isRefreshing,
|
|
244
|
-
getUser
|
|
286
|
+
getUser,
|
|
287
|
+
getAccessToken
|
|
245
288
|
};
|
|
246
289
|
}
|
|
247
290
|
function useLogin() {
|
package/dist/node/index.js
CHANGED
|
@@ -10,6 +10,7 @@ import { handleLoginCallback as handleAuthCallback } from "@imtbl/auth";
|
|
|
10
10
|
var IMMUTABLE_PROVIDER_ID = "immutable";
|
|
11
11
|
var DEFAULT_TOKEN_EXPIRY_SECONDS = 900;
|
|
12
12
|
var DEFAULT_TOKEN_EXPIRY_MS = DEFAULT_TOKEN_EXPIRY_SECONDS * 1e3;
|
|
13
|
+
var TOKEN_EXPIRY_BUFFER_MS = 60 * 1e3;
|
|
13
14
|
|
|
14
15
|
// src/callback.tsx
|
|
15
16
|
import { jsx, jsxs } from "react/jsx-runtime";
|
|
@@ -149,7 +150,12 @@ function CallbackPage({
|
|
|
149
150
|
}
|
|
150
151
|
|
|
151
152
|
// src/hooks.tsx
|
|
152
|
-
import {
|
|
153
|
+
import {
|
|
154
|
+
useCallback,
|
|
155
|
+
useEffect as useEffect2,
|
|
156
|
+
useRef as useRef2,
|
|
157
|
+
useState as useState2
|
|
158
|
+
} from "react";
|
|
153
159
|
import { useSession, signIn as signIn2, signOut } from "next-auth/react";
|
|
154
160
|
import {
|
|
155
161
|
loginWithPopup as rawLoginWithPopup,
|
|
@@ -157,6 +163,15 @@ import {
|
|
|
157
163
|
loginWithRedirect as rawLoginWithRedirect,
|
|
158
164
|
logoutWithRedirect as rawLogoutWithRedirect
|
|
159
165
|
} from "@imtbl/auth";
|
|
166
|
+
var pendingRefresh = null;
|
|
167
|
+
function deduplicatedUpdate(update) {
|
|
168
|
+
if (!pendingRefresh) {
|
|
169
|
+
pendingRefresh = update().finally(() => {
|
|
170
|
+
pendingRefresh = null;
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
return pendingRefresh;
|
|
174
|
+
}
|
|
160
175
|
function useImmutableSession() {
|
|
161
176
|
const { data: sessionData, status, update } = useSession();
|
|
162
177
|
const [isRefreshing, setIsRefreshing] = useState2(false);
|
|
@@ -173,6 +188,13 @@ function useImmutableSession() {
|
|
|
173
188
|
updateRef.current = update;
|
|
174
189
|
const setIsRefreshingRef = useRef2(setIsRefreshing);
|
|
175
190
|
setIsRefreshingRef.current = setIsRefreshing;
|
|
191
|
+
useEffect2(() => {
|
|
192
|
+
if (!session?.accessTokenExpires) return;
|
|
193
|
+
const timeUntilExpiry = session.accessTokenExpires - Date.now() - TOKEN_EXPIRY_BUFFER_MS;
|
|
194
|
+
if (timeUntilExpiry <= 0) {
|
|
195
|
+
deduplicatedUpdate(() => updateRef.current());
|
|
196
|
+
}
|
|
197
|
+
}, [session?.accessTokenExpires]);
|
|
176
198
|
const getUser = useCallback(async (forceRefresh) => {
|
|
177
199
|
let currentSession;
|
|
178
200
|
if (forceRefresh) {
|
|
@@ -189,6 +211,14 @@ function useImmutableSession() {
|
|
|
189
211
|
} finally {
|
|
190
212
|
setIsRefreshingRef.current(false);
|
|
191
213
|
}
|
|
214
|
+
} else if (pendingRefresh) {
|
|
215
|
+
const refreshed = await pendingRefresh;
|
|
216
|
+
if (refreshed) {
|
|
217
|
+
currentSession = refreshed;
|
|
218
|
+
sessionRef.current = currentSession;
|
|
219
|
+
} else {
|
|
220
|
+
currentSession = sessionRef.current;
|
|
221
|
+
}
|
|
192
222
|
} else {
|
|
193
223
|
currentSession = sessionRef.current;
|
|
194
224
|
}
|
|
@@ -211,13 +241,31 @@ function useImmutableSession() {
|
|
|
211
241
|
zkEvm: currentSession.zkEvm
|
|
212
242
|
};
|
|
213
243
|
}, []);
|
|
244
|
+
const getAccessToken = useCallback(async () => {
|
|
245
|
+
const currentSession = sessionRef.current;
|
|
246
|
+
if (currentSession?.accessToken && currentSession.accessTokenExpires && Date.now() < currentSession.accessTokenExpires - TOKEN_EXPIRY_BUFFER_MS && !currentSession.error) {
|
|
247
|
+
return currentSession.accessToken;
|
|
248
|
+
}
|
|
249
|
+
const refreshed = await deduplicatedUpdate(
|
|
250
|
+
() => updateRef.current()
|
|
251
|
+
);
|
|
252
|
+
if (!refreshed?.accessToken || refreshed.error) {
|
|
253
|
+
throw new Error(
|
|
254
|
+
`[auth-next-client] Failed to get access token: ${refreshed?.error || "no session"}`
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
sessionRef.current = refreshed;
|
|
258
|
+
return refreshed.accessToken;
|
|
259
|
+
}, []);
|
|
260
|
+
const publicSession = session;
|
|
214
261
|
return {
|
|
215
|
-
session,
|
|
262
|
+
session: publicSession,
|
|
216
263
|
status,
|
|
217
264
|
isLoading,
|
|
218
265
|
isAuthenticated,
|
|
219
266
|
isRefreshing,
|
|
220
|
-
getUser
|
|
267
|
+
getUser,
|
|
268
|
+
getAccessToken
|
|
221
269
|
};
|
|
222
270
|
}
|
|
223
271
|
function useLogin() {
|
|
@@ -30,3 +30,8 @@ export declare const DEFAULT_TOKEN_EXPIRY_SECONDS = 900;
|
|
|
30
30
|
* Default token expiry in milliseconds
|
|
31
31
|
*/
|
|
32
32
|
export declare const DEFAULT_TOKEN_EXPIRY_MS: number;
|
|
33
|
+
/**
|
|
34
|
+
* Buffer time in milliseconds before token expiry to trigger refresh.
|
|
35
|
+
* Matches TOKEN_EXPIRY_BUFFER_SECONDS (60s) in @imtbl/auth-next-server.
|
|
36
|
+
*/
|
|
37
|
+
export declare const TOKEN_EXPIRY_BUFFER_MS: number;
|
package/dist/types/hooks.d.ts
CHANGED
|
@@ -2,9 +2,10 @@ import type { Session } from 'next-auth';
|
|
|
2
2
|
import type { User, LoginConfig, StandaloneLoginOptions, LogoutConfig } from '@imtbl/auth';
|
|
3
3
|
import type { ZkEvmInfo } from './types';
|
|
4
4
|
/**
|
|
5
|
-
*
|
|
5
|
+
* Internal session type with full token data (not exported).
|
|
6
|
+
* Used internally by the hook for token validation, refresh logic, and getUser/getAccessToken.
|
|
6
7
|
*/
|
|
7
|
-
|
|
8
|
+
interface ImmutableSessionInternal extends Session {
|
|
8
9
|
accessToken: string;
|
|
9
10
|
refreshToken?: string;
|
|
10
11
|
idToken?: string;
|
|
@@ -12,6 +13,14 @@ export interface ImmutableSession extends Session {
|
|
|
12
13
|
zkEvm?: ZkEvmInfo;
|
|
13
14
|
error?: string;
|
|
14
15
|
}
|
|
16
|
+
/**
|
|
17
|
+
* Public session type exposed to consumers.
|
|
18
|
+
*
|
|
19
|
+
* Does **not** include `accessToken` -- consumers must use the `getAccessToken()`
|
|
20
|
+
* function returned by `useImmutableSession()` to obtain a guaranteed-fresh token.
|
|
21
|
+
* This prevents accidental use of stale/expired tokens.
|
|
22
|
+
*/
|
|
23
|
+
export type ImmutableSession = Omit<ImmutableSessionInternal, 'accessToken'>;
|
|
15
24
|
/**
|
|
16
25
|
* Return type for useImmutableSession hook
|
|
17
26
|
*/
|
|
@@ -35,6 +44,13 @@ export interface UseImmutableSessionReturn {
|
|
|
35
44
|
* The refreshed session will include updated zkEvm data if available.
|
|
36
45
|
*/
|
|
37
46
|
getUser: (forceRefresh?: boolean) => Promise<User | null>;
|
|
47
|
+
/**
|
|
48
|
+
* Get a guaranteed-fresh access token.
|
|
49
|
+
* Returns immediately if the current token is valid.
|
|
50
|
+
* If expired, triggers a refresh and blocks (awaits) until the fresh token is available.
|
|
51
|
+
* Throws if the user is not authenticated or if refresh fails.
|
|
52
|
+
*/
|
|
53
|
+
getAccessToken: () => Promise<string>;
|
|
38
54
|
}
|
|
39
55
|
/**
|
|
40
56
|
* Hook to access Immutable session with a getUser function for wallet integration.
|
|
@@ -186,3 +202,4 @@ export interface UseLogoutReturn {
|
|
|
186
202
|
* ```
|
|
187
203
|
*/
|
|
188
204
|
export declare function useLogout(): UseLogoutReturn;
|
|
205
|
+
export {};
|
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.7",
|
|
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.7",
|
|
31
|
+
"@imtbl/auth-next-server": "2.12.7-alpha.7"
|
|
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/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,34 @@ 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
|
+
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
// Module-level deduplication for session refresh
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
20
26
|
|
|
21
27
|
/**
|
|
22
|
-
*
|
|
28
|
+
* Deduplicates concurrent session refresh calls.
|
|
29
|
+
* Multiple components may mount useImmutableSession simultaneously; without
|
|
30
|
+
* deduplication each would trigger its own update() call, which could fail
|
|
31
|
+
* if the auth server rotates refresh tokens.
|
|
23
32
|
*/
|
|
24
|
-
|
|
33
|
+
let pendingRefresh: Promise<Session | null | undefined> | null = null;
|
|
34
|
+
|
|
35
|
+
function deduplicatedUpdate(
|
|
36
|
+
update: () => Promise<Session | null | undefined>,
|
|
37
|
+
): Promise<Session | null | undefined> {
|
|
38
|
+
if (!pendingRefresh) {
|
|
39
|
+
pendingRefresh = update().finally(() => { pendingRefresh = null; });
|
|
40
|
+
}
|
|
41
|
+
return pendingRefresh;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Internal session type with full token data (not exported).
|
|
46
|
+
* Used internally by the hook for token validation, refresh logic, and getUser/getAccessToken.
|
|
47
|
+
*/
|
|
48
|
+
interface ImmutableSessionInternal extends Session {
|
|
25
49
|
accessToken: string;
|
|
26
50
|
refreshToken?: string;
|
|
27
51
|
idToken?: string;
|
|
@@ -30,6 +54,15 @@ export interface ImmutableSession extends Session {
|
|
|
30
54
|
error?: string;
|
|
31
55
|
}
|
|
32
56
|
|
|
57
|
+
/**
|
|
58
|
+
* Public session type exposed to consumers.
|
|
59
|
+
*
|
|
60
|
+
* Does **not** include `accessToken` -- consumers must use the `getAccessToken()`
|
|
61
|
+
* function returned by `useImmutableSession()` to obtain a guaranteed-fresh token.
|
|
62
|
+
* This prevents accidental use of stale/expired tokens.
|
|
63
|
+
*/
|
|
64
|
+
export type ImmutableSession = Omit<ImmutableSessionInternal, 'accessToken'>;
|
|
65
|
+
|
|
33
66
|
/**
|
|
34
67
|
* Return type for useImmutableSession hook
|
|
35
68
|
*/
|
|
@@ -53,6 +86,13 @@ export interface UseImmutableSessionReturn {
|
|
|
53
86
|
* The refreshed session will include updated zkEvm data if available.
|
|
54
87
|
*/
|
|
55
88
|
getUser: (forceRefresh?: boolean) => Promise<User | null>;
|
|
89
|
+
/**
|
|
90
|
+
* Get a guaranteed-fresh access token.
|
|
91
|
+
* Returns immediately if the current token is valid.
|
|
92
|
+
* If expired, triggers a refresh and blocks (awaits) until the fresh token is available.
|
|
93
|
+
* Throws if the user is not authenticated or if refresh fails.
|
|
94
|
+
*/
|
|
95
|
+
getAccessToken: () => Promise<string>;
|
|
56
96
|
}
|
|
57
97
|
|
|
58
98
|
/**
|
|
@@ -92,8 +132,8 @@ export function useImmutableSession(): UseImmutableSessionReturn {
|
|
|
92
132
|
// Track when a manual refresh is in progress (via getUser(true))
|
|
93
133
|
const [isRefreshing, setIsRefreshing] = useState(false);
|
|
94
134
|
|
|
95
|
-
// Cast session to our
|
|
96
|
-
const session = sessionData as
|
|
135
|
+
// Cast session to our internal type (includes accessToken for internal logic)
|
|
136
|
+
const session = sessionData as ImmutableSessionInternal | null;
|
|
97
137
|
|
|
98
138
|
const isLoading = status === 'loading';
|
|
99
139
|
|
|
@@ -114,7 +154,7 @@ export function useImmutableSession(): UseImmutableSessionReturn {
|
|
|
114
154
|
// Use a ref to always have access to the latest session.
|
|
115
155
|
// This avoids stale closure issues when the wallet stores the getUser function
|
|
116
156
|
// and calls it later - the ref always points to the current session.
|
|
117
|
-
const sessionRef = useRef<
|
|
157
|
+
const sessionRef = useRef<ImmutableSessionInternal | null>(session);
|
|
118
158
|
sessionRef.current = session;
|
|
119
159
|
|
|
120
160
|
// Also store update in a ref so the callback is stable
|
|
@@ -125,6 +165,30 @@ export function useImmutableSession(): UseImmutableSessionReturn {
|
|
|
125
165
|
const setIsRefreshingRef = useRef(setIsRefreshing);
|
|
126
166
|
setIsRefreshingRef.current = setIsRefreshing;
|
|
127
167
|
|
|
168
|
+
// ---------------------------------------------------------------------------
|
|
169
|
+
// Proactive token refresh
|
|
170
|
+
// ---------------------------------------------------------------------------
|
|
171
|
+
|
|
172
|
+
// Reactive refresh: when the effect runs and the token is already expired
|
|
173
|
+
// (e.g., after tab regains focus), trigger an immediate silent refresh.
|
|
174
|
+
// For tokens that are still valid, getAccessToken() handles refresh on demand.
|
|
175
|
+
//
|
|
176
|
+
// NOTE: This intentionally does NOT set isRefreshing. isRefreshing is reserved
|
|
177
|
+
// for explicit user-triggered refreshes (e.g., getUser(true) after wallet
|
|
178
|
+
// registration). Background token refreshes must be invisible to consumers --
|
|
179
|
+
// setting isRefreshing would cause downstream hooks that gate SWR keys on
|
|
180
|
+
// `!isRefreshing` to briefly lose their cached data, resulting in UI flicker.
|
|
181
|
+
useEffect(() => {
|
|
182
|
+
if (!session?.accessTokenExpires) return;
|
|
183
|
+
|
|
184
|
+
const timeUntilExpiry = session.accessTokenExpires - Date.now() - TOKEN_EXPIRY_BUFFER_MS;
|
|
185
|
+
|
|
186
|
+
if (timeUntilExpiry <= 0) {
|
|
187
|
+
// Already expired -- refresh silently
|
|
188
|
+
deduplicatedUpdate(() => updateRef.current());
|
|
189
|
+
}
|
|
190
|
+
}, [session?.accessTokenExpires]);
|
|
191
|
+
|
|
128
192
|
/**
|
|
129
193
|
* Get user function for wallet integration.
|
|
130
194
|
* Returns a User object compatible with @imtbl/wallet's getUser option.
|
|
@@ -135,7 +199,7 @@ export function useImmutableSession(): UseImmutableSessionReturn {
|
|
|
135
199
|
* @param forceRefresh - When true, triggers a server-side token refresh
|
|
136
200
|
*/
|
|
137
201
|
const getUser = useCallback(async (forceRefresh?: boolean): Promise<User | null> => {
|
|
138
|
-
let currentSession:
|
|
202
|
+
let currentSession: ImmutableSessionInternal | null;
|
|
139
203
|
|
|
140
204
|
// If forceRefresh is requested, trigger server-side refresh via NextAuth
|
|
141
205
|
// This calls the jwt callback with trigger='update' and sessionUpdate.forceRefresh=true
|
|
@@ -145,7 +209,7 @@ export function useImmutableSession(): UseImmutableSessionReturn {
|
|
|
145
209
|
try {
|
|
146
210
|
// update() returns the refreshed session
|
|
147
211
|
const updatedSession = await updateRef.current({ forceRefresh: true });
|
|
148
|
-
currentSession = updatedSession as
|
|
212
|
+
currentSession = updatedSession as ImmutableSessionInternal | null;
|
|
149
213
|
// Also update the ref so subsequent calls get the fresh data
|
|
150
214
|
if (currentSession) {
|
|
151
215
|
sessionRef.current = currentSession;
|
|
@@ -158,6 +222,16 @@ export function useImmutableSession(): UseImmutableSessionReturn {
|
|
|
158
222
|
} finally {
|
|
159
223
|
setIsRefreshingRef.current(false);
|
|
160
224
|
}
|
|
225
|
+
} else if (pendingRefresh) {
|
|
226
|
+
// If a refresh is in-flight (proactive timer or another getAccessToken call),
|
|
227
|
+
// wait for it and use the refreshed session rather than returning a stale token.
|
|
228
|
+
const refreshed = await pendingRefresh;
|
|
229
|
+
if (refreshed) {
|
|
230
|
+
currentSession = refreshed as ImmutableSessionInternal;
|
|
231
|
+
sessionRef.current = currentSession;
|
|
232
|
+
} else {
|
|
233
|
+
currentSession = sessionRef.current;
|
|
234
|
+
}
|
|
161
235
|
} else {
|
|
162
236
|
// Read from ref - instant, no network call
|
|
163
237
|
// The ref is always updated on each render with the latest session
|
|
@@ -188,13 +262,55 @@ export function useImmutableSession(): UseImmutableSessionReturn {
|
|
|
188
262
|
};
|
|
189
263
|
}, []); // Empty deps - uses refs for latest values
|
|
190
264
|
|
|
265
|
+
/**
|
|
266
|
+
* Get a guaranteed-fresh access token.
|
|
267
|
+
* Returns immediately if the current token is valid (fast path, no network call).
|
|
268
|
+
* If expired, triggers a server-side refresh and blocks (awaits) until the fresh
|
|
269
|
+
* token is available. Piggybacks on any in-flight refresh to avoid duplicate calls.
|
|
270
|
+
*
|
|
271
|
+
* @throws Error if the user is not authenticated or if the refresh fails.
|
|
272
|
+
*/
|
|
273
|
+
const getAccessToken = useCallback(async (): Promise<string> => {
|
|
274
|
+
const currentSession = sessionRef.current;
|
|
275
|
+
|
|
276
|
+
// Fast path: token is valid -- return immediately
|
|
277
|
+
if (
|
|
278
|
+
currentSession?.accessToken
|
|
279
|
+
&& currentSession.accessTokenExpires
|
|
280
|
+
&& Date.now() < currentSession.accessTokenExpires - TOKEN_EXPIRY_BUFFER_MS
|
|
281
|
+
&& !currentSession.error
|
|
282
|
+
) {
|
|
283
|
+
return currentSession.accessToken;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// Token is expired or missing -- wait for in-flight refresh or trigger one
|
|
287
|
+
const refreshed = await deduplicatedUpdate(
|
|
288
|
+
() => updateRef.current(),
|
|
289
|
+
) as ImmutableSessionInternal | null;
|
|
290
|
+
|
|
291
|
+
if (!refreshed?.accessToken || refreshed.error) {
|
|
292
|
+
throw new Error(
|
|
293
|
+
`[auth-next-client] Failed to get access token: ${refreshed?.error || 'no session'}`,
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// Update ref so subsequent sync reads get the fresh data
|
|
298
|
+
sessionRef.current = refreshed;
|
|
299
|
+
return refreshed.accessToken;
|
|
300
|
+
}, []); // Empty deps -- uses refs for latest values
|
|
301
|
+
|
|
302
|
+
// Cast to public type (omits accessToken) to prevent consumers from
|
|
303
|
+
// accidentally using a potentially stale token. Use getAccessToken() instead.
|
|
304
|
+
const publicSession = session as ImmutableSession | null;
|
|
305
|
+
|
|
191
306
|
return {
|
|
192
|
-
session,
|
|
307
|
+
session: publicSession,
|
|
193
308
|
status,
|
|
194
309
|
isLoading,
|
|
195
310
|
isAuthenticated,
|
|
196
311
|
isRefreshing,
|
|
197
312
|
getUser,
|
|
313
|
+
getAccessToken,
|
|
198
314
|
};
|
|
199
315
|
}
|
|
200
316
|
|