@imtbl/auth-next-client 2.12.7-alpha.9 → 2.12.8-alpha.0
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 +40 -0
- package/dist/node/index.cjs +69 -8
- package/dist/node/index.js +61 -8
- package/dist/types/constants.d.ts +8 -30
- package/dist/types/defaultConfig.d.ts +8 -0
- package/dist/types/hooks.d.ts +85 -21
- package/dist/types/index.d.ts +2 -0
- package/package.json +5 -5
- package/src/constants.ts +8 -37
- package/src/defaultConfig.ts +19 -0
- package/src/hooks.test.tsx +51 -0
- package/src/hooks.tsx +147 -31
- package/src/index.ts +12 -0
package/README.md
CHANGED
|
@@ -27,6 +27,10 @@ npm install @imtbl/auth-next-client @imtbl/auth-next-server next-auth@5
|
|
|
27
27
|
- `next` >= 14.0.0
|
|
28
28
|
- `next-auth` >= 5.0.0-beta.25
|
|
29
29
|
|
|
30
|
+
### Next.js 14 Compatibility
|
|
31
|
+
|
|
32
|
+
This package is compatible with both Next.js 14 and 15. It uses only standard APIs available in both versions (`next/navigation` for `useRouter`, `next-auth/react`). No Next.js 15-only APIs are used.
|
|
33
|
+
|
|
30
34
|
## Quick Start
|
|
31
35
|
|
|
32
36
|
### 1. Set Up Server-Side Auth
|
|
@@ -100,6 +104,42 @@ export default function Callback() {
|
|
|
100
104
|
}
|
|
101
105
|
```
|
|
102
106
|
|
|
107
|
+
### Default Auth (Zero Config)
|
|
108
|
+
|
|
109
|
+
When using `createAuthConfig()` with no args on the server, you can call login/logout with no config—sandbox clientId and redirectUri are used:
|
|
110
|
+
|
|
111
|
+
```tsx
|
|
112
|
+
// With default auth - no config needed
|
|
113
|
+
function LoginButton() {
|
|
114
|
+
const { isAuthenticated } = useImmutableSession();
|
|
115
|
+
const { loginWithPopup, isLoggingIn, error } = useLogin();
|
|
116
|
+
|
|
117
|
+
if (isAuthenticated) return <p>You are logged in!</p>;
|
|
118
|
+
|
|
119
|
+
return (
|
|
120
|
+
<button onClick={() => loginWithPopup()} disabled={isLoggingIn}>
|
|
121
|
+
{isLoggingIn ? "Signing in..." : "Sign In"}
|
|
122
|
+
</button>
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
Or with custom config (pass full LoginConfig/LogoutConfig when overriding):
|
|
128
|
+
|
|
129
|
+
```tsx
|
|
130
|
+
// With custom config - pass complete config
|
|
131
|
+
loginWithPopup({
|
|
132
|
+
clientId: process.env.NEXT_PUBLIC_IMMUTABLE_CLIENT_ID!,
|
|
133
|
+
redirectUri: `${window.location.origin}/callback`,
|
|
134
|
+
});
|
|
135
|
+
logout({
|
|
136
|
+
clientId: process.env.NEXT_PUBLIC_IMMUTABLE_CLIENT_ID!,
|
|
137
|
+
logoutRedirectUri: process.env.NEXT_PUBLIC_BASE_URL!,
|
|
138
|
+
});
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
See the [wallets-connect-with-nextjs](../../examples/passport/wallets-connect-with-nextjs) example for a full integration with `@imtbl/wallet`.
|
|
142
|
+
|
|
103
143
|
### 5. Add Login Button
|
|
104
144
|
|
|
105
145
|
Use the `useLogin` hook for login flows with built-in state management:
|
package/dist/node/index.cjs
CHANGED
|
@@ -22,7 +22,15 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
|
|
|
22
22
|
var src_exports = {};
|
|
23
23
|
__export(src_exports, {
|
|
24
24
|
CallbackPage: () => CallbackPage,
|
|
25
|
+
DEFAULT_AUDIENCE: () => DEFAULT_AUDIENCE,
|
|
26
|
+
DEFAULT_AUTH_DOMAIN: () => DEFAULT_AUTH_DOMAIN,
|
|
27
|
+
DEFAULT_LOGOUT_REDIRECT_URI_PATH: () => DEFAULT_LOGOUT_REDIRECT_URI_PATH,
|
|
28
|
+
DEFAULT_REDIRECT_URI_PATH: () => DEFAULT_REDIRECT_URI_PATH,
|
|
29
|
+
DEFAULT_SANDBOX_CLIENT_ID: () => DEFAULT_SANDBOX_CLIENT_ID,
|
|
30
|
+
DEFAULT_SCOPE: () => DEFAULT_SCOPE,
|
|
31
|
+
IMMUTABLE_PROVIDER_ID: () => IMMUTABLE_PROVIDER_ID,
|
|
25
32
|
MarketingConsentStatus: () => import_auth3.MarketingConsentStatus,
|
|
33
|
+
deriveDefaultRedirectUri: () => deriveDefaultRedirectUri,
|
|
26
34
|
useImmutableSession: () => useImmutableSession,
|
|
27
35
|
useLogin: () => useLogin,
|
|
28
36
|
useLogout: () => useLogout
|
|
@@ -36,10 +44,14 @@ var import_react2 = require("next-auth/react");
|
|
|
36
44
|
var import_auth = require("@imtbl/auth");
|
|
37
45
|
|
|
38
46
|
// src/constants.ts
|
|
47
|
+
var DEFAULT_AUTH_DOMAIN = "https://auth.immutable.com";
|
|
48
|
+
var DEFAULT_AUDIENCE = "platform_api";
|
|
49
|
+
var DEFAULT_SCOPE = "openid profile email offline_access transact";
|
|
39
50
|
var IMMUTABLE_PROVIDER_ID = "immutable";
|
|
40
|
-
var
|
|
41
|
-
var
|
|
42
|
-
var
|
|
51
|
+
var DEFAULT_SANDBOX_CLIENT_ID = "mjtCL8mt06BtbxSkp2vbrYStKWnXVZfo";
|
|
52
|
+
var DEFAULT_REDIRECT_URI_PATH = "/callback";
|
|
53
|
+
var DEFAULT_LOGOUT_REDIRECT_URI_PATH = "/";
|
|
54
|
+
var TOKEN_EXPIRY_BUFFER_MS = 6e4;
|
|
43
55
|
|
|
44
56
|
// src/idTokenStorage.ts
|
|
45
57
|
var ID_TOKEN_STORAGE_KEY = "imtbl_id_token";
|
|
@@ -213,6 +225,18 @@ function CallbackPage({
|
|
|
213
225
|
var import_react3 = require("react");
|
|
214
226
|
var import_react4 = require("next-auth/react");
|
|
215
227
|
var import_auth2 = require("@imtbl/auth");
|
|
228
|
+
|
|
229
|
+
// src/defaultConfig.ts
|
|
230
|
+
function deriveDefaultRedirectUri() {
|
|
231
|
+
if (typeof window === "undefined") {
|
|
232
|
+
throw new Error(
|
|
233
|
+
"[auth-next-client] deriveDefaultRedirectUri requires window. Login hooks run in the browser when the user triggers login."
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
return `${window.location.origin}${DEFAULT_REDIRECT_URI_PATH}`;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// src/hooks.tsx
|
|
216
240
|
var pendingRefresh = null;
|
|
217
241
|
function deduplicatedUpdate(update) {
|
|
218
242
|
if (!pendingRefresh) {
|
|
@@ -222,6 +246,30 @@ function deduplicatedUpdate(update) {
|
|
|
222
246
|
}
|
|
223
247
|
return pendingRefresh;
|
|
224
248
|
}
|
|
249
|
+
function getSandboxLoginConfig() {
|
|
250
|
+
const redirectUri = deriveDefaultRedirectUri();
|
|
251
|
+
return {
|
|
252
|
+
clientId: DEFAULT_SANDBOX_CLIENT_ID,
|
|
253
|
+
redirectUri,
|
|
254
|
+
popupRedirectUri: redirectUri,
|
|
255
|
+
scope: DEFAULT_SCOPE,
|
|
256
|
+
audience: DEFAULT_AUDIENCE,
|
|
257
|
+
authenticationDomain: DEFAULT_AUTH_DOMAIN
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
function getSandboxLogoutConfig() {
|
|
261
|
+
if (typeof window === "undefined") {
|
|
262
|
+
throw new Error(
|
|
263
|
+
"[auth-next-client] getSandboxLogoutConfig requires window. Logout runs in the browser when the user triggers it."
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
const logoutRedirectUri = window.location.origin + DEFAULT_LOGOUT_REDIRECT_URI_PATH;
|
|
267
|
+
return {
|
|
268
|
+
clientId: DEFAULT_SANDBOX_CLIENT_ID,
|
|
269
|
+
logoutRedirectUri,
|
|
270
|
+
authenticationDomain: DEFAULT_AUTH_DOMAIN
|
|
271
|
+
};
|
|
272
|
+
}
|
|
225
273
|
function useImmutableSession() {
|
|
226
274
|
const { data: sessionData, status, update } = (0, import_react4.useSession)();
|
|
227
275
|
const [isRefreshing, setIsRefreshing] = (0, import_react3.useState)(false);
|
|
@@ -240,11 +288,12 @@ function useImmutableSession() {
|
|
|
240
288
|
setIsRefreshingRef.current = setIsRefreshing;
|
|
241
289
|
(0, import_react3.useEffect)(() => {
|
|
242
290
|
if (!session?.accessTokenExpires) return;
|
|
291
|
+
if (session?.error) return;
|
|
243
292
|
const timeUntilExpiry = session.accessTokenExpires - Date.now() - TOKEN_EXPIRY_BUFFER_MS;
|
|
244
293
|
if (timeUntilExpiry <= 0) {
|
|
245
294
|
deduplicatedUpdate(() => updateRef.current());
|
|
246
295
|
}
|
|
247
|
-
}, [session?.accessTokenExpires]);
|
|
296
|
+
}, [session?.accessTokenExpires, session?.error]);
|
|
248
297
|
(0, import_react3.useEffect)(() => {
|
|
249
298
|
if (session?.idToken) {
|
|
250
299
|
storeIdToken(session.idToken);
|
|
@@ -353,7 +402,8 @@ function useLogin() {
|
|
|
353
402
|
setIsLoggingIn(true);
|
|
354
403
|
setError(null);
|
|
355
404
|
try {
|
|
356
|
-
const
|
|
405
|
+
const fullConfig = config ?? getSandboxLoginConfig();
|
|
406
|
+
const tokens = await (0, import_auth2.loginWithPopup)(fullConfig, options);
|
|
357
407
|
await signInWithTokens(tokens);
|
|
358
408
|
} catch (err) {
|
|
359
409
|
const errorMessage = err instanceof Error ? err.message : "Login failed";
|
|
@@ -367,7 +417,8 @@ function useLogin() {
|
|
|
367
417
|
setIsLoggingIn(true);
|
|
368
418
|
setError(null);
|
|
369
419
|
try {
|
|
370
|
-
const
|
|
420
|
+
const fullConfig = config ?? getSandboxLoginConfig();
|
|
421
|
+
const tokens = await (0, import_auth2.loginWithEmbedded)(fullConfig);
|
|
371
422
|
await signInWithTokens(tokens);
|
|
372
423
|
} catch (err) {
|
|
373
424
|
const errorMessage = err instanceof Error ? err.message : "Login failed";
|
|
@@ -381,7 +432,8 @@ function useLogin() {
|
|
|
381
432
|
setIsLoggingIn(true);
|
|
382
433
|
setError(null);
|
|
383
434
|
try {
|
|
384
|
-
|
|
435
|
+
const fullConfig = config ?? getSandboxLoginConfig();
|
|
436
|
+
await (0, import_auth2.loginWithRedirect)(fullConfig, options);
|
|
385
437
|
} catch (err) {
|
|
386
438
|
const errorMessage = err instanceof Error ? err.message : "Login failed";
|
|
387
439
|
setError(errorMessage);
|
|
@@ -406,7 +458,8 @@ function useLogout() {
|
|
|
406
458
|
try {
|
|
407
459
|
clearStoredIdToken();
|
|
408
460
|
await (0, import_react4.signOut)({ redirect: false });
|
|
409
|
-
|
|
461
|
+
const fullConfig = config ?? getSandboxLogoutConfig();
|
|
462
|
+
(0, import_auth2.logoutWithRedirect)(fullConfig);
|
|
410
463
|
} catch (err) {
|
|
411
464
|
const errorMessage = err instanceof Error ? err.message : "Logout failed";
|
|
412
465
|
setError(errorMessage);
|
|
@@ -426,7 +479,15 @@ var import_auth3 = require("@imtbl/auth");
|
|
|
426
479
|
// Annotate the CommonJS export names for ESM import in node:
|
|
427
480
|
0 && (module.exports = {
|
|
428
481
|
CallbackPage,
|
|
482
|
+
DEFAULT_AUDIENCE,
|
|
483
|
+
DEFAULT_AUTH_DOMAIN,
|
|
484
|
+
DEFAULT_LOGOUT_REDIRECT_URI_PATH,
|
|
485
|
+
DEFAULT_REDIRECT_URI_PATH,
|
|
486
|
+
DEFAULT_SANDBOX_CLIENT_ID,
|
|
487
|
+
DEFAULT_SCOPE,
|
|
488
|
+
IMMUTABLE_PROVIDER_ID,
|
|
429
489
|
MarketingConsentStatus,
|
|
490
|
+
deriveDefaultRedirectUri,
|
|
430
491
|
useImmutableSession,
|
|
431
492
|
useLogin,
|
|
432
493
|
useLogout
|
package/dist/node/index.js
CHANGED
|
@@ -7,10 +7,14 @@ import { signIn } from "next-auth/react";
|
|
|
7
7
|
import { handleLoginCallback as handleAuthCallback } from "@imtbl/auth";
|
|
8
8
|
|
|
9
9
|
// src/constants.ts
|
|
10
|
+
var DEFAULT_AUTH_DOMAIN = "https://auth.immutable.com";
|
|
11
|
+
var DEFAULT_AUDIENCE = "platform_api";
|
|
12
|
+
var DEFAULT_SCOPE = "openid profile email offline_access transact";
|
|
10
13
|
var IMMUTABLE_PROVIDER_ID = "immutable";
|
|
11
|
-
var
|
|
12
|
-
var
|
|
13
|
-
var
|
|
14
|
+
var DEFAULT_SANDBOX_CLIENT_ID = "mjtCL8mt06BtbxSkp2vbrYStKWnXVZfo";
|
|
15
|
+
var DEFAULT_REDIRECT_URI_PATH = "/callback";
|
|
16
|
+
var DEFAULT_LOGOUT_REDIRECT_URI_PATH = "/";
|
|
17
|
+
var TOKEN_EXPIRY_BUFFER_MS = 6e4;
|
|
14
18
|
|
|
15
19
|
// src/idTokenStorage.ts
|
|
16
20
|
var ID_TOKEN_STORAGE_KEY = "imtbl_id_token";
|
|
@@ -194,6 +198,18 @@ import {
|
|
|
194
198
|
loginWithRedirect as rawLoginWithRedirect,
|
|
195
199
|
logoutWithRedirect as rawLogoutWithRedirect
|
|
196
200
|
} from "@imtbl/auth";
|
|
201
|
+
|
|
202
|
+
// src/defaultConfig.ts
|
|
203
|
+
function deriveDefaultRedirectUri() {
|
|
204
|
+
if (typeof window === "undefined") {
|
|
205
|
+
throw new Error(
|
|
206
|
+
"[auth-next-client] deriveDefaultRedirectUri requires window. Login hooks run in the browser when the user triggers login."
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
return `${window.location.origin}${DEFAULT_REDIRECT_URI_PATH}`;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// src/hooks.tsx
|
|
197
213
|
var pendingRefresh = null;
|
|
198
214
|
function deduplicatedUpdate(update) {
|
|
199
215
|
if (!pendingRefresh) {
|
|
@@ -203,6 +219,30 @@ function deduplicatedUpdate(update) {
|
|
|
203
219
|
}
|
|
204
220
|
return pendingRefresh;
|
|
205
221
|
}
|
|
222
|
+
function getSandboxLoginConfig() {
|
|
223
|
+
const redirectUri = deriveDefaultRedirectUri();
|
|
224
|
+
return {
|
|
225
|
+
clientId: DEFAULT_SANDBOX_CLIENT_ID,
|
|
226
|
+
redirectUri,
|
|
227
|
+
popupRedirectUri: redirectUri,
|
|
228
|
+
scope: DEFAULT_SCOPE,
|
|
229
|
+
audience: DEFAULT_AUDIENCE,
|
|
230
|
+
authenticationDomain: DEFAULT_AUTH_DOMAIN
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
function getSandboxLogoutConfig() {
|
|
234
|
+
if (typeof window === "undefined") {
|
|
235
|
+
throw new Error(
|
|
236
|
+
"[auth-next-client] getSandboxLogoutConfig requires window. Logout runs in the browser when the user triggers it."
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
const logoutRedirectUri = window.location.origin + DEFAULT_LOGOUT_REDIRECT_URI_PATH;
|
|
240
|
+
return {
|
|
241
|
+
clientId: DEFAULT_SANDBOX_CLIENT_ID,
|
|
242
|
+
logoutRedirectUri,
|
|
243
|
+
authenticationDomain: DEFAULT_AUTH_DOMAIN
|
|
244
|
+
};
|
|
245
|
+
}
|
|
206
246
|
function useImmutableSession() {
|
|
207
247
|
const { data: sessionData, status, update } = useSession();
|
|
208
248
|
const [isRefreshing, setIsRefreshing] = useState2(false);
|
|
@@ -221,11 +261,12 @@ function useImmutableSession() {
|
|
|
221
261
|
setIsRefreshingRef.current = setIsRefreshing;
|
|
222
262
|
useEffect2(() => {
|
|
223
263
|
if (!session?.accessTokenExpires) return;
|
|
264
|
+
if (session?.error) return;
|
|
224
265
|
const timeUntilExpiry = session.accessTokenExpires - Date.now() - TOKEN_EXPIRY_BUFFER_MS;
|
|
225
266
|
if (timeUntilExpiry <= 0) {
|
|
226
267
|
deduplicatedUpdate(() => updateRef.current());
|
|
227
268
|
}
|
|
228
|
-
}, [session?.accessTokenExpires]);
|
|
269
|
+
}, [session?.accessTokenExpires, session?.error]);
|
|
229
270
|
useEffect2(() => {
|
|
230
271
|
if (session?.idToken) {
|
|
231
272
|
storeIdToken(session.idToken);
|
|
@@ -334,7 +375,8 @@ function useLogin() {
|
|
|
334
375
|
setIsLoggingIn(true);
|
|
335
376
|
setError(null);
|
|
336
377
|
try {
|
|
337
|
-
const
|
|
378
|
+
const fullConfig = config ?? getSandboxLoginConfig();
|
|
379
|
+
const tokens = await rawLoginWithPopup(fullConfig, options);
|
|
338
380
|
await signInWithTokens(tokens);
|
|
339
381
|
} catch (err) {
|
|
340
382
|
const errorMessage = err instanceof Error ? err.message : "Login failed";
|
|
@@ -348,7 +390,8 @@ function useLogin() {
|
|
|
348
390
|
setIsLoggingIn(true);
|
|
349
391
|
setError(null);
|
|
350
392
|
try {
|
|
351
|
-
const
|
|
393
|
+
const fullConfig = config ?? getSandboxLoginConfig();
|
|
394
|
+
const tokens = await rawLoginWithEmbedded(fullConfig);
|
|
352
395
|
await signInWithTokens(tokens);
|
|
353
396
|
} catch (err) {
|
|
354
397
|
const errorMessage = err instanceof Error ? err.message : "Login failed";
|
|
@@ -362,7 +405,8 @@ function useLogin() {
|
|
|
362
405
|
setIsLoggingIn(true);
|
|
363
406
|
setError(null);
|
|
364
407
|
try {
|
|
365
|
-
|
|
408
|
+
const fullConfig = config ?? getSandboxLoginConfig();
|
|
409
|
+
await rawLoginWithRedirect(fullConfig, options);
|
|
366
410
|
} catch (err) {
|
|
367
411
|
const errorMessage = err instanceof Error ? err.message : "Login failed";
|
|
368
412
|
setError(errorMessage);
|
|
@@ -387,7 +431,8 @@ function useLogout() {
|
|
|
387
431
|
try {
|
|
388
432
|
clearStoredIdToken();
|
|
389
433
|
await signOut({ redirect: false });
|
|
390
|
-
|
|
434
|
+
const fullConfig = config ?? getSandboxLogoutConfig();
|
|
435
|
+
rawLogoutWithRedirect(fullConfig);
|
|
391
436
|
} catch (err) {
|
|
392
437
|
const errorMessage = err instanceof Error ? err.message : "Logout failed";
|
|
393
438
|
setError(errorMessage);
|
|
@@ -406,7 +451,15 @@ function useLogout() {
|
|
|
406
451
|
import { MarketingConsentStatus } from "@imtbl/auth";
|
|
407
452
|
export {
|
|
408
453
|
CallbackPage,
|
|
454
|
+
DEFAULT_AUDIENCE,
|
|
455
|
+
DEFAULT_AUTH_DOMAIN,
|
|
456
|
+
DEFAULT_LOGOUT_REDIRECT_URI_PATH,
|
|
457
|
+
DEFAULT_REDIRECT_URI_PATH,
|
|
458
|
+
DEFAULT_SANDBOX_CLIENT_ID,
|
|
459
|
+
DEFAULT_SCOPE,
|
|
460
|
+
IMMUTABLE_PROVIDER_ID,
|
|
409
461
|
MarketingConsentStatus,
|
|
462
|
+
deriveDefaultRedirectUri,
|
|
410
463
|
useImmutableSession,
|
|
411
464
|
useLogin,
|
|
412
465
|
useLogout
|
|
@@ -1,37 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
* Default Immutable authentication domain
|
|
2
|
+
* Client-side constants for @imtbl/auth-next-client.
|
|
3
|
+
* Defined locally to avoid importing from auth-next-server (which uses next/server).
|
|
4
|
+
* Values must stay in sync with auth-next-server constants.
|
|
6
5
|
*/
|
|
7
6
|
export declare const DEFAULT_AUTH_DOMAIN = "https://auth.immutable.com";
|
|
8
|
-
/**
|
|
9
|
-
* Default OAuth audience
|
|
10
|
-
*/
|
|
11
7
|
export declare const DEFAULT_AUDIENCE = "platform_api";
|
|
12
|
-
/**
|
|
13
|
-
* Default OAuth scopes
|
|
14
|
-
*/
|
|
15
8
|
export declare const DEFAULT_SCOPE = "openid profile email offline_access transact";
|
|
16
|
-
/**
|
|
17
|
-
* NextAuth credentials provider ID for Immutable
|
|
18
|
-
*/
|
|
19
9
|
export declare const IMMUTABLE_PROVIDER_ID = "immutable";
|
|
20
|
-
/**
|
|
21
|
-
* Default NextAuth API base path
|
|
22
|
-
*/
|
|
23
10
|
export declare const DEFAULT_NEXTAUTH_BASE_PATH = "/api/auth";
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
export declare const
|
|
29
|
-
/**
|
|
30
|
-
* Default token expiry in milliseconds
|
|
31
|
-
*/
|
|
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;
|
|
11
|
+
export declare const DEFAULT_SANDBOX_CLIENT_ID = "mjtCL8mt06BtbxSkp2vbrYStKWnXVZfo";
|
|
12
|
+
export declare const DEFAULT_REDIRECT_URI_PATH = "/callback";
|
|
13
|
+
export declare const DEFAULT_LOGOUT_REDIRECT_URI_PATH = "/";
|
|
14
|
+
export declare const DEFAULT_TOKEN_EXPIRY_MS = 900000;
|
|
15
|
+
export declare const TOKEN_EXPIRY_BUFFER_MS = 60000;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sandbox default redirect URI for zero-config mode.
|
|
3
|
+
* Defined locally to avoid importing from auth-next-server (which uses next/server).
|
|
4
|
+
* OAuth requires an absolute URL; this runs in the browser when login is invoked.
|
|
5
|
+
*
|
|
6
|
+
* @internal
|
|
7
|
+
*/
|
|
8
|
+
export declare function deriveDefaultRedirectUri(): string;
|
package/dist/types/hooks.d.ts
CHANGED
|
@@ -86,53 +86,87 @@ export interface UseImmutableSessionReturn {
|
|
|
86
86
|
export declare function useImmutableSession(): UseImmutableSessionReturn;
|
|
87
87
|
/**
|
|
88
88
|
* Return type for useLogin hook
|
|
89
|
+
*
|
|
90
|
+
* Config is optional - when omitted, defaults are auto-derived (clientId, redirectUri, etc.).
|
|
91
|
+
* When provided, must be a complete LoginConfig.
|
|
89
92
|
*/
|
|
90
93
|
export interface UseLoginReturn {
|
|
91
94
|
/** Start login with popup flow */
|
|
92
|
-
loginWithPopup: (config
|
|
95
|
+
loginWithPopup: (config?: LoginConfig, options?: StandaloneLoginOptions) => Promise<void>;
|
|
93
96
|
/** Start login with embedded modal flow */
|
|
94
|
-
loginWithEmbedded: (config
|
|
97
|
+
loginWithEmbedded: (config?: LoginConfig) => Promise<void>;
|
|
95
98
|
/** Start login with redirect flow (navigates away from page) */
|
|
96
|
-
loginWithRedirect: (config
|
|
99
|
+
loginWithRedirect: (config?: LoginConfig, options?: StandaloneLoginOptions) => Promise<void>;
|
|
97
100
|
/** Whether login is currently in progress */
|
|
98
101
|
isLoggingIn: boolean;
|
|
99
102
|
/** Error message from the last login attempt, or null if none */
|
|
100
103
|
error: string | null;
|
|
101
104
|
}
|
|
102
105
|
/**
|
|
103
|
-
* Hook to handle Immutable authentication login flows.
|
|
106
|
+
* Hook to handle Immutable authentication login flows with automatic defaults.
|
|
104
107
|
*
|
|
105
108
|
* Provides login functions that:
|
|
106
109
|
* 1. Handle OAuth authentication via popup, embedded modal, or redirect
|
|
107
110
|
* 2. Automatically sign in to NextAuth after successful authentication
|
|
108
111
|
* 3. Track loading and error states
|
|
112
|
+
* 4. Auto-detect clientId and redirectUri if not provided (uses defaults)
|
|
109
113
|
*
|
|
110
|
-
* Config
|
|
111
|
-
*
|
|
114
|
+
* Config can be passed at call time or omitted to use sensible defaults:
|
|
115
|
+
* - `clientId`: Auto-detected based on environment (sandbox vs production)
|
|
116
|
+
* - `redirectUri`: Auto-derived from `window.location.origin + '/callback'`
|
|
117
|
+
* - `popupRedirectUri`: Auto-derived from `window.location.origin + '/callback'` (same as redirectUri)
|
|
118
|
+
* - `logoutRedirectUri`: Auto-derived from `window.location.origin`
|
|
119
|
+
* - `scope`: `'openid profile email offline_access transact'`
|
|
120
|
+
* - `audience`: `'platform_api'`
|
|
121
|
+
* - `authenticationDomain`: `'https://auth.immutable.com'`
|
|
112
122
|
*
|
|
113
123
|
* Must be used within a SessionProvider from next-auth/react.
|
|
114
124
|
*
|
|
115
|
-
* @example
|
|
125
|
+
* @example Minimal usage (uses all defaults)
|
|
116
126
|
* ```tsx
|
|
117
127
|
* import { useLogin, useImmutableSession } from '@imtbl/auth-next-client';
|
|
118
128
|
*
|
|
119
|
-
*
|
|
120
|
-
*
|
|
121
|
-
*
|
|
122
|
-
*
|
|
123
|
-
*
|
|
129
|
+
* function LoginButton() {
|
|
130
|
+
* const { isAuthenticated } = useImmutableSession();
|
|
131
|
+
* const { loginWithPopup, isLoggingIn, error } = useLogin();
|
|
132
|
+
*
|
|
133
|
+
* if (isAuthenticated) {
|
|
134
|
+
* return <p>You are logged in!</p>;
|
|
135
|
+
* }
|
|
136
|
+
*
|
|
137
|
+
* return (
|
|
138
|
+
* <>
|
|
139
|
+
* <button onClick={() => loginWithPopup()} disabled={isLoggingIn}>
|
|
140
|
+
* {isLoggingIn ? 'Signing in...' : 'Sign In'}
|
|
141
|
+
* </button>
|
|
142
|
+
* {error && <p style={{ color: 'red' }}>{error}</p>}
|
|
143
|
+
* </>
|
|
144
|
+
* );
|
|
145
|
+
* }
|
|
146
|
+
* ```
|
|
147
|
+
*
|
|
148
|
+
* @example With custom configuration
|
|
149
|
+
* ```tsx
|
|
150
|
+
* import { useLogin, useImmutableSession } from '@imtbl/auth-next-client';
|
|
124
151
|
*
|
|
125
152
|
* function LoginButton() {
|
|
126
153
|
* const { isAuthenticated } = useImmutableSession();
|
|
127
154
|
* const { loginWithPopup, isLoggingIn, error } = useLogin();
|
|
128
155
|
*
|
|
156
|
+
* const handleLogin = () => {
|
|
157
|
+
* loginWithPopup({
|
|
158
|
+
* clientId: process.env.NEXT_PUBLIC_IMMUTABLE_CLIENT_ID,
|
|
159
|
+
* redirectUri: `${window.location.origin}/callback`,
|
|
160
|
+
* });
|
|
161
|
+
* };
|
|
162
|
+
*
|
|
129
163
|
* if (isAuthenticated) {
|
|
130
164
|
* return <p>You are logged in!</p>;
|
|
131
165
|
* }
|
|
132
166
|
*
|
|
133
167
|
* return (
|
|
134
168
|
* <>
|
|
135
|
-
* <button onClick={
|
|
169
|
+
* <button onClick={handleLogin} disabled={isLoggingIn}>
|
|
136
170
|
* {isLoggingIn ? 'Signing in...' : 'Sign In'}
|
|
137
171
|
* </button>
|
|
138
172
|
* {error && <p style={{ color: 'red' }}>{error}</p>}
|
|
@@ -152,9 +186,11 @@ export interface UseLogoutReturn {
|
|
|
152
186
|
* This ensures that when the user logs in again, they will be prompted to select
|
|
153
187
|
* an account instead of being automatically logged in with the previous account.
|
|
154
188
|
*
|
|
155
|
-
*
|
|
189
|
+
* Config is optional - defaults will be auto-derived if not provided.
|
|
190
|
+
*
|
|
191
|
+
* @param config - Optional logout configuration with clientId and optional redirectUri
|
|
156
192
|
*/
|
|
157
|
-
logout: (config
|
|
193
|
+
logout: (config?: LogoutConfig) => Promise<void>;
|
|
158
194
|
/** Whether logout is currently in progress */
|
|
159
195
|
isLoggingOut: boolean;
|
|
160
196
|
/** Error message from the last logout attempt, or null if none */
|
|
@@ -171,16 +207,38 @@ export interface UseLogoutReturn {
|
|
|
171
207
|
* an account (for social logins like Google) instead of being automatically logged
|
|
172
208
|
* in with the previous account.
|
|
173
209
|
*
|
|
210
|
+
* Config is optional - defaults will be auto-derived if not provided:
|
|
211
|
+
* - `clientId`: Auto-detected based on environment (sandbox vs production)
|
|
212
|
+
* - `logoutRedirectUri`: Auto-derived from `window.location.origin`
|
|
213
|
+
*
|
|
174
214
|
* Must be used within a SessionProvider from next-auth/react.
|
|
175
215
|
*
|
|
176
|
-
* @example
|
|
216
|
+
* @example Minimal usage (uses all defaults)
|
|
177
217
|
* ```tsx
|
|
178
218
|
* import { useLogout, useImmutableSession } from '@imtbl/auth-next-client';
|
|
179
219
|
*
|
|
180
|
-
*
|
|
181
|
-
*
|
|
182
|
-
*
|
|
183
|
-
*
|
|
220
|
+
* function LogoutButton() {
|
|
221
|
+
* const { isAuthenticated } = useImmutableSession();
|
|
222
|
+
* const { logout, isLoggingOut, error } = useLogout();
|
|
223
|
+
*
|
|
224
|
+
* if (!isAuthenticated) {
|
|
225
|
+
* return null;
|
|
226
|
+
* }
|
|
227
|
+
*
|
|
228
|
+
* return (
|
|
229
|
+
* <>
|
|
230
|
+
* <button onClick={() => logout()} disabled={isLoggingOut}>
|
|
231
|
+
* {isLoggingOut ? 'Signing out...' : 'Sign Out'}
|
|
232
|
+
* </button>
|
|
233
|
+
* {error && <p style={{ color: 'red' }}>{error}</p>}
|
|
234
|
+
* </>
|
|
235
|
+
* );
|
|
236
|
+
* }
|
|
237
|
+
* ```
|
|
238
|
+
*
|
|
239
|
+
* @example With custom configuration
|
|
240
|
+
* ```tsx
|
|
241
|
+
* import { useLogout, useImmutableSession } from '@imtbl/auth-next-client';
|
|
184
242
|
*
|
|
185
243
|
* function LogoutButton() {
|
|
186
244
|
* const { isAuthenticated } = useImmutableSession();
|
|
@@ -192,7 +250,13 @@ export interface UseLogoutReturn {
|
|
|
192
250
|
*
|
|
193
251
|
* return (
|
|
194
252
|
* <>
|
|
195
|
-
* <button
|
|
253
|
+
* <button
|
|
254
|
+
* onClick={() => logout({
|
|
255
|
+
* clientId: process.env.NEXT_PUBLIC_IMMUTABLE_CLIENT_ID,
|
|
256
|
+
* logoutRedirectUri: `${window.location.origin}/custom-logout`,
|
|
257
|
+
* })}
|
|
258
|
+
* disabled={isLoggingOut}
|
|
259
|
+
* >
|
|
196
260
|
* {isLoggingOut ? 'Signing out...' : 'Sign Out'}
|
|
197
261
|
* </button>
|
|
198
262
|
* {error && <p style={{ color: 'red' }}>{error}</p>}
|
package/dist/types/index.d.ts
CHANGED
|
@@ -28,3 +28,5 @@ export type { ImmutableUserClient, ImmutableTokenDataClient, ZkEvmInfo, } from '
|
|
|
28
28
|
export type { ImmutableAuthConfig, ImmutableTokenData, ImmutableUser, AuthProps, AuthPropsWithData, ProtectedAuthProps, ProtectedAuthPropsWithData, } from '@imtbl/auth-next-server';
|
|
29
29
|
export type { LoginConfig, StandaloneLoginOptions, DirectLoginOptions, LogoutConfig, } from '@imtbl/auth';
|
|
30
30
|
export { MarketingConsentStatus } from '@imtbl/auth';
|
|
31
|
+
export { DEFAULT_AUTH_DOMAIN, DEFAULT_AUDIENCE, DEFAULT_SCOPE, IMMUTABLE_PROVIDER_ID, DEFAULT_SANDBOX_CLIENT_ID, DEFAULT_REDIRECT_URI_PATH, DEFAULT_LOGOUT_REDIRECT_URI_PATH, } from './constants';
|
|
32
|
+
export { deriveDefaultRedirectUri } from './defaultConfig';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@imtbl/auth-next-client",
|
|
3
|
-
"version": "2.12.
|
|
3
|
+
"version": "2.12.8-alpha.0",
|
|
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,11 +27,11 @@
|
|
|
27
27
|
}
|
|
28
28
|
},
|
|
29
29
|
"dependencies": {
|
|
30
|
-
"@imtbl/auth": "2.12.
|
|
31
|
-
"@imtbl/auth-next-server": "2.12.
|
|
30
|
+
"@imtbl/auth": "2.12.8-alpha.0",
|
|
31
|
+
"@imtbl/auth-next-server": "2.12.8-alpha.0"
|
|
32
32
|
},
|
|
33
33
|
"peerDependencies": {
|
|
34
|
-
"next": "^15.0.0",
|
|
34
|
+
"next": "^14.0.0 || ^15.0.0",
|
|
35
35
|
"next-auth": "^5.0.0-beta.25",
|
|
36
36
|
"react": "^18.2.0 || ^19.0.0"
|
|
37
37
|
},
|
|
@@ -56,7 +56,7 @@
|
|
|
56
56
|
"@types/react": "^18.3.5",
|
|
57
57
|
"eslint": "^8.56.0",
|
|
58
58
|
"jest": "^29.7.0",
|
|
59
|
-
"next": "^15.
|
|
59
|
+
"next": "^15.2.6",
|
|
60
60
|
"next-auth": "^5.0.0-beta.30",
|
|
61
61
|
"react": "^18.2.0",
|
|
62
62
|
"tsup": "^8.3.0",
|
package/src/constants.ts
CHANGED
|
@@ -1,45 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Client-side constants for @imtbl/auth-next-client.
|
|
3
|
+
* Defined locally to avoid importing from auth-next-server (which uses next/server).
|
|
4
|
+
* Values must stay in sync with auth-next-server constants.
|
|
3
5
|
*/
|
|
4
6
|
|
|
5
|
-
/**
|
|
6
|
-
* Default Immutable authentication domain
|
|
7
|
-
*/
|
|
8
7
|
export const DEFAULT_AUTH_DOMAIN = 'https://auth.immutable.com';
|
|
9
|
-
|
|
10
|
-
/**
|
|
11
|
-
* Default OAuth audience
|
|
12
|
-
*/
|
|
13
8
|
export const DEFAULT_AUDIENCE = 'platform_api';
|
|
14
|
-
|
|
15
|
-
/**
|
|
16
|
-
* Default OAuth scopes
|
|
17
|
-
*/
|
|
18
9
|
export const DEFAULT_SCOPE = 'openid profile email offline_access transact';
|
|
19
|
-
|
|
20
|
-
/**
|
|
21
|
-
* NextAuth credentials provider ID for Immutable
|
|
22
|
-
*/
|
|
23
10
|
export const IMMUTABLE_PROVIDER_ID = 'immutable';
|
|
24
|
-
|
|
25
|
-
/**
|
|
26
|
-
* Default NextAuth API base path
|
|
27
|
-
*/
|
|
28
11
|
export const DEFAULT_NEXTAUTH_BASE_PATH = '/api/auth';
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
export const DEFAULT_TOKEN_EXPIRY_SECONDS = 900;
|
|
35
|
-
|
|
36
|
-
/**
|
|
37
|
-
* Default token expiry in milliseconds
|
|
38
|
-
*/
|
|
39
|
-
export const DEFAULT_TOKEN_EXPIRY_MS = DEFAULT_TOKEN_EXPIRY_SECONDS * 1000;
|
|
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;
|
|
12
|
+
export const DEFAULT_SANDBOX_CLIENT_ID = 'mjtCL8mt06BtbxSkp2vbrYStKWnXVZfo';
|
|
13
|
+
export const DEFAULT_REDIRECT_URI_PATH = '/callback';
|
|
14
|
+
export const DEFAULT_LOGOUT_REDIRECT_URI_PATH = '/';
|
|
15
|
+
export const DEFAULT_TOKEN_EXPIRY_MS = 900_000;
|
|
16
|
+
export const TOKEN_EXPIRY_BUFFER_MS = 60_000;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sandbox default redirect URI for zero-config mode.
|
|
3
|
+
* Defined locally to avoid importing from auth-next-server (which uses next/server).
|
|
4
|
+
* OAuth requires an absolute URL; this runs in the browser when login is invoked.
|
|
5
|
+
*
|
|
6
|
+
* @internal
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { DEFAULT_REDIRECT_URI_PATH } from './constants';
|
|
10
|
+
|
|
11
|
+
export function deriveDefaultRedirectUri(): string {
|
|
12
|
+
if (typeof window === 'undefined') {
|
|
13
|
+
throw new Error(
|
|
14
|
+
'[auth-next-client] deriveDefaultRedirectUri requires window. '
|
|
15
|
+
+ 'Login hooks run in the browser when the user triggers login.',
|
|
16
|
+
);
|
|
17
|
+
}
|
|
18
|
+
return `${window.location.origin}${DEFAULT_REDIRECT_URI_PATH}`;
|
|
19
|
+
}
|
package/src/hooks.test.tsx
CHANGED
|
@@ -23,6 +23,10 @@ jest.mock('@imtbl/auth', () => ({
|
|
|
23
23
|
logoutWithRedirect: jest.fn(),
|
|
24
24
|
}));
|
|
25
25
|
|
|
26
|
+
jest.mock('./defaultConfig', () => ({
|
|
27
|
+
deriveDefaultRedirectUri: jest.fn(() => 'http://localhost:3000/callback'),
|
|
28
|
+
}));
|
|
29
|
+
|
|
26
30
|
import { useImmutableSession } from './hooks';
|
|
27
31
|
|
|
28
32
|
// ---------------------------------------------------------------------------
|
|
@@ -287,6 +291,23 @@ describe('useImmutableSession', () => {
|
|
|
287
291
|
// Should NOT have called update -- token is still valid
|
|
288
292
|
expect(mockUpdate).not.toHaveBeenCalled();
|
|
289
293
|
});
|
|
294
|
+
|
|
295
|
+
it('does not trigger refresh when session has error (prevents infinite loop)', async () => {
|
|
296
|
+
// Simulate: token expired and last refresh failed (e.g. RefreshTokenError)
|
|
297
|
+
const sessionWithError = createSession({
|
|
298
|
+
accessTokenExpires: Date.now() - 1000, // expired
|
|
299
|
+
error: 'RefreshTokenError',
|
|
300
|
+
});
|
|
301
|
+
setupUseSession(sessionWithError);
|
|
302
|
+
|
|
303
|
+
await act(async () => {
|
|
304
|
+
renderHook(() => useImmutableSession());
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
// Must NOT call update - otherwise we would retry refresh repeatedly
|
|
308
|
+
// and cause an infinite loop (update -> same session with error -> effect re-runs -> update again).
|
|
309
|
+
expect(mockUpdate).not.toHaveBeenCalled();
|
|
310
|
+
});
|
|
290
311
|
});
|
|
291
312
|
|
|
292
313
|
describe('getUser() respects pending refresh', () => {
|
|
@@ -313,5 +334,35 @@ describe('useImmutableSession', () => {
|
|
|
313
334
|
// getUser() should have waited for the refresh and gotten the fresh token
|
|
314
335
|
expect(user?.accessToken).toBe('user-fresh-token');
|
|
315
336
|
});
|
|
337
|
+
|
|
338
|
+
it('getUser(true) still calls update with forceRefresh even when session has error', async () => {
|
|
339
|
+
// Session is in error state (e.g. previous refresh failed)
|
|
340
|
+
const sessionWithError = createSession({
|
|
341
|
+
accessTokenExpires: Date.now() - 1000,
|
|
342
|
+
error: 'RefreshTokenError',
|
|
343
|
+
});
|
|
344
|
+
setupUseSession(sessionWithError);
|
|
345
|
+
|
|
346
|
+
// Server recovers and returns a valid session (e.g. user re-authenticated elsewhere)
|
|
347
|
+
const recoveredSession = createSession({
|
|
348
|
+
accessToken: 'recovered-token',
|
|
349
|
+
accessTokenExpires: Date.now() + 10 * 60 * 1000,
|
|
350
|
+
user: { sub: 'user-1', email: 'recovered@test.com' },
|
|
351
|
+
});
|
|
352
|
+
mockUpdate.mockResolvedValue(recoveredSession);
|
|
353
|
+
|
|
354
|
+
const { result } = renderHook(() => useImmutableSession());
|
|
355
|
+
|
|
356
|
+
let user: any;
|
|
357
|
+
await act(async () => {
|
|
358
|
+
user = await result.current.getUser(true);
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
// forceRefresh must have been attempted (proactive effect does NOT run when session.error is set)
|
|
362
|
+
expect(mockUpdate).toHaveBeenCalledWith({ forceRefresh: true });
|
|
363
|
+
// When server returns a good session, we get the user
|
|
364
|
+
expect(user?.accessToken).toBe('recovered-token');
|
|
365
|
+
expect(user?.profile?.email).toBe('recovered@test.com');
|
|
366
|
+
});
|
|
316
367
|
});
|
|
317
368
|
});
|
package/src/hooks.tsx
CHANGED
|
@@ -18,7 +18,16 @@ import {
|
|
|
18
18
|
loginWithRedirect as rawLoginWithRedirect,
|
|
19
19
|
logoutWithRedirect as rawLogoutWithRedirect,
|
|
20
20
|
} from '@imtbl/auth';
|
|
21
|
-
import {
|
|
21
|
+
import { deriveDefaultRedirectUri } from './defaultConfig';
|
|
22
|
+
import {
|
|
23
|
+
IMMUTABLE_PROVIDER_ID,
|
|
24
|
+
TOKEN_EXPIRY_BUFFER_MS,
|
|
25
|
+
DEFAULT_SANDBOX_CLIENT_ID,
|
|
26
|
+
DEFAULT_LOGOUT_REDIRECT_URI_PATH,
|
|
27
|
+
DEFAULT_AUTH_DOMAIN,
|
|
28
|
+
DEFAULT_SCOPE,
|
|
29
|
+
DEFAULT_AUDIENCE,
|
|
30
|
+
} from './constants';
|
|
22
31
|
import { storeIdToken, getStoredIdToken, clearStoredIdToken } from './idTokenStorage';
|
|
23
32
|
|
|
24
33
|
// ---------------------------------------------------------------------------
|
|
@@ -42,6 +51,37 @@ function deduplicatedUpdate(
|
|
|
42
51
|
return pendingRefresh;
|
|
43
52
|
}
|
|
44
53
|
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
// Sandbox defaults for zero-config (no config or full config - no merge)
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
|
|
58
|
+
function getSandboxLoginConfig(): LoginConfig {
|
|
59
|
+
const redirectUri = deriveDefaultRedirectUri();
|
|
60
|
+
return {
|
|
61
|
+
clientId: DEFAULT_SANDBOX_CLIENT_ID,
|
|
62
|
+
redirectUri,
|
|
63
|
+
popupRedirectUri: redirectUri,
|
|
64
|
+
scope: DEFAULT_SCOPE,
|
|
65
|
+
audience: DEFAULT_AUDIENCE,
|
|
66
|
+
authenticationDomain: DEFAULT_AUTH_DOMAIN,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function getSandboxLogoutConfig(): LogoutConfig {
|
|
71
|
+
if (typeof window === 'undefined') {
|
|
72
|
+
throw new Error(
|
|
73
|
+
'[auth-next-client] getSandboxLogoutConfig requires window. '
|
|
74
|
+
+ 'Logout runs in the browser when the user triggers it.',
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
const logoutRedirectUri = window.location.origin + DEFAULT_LOGOUT_REDIRECT_URI_PATH;
|
|
78
|
+
return {
|
|
79
|
+
clientId: DEFAULT_SANDBOX_CLIENT_ID,
|
|
80
|
+
logoutRedirectUri,
|
|
81
|
+
authenticationDomain: DEFAULT_AUTH_DOMAIN,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
45
85
|
/**
|
|
46
86
|
* Internal session type with full token data (not exported).
|
|
47
87
|
* Used internally by the hook for token validation, refresh logic, and getUser/getAccessToken.
|
|
@@ -181,6 +221,8 @@ export function useImmutableSession(): UseImmutableSessionReturn {
|
|
|
181
221
|
// `!isRefreshing` to briefly lose their cached data, resulting in UI flicker.
|
|
182
222
|
useEffect(() => {
|
|
183
223
|
if (!session?.accessTokenExpires) return;
|
|
224
|
+
// Don't retry if the last refresh already failed - prevents infinite loops
|
|
225
|
+
if (session?.error) return;
|
|
184
226
|
|
|
185
227
|
const timeUntilExpiry = session.accessTokenExpires - Date.now() - TOKEN_EXPIRY_BUFFER_MS;
|
|
186
228
|
|
|
@@ -188,7 +230,7 @@ export function useImmutableSession(): UseImmutableSessionReturn {
|
|
|
188
230
|
// Already expired -- refresh silently
|
|
189
231
|
deduplicatedUpdate(() => updateRef.current());
|
|
190
232
|
}
|
|
191
|
-
}, [session?.accessTokenExpires]);
|
|
233
|
+
}, [session?.accessTokenExpires, session?.error]);
|
|
192
234
|
|
|
193
235
|
// ---------------------------------------------------------------------------
|
|
194
236
|
// Sync idToken to localStorage
|
|
@@ -341,14 +383,17 @@ export function useImmutableSession(): UseImmutableSessionReturn {
|
|
|
341
383
|
|
|
342
384
|
/**
|
|
343
385
|
* Return type for useLogin hook
|
|
386
|
+
*
|
|
387
|
+
* Config is optional - when omitted, defaults are auto-derived (clientId, redirectUri, etc.).
|
|
388
|
+
* When provided, must be a complete LoginConfig.
|
|
344
389
|
*/
|
|
345
390
|
export interface UseLoginReturn {
|
|
346
391
|
/** Start login with popup flow */
|
|
347
|
-
loginWithPopup: (config
|
|
392
|
+
loginWithPopup: (config?: LoginConfig, options?: StandaloneLoginOptions) => Promise<void>;
|
|
348
393
|
/** Start login with embedded modal flow */
|
|
349
|
-
loginWithEmbedded: (config
|
|
394
|
+
loginWithEmbedded: (config?: LoginConfig) => Promise<void>;
|
|
350
395
|
/** Start login with redirect flow (navigates away from page) */
|
|
351
|
-
loginWithRedirect: (config
|
|
396
|
+
loginWithRedirect: (config?: LoginConfig, options?: StandaloneLoginOptions) => Promise<void>;
|
|
352
397
|
/** Whether login is currently in progress */
|
|
353
398
|
isLoggingIn: boolean;
|
|
354
399
|
/** Error message from the last login attempt, or null if none */
|
|
@@ -356,39 +401,70 @@ export interface UseLoginReturn {
|
|
|
356
401
|
}
|
|
357
402
|
|
|
358
403
|
/**
|
|
359
|
-
* Hook to handle Immutable authentication login flows.
|
|
404
|
+
* Hook to handle Immutable authentication login flows with automatic defaults.
|
|
360
405
|
*
|
|
361
406
|
* Provides login functions that:
|
|
362
407
|
* 1. Handle OAuth authentication via popup, embedded modal, or redirect
|
|
363
408
|
* 2. Automatically sign in to NextAuth after successful authentication
|
|
364
409
|
* 3. Track loading and error states
|
|
410
|
+
* 4. Auto-detect clientId and redirectUri if not provided (uses defaults)
|
|
365
411
|
*
|
|
366
|
-
* Config
|
|
367
|
-
*
|
|
412
|
+
* Config can be passed at call time or omitted to use sensible defaults:
|
|
413
|
+
* - `clientId`: Auto-detected based on environment (sandbox vs production)
|
|
414
|
+
* - `redirectUri`: Auto-derived from `window.location.origin + '/callback'`
|
|
415
|
+
* - `popupRedirectUri`: Auto-derived from `window.location.origin + '/callback'` (same as redirectUri)
|
|
416
|
+
* - `logoutRedirectUri`: Auto-derived from `window.location.origin`
|
|
417
|
+
* - `scope`: `'openid profile email offline_access transact'`
|
|
418
|
+
* - `audience`: `'platform_api'`
|
|
419
|
+
* - `authenticationDomain`: `'https://auth.immutable.com'`
|
|
368
420
|
*
|
|
369
421
|
* Must be used within a SessionProvider from next-auth/react.
|
|
370
422
|
*
|
|
371
|
-
* @example
|
|
423
|
+
* @example Minimal usage (uses all defaults)
|
|
372
424
|
* ```tsx
|
|
373
425
|
* import { useLogin, useImmutableSession } from '@imtbl/auth-next-client';
|
|
374
426
|
*
|
|
375
|
-
*
|
|
376
|
-
*
|
|
377
|
-
*
|
|
378
|
-
*
|
|
379
|
-
*
|
|
427
|
+
* function LoginButton() {
|
|
428
|
+
* const { isAuthenticated } = useImmutableSession();
|
|
429
|
+
* const { loginWithPopup, isLoggingIn, error } = useLogin();
|
|
430
|
+
*
|
|
431
|
+
* if (isAuthenticated) {
|
|
432
|
+
* return <p>You are logged in!</p>;
|
|
433
|
+
* }
|
|
434
|
+
*
|
|
435
|
+
* return (
|
|
436
|
+
* <>
|
|
437
|
+
* <button onClick={() => loginWithPopup()} disabled={isLoggingIn}>
|
|
438
|
+
* {isLoggingIn ? 'Signing in...' : 'Sign In'}
|
|
439
|
+
* </button>
|
|
440
|
+
* {error && <p style={{ color: 'red' }}>{error}</p>}
|
|
441
|
+
* </>
|
|
442
|
+
* );
|
|
443
|
+
* }
|
|
444
|
+
* ```
|
|
445
|
+
*
|
|
446
|
+
* @example With custom configuration
|
|
447
|
+
* ```tsx
|
|
448
|
+
* import { useLogin, useImmutableSession } from '@imtbl/auth-next-client';
|
|
380
449
|
*
|
|
381
450
|
* function LoginButton() {
|
|
382
451
|
* const { isAuthenticated } = useImmutableSession();
|
|
383
452
|
* const { loginWithPopup, isLoggingIn, error } = useLogin();
|
|
384
453
|
*
|
|
454
|
+
* const handleLogin = () => {
|
|
455
|
+
* loginWithPopup({
|
|
456
|
+
* clientId: process.env.NEXT_PUBLIC_IMMUTABLE_CLIENT_ID,
|
|
457
|
+
* redirectUri: `${window.location.origin}/callback`,
|
|
458
|
+
* });
|
|
459
|
+
* };
|
|
460
|
+
*
|
|
385
461
|
* if (isAuthenticated) {
|
|
386
462
|
* return <p>You are logged in!</p>;
|
|
387
463
|
* }
|
|
388
464
|
*
|
|
389
465
|
* return (
|
|
390
466
|
* <>
|
|
391
|
-
* <button onClick={
|
|
467
|
+
* <button onClick={handleLogin} disabled={isLoggingIn}>
|
|
392
468
|
* {isLoggingIn ? 'Signing in...' : 'Sign In'}
|
|
393
469
|
* </button>
|
|
394
470
|
* {error && <p style={{ color: 'red' }}>{error}</p>}
|
|
@@ -434,16 +510,18 @@ export function useLogin(): UseLoginReturn {
|
|
|
434
510
|
/**
|
|
435
511
|
* Login with a popup window.
|
|
436
512
|
* Opens a popup for OAuth authentication, then signs in to NextAuth.
|
|
513
|
+
* Config is optional - defaults will be auto-derived if not provided.
|
|
437
514
|
*/
|
|
438
515
|
const loginWithPopup = useCallback(async (
|
|
439
|
-
config
|
|
516
|
+
config?: LoginConfig,
|
|
440
517
|
options?: StandaloneLoginOptions,
|
|
441
518
|
): Promise<void> => {
|
|
442
519
|
setIsLoggingIn(true);
|
|
443
520
|
setError(null);
|
|
444
521
|
|
|
445
522
|
try {
|
|
446
|
-
const
|
|
523
|
+
const fullConfig = config ?? getSandboxLoginConfig();
|
|
524
|
+
const tokens = await rawLoginWithPopup(fullConfig, options);
|
|
447
525
|
await signInWithTokens(tokens);
|
|
448
526
|
} catch (err) {
|
|
449
527
|
const errorMessage = err instanceof Error ? err.message : 'Login failed';
|
|
@@ -457,13 +535,15 @@ export function useLogin(): UseLoginReturn {
|
|
|
457
535
|
/**
|
|
458
536
|
* Login with an embedded modal.
|
|
459
537
|
* Shows a modal for login method selection, then opens a popup for OAuth.
|
|
538
|
+
* Config is optional - defaults will be auto-derived if not provided.
|
|
460
539
|
*/
|
|
461
|
-
const loginWithEmbedded = useCallback(async (config
|
|
540
|
+
const loginWithEmbedded = useCallback(async (config?: LoginConfig): Promise<void> => {
|
|
462
541
|
setIsLoggingIn(true);
|
|
463
542
|
setError(null);
|
|
464
543
|
|
|
465
544
|
try {
|
|
466
|
-
const
|
|
545
|
+
const fullConfig = config ?? getSandboxLoginConfig();
|
|
546
|
+
const tokens = await rawLoginWithEmbedded(fullConfig);
|
|
467
547
|
await signInWithTokens(tokens);
|
|
468
548
|
} catch (err) {
|
|
469
549
|
const errorMessage = err instanceof Error ? err.message : 'Login failed';
|
|
@@ -479,16 +559,18 @@ export function useLogin(): UseLoginReturn {
|
|
|
479
559
|
* Redirects the page to OAuth authentication.
|
|
480
560
|
* After authentication, the user will be redirected to your callback page.
|
|
481
561
|
* Use the CallbackPage component to complete the flow.
|
|
562
|
+
* Config is optional - defaults will be auto-derived if not provided.
|
|
482
563
|
*/
|
|
483
564
|
const loginWithRedirect = useCallback(async (
|
|
484
|
-
config
|
|
565
|
+
config?: LoginConfig,
|
|
485
566
|
options?: StandaloneLoginOptions,
|
|
486
567
|
): Promise<void> => {
|
|
487
568
|
setIsLoggingIn(true);
|
|
488
569
|
setError(null);
|
|
489
570
|
|
|
490
571
|
try {
|
|
491
|
-
|
|
572
|
+
const fullConfig = config ?? getSandboxLoginConfig();
|
|
573
|
+
await rawLoginWithRedirect(fullConfig, options);
|
|
492
574
|
// Note: The page will redirect, so this code may not run
|
|
493
575
|
} catch (err) {
|
|
494
576
|
const errorMessage = err instanceof Error ? err.message : 'Login failed';
|
|
@@ -518,9 +600,11 @@ export interface UseLogoutReturn {
|
|
|
518
600
|
* This ensures that when the user logs in again, they will be prompted to select
|
|
519
601
|
* an account instead of being automatically logged in with the previous account.
|
|
520
602
|
*
|
|
521
|
-
*
|
|
603
|
+
* Config is optional - defaults will be auto-derived if not provided.
|
|
604
|
+
*
|
|
605
|
+
* @param config - Optional logout configuration with clientId and optional redirectUri
|
|
522
606
|
*/
|
|
523
|
-
logout: (config
|
|
607
|
+
logout: (config?: LogoutConfig) => Promise<void>;
|
|
524
608
|
/** Whether logout is currently in progress */
|
|
525
609
|
isLoggingOut: boolean;
|
|
526
610
|
/** Error message from the last logout attempt, or null if none */
|
|
@@ -538,16 +622,38 @@ export interface UseLogoutReturn {
|
|
|
538
622
|
* an account (for social logins like Google) instead of being automatically logged
|
|
539
623
|
* in with the previous account.
|
|
540
624
|
*
|
|
625
|
+
* Config is optional - defaults will be auto-derived if not provided:
|
|
626
|
+
* - `clientId`: Auto-detected based on environment (sandbox vs production)
|
|
627
|
+
* - `logoutRedirectUri`: Auto-derived from `window.location.origin`
|
|
628
|
+
*
|
|
541
629
|
* Must be used within a SessionProvider from next-auth/react.
|
|
542
630
|
*
|
|
543
|
-
* @example
|
|
631
|
+
* @example Minimal usage (uses all defaults)
|
|
544
632
|
* ```tsx
|
|
545
633
|
* import { useLogout, useImmutableSession } from '@imtbl/auth-next-client';
|
|
546
634
|
*
|
|
547
|
-
*
|
|
548
|
-
*
|
|
549
|
-
*
|
|
550
|
-
*
|
|
635
|
+
* function LogoutButton() {
|
|
636
|
+
* const { isAuthenticated } = useImmutableSession();
|
|
637
|
+
* const { logout, isLoggingOut, error } = useLogout();
|
|
638
|
+
*
|
|
639
|
+
* if (!isAuthenticated) {
|
|
640
|
+
* return null;
|
|
641
|
+
* }
|
|
642
|
+
*
|
|
643
|
+
* return (
|
|
644
|
+
* <>
|
|
645
|
+
* <button onClick={() => logout()} disabled={isLoggingOut}>
|
|
646
|
+
* {isLoggingOut ? 'Signing out...' : 'Sign Out'}
|
|
647
|
+
* </button>
|
|
648
|
+
* {error && <p style={{ color: 'red' }}>{error}</p>}
|
|
649
|
+
* </>
|
|
650
|
+
* );
|
|
651
|
+
* }
|
|
652
|
+
* ```
|
|
653
|
+
*
|
|
654
|
+
* @example With custom configuration
|
|
655
|
+
* ```tsx
|
|
656
|
+
* import { useLogout, useImmutableSession } from '@imtbl/auth-next-client';
|
|
551
657
|
*
|
|
552
658
|
* function LogoutButton() {
|
|
553
659
|
* const { isAuthenticated } = useImmutableSession();
|
|
@@ -559,7 +665,13 @@ export interface UseLogoutReturn {
|
|
|
559
665
|
*
|
|
560
666
|
* return (
|
|
561
667
|
* <>
|
|
562
|
-
* <button
|
|
668
|
+
* <button
|
|
669
|
+
* onClick={() => logout({
|
|
670
|
+
* clientId: process.env.NEXT_PUBLIC_IMMUTABLE_CLIENT_ID,
|
|
671
|
+
* logoutRedirectUri: `${window.location.origin}/custom-logout`,
|
|
672
|
+
* })}
|
|
673
|
+
* disabled={isLoggingOut}
|
|
674
|
+
* >
|
|
563
675
|
* {isLoggingOut ? 'Signing out...' : 'Sign Out'}
|
|
564
676
|
* </button>
|
|
565
677
|
* {error && <p style={{ color: 'red' }}>{error}</p>}
|
|
@@ -575,8 +687,9 @@ export function useLogout(): UseLogoutReturn {
|
|
|
575
687
|
/**
|
|
576
688
|
* Logout with federated logout.
|
|
577
689
|
* First clears the NextAuth session, then redirects to the auth domain's logout endpoint.
|
|
690
|
+
* Config is optional - defaults will be auto-derived if not provided.
|
|
578
691
|
*/
|
|
579
|
-
const logout = useCallback(async (config
|
|
692
|
+
const logout = useCallback(async (config?: LogoutConfig): Promise<void> => {
|
|
580
693
|
setIsLoggingOut(true);
|
|
581
694
|
setError(null);
|
|
582
695
|
|
|
@@ -588,10 +701,13 @@ export function useLogout(): UseLogoutReturn {
|
|
|
588
701
|
// We use redirect: false to handle the redirect ourselves for federated logout
|
|
589
702
|
await signOut({ redirect: false });
|
|
590
703
|
|
|
704
|
+
// Create full config with defaults
|
|
705
|
+
const fullConfig = config ?? getSandboxLogoutConfig();
|
|
706
|
+
|
|
591
707
|
// Redirect to the auth domain's logout endpoint using the standalone function
|
|
592
708
|
// This clears the upstream session (Auth0/Immutable) so that on next login,
|
|
593
709
|
// the user will be prompted to select an account instead of auto-logging in
|
|
594
|
-
rawLogoutWithRedirect(
|
|
710
|
+
rawLogoutWithRedirect(fullConfig);
|
|
595
711
|
} catch (err) {
|
|
596
712
|
const errorMessage = err instanceof Error ? err.message : 'Logout failed';
|
|
597
713
|
setError(errorMessage);
|
package/src/index.ts
CHANGED
|
@@ -59,3 +59,15 @@ export type {
|
|
|
59
59
|
LogoutConfig,
|
|
60
60
|
} from '@imtbl/auth';
|
|
61
61
|
export { MarketingConsentStatus } from '@imtbl/auth';
|
|
62
|
+
|
|
63
|
+
// Re-export constants and default config helpers for consumer convenience
|
|
64
|
+
export {
|
|
65
|
+
DEFAULT_AUTH_DOMAIN,
|
|
66
|
+
DEFAULT_AUDIENCE,
|
|
67
|
+
DEFAULT_SCOPE,
|
|
68
|
+
IMMUTABLE_PROVIDER_ID,
|
|
69
|
+
DEFAULT_SANDBOX_CLIENT_ID,
|
|
70
|
+
DEFAULT_REDIRECT_URI_PATH,
|
|
71
|
+
DEFAULT_LOGOUT_REDIRECT_URI_PATH,
|
|
72
|
+
} from './constants';
|
|
73
|
+
export { deriveDefaultRedirectUri } from './defaultConfig';
|