@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 +1 -1
- package/config.js +27 -0
- package/core.js +248 -45
- package/index.js +48 -3
- package/package.json +1 -1
- package/react/AuthProvider.jsx +76 -5
- package/react/useSessionMonitor.js +93 -21
- package/token.js +29 -5
package/api.js
CHANGED
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: '
|
|
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
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
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
|
-
//
|
|
357
|
-
|
|
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 {
|
|
4
|
-
|
|
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
|
-
//
|
|
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
package/react/AuthProvider.jsx
CHANGED
|
@@ -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 {
|
|
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
|
-
//
|
|
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 =
|
|
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
|
-
|
|
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
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
|
|
71
|
+
|
|
72
|
+
console.log('✅ useSessionMonitor: Session still valid');
|
|
73
|
+
return { valid: true };
|
|
30
74
|
} catch (error) {
|
|
31
|
-
console.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:
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
|
|
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);
|