@imtbl/auth-next-client 2.12.7-alpha.0 → 2.12.7-alpha.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +157 -20
- package/dist/node/index.cjs +98 -7
- package/dist/node/index.js +104 -8
- package/dist/types/constants.d.ts +5 -0
- package/dist/types/hooks.d.ts +19 -2
- package/dist/types/idTokenStorage.d.ts +26 -0
- package/package.json +5 -3
- package/src/callback.tsx +7 -0
- package/src/constants.ts +6 -0
- package/src/hooks.test.tsx +317 -0
- package/src/hooks.tsx +171 -17
- package/src/idTokenStorage.ts +56 -0
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,131 @@ 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.
|
|
459
|
+
|
|
460
|
+
#### Checking Authentication Status
|
|
461
|
+
|
|
462
|
+
**Always use `isAuthenticated` to determine if a user is logged in.** Do not use the `session` object or `status` field directly for this purpose.
|
|
463
|
+
|
|
464
|
+
**Why not `!!session`?**
|
|
465
|
+
|
|
466
|
+
A `session` object can exist but be **unusable**. For example, the session may be present but the access token is missing, or a token refresh may have failed (indicated by `session.error === "RefreshTokenError"`). Checking `!!session` would incorrectly treat these broken sessions as authenticated.
|
|
467
|
+
|
|
468
|
+
**Why not `status === 'authenticated'`?**
|
|
469
|
+
|
|
470
|
+
The `status` field comes directly from NextAuth's `useSession` and only reflects whether NextAuth considers the session valid at the cookie/JWT level. It does **not** account for whether the access token is actually present or whether a token refresh has failed. A session can have `status === 'authenticated'` while `session.error` is set to `"RefreshTokenError"`, meaning the tokens are no longer usable.
|
|
471
|
+
|
|
472
|
+
**What `isAuthenticated` checks:**
|
|
473
|
+
|
|
474
|
+
The `isAuthenticated` boolean validates all of the following:
|
|
475
|
+
|
|
476
|
+
1. NextAuth reports `'authenticated'` status
|
|
477
|
+
2. The session object exists
|
|
478
|
+
3. A valid access token is present in the session
|
|
479
|
+
4. No session-level error exists (e.g., `RefreshTokenError`)
|
|
480
|
+
|
|
481
|
+
It also handles transient states gracefully — during session refetches (e.g., window focus) or manual refreshes (e.g., after wallet registration via `getUser(true)`), `isAuthenticated` remains `true` if the user was previously authenticated, preventing UI flicker.
|
|
482
|
+
|
|
483
|
+
```tsx
|
|
484
|
+
// ✅ Correct - uses isAuthenticated
|
|
485
|
+
const { isAuthenticated } = useImmutableSession();
|
|
486
|
+
if (!isAuthenticated) return <div>Please log in</div>;
|
|
487
|
+
|
|
488
|
+
// ❌ Incorrect - session can exist with expired/invalid tokens
|
|
489
|
+
const { session } = useImmutableSession();
|
|
490
|
+
if (!session) return <div>Please log in</div>;
|
|
491
|
+
|
|
492
|
+
// ❌ Incorrect - status doesn't account for token errors
|
|
493
|
+
const { status } = useImmutableSession();
|
|
494
|
+
if (status !== "authenticated") return <div>Please log in</div>;
|
|
495
|
+
```
|
|
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
|
+
```
|
|
430
554
|
|
|
431
555
|
#### The `getUser` Function
|
|
432
556
|
|
|
@@ -452,13 +576,13 @@ When `forceRefresh` is `true`:
|
|
|
452
576
|
|
|
453
577
|
### ImmutableSession
|
|
454
578
|
|
|
455
|
-
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.
|
|
456
580
|
|
|
457
581
|
```typescript
|
|
458
582
|
interface ImmutableSession {
|
|
459
|
-
accessToken
|
|
583
|
+
// accessToken is NOT exposed -- use getAccessToken() instead
|
|
460
584
|
refreshToken?: string;
|
|
461
|
-
idToken?: string;
|
|
585
|
+
idToken?: string; // Only present transiently after sign-in or token refresh (not stored in cookie)
|
|
462
586
|
accessTokenExpires: number;
|
|
463
587
|
zkEvm?: {
|
|
464
588
|
ethAddress: string;
|
|
@@ -473,6 +597,8 @@ interface ImmutableSession {
|
|
|
473
597
|
}
|
|
474
598
|
```
|
|
475
599
|
|
|
600
|
+
> **Note:** The `idToken` is **not** stored in the session cookie (to avoid CloudFront 413 errors from oversized headers). It is only present in the session response transiently after sign-in or token refresh. `@imtbl/auth-next-client` automatically persists it in `localStorage` so that `getUser()` always returns a valid `idToken` for wallet operations. All data extracted from the idToken (`email`, `nickname`, `zkEvm`) remains in the cookie as separate fields and is always available in the session.
|
|
601
|
+
|
|
476
602
|
### LoginConfig
|
|
477
603
|
|
|
478
604
|
Configuration for the `useLogin` hook's login functions:
|
|
@@ -531,17 +657,19 @@ interface LogoutConfig {
|
|
|
531
657
|
|
|
532
658
|
The session may contain an `error` field indicating authentication issues:
|
|
533
659
|
|
|
534
|
-
| Error | Description | Handling
|
|
535
|
-
| --------------------- | --------------------- |
|
|
536
|
-
| `"TokenExpired"` | Access token expired |
|
|
537
|
-
| `"RefreshTokenError"` | Refresh token invalid | Prompt user to sign in again
|
|
660
|
+
| Error | Description | Handling |
|
|
661
|
+
| --------------------- | --------------------- | -------------------------------------------- |
|
|
662
|
+
| `"TokenExpired"` | Access token expired | Proactive refresh handles this automatically |
|
|
663
|
+
| `"RefreshTokenError"` | Refresh token invalid | Prompt user to sign in again |
|
|
664
|
+
|
|
665
|
+
`getAccessToken()` throws an error if the token cannot be obtained (e.g., refresh failure). Handle it with try/catch:
|
|
538
666
|
|
|
539
667
|
```tsx
|
|
540
668
|
import { useImmutableSession } from "@imtbl/auth-next-client";
|
|
541
|
-
import {
|
|
669
|
+
import { signOut } from "next-auth/react";
|
|
542
670
|
|
|
543
671
|
function ProtectedContent() {
|
|
544
|
-
const { session, isAuthenticated } = useImmutableSession();
|
|
672
|
+
const { session, isAuthenticated, getAccessToken } = useImmutableSession();
|
|
545
673
|
|
|
546
674
|
if (session?.error === "RefreshTokenError") {
|
|
547
675
|
return (
|
|
@@ -556,11 +684,20 @@ function ProtectedContent() {
|
|
|
556
684
|
return (
|
|
557
685
|
<div>
|
|
558
686
|
<p>Please sign in to continue.</p>
|
|
559
|
-
<button onClick={() => signIn()}>Sign In</button>
|
|
560
687
|
</div>
|
|
561
688
|
);
|
|
562
689
|
}
|
|
563
690
|
|
|
691
|
+
const handleFetch = async () => {
|
|
692
|
+
try {
|
|
693
|
+
const token = await getAccessToken();
|
|
694
|
+
// Use token for authenticated requests
|
|
695
|
+
} catch (error) {
|
|
696
|
+
// Token refresh failed -- session may be expired
|
|
697
|
+
console.error("Failed to get access token:", error);
|
|
698
|
+
}
|
|
699
|
+
};
|
|
700
|
+
|
|
564
701
|
return <div>Protected content here</div>;
|
|
565
702
|
}
|
|
566
703
|
```
|
package/dist/node/index.cjs
CHANGED
|
@@ -39,6 +39,35 @@ 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;
|
|
43
|
+
|
|
44
|
+
// src/idTokenStorage.ts
|
|
45
|
+
var ID_TOKEN_STORAGE_KEY = "imtbl_id_token";
|
|
46
|
+
function storeIdToken(idToken) {
|
|
47
|
+
try {
|
|
48
|
+
if (typeof window !== "undefined" && window.localStorage) {
|
|
49
|
+
window.localStorage.setItem(ID_TOKEN_STORAGE_KEY, idToken);
|
|
50
|
+
}
|
|
51
|
+
} catch {
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
function getStoredIdToken() {
|
|
55
|
+
try {
|
|
56
|
+
if (typeof window !== "undefined" && window.localStorage) {
|
|
57
|
+
return window.localStorage.getItem(ID_TOKEN_STORAGE_KEY) ?? void 0;
|
|
58
|
+
}
|
|
59
|
+
} catch {
|
|
60
|
+
}
|
|
61
|
+
return void 0;
|
|
62
|
+
}
|
|
63
|
+
function clearStoredIdToken() {
|
|
64
|
+
try {
|
|
65
|
+
if (typeof window !== "undefined" && window.localStorage) {
|
|
66
|
+
window.localStorage.removeItem(ID_TOKEN_STORAGE_KEY);
|
|
67
|
+
}
|
|
68
|
+
} catch {
|
|
69
|
+
}
|
|
70
|
+
}
|
|
42
71
|
|
|
43
72
|
// src/callback.tsx
|
|
44
73
|
var import_jsx_runtime = require("react/jsx-runtime");
|
|
@@ -89,6 +118,9 @@ function CallbackPage({
|
|
|
89
118
|
window.close();
|
|
90
119
|
} else {
|
|
91
120
|
const tokenData = mapTokensToSignInData(tokens);
|
|
121
|
+
if (tokens.idToken) {
|
|
122
|
+
storeIdToken(tokens.idToken);
|
|
123
|
+
}
|
|
92
124
|
const result = await (0, import_react2.signIn)(IMMUTABLE_PROVIDER_ID, {
|
|
93
125
|
tokens: JSON.stringify(tokenData),
|
|
94
126
|
redirect: false
|
|
@@ -181,22 +213,43 @@ function CallbackPage({
|
|
|
181
213
|
var import_react3 = require("react");
|
|
182
214
|
var import_react4 = require("next-auth/react");
|
|
183
215
|
var import_auth2 = require("@imtbl/auth");
|
|
216
|
+
var pendingRefresh = null;
|
|
217
|
+
function deduplicatedUpdate(update) {
|
|
218
|
+
if (!pendingRefresh) {
|
|
219
|
+
pendingRefresh = update().finally(() => {
|
|
220
|
+
pendingRefresh = null;
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
return pendingRefresh;
|
|
224
|
+
}
|
|
184
225
|
function useImmutableSession() {
|
|
185
226
|
const { data: sessionData, status, update } = (0, import_react4.useSession)();
|
|
186
227
|
const [isRefreshing, setIsRefreshing] = (0, import_react3.useState)(false);
|
|
187
228
|
const session = sessionData;
|
|
188
229
|
const isLoading = status === "loading";
|
|
189
|
-
const
|
|
230
|
+
const hasValidSession = status === "authenticated" && !!session && !!session.accessToken && !session.error;
|
|
190
231
|
const hadSessionRef = (0, import_react3.useRef)(false);
|
|
191
|
-
if (
|
|
192
|
-
if (!
|
|
193
|
-
const isAuthenticated =
|
|
232
|
+
if (hasValidSession) hadSessionRef.current = true;
|
|
233
|
+
if (!hasValidSession && !isLoading && !isRefreshing) hadSessionRef.current = false;
|
|
234
|
+
const isAuthenticated = hasValidSession || (isLoading || isRefreshing) && hadSessionRef.current;
|
|
194
235
|
const sessionRef = (0, import_react3.useRef)(session);
|
|
195
236
|
sessionRef.current = session;
|
|
196
237
|
const updateRef = (0, import_react3.useRef)(update);
|
|
197
238
|
updateRef.current = update;
|
|
198
239
|
const setIsRefreshingRef = (0, import_react3.useRef)(setIsRefreshing);
|
|
199
240
|
setIsRefreshingRef.current = setIsRefreshing;
|
|
241
|
+
(0, import_react3.useEffect)(() => {
|
|
242
|
+
if (!session?.accessTokenExpires) return;
|
|
243
|
+
const timeUntilExpiry = session.accessTokenExpires - Date.now() - TOKEN_EXPIRY_BUFFER_MS;
|
|
244
|
+
if (timeUntilExpiry <= 0) {
|
|
245
|
+
deduplicatedUpdate(() => updateRef.current());
|
|
246
|
+
}
|
|
247
|
+
}, [session?.accessTokenExpires]);
|
|
248
|
+
(0, import_react3.useEffect)(() => {
|
|
249
|
+
if (session?.idToken) {
|
|
250
|
+
storeIdToken(session.idToken);
|
|
251
|
+
}
|
|
252
|
+
}, [session?.idToken]);
|
|
200
253
|
const getUser = (0, import_react3.useCallback)(async (forceRefresh) => {
|
|
201
254
|
let currentSession;
|
|
202
255
|
if (forceRefresh) {
|
|
@@ -206,6 +259,9 @@ function useImmutableSession() {
|
|
|
206
259
|
currentSession = updatedSession;
|
|
207
260
|
if (currentSession) {
|
|
208
261
|
sessionRef.current = currentSession;
|
|
262
|
+
if (currentSession.idToken) {
|
|
263
|
+
storeIdToken(currentSession.idToken);
|
|
264
|
+
}
|
|
209
265
|
}
|
|
210
266
|
} catch (error) {
|
|
211
267
|
console.error("[auth-next-client] Force refresh failed:", error);
|
|
@@ -213,6 +269,17 @@ function useImmutableSession() {
|
|
|
213
269
|
} finally {
|
|
214
270
|
setIsRefreshingRef.current(false);
|
|
215
271
|
}
|
|
272
|
+
} else if (pendingRefresh) {
|
|
273
|
+
const refreshed = await pendingRefresh;
|
|
274
|
+
if (refreshed) {
|
|
275
|
+
currentSession = refreshed;
|
|
276
|
+
sessionRef.current = currentSession;
|
|
277
|
+
if (currentSession.idToken) {
|
|
278
|
+
storeIdToken(currentSession.idToken);
|
|
279
|
+
}
|
|
280
|
+
} else {
|
|
281
|
+
currentSession = sessionRef.current;
|
|
282
|
+
}
|
|
216
283
|
} else {
|
|
217
284
|
currentSession = sessionRef.current;
|
|
218
285
|
}
|
|
@@ -226,7 +293,9 @@ function useImmutableSession() {
|
|
|
226
293
|
return {
|
|
227
294
|
accessToken: currentSession.accessToken,
|
|
228
295
|
refreshToken: currentSession.refreshToken,
|
|
229
|
-
idToken
|
|
296
|
+
// Prefer session idToken (fresh after sign-in or refresh, before useEffect
|
|
297
|
+
// stores it), fall back to localStorage for normal reads (cookie has no idToken).
|
|
298
|
+
idToken: currentSession.idToken || getStoredIdToken(),
|
|
230
299
|
profile: {
|
|
231
300
|
sub: currentSession.user?.sub ?? "",
|
|
232
301
|
email: currentSession.user?.email ?? void 0,
|
|
@@ -235,19 +304,40 @@ function useImmutableSession() {
|
|
|
235
304
|
zkEvm: currentSession.zkEvm
|
|
236
305
|
};
|
|
237
306
|
}, []);
|
|
307
|
+
const getAccessToken = (0, import_react3.useCallback)(async () => {
|
|
308
|
+
const currentSession = sessionRef.current;
|
|
309
|
+
if (currentSession?.accessToken && currentSession.accessTokenExpires && Date.now() < currentSession.accessTokenExpires - TOKEN_EXPIRY_BUFFER_MS && !currentSession.error) {
|
|
310
|
+
return currentSession.accessToken;
|
|
311
|
+
}
|
|
312
|
+
const refreshed = await deduplicatedUpdate(
|
|
313
|
+
() => updateRef.current()
|
|
314
|
+
);
|
|
315
|
+
if (!refreshed?.accessToken || refreshed.error) {
|
|
316
|
+
throw new Error(
|
|
317
|
+
`[auth-next-client] Failed to get access token: ${refreshed?.error || "no session"}`
|
|
318
|
+
);
|
|
319
|
+
}
|
|
320
|
+
sessionRef.current = refreshed;
|
|
321
|
+
return refreshed.accessToken;
|
|
322
|
+
}, []);
|
|
323
|
+
const publicSession = session;
|
|
238
324
|
return {
|
|
239
|
-
session,
|
|
325
|
+
session: publicSession,
|
|
240
326
|
status,
|
|
241
327
|
isLoading,
|
|
242
328
|
isAuthenticated,
|
|
243
329
|
isRefreshing,
|
|
244
|
-
getUser
|
|
330
|
+
getUser,
|
|
331
|
+
getAccessToken
|
|
245
332
|
};
|
|
246
333
|
}
|
|
247
334
|
function useLogin() {
|
|
248
335
|
const [isLoggingIn, setIsLoggingIn] = (0, import_react3.useState)(false);
|
|
249
336
|
const [error, setError] = (0, import_react3.useState)(null);
|
|
250
337
|
const signInWithTokens = (0, import_react3.useCallback)(async (tokens) => {
|
|
338
|
+
if (tokens.idToken) {
|
|
339
|
+
storeIdToken(tokens.idToken);
|
|
340
|
+
}
|
|
251
341
|
const result = await (0, import_react4.signIn)(IMMUTABLE_PROVIDER_ID, {
|
|
252
342
|
tokens: JSON.stringify(tokens),
|
|
253
343
|
redirect: false
|
|
@@ -314,6 +404,7 @@ function useLogout() {
|
|
|
314
404
|
setIsLoggingOut(true);
|
|
315
405
|
setError(null);
|
|
316
406
|
try {
|
|
407
|
+
clearStoredIdToken();
|
|
317
408
|
await (0, import_react4.signOut)({ redirect: false });
|
|
318
409
|
(0, import_auth2.logoutWithRedirect)(config);
|
|
319
410
|
} catch (err) {
|
package/dist/node/index.js
CHANGED
|
@@ -10,6 +10,35 @@ 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;
|
|
14
|
+
|
|
15
|
+
// src/idTokenStorage.ts
|
|
16
|
+
var ID_TOKEN_STORAGE_KEY = "imtbl_id_token";
|
|
17
|
+
function storeIdToken(idToken) {
|
|
18
|
+
try {
|
|
19
|
+
if (typeof window !== "undefined" && window.localStorage) {
|
|
20
|
+
window.localStorage.setItem(ID_TOKEN_STORAGE_KEY, idToken);
|
|
21
|
+
}
|
|
22
|
+
} catch {
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
function getStoredIdToken() {
|
|
26
|
+
try {
|
|
27
|
+
if (typeof window !== "undefined" && window.localStorage) {
|
|
28
|
+
return window.localStorage.getItem(ID_TOKEN_STORAGE_KEY) ?? void 0;
|
|
29
|
+
}
|
|
30
|
+
} catch {
|
|
31
|
+
}
|
|
32
|
+
return void 0;
|
|
33
|
+
}
|
|
34
|
+
function clearStoredIdToken() {
|
|
35
|
+
try {
|
|
36
|
+
if (typeof window !== "undefined" && window.localStorage) {
|
|
37
|
+
window.localStorage.removeItem(ID_TOKEN_STORAGE_KEY);
|
|
38
|
+
}
|
|
39
|
+
} catch {
|
|
40
|
+
}
|
|
41
|
+
}
|
|
13
42
|
|
|
14
43
|
// src/callback.tsx
|
|
15
44
|
import { jsx, jsxs } from "react/jsx-runtime";
|
|
@@ -60,6 +89,9 @@ function CallbackPage({
|
|
|
60
89
|
window.close();
|
|
61
90
|
} else {
|
|
62
91
|
const tokenData = mapTokensToSignInData(tokens);
|
|
92
|
+
if (tokens.idToken) {
|
|
93
|
+
storeIdToken(tokens.idToken);
|
|
94
|
+
}
|
|
63
95
|
const result = await signIn(IMMUTABLE_PROVIDER_ID, {
|
|
64
96
|
tokens: JSON.stringify(tokenData),
|
|
65
97
|
redirect: false
|
|
@@ -149,7 +181,12 @@ function CallbackPage({
|
|
|
149
181
|
}
|
|
150
182
|
|
|
151
183
|
// src/hooks.tsx
|
|
152
|
-
import {
|
|
184
|
+
import {
|
|
185
|
+
useCallback,
|
|
186
|
+
useEffect as useEffect2,
|
|
187
|
+
useRef as useRef2,
|
|
188
|
+
useState as useState2
|
|
189
|
+
} from "react";
|
|
153
190
|
import { useSession, signIn as signIn2, signOut } from "next-auth/react";
|
|
154
191
|
import {
|
|
155
192
|
loginWithPopup as rawLoginWithPopup,
|
|
@@ -157,22 +194,43 @@ import {
|
|
|
157
194
|
loginWithRedirect as rawLoginWithRedirect,
|
|
158
195
|
logoutWithRedirect as rawLogoutWithRedirect
|
|
159
196
|
} from "@imtbl/auth";
|
|
197
|
+
var pendingRefresh = null;
|
|
198
|
+
function deduplicatedUpdate(update) {
|
|
199
|
+
if (!pendingRefresh) {
|
|
200
|
+
pendingRefresh = update().finally(() => {
|
|
201
|
+
pendingRefresh = null;
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
return pendingRefresh;
|
|
205
|
+
}
|
|
160
206
|
function useImmutableSession() {
|
|
161
207
|
const { data: sessionData, status, update } = useSession();
|
|
162
208
|
const [isRefreshing, setIsRefreshing] = useState2(false);
|
|
163
209
|
const session = sessionData;
|
|
164
210
|
const isLoading = status === "loading";
|
|
165
|
-
const
|
|
211
|
+
const hasValidSession = status === "authenticated" && !!session && !!session.accessToken && !session.error;
|
|
166
212
|
const hadSessionRef = useRef2(false);
|
|
167
|
-
if (
|
|
168
|
-
if (!
|
|
169
|
-
const isAuthenticated =
|
|
213
|
+
if (hasValidSession) hadSessionRef.current = true;
|
|
214
|
+
if (!hasValidSession && !isLoading && !isRefreshing) hadSessionRef.current = false;
|
|
215
|
+
const isAuthenticated = hasValidSession || (isLoading || isRefreshing) && hadSessionRef.current;
|
|
170
216
|
const sessionRef = useRef2(session);
|
|
171
217
|
sessionRef.current = session;
|
|
172
218
|
const updateRef = useRef2(update);
|
|
173
219
|
updateRef.current = update;
|
|
174
220
|
const setIsRefreshingRef = useRef2(setIsRefreshing);
|
|
175
221
|
setIsRefreshingRef.current = setIsRefreshing;
|
|
222
|
+
useEffect2(() => {
|
|
223
|
+
if (!session?.accessTokenExpires) return;
|
|
224
|
+
const timeUntilExpiry = session.accessTokenExpires - Date.now() - TOKEN_EXPIRY_BUFFER_MS;
|
|
225
|
+
if (timeUntilExpiry <= 0) {
|
|
226
|
+
deduplicatedUpdate(() => updateRef.current());
|
|
227
|
+
}
|
|
228
|
+
}, [session?.accessTokenExpires]);
|
|
229
|
+
useEffect2(() => {
|
|
230
|
+
if (session?.idToken) {
|
|
231
|
+
storeIdToken(session.idToken);
|
|
232
|
+
}
|
|
233
|
+
}, [session?.idToken]);
|
|
176
234
|
const getUser = useCallback(async (forceRefresh) => {
|
|
177
235
|
let currentSession;
|
|
178
236
|
if (forceRefresh) {
|
|
@@ -182,6 +240,9 @@ function useImmutableSession() {
|
|
|
182
240
|
currentSession = updatedSession;
|
|
183
241
|
if (currentSession) {
|
|
184
242
|
sessionRef.current = currentSession;
|
|
243
|
+
if (currentSession.idToken) {
|
|
244
|
+
storeIdToken(currentSession.idToken);
|
|
245
|
+
}
|
|
185
246
|
}
|
|
186
247
|
} catch (error) {
|
|
187
248
|
console.error("[auth-next-client] Force refresh failed:", error);
|
|
@@ -189,6 +250,17 @@ function useImmutableSession() {
|
|
|
189
250
|
} finally {
|
|
190
251
|
setIsRefreshingRef.current(false);
|
|
191
252
|
}
|
|
253
|
+
} else if (pendingRefresh) {
|
|
254
|
+
const refreshed = await pendingRefresh;
|
|
255
|
+
if (refreshed) {
|
|
256
|
+
currentSession = refreshed;
|
|
257
|
+
sessionRef.current = currentSession;
|
|
258
|
+
if (currentSession.idToken) {
|
|
259
|
+
storeIdToken(currentSession.idToken);
|
|
260
|
+
}
|
|
261
|
+
} else {
|
|
262
|
+
currentSession = sessionRef.current;
|
|
263
|
+
}
|
|
192
264
|
} else {
|
|
193
265
|
currentSession = sessionRef.current;
|
|
194
266
|
}
|
|
@@ -202,7 +274,9 @@ function useImmutableSession() {
|
|
|
202
274
|
return {
|
|
203
275
|
accessToken: currentSession.accessToken,
|
|
204
276
|
refreshToken: currentSession.refreshToken,
|
|
205
|
-
idToken
|
|
277
|
+
// Prefer session idToken (fresh after sign-in or refresh, before useEffect
|
|
278
|
+
// stores it), fall back to localStorage for normal reads (cookie has no idToken).
|
|
279
|
+
idToken: currentSession.idToken || getStoredIdToken(),
|
|
206
280
|
profile: {
|
|
207
281
|
sub: currentSession.user?.sub ?? "",
|
|
208
282
|
email: currentSession.user?.email ?? void 0,
|
|
@@ -211,19 +285,40 @@ function useImmutableSession() {
|
|
|
211
285
|
zkEvm: currentSession.zkEvm
|
|
212
286
|
};
|
|
213
287
|
}, []);
|
|
288
|
+
const getAccessToken = useCallback(async () => {
|
|
289
|
+
const currentSession = sessionRef.current;
|
|
290
|
+
if (currentSession?.accessToken && currentSession.accessTokenExpires && Date.now() < currentSession.accessTokenExpires - TOKEN_EXPIRY_BUFFER_MS && !currentSession.error) {
|
|
291
|
+
return currentSession.accessToken;
|
|
292
|
+
}
|
|
293
|
+
const refreshed = await deduplicatedUpdate(
|
|
294
|
+
() => updateRef.current()
|
|
295
|
+
);
|
|
296
|
+
if (!refreshed?.accessToken || refreshed.error) {
|
|
297
|
+
throw new Error(
|
|
298
|
+
`[auth-next-client] Failed to get access token: ${refreshed?.error || "no session"}`
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
sessionRef.current = refreshed;
|
|
302
|
+
return refreshed.accessToken;
|
|
303
|
+
}, []);
|
|
304
|
+
const publicSession = session;
|
|
214
305
|
return {
|
|
215
|
-
session,
|
|
306
|
+
session: publicSession,
|
|
216
307
|
status,
|
|
217
308
|
isLoading,
|
|
218
309
|
isAuthenticated,
|
|
219
310
|
isRefreshing,
|
|
220
|
-
getUser
|
|
311
|
+
getUser,
|
|
312
|
+
getAccessToken
|
|
221
313
|
};
|
|
222
314
|
}
|
|
223
315
|
function useLogin() {
|
|
224
316
|
const [isLoggingIn, setIsLoggingIn] = useState2(false);
|
|
225
317
|
const [error, setError] = useState2(null);
|
|
226
318
|
const signInWithTokens = useCallback(async (tokens) => {
|
|
319
|
+
if (tokens.idToken) {
|
|
320
|
+
storeIdToken(tokens.idToken);
|
|
321
|
+
}
|
|
227
322
|
const result = await signIn2(IMMUTABLE_PROVIDER_ID, {
|
|
228
323
|
tokens: JSON.stringify(tokens),
|
|
229
324
|
redirect: false
|
|
@@ -290,6 +385,7 @@ function useLogout() {
|
|
|
290
385
|
setIsLoggingOut(true);
|
|
291
386
|
setError(null);
|
|
292
387
|
try {
|
|
388
|
+
clearStoredIdToken();
|
|
293
389
|
await signOut({ redirect: false });
|
|
294
390
|
rawLogoutWithRedirect(config);
|
|
295
391
|
} catch (err) {
|
|
@@ -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 {};
|