antaeus.keycloak.react 2.7.0 → 2.7.1

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
@@ -155,6 +155,27 @@ const { isEnabled, isConfigured, reason } = useKeycloakStatus();
155
155
 
156
156
  For mobile browsers and installed PWAs, token freshness is also checked when the app resumes from the background by default. Set `silentRenew.refreshOnResume` to `false` to disable this behavior.
157
157
 
158
+ ### Silent Renewal Fallback
159
+
160
+ `oidc-client-ts` always uses a stored refresh token when one is available. To permit iframe renewal only when no refresh token is stored, opt in explicitly:
161
+
162
+ ```tsx
163
+ <KeycloakProvider
164
+ authority="https://sso.example.com/realms/myrealm"
165
+ clientId="my-app"
166
+ config={{
167
+ silentRenew: {
168
+ allowIframeFallback: true,
169
+ transientRetry: { maxAttempts: 3, initialDelayMs: 1000, maxDelayMs: 30000 },
170
+ },
171
+ }}
172
+ >
173
+ <App />
174
+ </KeycloakProvider>
175
+ ```
176
+
177
+ The application must host `silentRenew.silentRedirectUri` (default `/silent-renew.html`), register its exact URL with the identity provider, and allow the identity provider to load in an iframe. Browser privacy settings and third-party-cookie policy can still prevent iframe renewal. `useRefreshToken` remains for source compatibility but cannot force iframe-only renewal.
178
+
158
179
  ## React Hooks
159
180
 
160
181
  ### useAuth()
package/dist/index.d.mts CHANGED
@@ -116,11 +116,23 @@ interface SilentRenewConfig {
116
116
  */
117
117
  loadingMessage?: string;
118
118
  /**
119
- * Enable using the refresh token grant instead of iframe silent renew when available.
120
- * Keeps long-lived sessions alive even after the app is closed.
119
+ * Legacy compatibility setting. oidc-client-ts automatically uses a stored
120
+ * refresh token when one is available.
121
121
  * @default true
122
122
  */
123
123
  useRefreshToken?: boolean;
124
+ /**
125
+ * Allow oidc-client-ts to use iframe silent renewal when no refresh token is stored.
126
+ * The application must host the configured silentRedirectUri and permit it in the
127
+ * identity provider redirect URI allowlist.
128
+ * @default false
129
+ */
130
+ allowIframeFallback?: boolean;
131
+ /**
132
+ * Retry policy for transient online silent-renew failures.
133
+ * Offline renewals wait for the browser's online event instead of using this policy.
134
+ */
135
+ transientRetry?: TransientRetryConfig;
124
136
  /**
125
137
  * Re-check token freshness when a suspended tab/PWA resumes.
126
138
  * Useful for mobile browsers and installed PWAs where timers pause in the background.
@@ -128,6 +140,14 @@ interface SilentRenewConfig {
128
140
  */
129
141
  refreshOnResume?: boolean;
130
142
  }
143
+ interface TransientRetryConfig {
144
+ /** Maximum automatic retries after a transient online failure. @default 3 */
145
+ maxAttempts?: number;
146
+ /** Delay before the first retry in milliseconds. @default 1000 */
147
+ initialDelayMs?: number;
148
+ /** Maximum exponential backoff delay in milliseconds. @default 30000 */
149
+ maxDelayMs?: number;
150
+ }
131
151
  /**
132
152
  * Event callbacks for authentication lifecycle
133
153
  */
package/dist/index.d.ts CHANGED
@@ -116,11 +116,23 @@ interface SilentRenewConfig {
116
116
  */
117
117
  loadingMessage?: string;
118
118
  /**
119
- * Enable using the refresh token grant instead of iframe silent renew when available.
120
- * Keeps long-lived sessions alive even after the app is closed.
119
+ * Legacy compatibility setting. oidc-client-ts automatically uses a stored
120
+ * refresh token when one is available.
121
121
  * @default true
122
122
  */
123
123
  useRefreshToken?: boolean;
124
+ /**
125
+ * Allow oidc-client-ts to use iframe silent renewal when no refresh token is stored.
126
+ * The application must host the configured silentRedirectUri and permit it in the
127
+ * identity provider redirect URI allowlist.
128
+ * @default false
129
+ */
130
+ allowIframeFallback?: boolean;
131
+ /**
132
+ * Retry policy for transient online silent-renew failures.
133
+ * Offline renewals wait for the browser's online event instead of using this policy.
134
+ */
135
+ transientRetry?: TransientRetryConfig;
124
136
  /**
125
137
  * Re-check token freshness when a suspended tab/PWA resumes.
126
138
  * Useful for mobile browsers and installed PWAs where timers pause in the background.
@@ -128,6 +140,14 @@ interface SilentRenewConfig {
128
140
  */
129
141
  refreshOnResume?: boolean;
130
142
  }
143
+ interface TransientRetryConfig {
144
+ /** Maximum automatic retries after a transient online failure. @default 3 */
145
+ maxAttempts?: number;
146
+ /** Delay before the first retry in milliseconds. @default 1000 */
147
+ initialDelayMs?: number;
148
+ /** Maximum exponential backoff delay in milliseconds. @default 30000 */
149
+ maxDelayMs?: number;
150
+ }
131
151
  /**
132
152
  * Event callbacks for authentication lifecycle
133
153
  */
package/dist/index.js CHANGED
@@ -37,6 +37,12 @@ var defaultConfig = {
37
37
  showLoadingIndicator: false,
38
38
  loadingMessage: "Refreshing session...",
39
39
  useRefreshToken: true,
40
+ allowIframeFallback: false,
41
+ transientRetry: {
42
+ maxAttempts: 3,
43
+ initialDelayMs: 1e3,
44
+ maxDelayMs: 3e4
45
+ },
40
46
  refreshOnResume: true
41
47
  },
42
48
  advanced: {
@@ -63,7 +69,6 @@ var KeycloakConfigBuilder = class {
63
69
  const config = this.mergeWithDefaults(userConfig);
64
70
  const storageType = config.advanced.storageType || "session";
65
71
  const storage = storageType === "local" ? window.localStorage : window.sessionStorage;
66
- const { useRefreshToken } = config.silentRenew;
67
72
  const oidcSettings = {
68
73
  authority: config.authority,
69
74
  client_id: config.clientId,
@@ -81,9 +86,6 @@ var KeycloakConfigBuilder = class {
81
86
  response_mode: "query"
82
87
  }
83
88
  };
84
- if (useRefreshToken !== void 0) {
85
- oidcSettings.useRefreshToken = useRefreshToken;
86
- }
87
89
  return oidcSettings;
88
90
  }
89
91
  /**
@@ -158,6 +160,9 @@ var useAuth = reactOidcContext.useAuth;
158
160
 
159
161
  // src/hooks/useTokenLifecycle.ts
160
162
  var STORAGE_KEY_PREFIX = "keycloak_first_login_";
163
+ var DEFAULT_MAX_TRANSIENT_RETRIES = 3;
164
+ var DEFAULT_RETRY_INITIAL_DELAY_MS = 1e3;
165
+ var DEFAULT_RETRY_MAX_DELAY_MS = 3e4;
161
166
  var isNavigatorOffline = () => typeof navigator !== "undefined" && "onLine" in navigator && navigator.onLine === false;
162
167
  var isTransientNetworkError = (error) => {
163
168
  const message = error.message?.toLowerCase?.() ?? "";
@@ -193,13 +198,34 @@ function useTokenLifecycle(options) {
193
198
  const [isRefreshing, setIsRefreshing] = react.useState(false);
194
199
  const [isRefreshTokenExpired, setIsRefreshTokenExpired] = react.useState(false);
195
200
  const isRefreshingRef = react.useRef(false);
201
+ const isMountedRef = react.useRef(true);
196
202
  const hasCheckedOnStartup = react.useRef(false);
197
- const supportsRefreshTokens = config.silentRenew?.useRefreshToken !== false;
203
+ const allowIframeFallback = config.silentRenew?.allowIframeFallback === true;
198
204
  const [pendingRetry, setPendingRetry] = react.useState(null);
199
205
  const deferredRefreshReasonRef = react.useRef(null);
206
+ const transientRetryAttemptsRef = react.useRef(0);
200
207
  const enqueueRetry = react.useCallback((reason) => {
201
208
  setPendingRetry((current) => current === reason ? current : reason);
202
209
  }, []);
210
+ const transientRetry = config.silentRenew?.transientRetry;
211
+ const maxTransientRetries = Math.max(
212
+ 0,
213
+ transientRetry?.maxAttempts ?? DEFAULT_MAX_TRANSIENT_RETRIES
214
+ );
215
+ const retryInitialDelayMs = Math.max(
216
+ 0,
217
+ transientRetry?.initialDelayMs ?? DEFAULT_RETRY_INITIAL_DELAY_MS
218
+ );
219
+ const retryMaxDelayMs = Math.max(
220
+ retryInitialDelayMs,
221
+ transientRetry?.maxDelayMs ?? DEFAULT_RETRY_MAX_DELAY_MS
222
+ );
223
+ react.useEffect(() => {
224
+ isMountedRef.current = true;
225
+ return () => {
226
+ isMountedRef.current = false;
227
+ };
228
+ }, []);
203
229
  const log = react.useCallback(
204
230
  (message, ...args) => {
205
231
  if (debug) {
@@ -269,6 +295,7 @@ function useTokenLifecycle(options) {
269
295
  log("Starting token refresh");
270
296
  await auth.signinSilent();
271
297
  log("Token refresh successful");
298
+ transientRetryAttemptsRef.current = 0;
272
299
  onRefreshSuccess?.();
273
300
  } catch (error) {
274
301
  const err = error instanceof Error ? error : new Error(String(error));
@@ -291,7 +318,15 @@ function useTokenLifecycle(options) {
291
318
  deferredRefreshReasonRef.current = reason;
292
319
  if (reason === "offline") {
293
320
  log("Token refresh deferred because device is offline");
321
+ transientRetryAttemptsRef.current = 0;
294
322
  } else {
323
+ if (transientRetryAttemptsRef.current >= maxTransientRetries) {
324
+ deferredRefreshReasonRef.current = null;
325
+ log("Token refresh retry limit reached:", err.message);
326
+ onRefreshError?.(err);
327
+ return;
328
+ }
329
+ transientRetryAttemptsRef.current += 1;
295
330
  log(
296
331
  "Token refresh failed due to transient network issue, will retry automatically:",
297
332
  err.message
@@ -317,7 +352,8 @@ function useTokenLifecycle(options) {
317
352
  onRefreshTokenExpired,
318
353
  enqueueRetry,
319
354
  getStorageKey,
320
- log
355
+ log,
356
+ maxTransientRetries
321
357
  ]);
322
358
  const shouldRefreshOnResume = react.useCallback((reason) => {
323
359
  const silentRenewEnabled = config.silentRenew?.enabled !== false;
@@ -334,7 +370,7 @@ function useTokenLifecycle(options) {
334
370
  if (!auth.user) {
335
371
  return false;
336
372
  }
337
- if (supportsRefreshTokens && !hasRefreshToken) {
373
+ if (!hasRefreshToken && !allowIframeFallback) {
338
374
  log("Skipping resume token check because no refresh token is available:", reason);
339
375
  return false;
340
376
  }
@@ -348,7 +384,7 @@ function useTokenLifecycle(options) {
348
384
  config.silentRenew?.enabled,
349
385
  config.silentRenew?.refreshOnResume,
350
386
  config.silentRenew?.refreshStrategy,
351
- supportsRefreshTokens,
387
+ allowIframeFallback,
352
388
  log
353
389
  ]);
354
390
  const checkAndRefreshTokens = react.useCallback(async () => {
@@ -421,10 +457,10 @@ function useTokenLifecycle(options) {
421
457
  }
422
458
  let cancelled = false;
423
459
  const executeRetry = () => {
424
- if (cancelled) {
460
+ if (!isMountedRef.current) {
425
461
  return;
426
462
  }
427
- if (isRefreshing) {
463
+ if (isRefreshingRef.current) {
428
464
  log("Skipping scheduled token refresh retry because a refresh is already in progress");
429
465
  return;
430
466
  }
@@ -448,19 +484,23 @@ function useTokenLifecycle(options) {
448
484
  window.removeEventListener("online", handleOnline);
449
485
  };
450
486
  }
487
+ const retryDelay = Math.min(
488
+ retryInitialDelayMs * 2 ** Math.max(0, transientRetryAttemptsRef.current - 1),
489
+ retryMaxDelayMs
490
+ );
451
491
  const timeoutId = window.setTimeout(() => {
452
492
  if (cancelled) {
453
493
  return;
454
494
  }
455
495
  log("Retrying token refresh after transient network failure delay");
456
496
  setPendingRetry(null);
457
- executeRetry();
458
- }, 5e3);
497
+ window.setTimeout(executeRetry, 0);
498
+ }, retryDelay);
459
499
  return () => {
460
500
  cancelled = true;
461
501
  window.clearTimeout(timeoutId);
462
502
  };
463
- }, [pendingRetry, refreshToken, isRefreshing, log]);
503
+ }, [pendingRetry, refreshToken, log, retryInitialDelayMs, retryMaxDelayMs]);
464
504
  react.useEffect(() => {
465
505
  const strategy = config.silentRenew?.refreshStrategy || "both";
466
506
  const shouldCheckOnStartup = strategy === "startup" || strategy === "both";
@@ -472,10 +512,7 @@ function useTokenLifecycle(options) {
472
512
  return;
473
513
  }
474
514
  if (!auth.isAuthenticated) {
475
- if (!supportsRefreshTokens) {
476
- return;
477
- }
478
- if (!auth.user || !hasRefreshToken) {
515
+ if (!auth.user || !hasRefreshToken && !allowIframeFallback) {
479
516
  return;
480
517
  }
481
518
  }
@@ -484,7 +521,7 @@ function useTokenLifecycle(options) {
484
521
  "Running startup token check with strategy:",
485
522
  strategy,
486
523
  "using refresh token:",
487
- !auth.isAuthenticated && supportsRefreshTokens
524
+ !auth.isAuthenticated && hasRefreshToken
488
525
  );
489
526
  checkAndRefreshTokens().catch((error) => {
490
527
  log("Startup token check failed:", error);
@@ -494,7 +531,7 @@ function useTokenLifecycle(options) {
494
531
  auth.isLoading,
495
532
  auth.user,
496
533
  config.silentRenew?.refreshStrategy,
497
- supportsRefreshTokens,
534
+ allowIframeFallback,
498
535
  checkAndRefreshTokens,
499
536
  log
500
537
  ]);
@@ -534,11 +571,11 @@ function useTokenLifecycle(options) {
534
571
  if (hasCheckedOnStartup.current) {
535
572
  const hasUser = Boolean(auth.user);
536
573
  const hasRefreshToken = Boolean(auth.user?.refresh_token);
537
- if (!auth.isAuthenticated && (!supportsRefreshTokens || !hasUser || !hasRefreshToken)) {
574
+ if (!auth.isAuthenticated && (!hasUser || !hasRefreshToken && !allowIframeFallback)) {
538
575
  hasCheckedOnStartup.current = false;
539
576
  }
540
577
  }
541
- }, [auth.isAuthenticated, auth.user, supportsRefreshTokens]);
578
+ }, [auth.isAuthenticated, auth.user, allowIframeFallback]);
542
579
  react.useEffect(() => {
543
580
  if (auth.user && !auth.isLoading) {
544
581
  storeFirstLoginTime();