@spidy092/auth-client 2.1.5 → 2.1.7

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/api.js CHANGED
@@ -12,7 +12,7 @@ api.interceptors.request.use((config) => {
12
12
  const runtimeConfig = getConfig();
13
13
 
14
14
  if (!config.baseURL) {
15
-
15
+
16
16
  config.baseURL = runtimeConfig?.authBaseUrl || 'http://auth.local.test:4000/auth';
17
17
  }
18
18
 
package/config.js CHANGED
@@ -1,10 +1,37 @@
1
1
  // auth-client/config.js
2
+
3
+ // ========== SESSION SECURITY CONFIGURATION ==========
4
+ // These settings control how the auth-client handles token refresh and session validation
5
+ // to ensure deleted sessions in Keycloak are detected quickly.
6
+
2
7
  let config = {
3
8
  clientKey: null,
4
9
  authBaseUrl: null,
5
10
  redirectUri: null,
6
11
  accountUiUrl: null,
7
12
  isRouter: false, // ✅ Add router flag
13
+
14
+ // ========== SESSION SECURITY SETTINGS ==========
15
+ // Buffer time (in seconds) before token expiry to trigger proactive refresh
16
+ // With 5-minute access tokens, refreshing 60s before expiry ensures seamless UX
17
+ tokenRefreshBuffer: 60,
18
+
19
+ // Interval (in milliseconds) for periodic session validation
20
+ // Validates that the session still exists in Keycloak (not deleted by admin)
21
+ // Default: 2 minutes (120000ms) - balances responsiveness vs server load
22
+ sessionValidationInterval: 2 * 60 * 1000,
23
+
24
+ // Enable/disable periodic session validation
25
+ // When enabled, the client will ping the server to verify session is still active
26
+ enableSessionValidation: true,
27
+
28
+ // Enable/disable proactive token refresh
29
+ // When enabled, tokens are refreshed before they expire (using tokenRefreshBuffer)
30
+ enableProactiveRefresh: true,
31
+
32
+ // Validate session when browser tab becomes visible again
33
+ // Catches session deletions that happened while the tab was inactive
34
+ validateOnVisibility: true,
8
35
  };
9
36
 
10
37
  export function setConfig(customConfig = {}) {
package/core.js CHANGED
@@ -15,7 +15,7 @@ let callbackProcessed = false;
15
15
  export function login(clientKeyArg, redirectUriArg) {
16
16
  // ✅ Reset callback state when starting new login
17
17
  resetCallbackState();
18
-
18
+
19
19
  const {
20
20
  clientKey: defaultClientKey,
21
21
  authBaseUrl,
@@ -51,14 +51,14 @@ export function login(clientKeyArg, redirectUriArg) {
51
51
  // ✅ Router mode: Direct backend call
52
52
  function routerLogin(clientKey, redirectUri) {
53
53
  const { authBaseUrl } = getConfig();
54
-
54
+
55
55
  const params = new URLSearchParams();
56
56
  if (redirectUri) {
57
57
  params.append('redirect_uri', redirectUri);
58
58
  }
59
59
  const query = params.toString();
60
60
  const backendLoginUrl = `${authBaseUrl}/login/${clientKey}${query ? `?${query}` : ''}`;
61
-
61
+
62
62
  console.log('🏭 Router Login: Direct backend authentication', {
63
63
  clientKey,
64
64
  redirectUri,
@@ -71,7 +71,7 @@ function routerLogin(clientKey, redirectUri) {
71
71
  // ✅ Client mode: Centralized login
72
72
  function clientLogin(clientKey, redirectUri) {
73
73
  const { accountUiUrl } = getConfig();
74
-
74
+
75
75
  const params = new URLSearchParams({
76
76
  client: clientKey
77
77
  });
@@ -79,7 +79,7 @@ function clientLogin(clientKey, redirectUri) {
79
79
  params.append('redirect_uri', redirectUri);
80
80
  }
81
81
  const centralizedLoginUrl = `${accountUiUrl}/login?${params.toString()}`;
82
-
82
+
83
83
  console.log('🔄 Client Login: Redirecting to centralized login', {
84
84
  clientKey,
85
85
  redirectUri,
@@ -91,7 +91,7 @@ function clientLogin(clientKey, redirectUri) {
91
91
 
92
92
  export function logout() {
93
93
  resetCallbackState();
94
-
94
+
95
95
  const { clientKey, authBaseUrl, accountUiUrl } = getConfig();
96
96
  const token = getToken();
97
97
 
@@ -116,7 +116,7 @@ async function routerLogout(clientKey, authBaseUrl, accountUiUrl, token) {
116
116
 
117
117
  try {
118
118
  const response = await fetch(`${authBaseUrl}/logout/${clientKey}`, {
119
- method: 'GET',
119
+ method: 'POST',
120
120
  credentials: 'include',
121
121
  headers: {
122
122
  'Authorization': token ? `Bearer ${token}` : '',
@@ -188,7 +188,7 @@ export function handleCallback() {
188
188
 
189
189
  if (accessToken) {
190
190
  setToken(accessToken);
191
-
191
+
192
192
  // ✅ For HTTP development, store refresh token from URL
193
193
  // In HTTPS production, refresh token is in httpOnly cookie (more secure)
194
194
  const refreshTokenInUrl = params.get('refresh_token');
@@ -201,7 +201,7 @@ export function handleCallback() {
201
201
  console.log('🔒 HTTPS mode: Refresh token is in httpOnly cookie (ignoring URL param)');
202
202
  }
203
203
  }
204
-
204
+
205
205
  const url = new URL(window.location);
206
206
  url.searchParams.delete('access_token');
207
207
  url.searchParams.delete('refresh_token');
@@ -209,7 +209,7 @@ export function handleCallback() {
209
209
  url.searchParams.delete('error');
210
210
  url.searchParams.delete('error_description');
211
211
  window.history.replaceState({}, '', url);
212
-
212
+
213
213
  console.log('✅ Callback processed successfully, token stored');
214
214
  return accessToken;
215
215
  }
@@ -227,25 +227,25 @@ let refreshPromise = null;
227
227
 
228
228
  export async function refreshToken() {
229
229
  const { clientKey, authBaseUrl } = getConfig();
230
-
230
+
231
231
  // ✅ Prevent concurrent refresh calls
232
232
  if (refreshInProgress && refreshPromise) {
233
233
  console.log('🔄 Token refresh already in progress, waiting...');
234
234
  return refreshPromise;
235
235
  }
236
-
236
+
237
237
  refreshInProgress = true;
238
238
  refreshPromise = (async () => {
239
239
  try {
240
240
  // Get stored refresh token (for HTTP development)
241
241
  const storedRefreshToken = getRefreshToken();
242
-
243
- console.log('🔄 Refreshing token:', {
244
- clientKey,
242
+
243
+ console.log('🔄 Refreshing token:', {
244
+ clientKey,
245
245
  mode: isRouterMode() ? 'ROUTER' : 'CLIENT',
246
246
  hasStoredRefreshToken: !!storedRefreshToken
247
247
  });
248
-
248
+
249
249
  // Build request options - send refresh token in body and header for HTTP dev
250
250
  const requestOptions = {
251
251
  method: 'POST',
@@ -254,13 +254,13 @@ export async function refreshToken() {
254
254
  'Content-Type': 'application/json'
255
255
  }
256
256
  };
257
-
257
+
258
258
  // For HTTP development, send refresh token in body and header
259
259
  if (storedRefreshToken) {
260
260
  requestOptions.headers['X-Refresh-Token'] = storedRefreshToken;
261
261
  requestOptions.body = JSON.stringify({ refreshToken: storedRefreshToken });
262
262
  }
263
-
263
+
264
264
  const response = await fetch(`${authBaseUrl}/refresh/${clientKey}`, requestOptions);
265
265
 
266
266
  if (!response.ok) {
@@ -271,20 +271,20 @@ export async function refreshToken() {
271
271
 
272
272
  const data = await response.json();
273
273
  const { access_token, refresh_token: new_refresh_token } = data;
274
-
274
+
275
275
  if (!access_token) {
276
276
  throw new Error('No access token in refresh response');
277
277
  }
278
-
278
+
279
279
  // ✅ This will trigger token listeners
280
280
  setToken(access_token);
281
-
281
+
282
282
  // ✅ Store new refresh token if provided (token rotation)
283
283
  if (new_refresh_token) {
284
284
  setRefreshToken(new_refresh_token);
285
285
  console.log('🔄 New refresh token stored from rotation');
286
286
  }
287
-
287
+
288
288
  console.log('✅ Token refresh successful, listeners notified');
289
289
  return access_token;
290
290
  } catch (err) {
@@ -298,7 +298,7 @@ export async function refreshToken() {
298
298
  refreshPromise = null;
299
299
  }
300
300
  })();
301
-
301
+
302
302
  return refreshPromise;
303
303
  }
304
304
 
@@ -306,7 +306,7 @@ export async function validateCurrentSession() {
306
306
  try {
307
307
  const { authBaseUrl } = getConfig();
308
308
  const token = getToken();
309
-
309
+
310
310
  if (!token || !authBaseUrl) {
311
311
  return false;
312
312
  }
@@ -338,30 +338,233 @@ export async function validateCurrentSession() {
338
338
  }
339
339
  }
340
340
 
341
+ // ========== SESSION SECURITY: PROACTIVE REFRESH & VALIDATION ==========
342
+ // These functions ensure that:
343
+ // 1. Tokens are refreshed before they expire (proactive refresh)
344
+ // 2. Sessions deleted in Keycloak Admin UI are detected quickly (periodic validation)
341
345
 
346
+ let proactiveRefreshTimer = null;
347
+ let sessionValidationTimer = null;
348
+ let visibilityHandler = null;
349
+ let sessionInvalidCallbacks = new Set();
342
350
 
351
+ // Register a callback to be called when session is invalidated
352
+ export function onSessionInvalid(callback) {
353
+ if (typeof callback === 'function') {
354
+ sessionInvalidCallbacks.add(callback);
355
+ }
356
+ return () => sessionInvalidCallbacks.delete(callback);
357
+ }
358
+
359
+ // Notify all registered callbacks that session is invalid
360
+ function notifySessionInvalid(reason = 'session_deleted') {
361
+ console.log('🚨 Session invalidated:', reason);
362
+ sessionInvalidCallbacks.forEach(callback => {
363
+ try {
364
+ callback(reason);
365
+ } catch (err) {
366
+ console.error('Session invalid callback error:', err);
367
+ }
368
+ });
369
+ }
370
+
371
+ // ========== PROACTIVE TOKEN REFRESH ==========
372
+ // Schedules token refresh before expiry to ensure seamless UX
373
+
374
+ export function startProactiveRefresh() {
375
+ const { enableProactiveRefresh, tokenRefreshBuffer } = getConfig();
376
+
377
+ if (!enableProactiveRefresh) {
378
+ console.log('⏸️ Proactive refresh disabled by config');
379
+ return null;
380
+ }
343
381
 
382
+ // Clear any existing timer
383
+ stopProactiveRefresh();
344
384
 
345
- // export async function refreshToken() {
346
- // const { clientKey, authBaseUrl } = getConfig();
347
-
348
- // console.log('🔄 Refreshing token:', { clientKey, mode: isRouterMode() ? 'ROUTER' : 'CLIENT' });
349
-
350
- // try {
351
- // const response = await fetch(`${authBaseUrl}/refresh/${clientKey}`, {
352
- // method: 'POST',
353
- // credentials: 'include',
354
- // });
385
+ const token = getToken();
386
+ if (!token) {
387
+ console.log('⏸️ No token, skipping proactive refresh setup');
388
+ return null;
389
+ }
390
+
391
+ const { getTimeUntilExpiry } = require('./token');
392
+ const timeUntilExpiry = getTimeUntilExpiry(token);
393
+
394
+ if (timeUntilExpiry <= 0) {
395
+ console.log('⚠️ Token already expired, attempting immediate refresh');
396
+ refreshToken().catch(err => {
397
+ console.error('❌ Immediate refresh failed:', err);
398
+ notifySessionInvalid('token_expired');
399
+ });
400
+ return null;
401
+ }
355
402
 
356
- // if (!response.ok) {
357
- // throw new Error('Refresh failed');
358
- // }
403
+ // Schedule refresh for (expiry - buffer) seconds from now
404
+ const refreshIn = Math.max(0, (timeUntilExpiry - tokenRefreshBuffer)) * 1000;
405
+
406
+ console.log(`🔄 Scheduling proactive refresh in ${Math.round(refreshIn / 1000)}s (token expires in ${timeUntilExpiry}s)`);
407
+
408
+ proactiveRefreshTimer = setTimeout(async () => {
409
+ try {
410
+ console.log('🔄 Proactive token refresh triggered');
411
+ await refreshToken();
412
+ console.log('✅ Proactive refresh successful, scheduling next refresh');
413
+ // Schedule next refresh after successful refresh
414
+ startProactiveRefresh();
415
+ } catch (err) {
416
+ console.error('❌ Proactive refresh failed:', err);
417
+
418
+ // Check if this is a permanent failure (token revoked, invalid, etc.)
419
+ const errorMessage = err.message?.toLowerCase() || '';
420
+ const isPermanentFailure =
421
+ errorMessage.includes('401') ||
422
+ errorMessage.includes('revoked') ||
423
+ errorMessage.includes('invalid') ||
424
+ errorMessage.includes('expired') ||
425
+ errorMessage.includes('unauthorized');
426
+
427
+ if (isPermanentFailure) {
428
+ console.log('🚨 Token permanently invalid, triggering session expiry');
429
+ notifySessionInvalid('refresh_token_revoked');
430
+ } else {
431
+ // Temporary failure (network issue), try again in 30 seconds
432
+ proactiveRefreshTimer = setTimeout(() => startProactiveRefresh(), 30000);
433
+ }
434
+ }
435
+ }, refreshIn);
436
+
437
+ return proactiveRefreshTimer;
438
+ }
439
+
440
+ export function stopProactiveRefresh() {
441
+ if (proactiveRefreshTimer) {
442
+ clearTimeout(proactiveRefreshTimer);
443
+ proactiveRefreshTimer = null;
444
+ console.log('⏹️ Proactive refresh stopped');
445
+ }
446
+ }
447
+
448
+ // ========== PERIODIC SESSION VALIDATION ==========
449
+ // Validates with server that session still exists in Keycloak
450
+ // Catches session deletions from Keycloak Admin UI
451
+
452
+ export function startSessionMonitor(onInvalid) {
453
+ const { enableSessionValidation, sessionValidationInterval, validateOnVisibility } = getConfig();
454
+
455
+ if (!enableSessionValidation) {
456
+ console.log('⏸️ Session validation disabled by config');
457
+ return null;
458
+ }
459
+
460
+ // Register callback if provided
461
+ if (onInvalid && typeof onInvalid === 'function') {
462
+ sessionInvalidCallbacks.add(onInvalid);
463
+ }
464
+
465
+ // Clear any existing timer
466
+ stopSessionMonitor();
467
+
468
+ const token = getToken();
469
+ if (!token) {
470
+ console.log('⏸️ No token, skipping session monitor setup');
471
+ return null;
472
+ }
473
+
474
+ console.log(`👁️ Starting session monitor (interval: ${sessionValidationInterval / 1000}s)`);
475
+
476
+ // Periodic validation
477
+ sessionValidationTimer = setInterval(async () => {
478
+ try {
479
+ const currentToken = getToken();
480
+ if (!currentToken) {
481
+ console.log('⏸️ No token, stopping session validation');
482
+ stopSessionMonitor();
483
+ return;
484
+ }
485
+
486
+ console.log('🔍 Validating session...');
487
+ const isValid = await validateCurrentSession();
488
+
489
+ if (!isValid) {
490
+ console.log('❌ Session no longer valid on server');
491
+ stopSessionMonitor();
492
+ stopProactiveRefresh();
493
+ clearToken();
494
+ clearRefreshToken();
495
+ notifySessionInvalid('session_deleted');
496
+ } else {
497
+ console.log('✅ Session still valid');
498
+ }
499
+ } catch (error) {
500
+ console.warn('⚠️ Session validation check failed:', error.message);
501
+ // Don't invalidate on network errors - wait for next check
502
+ }
503
+ }, sessionValidationInterval);
504
+
505
+ // Visibility-based validation (when tab becomes visible again)
506
+ if (validateOnVisibility && typeof document !== 'undefined') {
507
+ visibilityHandler = async () => {
508
+ if (document.visibilityState === 'visible') {
509
+ const currentToken = getToken();
510
+ if (!currentToken) return;
511
+
512
+ console.log('👁️ Tab visible - validating session');
513
+ try {
514
+ const isValid = await validateCurrentSession();
515
+ if (!isValid) {
516
+ console.log('❌ Session expired while tab was hidden');
517
+ stopSessionMonitor();
518
+ stopProactiveRefresh();
519
+ clearToken();
520
+ clearRefreshToken();
521
+ notifySessionInvalid('session_deleted_while_hidden');
522
+ }
523
+ } catch (error) {
524
+ console.warn('⚠️ Visibility check failed:', error.message);
525
+ }
526
+ }
527
+ };
528
+ document.addEventListener('visibilitychange', visibilityHandler);
529
+ }
530
+
531
+ return sessionValidationTimer;
532
+ }
533
+
534
+ export function stopSessionMonitor() {
535
+ if (sessionValidationTimer) {
536
+ clearInterval(sessionValidationTimer);
537
+ sessionValidationTimer = null;
538
+ console.log('⏹️ Session monitor stopped');
539
+ }
540
+
541
+ if (visibilityHandler && typeof document !== 'undefined') {
542
+ document.removeEventListener('visibilitychange', visibilityHandler);
543
+ visibilityHandler = null;
544
+ }
545
+ }
546
+
547
+ // ========== COMBINED SESSION SECURITY ==========
548
+ // Start both proactive refresh and session monitoring
549
+
550
+ export function startSessionSecurity(onSessionInvalidCallback) {
551
+ console.log('🔐 Starting session security (proactive refresh + session monitoring)');
552
+
553
+ startProactiveRefresh();
554
+ startSessionMonitor(onSessionInvalidCallback);
555
+
556
+ return {
557
+ stopAll: () => {
558
+ stopProactiveRefresh();
559
+ stopSessionMonitor();
560
+ }
561
+ };
562
+ }
563
+
564
+ export function stopSessionSecurity() {
565
+ stopProactiveRefresh();
566
+ stopSessionMonitor();
567
+ sessionInvalidCallbacks.clear();
568
+ console.log('🔐 Session security stopped');
569
+ }
359
570
 
360
- // const { access_token } = await response.json();
361
- // setToken(access_token);
362
- // return access_token;
363
- // } catch (err) {
364
- // clearToken();
365
- // throw err;
366
- // }
367
- // }
package/index.js CHANGED
@@ -1,7 +1,36 @@
1
1
  // auth-client/index.js
2
2
  import { setConfig, getConfig, isRouterMode } from './config';
3
- import { login, logout, handleCallback, refreshToken, resetCallbackState, validateCurrentSession } from './core';
4
- import { getToken, setToken, clearToken, setRefreshToken, getRefreshToken, clearRefreshToken, addTokenListener, removeTokenListener, getListenerCount } from './token';
3
+ import {
4
+ login,
5
+ logout,
6
+ handleCallback,
7
+ refreshToken,
8
+ resetCallbackState,
9
+ validateCurrentSession,
10
+ // Session Security Functions
11
+ startProactiveRefresh,
12
+ stopProactiveRefresh,
13
+ startSessionMonitor,
14
+ stopSessionMonitor,
15
+ startSessionSecurity,
16
+ stopSessionSecurity,
17
+ onSessionInvalid
18
+ } from './core';
19
+ import {
20
+ getToken,
21
+ setToken,
22
+ clearToken,
23
+ setRefreshToken,
24
+ getRefreshToken,
25
+ clearRefreshToken,
26
+ addTokenListener,
27
+ removeTokenListener,
28
+ getListenerCount,
29
+ // Token Expiry Utilities
30
+ getTokenExpiryTime,
31
+ getTimeUntilExpiry,
32
+ willExpireSoon
33
+ } from './token';
5
34
  import api from './api';
6
35
  import { decodeToken, isTokenExpired, isAuthenticated } from './utils/jwt';
7
36
 
@@ -38,8 +67,23 @@ export const auth = {
38
67
  isTokenExpired,
39
68
  isAuthenticated,
40
69
 
41
- // 🔄 Auto-refresh setup
70
+ // ⏱️ Token Expiry Utilities (NEW)
71
+ getTokenExpiryTime, // Get token expiry as Date object
72
+ getTimeUntilExpiry, // Get seconds until token expires
73
+ willExpireSoon, // Check if token expires within N seconds
74
+
75
+ // 🔐 Session Security (NEW - Short-lived tokens + Periodic validation)
76
+ startProactiveRefresh, // Start proactive token refresh before expiry
77
+ stopProactiveRefresh, // Stop proactive refresh
78
+ startSessionMonitor, // Start periodic session validation
79
+ stopSessionMonitor, // Stop session validation
80
+ startSessionSecurity, // Start both proactive refresh AND session monitoring
81
+ stopSessionSecurity, // Stop all session security
82
+ onSessionInvalid, // Register callback for session invalidation
83
+
84
+ // 🔄 Legacy auto-refresh (DEPRECATED - use startSessionSecurity instead)
42
85
  startTokenRefresh: () => {
86
+ console.warn('⚠️ startTokenRefresh is deprecated. Use startSessionSecurity() instead for better session management.');
43
87
  const interval = setInterval(async () => {
44
88
  const token = getToken();
45
89
  if (token && isTokenExpired(token, 300)) {
@@ -59,3 +103,4 @@ export const auth = {
59
103
  export { AuthProvider } from './react/AuthProvider';
60
104
  export { useAuth } from './react/useAuth';
61
105
  export { useSessionMonitor } from './react/useSessionMonitor';
106
+
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spidy092/auth-client",
3
- "version": "2.1.5",
3
+ "version": "2.1.7",
4
4
  "description": "Scalable frontend auth SDK for centralized login using Keycloak + Auth Service.",
5
5
  "main": "index.js",
6
6
  "module": "index.js",
@@ -1,18 +1,72 @@
1
1
  // auth-client/react/AuthProvider.jsx
2
- import React, { createContext, useState, useEffect } from 'react';
2
+ import React, { createContext, useState, useEffect, useRef } from 'react';
3
3
  import { getToken, setToken, clearToken } from '../token';
4
4
  import { getConfig } from '../config';
5
- import { login as coreLogin, logout as coreLogout } from '../core';
5
+ import {
6
+ login as coreLogin,
7
+ logout as coreLogout,
8
+ startSessionSecurity,
9
+ stopSessionSecurity,
10
+ onSessionInvalid
11
+ } from '../core';
6
12
 
7
13
  export const AuthContext = createContext();
8
14
 
9
- export function AuthProvider({ children }) {
15
+ export function AuthProvider({ children, onSessionExpired }) {
10
16
  const [token, setTokenState] = useState(getToken());
11
17
  const [user, setUser] = useState(null);
12
18
  const [loading, setLoading] = useState(!!token); // Loading if we have a token to validate
19
+ const [sessionValid, setSessionValid] = useState(true);
20
+ const sessionSecurityRef = useRef(null);
13
21
 
22
+ // Handle session invalidation (from Keycloak admin deletion or expiry)
23
+ const handleSessionInvalid = (reason) => {
24
+ console.log('🚨 AuthProvider: Session invalidated -', reason);
25
+ setSessionValid(false);
26
+ setUser(null);
27
+ setTokenState(null);
28
+
29
+ // Call custom callback if provided
30
+ if (onSessionExpired && typeof onSessionExpired === 'function') {
31
+ onSessionExpired(reason);
32
+ }
33
+ };
34
+
35
+ // Start session security on mount (when we have a token)
14
36
  useEffect(() => {
37
+ if (token && !sessionSecurityRef.current) {
38
+ console.log('🔐 AuthProvider: Starting session security');
39
+
40
+ // Register session invalid handler
41
+ const unsubscribe = onSessionInvalid(handleSessionInvalid);
42
+
43
+ // Start proactive refresh + session monitoring
44
+ sessionSecurityRef.current = startSessionSecurity(handleSessionInvalid);
45
+
46
+ return () => {
47
+ unsubscribe();
48
+ if (sessionSecurityRef.current) {
49
+ sessionSecurityRef.current.stopAll();
50
+ sessionSecurityRef.current = null;
51
+ }
52
+ };
53
+ }
54
+
55
+ // Cleanup when token is removed
56
+ if (!token && sessionSecurityRef.current) {
57
+ sessionSecurityRef.current.stopAll();
58
+ sessionSecurityRef.current = null;
59
+ }
60
+ }, [token]);
61
+
62
+ useEffect(() => {
63
+ console.log('🔍 AuthProvider useEffect triggered:', {
64
+ hasToken: !!token,
65
+ tokenLength: token?.length
66
+ });
67
+
15
68
  if (!token) {
69
+ console.log('⚠️ AuthProvider: No token, setting loading=false');
16
70
  setLoading(false);
17
71
  return;
18
72
  }
@@ -24,20 +78,28 @@ export function AuthProvider({ children }) {
24
78
  return;
25
79
  }
26
80
 
81
+ console.log('🌐 AuthProvider: Fetching profile with token...', {
82
+ authBaseUrl,
83
+ tokenPreview: token.slice(0, 50) + '...'
84
+ });
85
+
27
86
  fetch(`${authBaseUrl}/account/profile`, {
28
87
  headers: { Authorization: `Bearer ${token}` },
29
88
  credentials: 'include',
30
89
  })
31
90
  .then(res => {
91
+ console.log('📥 Profile response status:', res.status);
32
92
  if (!res.ok) throw new Error('Failed to fetch user');
33
93
  return res.json();
34
94
  })
35
95
  .then(userData => {
96
+ console.log('✅ Profile fetched successfully:', userData.email);
36
97
  setUser(userData);
98
+ setSessionValid(true);
37
99
  setLoading(false);
38
100
  })
39
101
  .catch(err => {
40
- console.error('Fetch user error:', err);
102
+ console.error('Fetch user error:', err);
41
103
  clearToken();
42
104
  setTokenState(null);
43
105
  setUser(null);
@@ -50,9 +112,14 @@ export function AuthProvider({ children }) {
50
112
  };
51
113
 
52
114
  const logout = () => {
115
+ // Stop session security before logout
116
+ stopSessionSecurity();
117
+ sessionSecurityRef.current = null;
118
+
53
119
  coreLogout();
54
120
  setUser(null);
55
121
  setTokenState(null);
122
+ setSessionValid(true);
56
123
  };
57
124
 
58
125
  const value = {
@@ -61,13 +128,17 @@ export function AuthProvider({ children }) {
61
128
  loading,
62
129
  login,
63
130
  logout,
64
- isAuthenticated: !!token && !!user,
131
+ isAuthenticated: !!token && !!user && sessionValid,
132
+ sessionValid,
65
133
  setUser,
66
134
  setToken: (newToken) => {
67
135
  setToken(newToken);
68
136
  setTokenState(newToken);
137
+ setSessionValid(true);
69
138
  },
70
139
  clearToken: () => {
140
+ stopSessionSecurity();
141
+ sessionSecurityRef.current = null;
71
142
  clearToken();
72
143
  setTokenState(null);
73
144
  setUser(null);
@@ -1,34 +1,78 @@
1
- // Create: auth-client/react/useSessionMonitor.js
1
+ // auth-client/react/useSessionMonitor.js
2
+ // Enhanced session monitoring hook for detecting Keycloak admin session deletions
2
3
  import { useQuery, useQueryClient } from '@tanstack/react-query';
4
+ import { useEffect, useCallback } from 'react';
3
5
  import { auth } from '../index';
4
6
 
7
+ /**
8
+ * useSessionMonitor - React hook for periodic session validation
9
+ *
10
+ * This hook validates that the user's session still exists in Keycloak.
11
+ * When an admin deletes a session from Keycloak Admin UI, this hook
12
+ * will detect it and trigger the onSessionInvalid callback.
13
+ *
14
+ * @param {Object} options Configuration options
15
+ * @param {boolean} options.enabled - Enable/disable monitoring (default: true)
16
+ * @param {number} options.refetchInterval - Validation interval in ms (default: 120000 = 2 min)
17
+ * @param {Function} options.onSessionInvalid - Callback when session is deleted
18
+ * @param {Function} options.onError - Callback for validation errors
19
+ * @param {boolean} options.autoLogout - Auto logout on invalid session (default: true)
20
+ * @param {boolean} options.validateOnMount - Validate session immediately on mount (default: true)
21
+ */
5
22
  export const useSessionMonitor = (options = {}) => {
6
23
  const queryClient = useQueryClient();
7
-
24
+
8
25
  const {
9
26
  enabled = true,
10
- refetchInterval = 30 * 1000, // 30 seconds
27
+ refetchInterval = 2 * 60 * 1000, // 2 minutes (matching config default)
11
28
  onSessionInvalid,
12
- onError
29
+ onError,
30
+ autoLogout = true,
31
+ validateOnMount = true,
13
32
  } = options;
14
33
 
15
- return useQuery({
34
+ // Handle session invalidation
35
+ const handleInvalid = useCallback(() => {
36
+ console.log('🚨 useSessionMonitor: Session invalid detected');
37
+
38
+ // Clear all react-query cache
39
+ queryClient.clear();
40
+
41
+ // Auto logout if enabled
42
+ if (autoLogout) {
43
+ auth.clearToken();
44
+ auth.clearRefreshToken();
45
+ }
46
+
47
+ // Call custom callback
48
+ if (onSessionInvalid) {
49
+ onSessionInvalid();
50
+ }
51
+ }, [queryClient, autoLogout, onSessionInvalid]);
52
+
53
+ // Session validation query
54
+ const query = useQuery({
16
55
  queryKey: ['session-validation'],
17
56
  queryFn: async () => {
18
57
  try {
58
+ const token = auth.getToken();
59
+ if (!token) {
60
+ return { valid: false, reason: 'no_token' };
61
+ }
62
+
63
+ console.log('🔍 useSessionMonitor: Validating session...');
19
64
  const isValid = await auth.validateCurrentSession();
20
-
21
- if (!isValid && onSessionInvalid) {
22
- // Clear all cached data
23
- queryClient.clear();
24
- // Trigger custom callback
25
- onSessionInvalid();
26
- return { valid: false };
65
+
66
+ if (!isValid) {
67
+ console.log('❌ useSessionMonitor: Session no longer valid');
68
+ handleInvalid();
69
+ return { valid: false, reason: 'session_deleted' };
27
70
  }
28
-
29
- return { valid: isValid };
71
+
72
+ console.log('✅ useSessionMonitor: Session still valid');
73
+ return { valid: true };
30
74
  } catch (error) {
31
- console.error('Session validation error:', error);
75
+ console.error('⚠️ useSessionMonitor: Validation error:', error);
32
76
  if (onError) {
33
77
  onError(error);
34
78
  }
@@ -38,12 +82,40 @@ export const useSessionMonitor = (options = {}) => {
38
82
  enabled: enabled && !!auth.getToken(),
39
83
  refetchInterval,
40
84
  refetchIntervalInBackground: true,
41
- retry: 1,
42
- onError: (error) => {
43
- console.error('Session monitor error:', error);
44
- if (onError) {
45
- onError(error);
85
+ retry: 2,
86
+ retryDelay: 5000,
87
+ staleTime: refetchInterval / 2, // Consider stale at half the interval
88
+ });
89
+
90
+ // Validate on visibility change (when user returns to tab)
91
+ useEffect(() => {
92
+ if (!enabled) return;
93
+
94
+ const handleVisibilityChange = () => {
95
+ if (document.visibilityState === 'visible' && auth.getToken()) {
96
+ console.log('👁️ useSessionMonitor: Tab visible - triggering validation');
97
+ queryClient.invalidateQueries({ queryKey: ['session-validation'] });
46
98
  }
99
+ };
100
+
101
+ document.addEventListener('visibilitychange', handleVisibilityChange);
102
+ return () => {
103
+ document.removeEventListener('visibilitychange', handleVisibilityChange);
104
+ };
105
+ }, [enabled, queryClient]);
106
+
107
+ // Validate immediately on mount if enabled
108
+ useEffect(() => {
109
+ if (validateOnMount && enabled && auth.getToken()) {
110
+ queryClient.invalidateQueries({ queryKey: ['session-validation'] });
47
111
  }
48
- });
112
+ }, [validateOnMount, enabled, queryClient]);
113
+
114
+ return {
115
+ ...query,
116
+ isSessionValid: query.data?.valid ?? true,
117
+ invalidationReason: query.data?.reason,
118
+ manualValidate: () => queryClient.invalidateQueries({ queryKey: ['session-validation'] }),
119
+ };
49
120
  };
121
+
package/token.js CHANGED
@@ -102,6 +102,30 @@ function isExpired(token, bufferSeconds = 60) {
102
102
  return decoded.exp < now + bufferSeconds;
103
103
  }
104
104
 
105
+ // ========== TOKEN EXPIRY UTILITIES ==========
106
+ // Get the exact expiry time of a token as a Date object
107
+ export function getTokenExpiryTime(token) {
108
+ if (!token) return null;
109
+ const decoded = decode(token);
110
+ if (!decoded?.exp) return null;
111
+ return new Date(decoded.exp * 1000);
112
+ }
113
+
114
+ // Get seconds until token expires (negative if already expired)
115
+ export function getTimeUntilExpiry(token) {
116
+ if (!token) return -1;
117
+ const decoded = decode(token);
118
+ if (!decoded?.exp) return -1;
119
+ const now = Date.now() / 1000;
120
+ return Math.floor(decoded.exp - now);
121
+ }
122
+
123
+ // Check if token will expire within the next N seconds
124
+ export function willExpireSoon(token, withinSeconds = 60) {
125
+ const timeLeft = getTimeUntilExpiry(token);
126
+ return timeLeft >= 0 && timeLeft <= withinSeconds;
127
+ }
128
+
105
129
  export function setToken(token) {
106
130
  const previousToken = accessToken;
107
131
  accessToken = token || null;
@@ -152,8 +176,8 @@ const REFRESH_TOKEN_KEY = 'auth_refresh_token';
152
176
 
153
177
  function isHttpDevelopment() {
154
178
  try {
155
- return typeof window !== 'undefined' &&
156
- window.location?.protocol === 'http:';
179
+ return typeof window !== 'undefined' &&
180
+ window.location?.protocol === 'http:';
157
181
  } catch (err) {
158
182
  return false;
159
183
  }
@@ -190,7 +214,7 @@ export function getRefreshToken() {
190
214
  return null;
191
215
  }
192
216
  }
193
-
217
+
194
218
  // In production, refresh token is in httpOnly cookie (not accessible via JS)
195
219
  // The refresh endpoint uses credentials: 'include' to send the cookie
196
220
  return null;
@@ -203,14 +227,14 @@ export function clearRefreshToken() {
203
227
  } catch (err) {
204
228
  // Ignore
205
229
  }
206
-
230
+
207
231
  // Clear cookie (for production)
208
232
  try {
209
233
  document.cookie = `${REFRESH_COOKIE}=; Path=/; SameSite=Strict${secureAttribute()}; Expires=Thu, 01 Jan 1970 00:00:00 GMT`;
210
234
  } catch (err) {
211
235
  console.warn('Could not clear refresh token cookie:', err);
212
236
  }
213
-
237
+
214
238
  // Clear sessionStorage
215
239
  try {
216
240
  sessionStorage.removeItem(REFRESH_COOKIE);