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