antaeus.keycloak.react 1.0.7 → 2.1.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/dist/index.js CHANGED
@@ -17,14 +17,20 @@ var defaultConfig = {
17
17
  scope: "openid profile email offline_access",
18
18
  features: {
19
19
  deviceFlow: false,
20
- sessionMonitor: false,
20
+ sessionMonitor: true,
21
21
  autoRefresh: true
22
22
  },
23
23
  silentRenew: {
24
24
  enabled: true,
25
25
  silentRedirectUri: typeof window !== "undefined" ? window.location.origin + "/silent-renew.html" : "/silent-renew.html",
26
- thresholdSeconds: 300
26
+ thresholdSeconds: 300,
27
27
  // 5 minutes
28
+ refreshStrategy: "both",
29
+ // Check on startup AND retry on 401
30
+ maxRefreshTokenLifetime: 2592e3,
31
+ // 30 days in seconds
32
+ showLoadingIndicator: false,
33
+ loadingMessage: "Refreshing session..."
28
34
  },
29
35
  advanced: {
30
36
  responseType: "code",
@@ -56,7 +62,11 @@ var KeycloakConfigBuilder = class {
56
62
  scope: config.scope,
57
63
  automaticSilentRenew: config.silentRenew.enabled,
58
64
  loadUserInfo: config.advanced.loadUserInfo,
59
- silent_redirect_uri: config.silentRenew.silentRedirectUri
65
+ silent_redirect_uri: config.silentRenew.silentRedirectUri,
66
+ // Add PKCE support (required for public clients)
67
+ ...config.advanced.codeChallengeMethod && {
68
+ response_mode: "query"
69
+ }
60
70
  };
61
71
  return oidcSettings;
62
72
  }
@@ -128,16 +138,711 @@ function getConfigFromEnv() {
128
138
  }
129
139
  };
130
140
  }
131
- var KeycloakProvider = ({
141
+ var useAuth = reactOidcContext.useAuth;
142
+
143
+ // src/hooks/useTokenLifecycle.ts
144
+ var STORAGE_KEY_PREFIX = "keycloak_first_login_";
145
+ function useTokenLifecycle(options) {
146
+ const {
147
+ config,
148
+ debug = false,
149
+ onRefreshStart,
150
+ onRefreshSuccess,
151
+ onRefreshError,
152
+ onRefreshTokenExpired
153
+ } = options;
154
+ const auth = useAuth();
155
+ const [isRefreshing, setIsRefreshing] = react.useState(false);
156
+ const [isRefreshTokenExpired, setIsRefreshTokenExpired] = react.useState(false);
157
+ const hasCheckedOnStartup = react.useRef(false);
158
+ const log = react.useCallback(
159
+ (message, ...args) => {
160
+ if (debug) {
161
+ console.log("[TokenLifecycle]", message, ...args);
162
+ }
163
+ },
164
+ [debug]
165
+ );
166
+ const getStorageKey = react.useCallback(() => {
167
+ const authority = config.authority;
168
+ const clientId = config.clientId;
169
+ return `${STORAGE_KEY_PREFIX}${authority}_${clientId}`;
170
+ }, [config.authority, config.clientId]);
171
+ const storeFirstLoginTime = react.useCallback(() => {
172
+ const key = getStorageKey();
173
+ const existingTime = localStorage.getItem(key);
174
+ if (!existingTime) {
175
+ const now = Date.now();
176
+ localStorage.setItem(key, now.toString());
177
+ log("Stored first login time:", new Date(now).toISOString());
178
+ }
179
+ }, [getStorageKey, log]);
180
+ const checkRefreshTokenLifetime = react.useCallback(() => {
181
+ const maxLifetime = config.silentRenew?.maxRefreshTokenLifetime;
182
+ if (!maxLifetime || maxLifetime <= 0) {
183
+ return false;
184
+ }
185
+ const key = getStorageKey();
186
+ const firstLoginStr = localStorage.getItem(key);
187
+ if (!firstLoginStr) {
188
+ storeFirstLoginTime();
189
+ return false;
190
+ }
191
+ const firstLoginTime = parseInt(firstLoginStr, 10);
192
+ const now = Date.now();
193
+ const elapsedSeconds = (now - firstLoginTime) / 1e3;
194
+ log("Refresh token lifetime check:", {
195
+ firstLogin: new Date(firstLoginTime).toISOString(),
196
+ elapsedSeconds,
197
+ maxLifetime,
198
+ isExpired: elapsedSeconds >= maxLifetime
199
+ });
200
+ return elapsedSeconds >= maxLifetime;
201
+ }, [config.silentRenew?.maxRefreshTokenLifetime, getStorageKey, log, storeFirstLoginTime]);
202
+ const refreshToken = react.useCallback(async () => {
203
+ if (isRefreshing) {
204
+ log("Refresh already in progress, skipping");
205
+ return;
206
+ }
207
+ if (checkRefreshTokenLifetime()) {
208
+ log("Refresh token exceeded max lifetime, cannot refresh");
209
+ setIsRefreshTokenExpired(true);
210
+ onRefreshTokenExpired?.();
211
+ return;
212
+ }
213
+ try {
214
+ setIsRefreshing(true);
215
+ onRefreshStart?.();
216
+ log("Starting token refresh");
217
+ await auth.signinSilent();
218
+ log("Token refresh successful");
219
+ onRefreshSuccess?.();
220
+ } catch (error) {
221
+ const err = error instanceof Error ? error : new Error(String(error));
222
+ log("Token refresh failed:", err.message);
223
+ onRefreshError?.(err);
224
+ throw err;
225
+ } finally {
226
+ setIsRefreshing(false);
227
+ }
228
+ }, [
229
+ isRefreshing,
230
+ auth,
231
+ checkRefreshTokenLifetime,
232
+ onRefreshStart,
233
+ onRefreshSuccess,
234
+ onRefreshError,
235
+ onRefreshTokenExpired,
236
+ log
237
+ ]);
238
+ const checkAndRefreshTokens = react.useCallback(async () => {
239
+ if (!auth.user) {
240
+ log("No user session, skipping token check");
241
+ return false;
242
+ }
243
+ if (checkRefreshTokenLifetime()) {
244
+ log("Refresh token exceeded max lifetime");
245
+ setIsRefreshTokenExpired(true);
246
+ onRefreshTokenExpired?.();
247
+ const key = getStorageKey();
248
+ localStorage.removeItem(key);
249
+ try {
250
+ await auth.removeUser();
251
+ } catch (error) {
252
+ log("Error removing user after max lifetime:", error);
253
+ }
254
+ return false;
255
+ }
256
+ const expiresAt = auth.user.expires_at;
257
+ if (!expiresAt) {
258
+ log("No expiration time found, skipping token check");
259
+ return false;
260
+ }
261
+ const now = Math.floor(Date.now() / 1e3);
262
+ const threshold = config.silentRenew?.thresholdSeconds || 300;
263
+ const timeUntilExpiry = expiresAt - now;
264
+ log("Token expiration check:", {
265
+ expiresAt: new Date(expiresAt * 1e3).toISOString(),
266
+ now: new Date(now * 1e3).toISOString(),
267
+ timeUntilExpiry,
268
+ threshold,
269
+ needsRefresh: timeUntilExpiry <= threshold
270
+ });
271
+ if (timeUntilExpiry <= threshold) {
272
+ try {
273
+ await refreshToken();
274
+ return true;
275
+ } catch (error) {
276
+ log("Failed to refresh tokens:", error);
277
+ return false;
278
+ }
279
+ }
280
+ log("Tokens are still valid, no refresh needed");
281
+ return false;
282
+ }, [
283
+ auth,
284
+ config.silentRenew?.thresholdSeconds,
285
+ checkRefreshTokenLifetime,
286
+ refreshToken,
287
+ onRefreshTokenExpired,
288
+ getStorageKey,
289
+ log
290
+ ]);
291
+ react.useEffect(() => {
292
+ const strategy = config.silentRenew?.refreshStrategy || "both";
293
+ const shouldCheckOnStartup = strategy === "startup" || strategy === "both";
294
+ if (!shouldCheckOnStartup || hasCheckedOnStartup.current) {
295
+ return;
296
+ }
297
+ if (!auth.isAuthenticated || auth.isLoading) {
298
+ return;
299
+ }
300
+ hasCheckedOnStartup.current = true;
301
+ log("Running startup token check with strategy:", strategy);
302
+ checkAndRefreshTokens().catch((error) => {
303
+ log("Startup token check failed:", error);
304
+ });
305
+ }, [
306
+ auth.isAuthenticated,
307
+ auth.isLoading,
308
+ config.silentRenew?.refreshStrategy,
309
+ checkAndRefreshTokens,
310
+ log
311
+ ]);
312
+ react.useEffect(() => {
313
+ if (auth.user && !auth.isLoading) {
314
+ storeFirstLoginTime();
315
+ }
316
+ }, [auth.user, auth.isLoading, storeFirstLoginTime]);
317
+ react.useEffect(() => {
318
+ if (!auth.isAuthenticated || auth.isLoading) {
319
+ return;
320
+ }
321
+ const maxLifetime = config.silentRenew?.maxRefreshTokenLifetime;
322
+ if (!maxLifetime || maxLifetime <= 0) {
323
+ return;
324
+ }
325
+ const intervalId = setInterval(() => {
326
+ if (checkRefreshTokenLifetime()) {
327
+ log("Refresh token expired during periodic check");
328
+ setIsRefreshTokenExpired(true);
329
+ onRefreshTokenExpired?.();
330
+ const key = getStorageKey();
331
+ localStorage.removeItem(key);
332
+ auth.removeUser().catch((error) => {
333
+ log("Error removing user during periodic check:", error);
334
+ });
335
+ }
336
+ }, 5 * 60 * 1e3);
337
+ return () => clearInterval(intervalId);
338
+ }, [
339
+ auth,
340
+ config.silentRenew?.maxRefreshTokenLifetime,
341
+ checkRefreshTokenLifetime,
342
+ onRefreshTokenExpired,
343
+ getStorageKey,
344
+ log
345
+ ]);
346
+ return {
347
+ isRefreshing,
348
+ isRefreshTokenExpired,
349
+ refreshToken,
350
+ checkAndRefreshTokens
351
+ };
352
+ }
353
+ var TokenLifecycleManager = ({
132
354
  config,
133
355
  children,
134
356
  debug = false
135
357
  }) => {
358
+ const [showLoading, setShowLoading] = react.useState(false);
359
+ const handleRefreshStart = react.useCallback(() => {
360
+ if (config.silentRenew?.showLoadingIndicator) {
361
+ setShowLoading(true);
362
+ }
363
+ }, [config.silentRenew?.showLoadingIndicator]);
364
+ const handleRefreshSuccess = react.useCallback(() => {
365
+ setShowLoading(false);
366
+ }, []);
367
+ const handleRefreshError = react.useCallback((error) => {
368
+ setShowLoading(false);
369
+ if (debug) {
370
+ console.error("[TokenLifecycleManager] Token refresh failed:", error);
371
+ }
372
+ config.events?.onSilentRenewError?.(error);
373
+ }, [config.events, debug]);
374
+ const handleRefreshTokenExpired = react.useCallback(() => {
375
+ setShowLoading(false);
376
+ if (debug) {
377
+ console.warn("[TokenLifecycleManager] Refresh token exceeded max lifetime");
378
+ }
379
+ config.events?.onTokenExpired?.();
380
+ }, [config.events, debug]);
381
+ const { isRefreshing, isRefreshTokenExpired } = useTokenLifecycle({
382
+ config,
383
+ debug,
384
+ onRefreshStart: handleRefreshStart,
385
+ onRefreshSuccess: handleRefreshSuccess,
386
+ onRefreshError: handleRefreshError,
387
+ onRefreshTokenExpired: handleRefreshTokenExpired
388
+ });
389
+ if (isRefreshTokenExpired) {
390
+ return /* @__PURE__ */ jsxRuntime.jsx(
391
+ "div",
392
+ {
393
+ style: {
394
+ display: "flex",
395
+ flexDirection: "column",
396
+ alignItems: "center",
397
+ justifyContent: "center",
398
+ height: "100vh",
399
+ fontFamily: "system-ui, -apple-system, sans-serif",
400
+ backgroundColor: "#f5f5f5",
401
+ padding: "20px",
402
+ textAlign: "center"
403
+ },
404
+ children: /* @__PURE__ */ jsxRuntime.jsxs(
405
+ "div",
406
+ {
407
+ style: {
408
+ backgroundColor: "white",
409
+ padding: "40px",
410
+ borderRadius: "8px",
411
+ boxShadow: "0 2px 8px rgba(0,0,0,0.1)",
412
+ maxWidth: "400px"
413
+ },
414
+ children: [
415
+ /* @__PURE__ */ jsxRuntime.jsx("h2", { style: { margin: "0 0 16px 0", color: "#333" }, children: "Session Expired" }),
416
+ /* @__PURE__ */ jsxRuntime.jsx("p", { style: { margin: "0 0 24px 0", color: "#666" }, children: "Your session has been inactive for too long. Please sign in again to continue." }),
417
+ /* @__PURE__ */ jsxRuntime.jsx(
418
+ "button",
419
+ {
420
+ onClick: () => window.location.reload(),
421
+ style: {
422
+ padding: "12px 24px",
423
+ backgroundColor: "#007bff",
424
+ color: "white",
425
+ border: "none",
426
+ borderRadius: "4px",
427
+ fontSize: "16px",
428
+ cursor: "pointer",
429
+ fontWeight: "500"
430
+ },
431
+ children: "Sign In Again"
432
+ }
433
+ )
434
+ ]
435
+ }
436
+ )
437
+ }
438
+ );
439
+ }
440
+ if (showLoading && isRefreshing) {
441
+ const message = config.silentRenew?.loadingMessage || "Refreshing session...";
442
+ return /* @__PURE__ */ jsxRuntime.jsxs(
443
+ "div",
444
+ {
445
+ style: {
446
+ display: "flex",
447
+ flexDirection: "column",
448
+ alignItems: "center",
449
+ justifyContent: "center",
450
+ height: "100vh",
451
+ fontFamily: "system-ui, -apple-system, sans-serif",
452
+ backgroundColor: "#f5f5f5"
453
+ },
454
+ children: [
455
+ /* @__PURE__ */ jsxRuntime.jsxs(
456
+ "div",
457
+ {
458
+ style: {
459
+ display: "flex",
460
+ flexDirection: "column",
461
+ alignItems: "center",
462
+ gap: "16px"
463
+ },
464
+ children: [
465
+ /* @__PURE__ */ jsxRuntime.jsx(
466
+ "div",
467
+ {
468
+ style: {
469
+ width: "40px",
470
+ height: "40px",
471
+ border: "4px solid #e0e0e0",
472
+ borderTop: "4px solid #007bff",
473
+ borderRadius: "50%",
474
+ animation: "spin 1s linear infinite"
475
+ }
476
+ }
477
+ ),
478
+ /* @__PURE__ */ jsxRuntime.jsx("p", { style: { margin: 0, color: "#666", fontSize: "16px" }, children: message })
479
+ ]
480
+ }
481
+ ),
482
+ /* @__PURE__ */ jsxRuntime.jsx("style", { children: `
483
+ @keyframes spin {
484
+ 0% { transform: rotate(0deg); }
485
+ 100% { transform: rotate(360deg); }
486
+ }
487
+ ` })
488
+ ]
489
+ }
490
+ );
491
+ }
492
+ return /* @__PURE__ */ jsxRuntime.jsx(jsxRuntime.Fragment, { children });
493
+ };
494
+ var SessionMonitor = ({
495
+ position = "top-right",
496
+ warningThreshold = 300,
497
+ // 5 minutes
498
+ criticalThreshold = 60,
499
+ // 1 minute
500
+ showProgressBar = true,
501
+ minimizable = true,
502
+ startMinimized = false,
503
+ className = "",
504
+ onWarning,
505
+ onCritical,
506
+ onExpired,
507
+ style
508
+ }) => {
509
+ const auth = useAuth();
510
+ const [timeRemaining, setTimeRemaining] = react.useState(null);
511
+ const [initialTimeRemaining, setInitialTimeRemaining] = react.useState(null);
512
+ const [minimized, setMinimized] = react.useState(startMinimized);
513
+ const [warningTriggered, setWarningTriggered] = react.useState(false);
514
+ const [criticalTriggered, setCriticalTriggered] = react.useState(false);
515
+ react.useEffect(() => {
516
+ if (!auth.isAuthenticated || !auth.user?.expires_at) {
517
+ setTimeRemaining(null);
518
+ setInitialTimeRemaining(null);
519
+ return;
520
+ }
521
+ let logoutTimeoutId = null;
522
+ const updateTimeRemaining = () => {
523
+ const expiresAt = auth.user.expires_at;
524
+ const now = Math.floor(Date.now() / 1e3);
525
+ const remaining = expiresAt - now;
526
+ if (initialTimeRemaining === null && remaining > 0) {
527
+ setInitialTimeRemaining(remaining);
528
+ }
529
+ if (remaining <= 0) {
530
+ setTimeRemaining(0);
531
+ onExpired?.();
532
+ logoutTimeoutId = setTimeout(() => {
533
+ auth.signoutRedirect();
534
+ }, 2e3);
535
+ } else {
536
+ setTimeRemaining(remaining);
537
+ if (remaining <= criticalThreshold && !criticalTriggered) {
538
+ setCriticalTriggered(true);
539
+ onCritical?.(remaining);
540
+ } else if (remaining <= warningThreshold && !warningTriggered) {
541
+ setWarningTriggered(true);
542
+ onWarning?.(remaining);
543
+ }
544
+ }
545
+ };
546
+ updateTimeRemaining();
547
+ const interval = setInterval(updateTimeRemaining, 1e3);
548
+ return () => {
549
+ clearInterval(interval);
550
+ if (logoutTimeoutId) {
551
+ clearTimeout(logoutTimeoutId);
552
+ }
553
+ };
554
+ }, [
555
+ auth,
556
+ warningThreshold,
557
+ criticalThreshold,
558
+ warningTriggered,
559
+ criticalTriggered,
560
+ initialTimeRemaining,
561
+ onWarning,
562
+ onCritical,
563
+ onExpired
564
+ ]);
565
+ react.useEffect(() => {
566
+ if (timeRemaining && timeRemaining > warningThreshold) {
567
+ setWarningTriggered(false);
568
+ setCriticalTriggered(false);
569
+ }
570
+ }, [timeRemaining, warningThreshold]);
571
+ const formatTime = react.useCallback((seconds) => {
572
+ const mins = Math.floor(seconds / 60);
573
+ const secs = seconds % 60;
574
+ return `${mins}:${secs.toString().padStart(2, "0")}`;
575
+ }, []);
576
+ const getStatusClass = react.useCallback(() => {
577
+ if (!timeRemaining) return "normal";
578
+ if (timeRemaining <= criticalThreshold) return "critical";
579
+ if (timeRemaining <= warningThreshold) return "warning";
580
+ return "normal";
581
+ }, [timeRemaining, warningThreshold, criticalThreshold]);
582
+ const getProgress = react.useCallback(() => {
583
+ if (!timeRemaining || !initialTimeRemaining) return 100;
584
+ return Math.max(0, Math.min(100, timeRemaining / initialTimeRemaining * 100));
585
+ }, [timeRemaining, initialTimeRemaining]);
586
+ if (!auth.isAuthenticated || timeRemaining === null) {
587
+ return null;
588
+ }
589
+ const baseClass = "kc-session-monitor";
590
+ const positionClass = `kc-session-monitor--${position}`;
591
+ const statusClass = `kc-session-monitor--${getStatusClass()}`;
592
+ const minimizedClass = minimized ? "kc-session-monitor--minimized" : "";
593
+ const classes = `${baseClass} ${positionClass} ${statusClass} ${minimizedClass} ${className}`.trim();
594
+ const progress = getProgress();
595
+ return /* @__PURE__ */ jsxRuntime.jsxs("div", { className: classes, style, children: [
596
+ minimizable && /* @__PURE__ */ jsxRuntime.jsx(
597
+ "button",
598
+ {
599
+ className: "kc-session-monitor__toggle",
600
+ onClick: () => setMinimized(!minimized),
601
+ "aria-label": minimized ? "Expand session monitor" : "Minimize session monitor",
602
+ title: minimized ? "Expand" : "Minimize",
603
+ children: minimized ? "\u25B2" : "\u25BC"
604
+ }
605
+ ),
606
+ !minimized && /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
607
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "kc-session-monitor__content", children: [
608
+ /* @__PURE__ */ jsxRuntime.jsx("span", { className: "kc-session-monitor__label", children: timeRemaining === 0 ? "Session expired" : "Session expires in:" }),
609
+ /* @__PURE__ */ jsxRuntime.jsx("span", { className: "kc-session-monitor__time", children: formatTime(timeRemaining) })
610
+ ] }),
611
+ showProgressBar && /* @__PURE__ */ jsxRuntime.jsx("div", { className: "kc-session-monitor__progress-bar", children: /* @__PURE__ */ jsxRuntime.jsx(
612
+ "div",
613
+ {
614
+ className: "kc-session-monitor__progress-fill",
615
+ style: { width: `${progress}%` },
616
+ role: "progressbar",
617
+ "aria-valuenow": progress,
618
+ "aria-valuemin": 0,
619
+ "aria-valuemax": 100
620
+ }
621
+ ) })
622
+ ] })
623
+ ] });
624
+ };
625
+
626
+ // src/config/presets.ts
627
+ var CONFIG_PRESETS = {
628
+ /**
629
+ * Default preset - Balanced configuration for most applications
630
+ *
631
+ * Features:
632
+ * - 30-day refresh token lifetime
633
+ * - Proactive token refresh on startup + reactive on 401
634
+ * - Silent background token refresh
635
+ * - Session monitoring enabled
636
+ * - Auto token refresh enabled
637
+ *
638
+ * Best for: Most web applications, dashboards, internal tools
639
+ */
640
+ default: {
641
+ scope: "openid profile email offline_access",
642
+ features: {
643
+ autoRefresh: true,
644
+ sessionMonitor: true,
645
+ deviceFlow: false
646
+ },
647
+ silentRenew: {
648
+ enabled: true,
649
+ thresholdSeconds: 300,
650
+ // 5 minutes
651
+ refreshStrategy: "both",
652
+ maxRefreshTokenLifetime: 2592e3,
653
+ // 30 days
654
+ showLoadingIndicator: false,
655
+ loadingMessage: "Refreshing session..."
656
+ },
657
+ advanced: {
658
+ responseType: "code",
659
+ codeChallengeMethod: "S256",
660
+ loadUserInfo: true,
661
+ automaticSilentSignIn: false
662
+ }
663
+ },
664
+ /**
665
+ * Mobile/PWA preset - Optimized for mobile and progressive web apps
666
+ *
667
+ * Features:
668
+ * - 30-day refresh token lifetime
669
+ * - Proactive startup refresh (apps close frequently)
670
+ * - Loading indicator during refresh (better UX for app reopening)
671
+ * - Session monitoring enabled
672
+ *
673
+ * Best for: Mobile apps, PWAs, apps that frequently close/reopen
674
+ */
675
+ mobile: {
676
+ scope: "openid profile email offline_access",
677
+ features: {
678
+ autoRefresh: true,
679
+ sessionMonitor: true,
680
+ deviceFlow: false
681
+ },
682
+ silentRenew: {
683
+ enabled: true,
684
+ thresholdSeconds: 300,
685
+ refreshStrategy: "startup",
686
+ // Focus on startup refresh
687
+ maxRefreshTokenLifetime: 2592e3,
688
+ // 30 days
689
+ showLoadingIndicator: true,
690
+ // Show loading for better UX
691
+ loadingMessage: "Restoring your session..."
692
+ },
693
+ advanced: {
694
+ responseType: "code",
695
+ codeChallengeMethod: "S256",
696
+ loadUserInfo: true,
697
+ automaticSilentSignIn: false
698
+ }
699
+ },
700
+ /**
701
+ * High Security preset - Strict security for sensitive applications
702
+ *
703
+ * Features:
704
+ * - 24-hour forced re-authentication
705
+ * - Proactive + reactive token refresh
706
+ * - 10-minute refresh threshold (earlier refresh)
707
+ * - Silent refresh (no UI hints about security measures)
708
+ * - Session monitoring enabled
709
+ *
710
+ * Best for: Banking, healthcare, admin panels, sensitive data applications
711
+ */
712
+ "high-security": {
713
+ scope: "openid profile email offline_access",
714
+ features: {
715
+ autoRefresh: true,
716
+ sessionMonitor: true,
717
+ deviceFlow: false
718
+ },
719
+ silentRenew: {
720
+ enabled: true,
721
+ thresholdSeconds: 600,
722
+ // 10 minutes (more aggressive)
723
+ refreshStrategy: "both",
724
+ maxRefreshTokenLifetime: 86400,
725
+ // 24 hours only
726
+ showLoadingIndicator: false,
727
+ // Silent for security
728
+ loadingMessage: "Validating session..."
729
+ },
730
+ advanced: {
731
+ responseType: "code",
732
+ codeChallengeMethod: "S256",
733
+ loadUserInfo: true,
734
+ automaticSilentSignIn: false
735
+ }
736
+ },
737
+ /**
738
+ * Long-Running preset - Optimized for desktop apps and long sessions
739
+ *
740
+ * Features:
741
+ * - 7-day refresh token lifetime
742
+ * - Proactive + reactive token refresh
743
+ * - Silent background refresh
744
+ * - Session monitoring enabled
745
+ *
746
+ * Best for: Desktop applications, monitoring dashboards, always-on tools
747
+ */
748
+ "long-running": {
749
+ scope: "openid profile email offline_access",
750
+ features: {
751
+ autoRefresh: true,
752
+ sessionMonitor: true,
753
+ deviceFlow: false
754
+ },
755
+ silentRenew: {
756
+ enabled: true,
757
+ thresholdSeconds: 300,
758
+ refreshStrategy: "both",
759
+ maxRefreshTokenLifetime: 604800,
760
+ // 7 days
761
+ showLoadingIndicator: false,
762
+ loadingMessage: "Refreshing session..."
763
+ },
764
+ advanced: {
765
+ responseType: "code",
766
+ codeChallengeMethod: "S256",
767
+ loadUserInfo: true,
768
+ automaticSilentSignIn: false
769
+ }
770
+ },
771
+ /**
772
+ * Development preset - Convenient settings for development
773
+ *
774
+ * Features:
775
+ * - No refresh token lifetime limit (never expires)
776
+ * - Proactive + reactive token refresh
777
+ * - Loading indicators visible (easier debugging)
778
+ * - Session monitoring enabled
779
+ * - More verbose feedback
780
+ *
781
+ * Best for: Local development, testing, debugging
782
+ * WARNING: Do not use in production!
783
+ */
784
+ development: {
785
+ scope: "openid profile email offline_access",
786
+ features: {
787
+ autoRefresh: true,
788
+ sessionMonitor: true,
789
+ deviceFlow: false
790
+ },
791
+ silentRenew: {
792
+ enabled: true,
793
+ thresholdSeconds: 300,
794
+ refreshStrategy: "both",
795
+ maxRefreshTokenLifetime: 0,
796
+ // No limit (for convenience)
797
+ showLoadingIndicator: true,
798
+ // Show for debugging
799
+ loadingMessage: "[DEV] Refreshing tokens..."
800
+ },
801
+ advanced: {
802
+ responseType: "code",
803
+ codeChallengeMethod: "S256",
804
+ loadUserInfo: true,
805
+ automaticSilentSignIn: false
806
+ }
807
+ }
808
+ };
809
+ function getConfigPreset(preset) {
810
+ return CONFIG_PRESETS[preset];
811
+ }
812
+ function mergeWithPreset(preset, customConfig) {
813
+ const presetConfig = getConfigPreset(preset);
814
+ return {
815
+ ...presetConfig,
816
+ ...customConfig,
817
+ features: {
818
+ ...presetConfig.features,
819
+ ...customConfig.features
820
+ },
821
+ silentRenew: {
822
+ ...presetConfig.silentRenew,
823
+ ...customConfig.silentRenew
824
+ },
825
+ advanced: {
826
+ ...presetConfig.advanced,
827
+ ...customConfig.advanced
828
+ },
829
+ events: {
830
+ ...presetConfig.events,
831
+ ...customConfig.events
832
+ }
833
+ };
834
+ }
835
+ function isZeroConfig(props) {
836
+ return "authority" in props && "clientId" in props;
837
+ }
838
+ var KeycloakProvider = (props) => {
839
+ const { children, debug = false } = props;
840
+ const config = isZeroConfig(props) ? buildZeroConfig(props) : props.config;
136
841
  const oidcConfig = KeycloakConfigBuilder.build(config);
137
842
  const log = react.useCallback(
138
843
  (level, message, ...args) => {
139
844
  if (debug) {
140
- const prefix = "[Keycloak]";
845
+ const prefix = "[KeycloakProvider]";
141
846
  switch (level) {
142
847
  case "info":
143
848
  console.log(prefix, message, ...args);
@@ -165,11 +870,13 @@ var KeycloakProvider = ({
165
870
  );
166
871
  const onSignoutCallback = react.useCallback(() => {
167
872
  log("info", "Sign out successful");
873
+ window.history.replaceState({}, document.title, window.location.pathname);
168
874
  config.events?.onLogout?.();
169
875
  }, [config.events, log]);
170
876
  const onRemoveUser = react.useCallback(() => {
171
- log("info", "User removed");
877
+ log("warn", "User removed from storage - token validation likely failed");
172
878
  }, [log]);
879
+ const showSessionMonitor = config.features?.sessionMonitor !== false;
173
880
  return /* @__PURE__ */ jsxRuntime.jsx(
174
881
  reactOidcContext.AuthProvider,
175
882
  {
@@ -177,11 +884,41 @@ var KeycloakProvider = ({
177
884
  onSigninCallback,
178
885
  onSignoutCallback,
179
886
  onRemoveUser,
180
- children
887
+ children: /* @__PURE__ */ jsxRuntime.jsxs(TokenLifecycleManager, { config, debug, children: [
888
+ showSessionMonitor && /* @__PURE__ */ jsxRuntime.jsx(SessionMonitor, {}),
889
+ children
890
+ ] })
181
891
  }
182
892
  );
183
893
  };
184
- var useAuth = reactOidcContext.useAuth;
894
+ function buildZeroConfig(props) {
895
+ const { authority, clientId, preset = "default", config: overrides = {} } = props;
896
+ const presetConfig = mergeWithPreset(preset, overrides);
897
+ const fullConfig = {
898
+ authority,
899
+ clientId,
900
+ redirectUri: presetConfig.redirectUri || defaultConfig.redirectUri || (typeof window !== "undefined" ? window.location.origin + "/" : "/"),
901
+ postLogoutRedirectUri: presetConfig.postLogoutRedirectUri || defaultConfig.postLogoutRedirectUri || (typeof window !== "undefined" ? window.location.origin + "/" : "/"),
902
+ scope: presetConfig.scope || defaultConfig.scope,
903
+ features: {
904
+ ...defaultConfig.features,
905
+ ...presetConfig.features
906
+ },
907
+ silentRenew: {
908
+ ...defaultConfig.silentRenew,
909
+ ...presetConfig.silentRenew
910
+ },
911
+ advanced: {
912
+ ...defaultConfig.advanced,
913
+ ...presetConfig.advanced
914
+ },
915
+ events: {
916
+ ...defaultConfig.events,
917
+ ...presetConfig.events
918
+ }
919
+ };
920
+ return fullConfig;
921
+ }
185
922
  function useRoles() {
186
923
  const auth = useAuth();
187
924
  const roles = react.useMemo(() => {
@@ -273,6 +1010,7 @@ var ProtectedRoute = ({
273
1010
  const { hasAnyRole, hasAllRoles } = useRoles();
274
1011
  const { hasAnyScope, hasAllScopes } = useScopes();
275
1012
  const location = reactRouterDom.useLocation();
1013
+ const hasRedirected = react.useRef(false);
276
1014
  const isAuthenticated = !auth.isLoading && auth.isAuthenticated;
277
1015
  const policyAccess = policy && auth.user ? policy(auth.user, { location }) : null;
278
1016
  const roleAccess = requiredRoles.length > 0 ? requireAllRoles ? hasAllRoles(requiredRoles) : hasAnyRole(requiredRoles) : null;
@@ -284,12 +1022,21 @@ var ProtectedRoute = ({
284
1022
  }
285
1023
  }, [unauthorizedReason, onUnauthorized]);
286
1024
  react.useEffect(() => {
287
- if (!auth.isLoading && !auth.isAuthenticated) {
1025
+ if (!auth.isLoading && !auth.isAuthenticated && !hasRedirected.current) {
1026
+ hasRedirected.current = true;
288
1027
  auth.signinRedirect({
289
1028
  redirect_uri: window.location.origin + location.pathname + location.search
1029
+ }).catch((error) => {
1030
+ hasRedirected.current = false;
1031
+ console.error("[ProtectedRoute] Signin redirect failed:", error);
290
1032
  });
291
1033
  }
292
- }, [auth.isLoading, auth.isAuthenticated, auth, location.pathname, location.search]);
1034
+ }, [auth.isLoading, auth.isAuthenticated, location.pathname, location.search]);
1035
+ react.useEffect(() => {
1036
+ if (auth.isAuthenticated) {
1037
+ hasRedirected.current = false;
1038
+ }
1039
+ }, [auth.isAuthenticated]);
293
1040
  if (auth.isLoading) {
294
1041
  return /* @__PURE__ */ jsxRuntime.jsx(jsxRuntime.Fragment, { children: loadingComponent || /* @__PURE__ */ jsxRuntime.jsx(DefaultLoadingComponent, {}) });
295
1042
  }
@@ -509,125 +1256,6 @@ function getInitials(name) {
509
1256
  }
510
1257
  return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
511
1258
  }
512
- var SessionMonitor = ({
513
- position = "top-right",
514
- warningThreshold = 300,
515
- // 5 minutes
516
- criticalThreshold = 60,
517
- // 1 minute
518
- showProgressBar = true,
519
- minimizable = true,
520
- startMinimized = false,
521
- className = "",
522
- onWarning,
523
- onCritical,
524
- onExpired,
525
- style
526
- }) => {
527
- const auth = useAuth();
528
- const [timeRemaining, setTimeRemaining] = react.useState(null);
529
- const [minimized, setMinimized] = react.useState(startMinimized);
530
- const [warningTriggered, setWarningTriggered] = react.useState(false);
531
- const [criticalTriggered, setCriticalTriggered] = react.useState(false);
532
- react.useEffect(() => {
533
- if (!auth.isAuthenticated || !auth.user?.expires_at) {
534
- setTimeRemaining(null);
535
- return;
536
- }
537
- const updateTimeRemaining = () => {
538
- const expiresAt = auth.user.expires_at;
539
- const now = Math.floor(Date.now() / 1e3);
540
- const remaining = expiresAt - now;
541
- if (remaining <= 0) {
542
- setTimeRemaining(0);
543
- onExpired?.();
544
- setTimeout(() => {
545
- auth.signoutRedirect();
546
- }, 2e3);
547
- } else {
548
- setTimeRemaining(remaining);
549
- if (remaining <= criticalThreshold && !criticalTriggered) {
550
- setCriticalTriggered(true);
551
- onCritical?.(remaining);
552
- } else if (remaining <= warningThreshold && !warningTriggered) {
553
- setWarningTriggered(true);
554
- onWarning?.(remaining);
555
- }
556
- }
557
- };
558
- updateTimeRemaining();
559
- const interval = setInterval(updateTimeRemaining, 1e3);
560
- return () => clearInterval(interval);
561
- }, [
562
- auth,
563
- warningThreshold,
564
- criticalThreshold,
565
- warningTriggered,
566
- criticalTriggered,
567
- onWarning,
568
- onCritical,
569
- onExpired
570
- ]);
571
- react.useEffect(() => {
572
- if (timeRemaining && timeRemaining > warningThreshold) {
573
- setWarningTriggered(false);
574
- setCriticalTriggered(false);
575
- }
576
- }, [timeRemaining, warningThreshold]);
577
- const formatTime = react.useCallback((seconds) => {
578
- const mins = Math.floor(seconds / 60);
579
- const secs = seconds % 60;
580
- return `${mins}:${secs.toString().padStart(2, "0")}`;
581
- }, []);
582
- const getStatusClass = react.useCallback(() => {
583
- if (!timeRemaining) return "normal";
584
- if (timeRemaining <= criticalThreshold) return "critical";
585
- if (timeRemaining <= warningThreshold) return "warning";
586
- return "normal";
587
- }, [timeRemaining, warningThreshold, criticalThreshold]);
588
- const getProgress = react.useCallback(() => {
589
- if (!timeRemaining || !auth.user?.expires_in) return 100;
590
- return timeRemaining / auth.user.expires_in * 100;
591
- }, [timeRemaining, auth.user]);
592
- if (!auth.isAuthenticated || timeRemaining === null) {
593
- return null;
594
- }
595
- const baseClass = "kc-session-monitor";
596
- const positionClass = `kc-session-monitor--${position}`;
597
- const statusClass = `kc-session-monitor--${getStatusClass()}`;
598
- const minimizedClass = minimized ? "kc-session-monitor--minimized" : "";
599
- const classes = `${baseClass} ${positionClass} ${statusClass} ${minimizedClass} ${className}`.trim();
600
- const progress = getProgress();
601
- return /* @__PURE__ */ jsxRuntime.jsxs("div", { className: classes, style, children: [
602
- minimizable && /* @__PURE__ */ jsxRuntime.jsx(
603
- "button",
604
- {
605
- className: "kc-session-monitor__toggle",
606
- onClick: () => setMinimized(!minimized),
607
- "aria-label": minimized ? "Expand session monitor" : "Minimize session monitor",
608
- title: minimized ? "Expand" : "Minimize",
609
- children: minimized ? "\u25B2" : "\u25BC"
610
- }
611
- ),
612
- !minimized && /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
613
- /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "kc-session-monitor__content", children: [
614
- /* @__PURE__ */ jsxRuntime.jsx("span", { className: "kc-session-monitor__label", children: timeRemaining === 0 ? "Session expired" : "Session expires in:" }),
615
- /* @__PURE__ */ jsxRuntime.jsx("span", { className: "kc-session-monitor__time", children: formatTime(timeRemaining) })
616
- ] }),
617
- showProgressBar && /* @__PURE__ */ jsxRuntime.jsx("div", { className: "kc-session-monitor__progress-bar", children: /* @__PURE__ */ jsxRuntime.jsx(
618
- "div",
619
- {
620
- className: "kc-session-monitor__progress-fill",
621
- style: { width: `${progress}%` },
622
- role: "progressbar",
623
- "aria-valuenow": progress,
624
- "aria-valuemin": 0,
625
- "aria-valuemax": 100
626
- }
627
- ) })
628
- ] })
629
- ] });
630
- };
631
1259
 
632
1260
  // src/utils/deviceFlow.ts
633
1261
  async function requestDeviceCode(apiBaseUrl) {
@@ -864,14 +1492,14 @@ function useProtectedFetch(options = {}) {
864
1492
  },
865
1493
  [baseUrl]
866
1494
  );
867
- const getAuthHeader = react.useCallback(() => {
1495
+ const getAuthHeader = () => {
868
1496
  if (!auth.user?.access_token) {
869
1497
  return {};
870
1498
  }
871
1499
  return {
872
1500
  Authorization: `Bearer ${auth.user.access_token}`
873
1501
  };
874
- }, [auth.user]);
1502
+ };
875
1503
  const buildHeaders = react.useCallback(
876
1504
  (requestHeaders) => {
877
1505
  const headers = new Headers(requestHeaders);
@@ -884,7 +1512,7 @@ function useProtectedFetch(options = {}) {
884
1512
  });
885
1513
  return headers;
886
1514
  },
887
- [getAuthHeader, customHeaders]
1515
+ [customHeaders]
888
1516
  );
889
1517
  const protectedFetch = react.useCallback(
890
1518
  async (url, init) => {
@@ -935,6 +1563,7 @@ function useProtectedFetch(options = {}) {
935
1563
  };
936
1564
  }
937
1565
 
1566
+ exports.CONFIG_PRESETS = CONFIG_PRESETS;
938
1567
  exports.DeviceLogin = DeviceLogin;
939
1568
  exports.KeycloakConfigBuilder = KeycloakConfigBuilder;
940
1569
  exports.KeycloakProvider = KeycloakProvider;
@@ -942,15 +1571,19 @@ exports.LoginButton = LoginButton;
942
1571
  exports.LogoutButton = LogoutButton;
943
1572
  exports.ProtectedRoute = ProtectedRoute;
944
1573
  exports.SessionMonitor = SessionMonitor;
1574
+ exports.TokenLifecycleManager = TokenLifecycleManager;
945
1575
  exports.UserProfile = UserProfile;
946
1576
  exports.defaultConfig = defaultConfig;
947
1577
  exports.fetchUserInfo = fetchUserInfo;
948
1578
  exports.getConfigFromEnv = getConfigFromEnv;
1579
+ exports.getConfigPreset = getConfigPreset;
1580
+ exports.mergeWithPreset = mergeWithPreset;
949
1581
  exports.pollForToken = pollForToken;
950
1582
  exports.requestDeviceCode = requestDeviceCode;
951
1583
  exports.useAuth = useAuth;
952
1584
  exports.useProtectedFetch = useProtectedFetch;
953
1585
  exports.useRoles = useRoles;
954
1586
  exports.useScopes = useScopes;
1587
+ exports.useTokenLifecycle = useTokenLifecycle;
955
1588
  //# sourceMappingURL=index.js.map
956
1589
  //# sourceMappingURL=index.js.map