Skip to content

Commit ea940ee

Browse files
chloe-yuunpham49
andauthored
fix keycloak session timeout errors (#1158)
Co-authored-by: npham49 <brian.1.pham@gov.bc.ca>
1 parent ec9fc42 commit ea940ee

File tree

3 files changed

+198
-17
lines changed

3 files changed

+198
-17
lines changed

client/src/providers/KeycloakProvider.js

Lines changed: 183 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import React, { createContext, useContext, useEffect, useState, useCallback } from 'react';
1+
import React, { createContext, useContext, useEffect, useState, useCallback, useRef } from 'react';
22
import Keycloak from 'keycloak-js';
33
import LinearProgress from '@mui/material/LinearProgress';
44
import { API_URL } from '../constants';
@@ -21,8 +21,16 @@ export const KeycloakProvider = ({ children, onTokens }) => {
2121
const [loading, setLoading] = useState(true);
2222
const [error, setError] = useState(null);
2323
const { dispatch } = useAuth();
24+
const initialized = useRef(false);
25+
const refreshTimerRef = useRef(null);
2426

2527
const initKeycloak = useCallback(async () => {
28+
// Prevent re-initialization
29+
if (initialized.current) {
30+
return;
31+
}
32+
initialized.current = true;
33+
2634
try {
2735
setLoading(true);
2836
setError(null);
@@ -47,23 +55,68 @@ export const KeycloakProvider = ({ children, onTokens }) => {
4755
}
4856

4957
// Create Keycloak instance
50-
const keycloakInstance = new Keycloak({
58+
let keycloakInstance = new Keycloak({
5159
realm: result.realm,
5260
url: result.url,
5361
clientId: result.clientId,
5462
});
5563

5664
// Initialize Keycloak
57-
const authenticated = await keycloakInstance.init({
58-
pkceMethod: 'S256',
59-
checkLoginIframe: false,
60-
});
65+
// Disable iframe check on localhost to avoid CORS/iframe issues
66+
const isLocalhost =
67+
window.location.hostname.includes('localhost') ||
68+
window.location.hostname.includes('127.0.0.1');
69+
70+
let authenticated;
71+
72+
if (isLocalhost) {
73+
console.log('Running on localhost - disabling iframe check');
74+
authenticated = await keycloakInstance.init({
75+
pkceMethod: 'S256',
76+
checkLoginIframe: false,
77+
});
78+
} else {
79+
try {
80+
authenticated = await keycloakInstance.init({
81+
pkceMethod: 'S256',
82+
checkLoginIframe: true,
83+
checkLoginIframeInterval: 30, // Check every 30 seconds
84+
messageReceiveTimeout: 10000, // Wait up to 10 seconds for iframe
85+
});
86+
} catch (initError) {
87+
console.warn(
88+
'Keycloak iframe initialization failed, falling back to token-only mode:',
89+
initError,
90+
);
91+
// Create a new instance for fallback since Keycloak can only be initialized once
92+
keycloakInstance = new Keycloak({
93+
realm: result.realm,
94+
url: result.url,
95+
clientId: result.clientId,
96+
});
97+
authenticated = await keycloakInstance.init({
98+
pkceMethod: 'S256',
99+
checkLoginIframe: false,
100+
});
101+
}
102+
}
61103

62104
if (authenticated) {
63105
// Extract user data from Keycloak token
64106
const tokenParsed = keycloakInstance.tokenParsed;
65107

66108
if (tokenParsed) {
109+
// Debug token expiration times
110+
const now = Math.floor(Date.now() / 1000);
111+
const tokenExp = tokenParsed.exp;
112+
const tokenMinutesLeft = Math.floor((tokenExp - now) / 60);
113+
console.log('=== TOKEN DEBUG INFO ===');
114+
console.log('Current time:', new Date(now * 1000).toLocaleTimeString());
115+
console.log('Token expires at:', new Date(tokenExp * 1000).toLocaleTimeString());
116+
console.log('Token valid for:', tokenMinutesLeft, 'minutes');
117+
console.log('checkLoginIframe enabled:', !keycloakInstance.checkLoginIframe === false);
118+
console.log('========================');
119+
67120
// Check different possible locations for roles
68121
const realmRoles = tokenParsed.realm_access?.roles || [];
69122
const clientRoles = tokenParsed.resource_access?.[tokenParsed.aud]?.roles || [];
@@ -131,6 +184,46 @@ export const KeycloakProvider = ({ children, onTokens }) => {
131184
setKeycloak(keycloakInstance);
132185
setAuthenticated(authenticated);
133186

187+
// Set up global fetch interceptor for 401 errors
188+
if (authenticated) {
189+
const originalFetch = window.fetch;
190+
window.fetch = async (...args) => {
191+
try {
192+
const response = await originalFetch(...args);
193+
194+
// If we get 401 Unauthorized, session has expired
195+
if (response.status === 401 && storage.get('TOKEN')) {
196+
console.error('401 Unauthorized - clearing session');
197+
storage.remove('TOKEN');
198+
setAuthenticated(false);
199+
alert('Your session has expired. Please login again.');
200+
keycloakInstance.login({
201+
redirectUri: window.location.href,
202+
});
203+
return response;
204+
}
205+
206+
return response;
207+
} catch (error) {
208+
// Handle network errors (CORS, network failure, etc.)
209+
// These often happen when token is expired and server rejects the request
210+
if (
211+
storage.get('TOKEN') &&
212+
(error.message.includes('Failed to fetch') || error.message.includes('CORS'))
213+
) {
214+
console.error('Network error with expired token - clearing session');
215+
storage.remove('TOKEN');
216+
setAuthenticated(false);
217+
alert('Your session has expired. Please login again.');
218+
keycloakInstance.login({
219+
redirectUri: window.location.href,
220+
});
221+
}
222+
throw error; // Re-throw for other types of errors
223+
}
224+
};
225+
}
226+
134227
// Set up token refresh
135228
if (authenticated) {
136229
const tokens = {
@@ -143,15 +236,13 @@ export const KeycloakProvider = ({ children, onTokens }) => {
143236
onTokens(tokens);
144237
}
145238

146-
// Also call user info loading here if needed
147-
// You can pass a getUserInfo callback through props
148-
149-
// Set up automatic token refresh
150-
keycloakInstance.onTokenExpired = () => {
239+
// Helper function to handle token refresh
240+
const refreshToken = () => {
151241
keycloakInstance
152-
.updateToken(30)
242+
.updateToken(60) // Refresh if token expires in less than 60 seconds
153243
.then((refreshed) => {
154244
if (refreshed) {
245+
console.log('Token refreshed successfully');
155246
const newTokens = {
156247
token: keycloakInstance.token,
157248
refreshToken: keycloakInstance.refreshToken,
@@ -160,13 +251,77 @@ export const KeycloakProvider = ({ children, onTokens }) => {
160251
if (onTokens) {
161252
onTokens(newTokens);
162253
}
254+
storage.set('TOKEN', keycloakInstance.token);
255+
256+
// Schedule next refresh after successful refresh
257+
scheduleTokenRefresh();
258+
} else {
259+
console.log('Token not refreshed, still valid');
260+
// Token still valid, check again in 10 seconds
261+
// This prevents tight loops when token is close to but not within refresh threshold
262+
refreshTimerRef.current = setTimeout(() => {
263+
refreshToken();
264+
}, 10000);
163265
}
164266
})
165-
.catch(() => {
166-
console.error('Failed to refresh token');
167-
keycloakInstance.login();
267+
.catch((error) => {
268+
console.error('Failed to refresh token - session expired', error);
269+
// Clear stored token to prevent further API calls with expired token
270+
storage.remove('TOKEN');
271+
// Alert user and redirect to login
272+
alert('Your session has expired. You will be redirected to login.');
273+
keycloakInstance.login({
274+
redirectUri: window.location.href,
275+
});
168276
});
169277
};
278+
279+
// Schedule token refresh before expiration
280+
const scheduleTokenRefresh = () => {
281+
// Clear any existing timer
282+
if (refreshTimerRef.current) {
283+
clearTimeout(refreshTimerRef.current);
284+
}
285+
286+
// Calculate when to refresh (e.g., 1 minute before expiration)
287+
const tokenParsed = keycloakInstance.tokenParsed;
288+
if (tokenParsed && tokenParsed.exp) {
289+
const now = Math.floor(Date.now() / 1000);
290+
const tokenExp = tokenParsed.exp;
291+
const timeUntilExpiry = tokenExp - now;
292+
293+
// Refresh 60 seconds before expiration
294+
// Add minimum delay of 10 seconds to prevent tight loops
295+
const refreshTime = Math.max(10000, (timeUntilExpiry - 60) * 1000);
296+
297+
console.log(`Next token refresh check in ${Math.floor(refreshTime / 1000)} seconds`);
298+
299+
refreshTimerRef.current = setTimeout(() => {
300+
refreshToken();
301+
}, refreshTime);
302+
}
303+
};
304+
305+
// Start the refresh schedule
306+
scheduleTokenRefresh();
307+
308+
// Set up automatic token refresh as fallback (in case timer misses)
309+
keycloakInstance.onTokenExpired = () => {
310+
console.warn('Token expired - refreshing immediately (fallback handler)');
311+
refreshToken();
312+
};
313+
314+
// Monitor SSO session state when checkLoginIframe is enabled
315+
keycloakInstance.onAuthLogout = () => {
316+
console.log('SSO logout detected');
317+
if (refreshTimerRef.current) {
318+
clearTimeout(refreshTimerRef.current);
319+
}
320+
storage.remove('TOKEN');
321+
setAuthenticated(false);
322+
alert('You have been logged out. Please login again.');
323+
window.location.reload();
324+
};
170325
}
171326

172327
// Save environment variables
@@ -178,18 +333,30 @@ export const KeycloakProvider = ({ children, onTokens }) => {
178333
} catch (err) {
179334
console.error('Failed to initialize Keycloak:', err);
180335
setError(err.message);
336+
// Set a fallback null keycloak to prevent crashes
337+
setKeycloak(null);
338+
setAuthenticated(false);
181339
} finally {
182340
setLoading(false);
183341
}
184342
}, [onTokens, dispatch]);
185343

186344
useEffect(() => {
187345
initKeycloak();
346+
347+
// Cleanup function to clear refresh timer
348+
return () => {
349+
if (refreshTimerRef.current) {
350+
clearTimeout(refreshTimerRef.current);
351+
}
352+
};
188353
}, [initKeycloak]);
189354

190355
const login = useCallback(() => {
191356
if (keycloak) {
192-
keycloak.login();
357+
keycloak.login({
358+
redirectUri: window.location.href,
359+
});
193360
}
194361
}, [keycloak]);
195362

openshift/server.dc.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ objects:
3030
metadata:
3131
name: ${APP_NAME}-server
3232
annotations:
33-
haproxy.router.openshift.io/timeout: 90s
33+
haproxy.router.openshift.io/timeout: 300s
3434
haproxy.router.openshift.io/disable_cookies: true
3535
spec:
3636
host: ''

server/package-lock.json

Lines changed: 14 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)