@spidy092/auth-client 2.1.3 → 2.1.6

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/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
 
@@ -188,24 +188,28 @@ export function handleCallback() {
188
188
 
189
189
  if (accessToken) {
190
190
  setToken(accessToken);
191
-
192
- // ✅ Refresh token should NOT be in URL - it's in httpOnly cookie
193
- // If refresh token is in URL, log warning but don't store it client-side
191
+
192
+ // ✅ For HTTP development, store refresh token from URL
193
+ // In HTTPS production, refresh token is in httpOnly cookie (more secure)
194
194
  const refreshTokenInUrl = params.get('refresh_token');
195
195
  if (refreshTokenInUrl) {
196
- console.warn('⚠️ SECURITY WARNING: Refresh token found in URL - this should not happen!');
197
- // DO NOT store refresh token from URL - it should only be in httpOnly cookie
196
+ const isHttpDev = typeof window !== 'undefined' && window.location?.protocol === 'http:';
197
+ if (isHttpDev) {
198
+ console.log('📦 HTTP dev mode: Storing refresh token from callback URL');
199
+ setRefreshToken(refreshTokenInUrl);
200
+ } else {
201
+ console.log('🔒 HTTPS mode: Refresh token is in httpOnly cookie (ignoring URL param)');
202
+ }
198
203
  }
199
-
204
+
200
205
  const url = new URL(window.location);
201
206
  url.searchParams.delete('access_token');
202
207
  url.searchParams.delete('refresh_token');
203
- url.searchParams.delete('refresh_token');
204
208
  url.searchParams.delete('state');
205
209
  url.searchParams.delete('error');
206
210
  url.searchParams.delete('error_description');
207
211
  window.history.replaceState({}, '', url);
208
-
212
+
209
213
  console.log('✅ Callback processed successfully, token stored');
210
214
  return accessToken;
211
215
  }
@@ -223,25 +227,25 @@ let refreshPromise = null;
223
227
 
224
228
  export async function refreshToken() {
225
229
  const { clientKey, authBaseUrl } = getConfig();
226
-
230
+
227
231
  // ✅ Prevent concurrent refresh calls
228
232
  if (refreshInProgress && refreshPromise) {
229
233
  console.log('🔄 Token refresh already in progress, waiting...');
230
234
  return refreshPromise;
231
235
  }
232
-
236
+
233
237
  refreshInProgress = true;
234
238
  refreshPromise = (async () => {
235
239
  try {
236
240
  // Get stored refresh token (for HTTP development)
237
241
  const storedRefreshToken = getRefreshToken();
238
-
239
- console.log('🔄 Refreshing token:', {
240
- clientKey,
242
+
243
+ console.log('🔄 Refreshing token:', {
244
+ clientKey,
241
245
  mode: isRouterMode() ? 'ROUTER' : 'CLIENT',
242
246
  hasStoredRefreshToken: !!storedRefreshToken
243
247
  });
244
-
248
+
245
249
  // Build request options - send refresh token in body and header for HTTP dev
246
250
  const requestOptions = {
247
251
  method: 'POST',
@@ -250,13 +254,13 @@ export async function refreshToken() {
250
254
  'Content-Type': 'application/json'
251
255
  }
252
256
  };
253
-
257
+
254
258
  // For HTTP development, send refresh token in body and header
255
259
  if (storedRefreshToken) {
256
260
  requestOptions.headers['X-Refresh-Token'] = storedRefreshToken;
257
261
  requestOptions.body = JSON.stringify({ refreshToken: storedRefreshToken });
258
262
  }
259
-
263
+
260
264
  const response = await fetch(`${authBaseUrl}/refresh/${clientKey}`, requestOptions);
261
265
 
262
266
  if (!response.ok) {
@@ -267,20 +271,20 @@ export async function refreshToken() {
267
271
 
268
272
  const data = await response.json();
269
273
  const { access_token, refresh_token: new_refresh_token } = data;
270
-
274
+
271
275
  if (!access_token) {
272
276
  throw new Error('No access token in refresh response');
273
277
  }
274
-
278
+
275
279
  // ✅ This will trigger token listeners
276
280
  setToken(access_token);
277
-
281
+
278
282
  // ✅ Store new refresh token if provided (token rotation)
279
283
  if (new_refresh_token) {
280
284
  setRefreshToken(new_refresh_token);
281
285
  console.log('🔄 New refresh token stored from rotation');
282
286
  }
283
-
287
+
284
288
  console.log('✅ Token refresh successful, listeners notified');
285
289
  return access_token;
286
290
  } catch (err) {
@@ -294,7 +298,7 @@ export async function refreshToken() {
294
298
  refreshPromise = null;
295
299
  }
296
300
  })();
297
-
301
+
298
302
  return refreshPromise;
299
303
  }
300
304
 
@@ -302,7 +306,7 @@ export async function validateCurrentSession() {
302
306
  try {
303
307
  const { authBaseUrl } = getConfig();
304
308
  const token = getToken();
305
-
309
+
306
310
  if (!token || !authBaseUrl) {
307
311
  return false;
308
312
  }
@@ -334,30 +338,218 @@ export async function validateCurrentSession() {
334
338
  }
335
339
  }
336
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)
345
+
346
+ let proactiveRefreshTimer = null;
347
+ let sessionValidationTimer = null;
348
+ let visibilityHandler = null;
349
+ let sessionInvalidCallbacks = new Set();
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
+ }
381
+
382
+ // Clear any existing timer
383
+ stopProactiveRefresh();
384
+
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
+ }
402
+
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
+ // Try again in 30 seconds if refresh fails
418
+ proactiveRefreshTimer = setTimeout(() => startProactiveRefresh(), 30000);
419
+ }
420
+ }, refreshIn);
421
+
422
+ return proactiveRefreshTimer;
423
+ }
424
+
425
+ export function stopProactiveRefresh() {
426
+ if (proactiveRefreshTimer) {
427
+ clearTimeout(proactiveRefreshTimer);
428
+ proactiveRefreshTimer = null;
429
+ console.log('⏹️ Proactive refresh stopped');
430
+ }
431
+ }
432
+
433
+ // ========== PERIODIC SESSION VALIDATION ==========
434
+ // Validates with server that session still exists in Keycloak
435
+ // Catches session deletions from Keycloak Admin UI
337
436
 
437
+ export function startSessionMonitor(onInvalid) {
438
+ const { enableSessionValidation, sessionValidationInterval, validateOnVisibility } = getConfig();
338
439
 
440
+ if (!enableSessionValidation) {
441
+ console.log('⏸️ Session validation disabled by config');
442
+ return null;
443
+ }
339
444
 
445
+ // Register callback if provided
446
+ if (onInvalid && typeof onInvalid === 'function') {
447
+ sessionInvalidCallbacks.add(onInvalid);
448
+ }
340
449
 
341
- // export async function refreshToken() {
342
- // const { clientKey, authBaseUrl } = getConfig();
343
-
344
- // console.log('🔄 Refreshing token:', { clientKey, mode: isRouterMode() ? 'ROUTER' : 'CLIENT' });
345
-
346
- // try {
347
- // const response = await fetch(`${authBaseUrl}/refresh/${clientKey}`, {
348
- // method: 'POST',
349
- // credentials: 'include',
350
- // });
450
+ // Clear any existing timer
451
+ stopSessionMonitor();
351
452
 
352
- // if (!response.ok) {
353
- // throw new Error('Refresh failed');
354
- // }
453
+ const token = getToken();
454
+ if (!token) {
455
+ console.log('⏸️ No token, skipping session monitor setup');
456
+ return null;
457
+ }
458
+
459
+ console.log(`👁️ Starting session monitor (interval: ${sessionValidationInterval / 1000}s)`);
460
+
461
+ // Periodic validation
462
+ sessionValidationTimer = setInterval(async () => {
463
+ try {
464
+ const currentToken = getToken();
465
+ if (!currentToken) {
466
+ console.log('⏸️ No token, stopping session validation');
467
+ stopSessionMonitor();
468
+ return;
469
+ }
470
+
471
+ console.log('🔍 Validating session...');
472
+ const isValid = await validateCurrentSession();
473
+
474
+ if (!isValid) {
475
+ console.log('❌ Session no longer valid on server');
476
+ stopSessionMonitor();
477
+ stopProactiveRefresh();
478
+ clearToken();
479
+ clearRefreshToken();
480
+ notifySessionInvalid('session_deleted');
481
+ } else {
482
+ console.log('✅ Session still valid');
483
+ }
484
+ } catch (error) {
485
+ console.warn('⚠️ Session validation check failed:', error.message);
486
+ // Don't invalidate on network errors - wait for next check
487
+ }
488
+ }, sessionValidationInterval);
489
+
490
+ // Visibility-based validation (when tab becomes visible again)
491
+ if (validateOnVisibility && typeof document !== 'undefined') {
492
+ visibilityHandler = async () => {
493
+ if (document.visibilityState === 'visible') {
494
+ const currentToken = getToken();
495
+ if (!currentToken) return;
496
+
497
+ console.log('👁️ Tab visible - validating session');
498
+ try {
499
+ const isValid = await validateCurrentSession();
500
+ if (!isValid) {
501
+ console.log('❌ Session expired while tab was hidden');
502
+ stopSessionMonitor();
503
+ stopProactiveRefresh();
504
+ clearToken();
505
+ clearRefreshToken();
506
+ notifySessionInvalid('session_deleted_while_hidden');
507
+ }
508
+ } catch (error) {
509
+ console.warn('⚠️ Visibility check failed:', error.message);
510
+ }
511
+ }
512
+ };
513
+ document.addEventListener('visibilitychange', visibilityHandler);
514
+ }
515
+
516
+ return sessionValidationTimer;
517
+ }
518
+
519
+ export function stopSessionMonitor() {
520
+ if (sessionValidationTimer) {
521
+ clearInterval(sessionValidationTimer);
522
+ sessionValidationTimer = null;
523
+ console.log('⏹️ Session monitor stopped');
524
+ }
525
+
526
+ if (visibilityHandler && typeof document !== 'undefined') {
527
+ document.removeEventListener('visibilitychange', visibilityHandler);
528
+ visibilityHandler = null;
529
+ }
530
+ }
531
+
532
+ // ========== COMBINED SESSION SECURITY ==========
533
+ // Start both proactive refresh and session monitoring
534
+
535
+ export function startSessionSecurity(onSessionInvalidCallback) {
536
+ console.log('🔐 Starting session security (proactive refresh + session monitoring)');
537
+
538
+ startProactiveRefresh();
539
+ startSessionMonitor(onSessionInvalidCallback);
540
+
541
+ return {
542
+ stopAll: () => {
543
+ stopProactiveRefresh();
544
+ stopSessionMonitor();
545
+ }
546
+ };
547
+ }
548
+
549
+ export function stopSessionSecurity() {
550
+ stopProactiveRefresh();
551
+ stopSessionMonitor();
552
+ sessionInvalidCallbacks.clear();
553
+ console.log('🔐 Session security stopped');
554
+ }
355
555
 
356
- // const { access_token } = await response.json();
357
- // setToken(access_token);
358
- // return access_token;
359
- // } catch (err) {
360
- // clearToken();
361
- // throw err;
362
- // }
363
- // }
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.3",
3
+ "version": "2.1.6",
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);