antaeus.keycloak.react 2.1.2 → 2.2.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
@@ -187,7 +187,7 @@ interface KeycloakConfig {
187
187
  authority: string; // Required
188
188
  clientId: string; // Required
189
189
  scope?: string; // Default: 'openid profile email offline_access'
190
-
190
+
191
191
  features?: {
192
192
  autoRefresh?: boolean; // Default: true
193
193
  sessionMonitor?: boolean; // Default: true
@@ -201,6 +201,13 @@ interface KeycloakConfig {
201
201
  showLoadingIndicator?: boolean; // Default: false
202
202
  };
203
203
 
204
+ advanced?: {
205
+ storageType?: 'session' | 'local'; // Default: 'session'
206
+ responseType?: string; // Default: 'code'
207
+ codeChallengeMethod?: string; // Default: 'S256'
208
+ loadUserInfo?: boolean; // Default: true
209
+ };
210
+
204
211
  events?: {
205
212
  onLogin?: (user: User) => void;
206
213
  onLogout?: () => void;
@@ -210,6 +217,35 @@ interface KeycloakConfig {
210
217
  }
211
218
  ```
212
219
 
220
+ ### Storage Options
221
+
222
+ By default, the library uses **sessionStorage** which means tokens are cleared when the browser tab is closed. To persist sessions across browser tabs and restarts, use **localStorage**:
223
+
224
+ ```tsx
225
+ <KeycloakProvider
226
+ authority="https://sso.example.com/realms/myrealm"
227
+ clientId="my-app"
228
+ config={{
229
+ advanced: {
230
+ storageType: 'local' // Persist across tabs/restarts
231
+ }
232
+ }}
233
+ >
234
+ <App />
235
+ </KeycloakProvider>
236
+ ```
237
+
238
+ **Storage Comparison:**
239
+
240
+ | Storage Type | Persistence | Use Case |
241
+ |--------------|-------------|----------|
242
+ | `session` (default) | Cleared when tab closes | Higher security, temporary sessions |
243
+ | `local` | Persists across tabs/restarts | Better UX, permanent sessions |
244
+
245
+ **Security Considerations:**
246
+ - `sessionStorage`: More secure as tokens are cleared when tab closes
247
+ - `localStorage`: More convenient but tokens persist longer (use `maxRefreshTokenLifetime` to limit)
248
+
213
249
  ## What's New in v2.0.0
214
250
 
215
251
  - Zero-config setup
@@ -219,10 +255,144 @@ interface KeycloakConfig {
219
255
  - Auto-included SessionMonitor
220
256
  - Built-in error notifications
221
257
 
258
+ ## Token Refresh Behavior & Edge Cases
259
+
260
+ ### Understanding 401 vs 403 Errors
261
+
262
+ The library handles authentication errors intelligently:
263
+
264
+ **401 Unauthorized** - Token expired or invalid
265
+ - Automatically triggers silent token refresh
266
+ - Retries the failed request once with new token
267
+ - Protected by race condition prevention (1-second cooldown)
268
+ - Only retries when `retryOn401: true` (default)
269
+
270
+ **403 Forbidden** - Valid token, insufficient permissions
271
+ - Does NOT trigger token refresh (by design)
272
+ - Returns the 403 error to your application
273
+ - Indicates a permissions issue, not an auth issue
274
+
275
+ ```tsx
276
+ // Example: API call with 401 handling
277
+ const { fetchJson } = useProtectedFetch({
278
+ baseUrl: 'https://api.example.com',
279
+ retryOn401: true, // Default: automatically retry after refresh
280
+ });
281
+
282
+ try {
283
+ const data = await fetchJson('/api/data');
284
+ } catch (error) {
285
+ // 401: Already handled automatically
286
+ // 403: User lacks permissions - handle in your app
287
+ // Other errors: Network, server errors, etc.
288
+ }
289
+ ```
290
+
291
+ ### Race Condition Protection
292
+
293
+ Multiple concurrent API calls won't cause multiple token refresh attempts:
294
+
295
+ - First 401 triggers refresh, subsequent calls wait
296
+ - 1-second cooldown prevents rapid retry loops
297
+ - Refresh state shared across all API calls
298
+ - Fresh token always used after refresh completes
299
+
300
+ ### Refresh Token Expiration
301
+
302
+ If your refresh token expires (exceeds `maxRefreshTokenLifetime`):
303
+
304
+ 1. Library detects expiration (checked every 5 minutes)
305
+ 2. User sees "Session Expired" screen
306
+ 3. Local storage is cleaned up
307
+ 4. User must sign in again
308
+
309
+ ```tsx
310
+ // Configure max lifetime (default: 30 days)
311
+ <KeycloakProvider
312
+ authority="..."
313
+ clientId="my-app"
314
+ config={{
315
+ silentRenew: {
316
+ maxRefreshTokenLifetime: 604800, // 7 days
317
+ },
318
+ events: {
319
+ onRefreshTokenExpired: () => {
320
+ console.log('Session exceeded max lifetime');
321
+ // Optional: show custom message
322
+ }
323
+ }
324
+ }}
325
+ />
326
+ ```
327
+
328
+ ### Startup Token Refresh
329
+
330
+ When your app loads with an existing session:
331
+
332
+ - **Strategy: 'startup'** - Checks token immediately, refreshes if needed
333
+ - **Strategy: 'on-demand'** - Waits for first API call to fail
334
+ - **Strategy: 'both'** (recommended) - Proactive startup check + 401 retry
335
+
336
+ ```tsx
337
+ // Recommended for best UX
338
+ config={{
339
+ silentRenew: {
340
+ refreshStrategy: 'both',
341
+ thresholdSeconds: 300, // Refresh when <5min remaining
342
+ }
343
+ }}
344
+ ```
345
+
346
+ ### Edge Case: Refresh Fails During Startup
347
+
348
+ If token refresh fails when the app loads:
349
+
350
+ - `onRefreshError` callback is triggered
351
+ - Error is logged (if debug enabled)
352
+ - App continues to render (non-blocking)
353
+ - User can manually sign in
354
+
355
+ ```tsx
356
+ <KeycloakProvider
357
+ authority="..."
358
+ clientId="my-app"
359
+ debug={true} // Enable console logging
360
+ config={{
361
+ events: {
362
+ onSilentRenewError: (error) => {
363
+ console.error('Token refresh failed:', error);
364
+ // Optional: show notification to user
365
+ }
366
+ }
367
+ }}
368
+ />
369
+ ```
370
+
371
+ ### Preventing Infinite Retry Loops
372
+
373
+ The library has multiple safeguards:
374
+
375
+ 1. Only retries each failed request **once**
376
+ 2. Uses `refreshInProgressRef` flag to prevent concurrent refreshes
377
+ 3. 1-second cooldown between refresh attempts
378
+ 4. Refresh token expiration check prevents dead-end retries
379
+
380
+ ### Network Offline Scenarios
381
+
382
+ When network is unavailable:
383
+
384
+ - Token refresh will fail (network error)
385
+ - Original API request fails
386
+ - `onRefreshError` / `onSilentRenewError` callbacks triggered
387
+ - No infinite retry loop (protected by cooldown)
388
+ - User can retry manually when online
389
+
222
390
  ## Troubleshooting
223
391
 
224
392
  ### 401 errors after app reopens?
225
393
 
394
+ Enable startup refresh strategy:
395
+
226
396
  ```tsx
227
397
  config={{ silentRenew: { refreshStrategy: 'both' } }}
228
398
  ```
@@ -235,7 +405,45 @@ Make sure offline_access scope is included:
235
405
  config={{ scope: 'openid profile email offline_access' }}
236
406
  ```
237
407
 
408
+ ### Getting 403 errors on API calls?
409
+
410
+ This is a permissions issue, not authentication:
411
+
412
+ ```tsx
413
+ // 403 means valid token but insufficient permissions
414
+ // Check user roles/permissions in Keycloak
415
+ const { hasRole } = useRoles();
416
+
417
+ if (!hasRole('admin')) {
418
+ return <div>Access denied</div>;
419
+ }
420
+ ```
421
+
422
+ ### Token refreshing too frequently?
423
+
424
+ Increase the threshold:
425
+
426
+ ```tsx
427
+ config={{
428
+ silentRenew: {
429
+ thresholdSeconds: 600, // Refresh when <10min remaining (default: 5min)
430
+ }
431
+ }}
432
+ ```
433
+
434
+ ### Want to disable automatic retry on 401?
435
+
436
+ ```tsx
437
+ const { fetchJson } = useProtectedFetch({
438
+ baseUrl: 'https://api.example.com',
439
+ retryOn401: false, // Disable automatic retry
440
+ onUnauthorized: () => {
441
+ console.log('Got 401, handle manually');
442
+ }
443
+ });
444
+ ```
445
+
238
446
  ## License
239
447
 
240
- MIT � Saeed Aghdam
448
+ MIT � Saeed Aghdam
241
449
 
package/dist/index.d.mts CHANGED
@@ -163,6 +163,13 @@ interface AdvancedOidcOptions {
163
163
  * @default false
164
164
  */
165
165
  automaticSilentSignIn?: boolean;
166
+ /**
167
+ * Storage mechanism for tokens and user data
168
+ * - 'session': Use sessionStorage (cleared when tab closes)
169
+ * - 'local': Use localStorage (persists across browser sessions)
170
+ * @default 'session'
171
+ */
172
+ storageType?: 'session' | 'local';
166
173
  }
167
174
 
168
175
  /**
@@ -319,6 +326,21 @@ type KeycloakProviderProps = KeycloakProviderZeroConfigProps | KeycloakProviderF
319
326
  * </KeycloakProvider>
320
327
  * ```
321
328
  *
329
+ * @example Using localStorage to persist sessions across tabs/windows
330
+ * ```tsx
331
+ * <KeycloakProvider
332
+ * authority="https://sso.example.com/realms/myrealm"
333
+ * clientId="my-app"
334
+ * config={{
335
+ * advanced: {
336
+ * storageType: 'local', // Use localStorage instead of sessionStorage
337
+ * }
338
+ * }}
339
+ * >
340
+ * <YourApp />
341
+ * </KeycloakProvider>
342
+ * ```
343
+ *
322
344
  * ## Full-Config Mode (Advanced)
323
345
  *
324
346
  * Complete control over all configuration options.
package/dist/index.d.ts CHANGED
@@ -163,6 +163,13 @@ interface AdvancedOidcOptions {
163
163
  * @default false
164
164
  */
165
165
  automaticSilentSignIn?: boolean;
166
+ /**
167
+ * Storage mechanism for tokens and user data
168
+ * - 'session': Use sessionStorage (cleared when tab closes)
169
+ * - 'local': Use localStorage (persists across browser sessions)
170
+ * @default 'session'
171
+ */
172
+ storageType?: 'session' | 'local';
166
173
  }
167
174
 
168
175
  /**
@@ -319,6 +326,21 @@ type KeycloakProviderProps = KeycloakProviderZeroConfigProps | KeycloakProviderF
319
326
  * </KeycloakProvider>
320
327
  * ```
321
328
  *
329
+ * @example Using localStorage to persist sessions across tabs/windows
330
+ * ```tsx
331
+ * <KeycloakProvider
332
+ * authority="https://sso.example.com/realms/myrealm"
333
+ * clientId="my-app"
334
+ * config={{
335
+ * advanced: {
336
+ * storageType: 'local', // Use localStorage instead of sessionStorage
337
+ * }
338
+ * }}
339
+ * >
340
+ * <YourApp />
341
+ * </KeycloakProvider>
342
+ * ```
343
+ *
322
344
  * ## Full-Config Mode (Advanced)
323
345
  *
324
346
  * Complete control over all configuration options.
package/dist/index.js CHANGED
@@ -2,10 +2,10 @@
2
2
 
3
3
  var react = require('react');
4
4
  var reactOidcContext = require('react-oidc-context');
5
+ var oidcClientTs = require('oidc-client-ts');
5
6
  var jsxRuntime = require('react/jsx-runtime');
6
7
  var reactRouterDom = require('react-router-dom');
7
8
  var qrcode_react = require('qrcode.react');
8
- var oidcClientTs = require('oidc-client-ts');
9
9
 
10
10
  var _documentCurrentScript = typeof document !== 'undefined' ? document.currentScript : null;
11
11
  // src/components/KeycloakProvider.tsx
@@ -36,7 +36,8 @@ var defaultConfig = {
36
36
  responseType: "code",
37
37
  codeChallengeMethod: "S256",
38
38
  loadUserInfo: true,
39
- automaticSilentSignIn: false
39
+ automaticSilentSignIn: false,
40
+ storageType: "session"
40
41
  },
41
42
  events: {}
42
43
  };
@@ -53,6 +54,8 @@ var KeycloakConfigBuilder = class {
53
54
  static build(userConfig) {
54
55
  this.validate(userConfig);
55
56
  const config = this.mergeWithDefaults(userConfig);
57
+ const storageType = config.advanced.storageType || "session";
58
+ const storage = storageType === "local" ? window.localStorage : window.sessionStorage;
56
59
  const oidcSettings = {
57
60
  authority: config.authority,
58
61
  client_id: config.clientId,
@@ -63,6 +66,8 @@ var KeycloakConfigBuilder = class {
63
66
  automaticSilentRenew: config.silentRenew.enabled,
64
67
  loadUserInfo: config.advanced.loadUserInfo,
65
68
  silent_redirect_uri: config.silentRenew.silentRedirectUri,
69
+ // Configure storage for user data and tokens
70
+ userStore: new oidcClientTs.WebStorageStateStore({ store: storage }),
66
71
  // Add PKCE support (required for public clients)
67
72
  ...config.advanced.codeChallengeMethod && {
68
73
  response_mode: "query"
@@ -1485,6 +1490,7 @@ var DeviceLogin = ({
1485
1490
  function useProtectedFetch(options = {}) {
1486
1491
  const auth = useAuth();
1487
1492
  const refreshInProgressRef = react.useRef(false);
1493
+ const { user, isLoading, signinSilent } = auth;
1488
1494
  const {
1489
1495
  baseUrl = "",
1490
1496
  onUnauthorized,
@@ -1503,11 +1509,11 @@ function useProtectedFetch(options = {}) {
1503
1509
  [baseUrl]
1504
1510
  );
1505
1511
  const getAuthHeader = () => {
1506
- if (!auth.user?.access_token) {
1512
+ if (!user?.access_token) {
1507
1513
  return {};
1508
1514
  }
1509
1515
  return {
1510
- Authorization: `Bearer ${auth.user.access_token}`
1516
+ Authorization: `Bearer ${user.access_token}`
1511
1517
  };
1512
1518
  };
1513
1519
  const buildHeaders = react.useCallback(
@@ -1534,10 +1540,10 @@ function useProtectedFetch(options = {}) {
1534
1540
  });
1535
1541
  if (response.status === 401) {
1536
1542
  onUnauthorized?.();
1537
- if (retryOn401 && auth.user && !refreshInProgressRef.current) {
1543
+ if (retryOn401 && user && !refreshInProgressRef.current) {
1538
1544
  refreshInProgressRef.current = true;
1539
1545
  try {
1540
- await auth.signinSilent();
1546
+ await signinSilent();
1541
1547
  const newHeaders = buildHeaders(init?.headers);
1542
1548
  const retryResponse = await fetch(fullUrl, {
1543
1549
  ...init,
@@ -1555,7 +1561,7 @@ function useProtectedFetch(options = {}) {
1555
1561
  }
1556
1562
  return response;
1557
1563
  },
1558
- [buildUrl, buildHeaders, onUnauthorized, retryOn401, auth]
1564
+ [buildUrl, buildHeaders, onUnauthorized, retryOn401, user, signinSilent]
1559
1565
  );
1560
1566
  const fetchJson = react.useCallback(
1561
1567
  async (url, init) => {
@@ -1574,7 +1580,7 @@ function useProtectedFetch(options = {}) {
1574
1580
  return {
1575
1581
  fetchJson,
1576
1582
  fetch: protectedFetch,
1577
- isLoading: auth.isLoading
1583
+ isLoading
1578
1584
  };
1579
1585
  }
1580
1586