@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 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:
@@ -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 DEFAULT_TOKEN_EXPIRY_SECONDS = 900;
41
- var DEFAULT_TOKEN_EXPIRY_MS = DEFAULT_TOKEN_EXPIRY_SECONDS * 1e3;
42
- var TOKEN_EXPIRY_BUFFER_MS = 60 * 1e3;
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 tokens = await (0, import_auth2.loginWithPopup)(config, options);
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 tokens = await (0, import_auth2.loginWithEmbedded)(config);
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
- await (0, import_auth2.loginWithRedirect)(config, options);
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
- (0, import_auth2.logoutWithRedirect)(config);
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
@@ -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 DEFAULT_TOKEN_EXPIRY_SECONDS = 900;
12
- var DEFAULT_TOKEN_EXPIRY_MS = DEFAULT_TOKEN_EXPIRY_SECONDS * 1e3;
13
- var TOKEN_EXPIRY_BUFFER_MS = 60 * 1e3;
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 tokens = await rawLoginWithPopup(config, options);
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 tokens = await rawLoginWithEmbedded(config);
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
- await rawLoginWithRedirect(config, options);
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
- rawLogoutWithRedirect(config);
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
- * Shared constants for @imtbl/auth-next-client
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
- * Default token expiry in seconds (15 minutes)
26
- * Used as fallback when exp claim cannot be extracted from JWT
27
- */
28
- export declare const DEFAULT_TOKEN_EXPIRY_SECONDS = 900;
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;
@@ -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: LoginConfig, options?: StandaloneLoginOptions) => Promise<void>;
95
+ loginWithPopup: (config?: LoginConfig, options?: StandaloneLoginOptions) => Promise<void>;
93
96
  /** Start login with embedded modal flow */
94
- loginWithEmbedded: (config: LoginConfig) => Promise<void>;
97
+ loginWithEmbedded: (config?: LoginConfig) => Promise<void>;
95
98
  /** Start login with redirect flow (navigates away from page) */
96
- loginWithRedirect: (config: LoginConfig, options?: StandaloneLoginOptions) => Promise<void>;
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 is passed at call time to allow different configurations for different
111
- * login methods (e.g., different redirectUri vs popupRedirectUri).
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
- * const config = {
120
- * clientId: process.env.NEXT_PUBLIC_IMMUTABLE_CLIENT_ID!,
121
- * redirectUri: `${process.env.NEXT_PUBLIC_BASE_URL}/callback`,
122
- * popupRedirectUri: `${process.env.NEXT_PUBLIC_BASE_URL}/callback/popup`,
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={() => loginWithPopup(config)} disabled={isLoggingIn}>
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
- * @param config - Logout configuration with clientId and optional redirectUri
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: LogoutConfig) => Promise<void>;
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
- * const logoutConfig = {
181
- * clientId: process.env.NEXT_PUBLIC_IMMUTABLE_CLIENT_ID!,
182
- * logoutRedirectUri: process.env.NEXT_PUBLIC_BASE_URL!,
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 onClick={() => logout(logoutConfig)} disabled={isLoggingOut}>
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>}
@@ -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.7-alpha.9",
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.7-alpha.9",
31
- "@imtbl/auth-next-server": "2.12.7-alpha.9"
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.1.6",
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
- * Shared constants for @imtbl/auth-next-client
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
- * Default token expiry in seconds (15 minutes)
32
- * Used as fallback when exp claim cannot be extracted from JWT
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
+ }
@@ -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 { IMMUTABLE_PROVIDER_ID, TOKEN_EXPIRY_BUFFER_MS } from './constants';
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: LoginConfig, options?: StandaloneLoginOptions) => Promise<void>;
392
+ loginWithPopup: (config?: LoginConfig, options?: StandaloneLoginOptions) => Promise<void>;
348
393
  /** Start login with embedded modal flow */
349
- loginWithEmbedded: (config: LoginConfig) => Promise<void>;
394
+ loginWithEmbedded: (config?: LoginConfig) => Promise<void>;
350
395
  /** Start login with redirect flow (navigates away from page) */
351
- loginWithRedirect: (config: LoginConfig, options?: StandaloneLoginOptions) => Promise<void>;
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 is passed at call time to allow different configurations for different
367
- * login methods (e.g., different redirectUri vs popupRedirectUri).
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
- * const config = {
376
- * clientId: process.env.NEXT_PUBLIC_IMMUTABLE_CLIENT_ID!,
377
- * redirectUri: `${process.env.NEXT_PUBLIC_BASE_URL}/callback`,
378
- * popupRedirectUri: `${process.env.NEXT_PUBLIC_BASE_URL}/callback/popup`,
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={() => loginWithPopup(config)} disabled={isLoggingIn}>
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: LoginConfig,
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 tokens = await rawLoginWithPopup(config, options);
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: LoginConfig): Promise<void> => {
540
+ const loginWithEmbedded = useCallback(async (config?: LoginConfig): Promise<void> => {
462
541
  setIsLoggingIn(true);
463
542
  setError(null);
464
543
 
465
544
  try {
466
- const tokens = await rawLoginWithEmbedded(config);
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: LoginConfig,
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
- await rawLoginWithRedirect(config, options);
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
- * @param config - Logout configuration with clientId and optional redirectUri
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: LogoutConfig) => Promise<void>;
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
- * const logoutConfig = {
548
- * clientId: process.env.NEXT_PUBLIC_IMMUTABLE_CLIENT_ID!,
549
- * logoutRedirectUri: process.env.NEXT_PUBLIC_BASE_URL!,
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 onClick={() => logout(logoutConfig)} disabled={isLoggingOut}>
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: LogoutConfig): Promise<void> => {
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(config);
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';