From 1b977ec517feab0a503282c8c3e3eafd2c5641bf Mon Sep 17 00:00:00 2001 From: michael Date: Wed, 3 Sep 2025 13:15:00 -0500 Subject: [PATCH] graceful handling of expired JWT token --- .DS_Store | Bin 14340 -> 14340 bytes .gitignore | 175 +++++++++++++++++- backend/.DS_Store | Bin 14340 -> 14340 bytes .../focus_group_ai.cpython-313.pyc | Bin 56522 -> 56652 bytes backend/app/routes/focus_group_ai.py | 8 +- backend/app/websocket_manager_async.py | 40 ++-- backend/requirements.txt | 42 +++-- dist/index.html | 2 +- src/contexts/AuthContext.tsx | 82 +++++--- src/contexts/NavigationContext.tsx | 1 + src/hooks/usePersonaDetails.ts | 11 +- src/lib/api.ts | 26 +++ src/pages/SyntheticUsers.tsx | 21 ++- src/services/websocketServiceNew.ts | 11 ++ 14 files changed, 355 insertions(+), 64 deletions(-) diff --git a/.DS_Store b/.DS_Store index 8fbbb0f027e7bd852724f78d7e12da38a511cbec..f32eb59739fc834528c266529377b51692cfe112 100644 GIT binary patch delta 1388 zcmeIwNlX(_7zgn8ABvrEBEC{11;iE#WeH#xs5BT-1;SEf-!uxgFviBF6k0(gxZ=U} zG70X{gAt7i8bpnH@In+#I2b})4q{?F$w3Joj1pgHF)?C{M-%7r<@?WM-uv?VdVRgV ziz>A_U1zan_oVB(O=eqGy3U$o8&a!QQu#3LTIi*$R$1h!_%$Z0%ja?X14iR3aAAt@ zMM_>+EQ(7sb@g=(jT@RN-Qkz2C66=U?vx6g0m&~{CTz7fx?9@3HSXO~GmY$YOI;Pd zfV<7>ua`RftO~+FMAVA-q_kC8w!HizN5%M3s)`gvc{U-Lqy2JiVj5rZ4YM52sklVWz$J4jTqL~k-qV^Apa*<&hbGm8BprT$&! O17iP~4T$)81HJ>^>OqnK delta 1308 zcmeIxT}V@590u_BdFRJjzT#P8OheCfIW0EbC(bNcABrE%i<%#)5R*+?Tbng8bx24S zr4rGp?m~^aEDEh8$&3n$D5#{ssH7_^@+t^Rtiv{;q`IoR&dqzChxg|H|MB*=_O=cw zl1*BbrNG>+RfP=3LbDVMdT~6{G*YVgkc2UDQn@cj844NoT9w&s_~}}Ax138+=NE0? z<@N3L2l~W^nF9JnXLholUiTJl(}o=9Pe}lJzegpBwn$SFQ|*4OeY#*B{t$9%_Km2$N(86qvR%;Adkp%@|w(&IWkYakgsGBGB6|} z2|QAf1vS<{0~0o2BZ^Uh9dMu;wP=JJ&1iuKejGptIuSw-`p}O79LE_9VhF=HkBhj5 z>kf?J1|Hxko?!}aFpGD1j|Cb_<7hlp&@`%}`BYC0)JTiyR%)m9v>`GLMRclYUYe2m zB%kz*@n72rW#tu>%>ILoQM71UB#g5f9naJPA1f`In^LBxt=2GcS|fNFFOz)a;mRWD z7!NKI3Rw~3V42$yKF6v54#^WTO=cvLPh=i(NRS`|U`b&RYr(;GrPfGb^r&z4Z8pU diff --git a/backend/app/routes/__pycache__/focus_group_ai.cpython-313.pyc b/backend/app/routes/__pycache__/focus_group_ai.cpython-313.pyc index 0067947a036b73b3d80d7b1452dbfd784dc068c3..e416ec314547359a511defd79d13221f98467c36 100644 GIT binary patch delta 419 zcmX@LlljapX5P=dyj%=G;NiX_W5!0_Ni*1*fxOQ-lecXPWC~`Q{9}$hGjk}@%^(oUJb9zP{N(vlr6&KJS;6Qxxp3Cx$*i-h zG%f)pUotc>d=L_46?h_HutDy!ghL1K2Q~(AmFr?w7saeDi`iTku$jDQb|A+`ZU#Zy zA78{K3zUW?eU1fcZDMpXVQi6elw?@M=qS#zSRTka$OIBR$jk&{vx3=d0wA^+m@Uo- zVoP#488RPKc4T(aVLqhB2;>}6X9a3Iq{9kg8!`jglPl&}Q^CW$^D4m}7M(nM-h|0I z^Gh_Y;qUK zoGGdm%%#T!R{%7km`R=?m^+Wzkr`+tm`-C%W3XkGXP7KBt9o+ZtjUailND!IF`r;y zn7m-ND7T>8bpex$0w$LQ%qAb69mxFS3(sVM(h$90pEZEiHkmo{GVEjm5_<%I#6eyz zCspQy;*QKt3e1P37=fHa3amhK^5r?!gU!E<^J*slo3|9`-^KGwm`_9fE2?x|#O9)i f&1Dh0$@U9E7=KKT%hR8Ha)CPIugxD9MB4xWQW$LN diff --git a/backend/app/routes/focus_group_ai.py b/backend/app/routes/focus_group_ai.py index 0fd177dc..67014c70 100644 --- a/backend/app/routes/focus_group_ai.py +++ b/backend/app/routes/focus_group_ai.py @@ -888,7 +888,7 @@ async def get_autonomous_conversation_status(focus_group_id): @focus_group_ai_bp.route('/conversation/state/', methods=['GET']) @jwt_required(optional=True) -def get_conversation_state(focus_group_id): +async def get_conversation_state(focus_group_id): """ Get the current conversation state for a focus group. @@ -900,7 +900,7 @@ def get_conversation_state(focus_group_id): state_manager = ConversationStateManager(focus_group_id) # Get state - state = state_manager.get_conversation_state() + state = await state_manager.get_conversation_state() if "error" in state: return jsonify(state), 404 if "not found" in state["error"] else 500 @@ -920,7 +920,7 @@ def get_conversation_state(focus_group_id): @focus_group_ai_bp.route('/conversation/analytics/', methods=['GET']) @jwt_required(optional=True) -def get_conversation_analytics(focus_group_id): +async def get_conversation_analytics(focus_group_id): """ Get detailed conversation analytics for a focus group. @@ -932,7 +932,7 @@ def get_conversation_analytics(focus_group_id): state_manager = ConversationStateManager(focus_group_id) # Get analytics - analytics = state_manager.get_conversation_analytics() + analytics = await state_manager.get_conversation_analytics() if "error" in analytics: return jsonify(analytics), 404 if "not found" in analytics["error"] else 500 diff --git a/backend/app/websocket_manager_async.py b/backend/app/websocket_manager_async.py index 4a58284a..378a6385 100644 --- a/backend/app/websocket_manager_async.py +++ b/backend/app/websocket_manager_async.py @@ -43,18 +43,15 @@ class AsyncWebSocketManager: print(f"🔌 ASYNC PROCESS DEBUG - Connection handler PID: {process_id}, Thread: {thread_id}") logger.info(f"WebSocket connection attempt from {sid}") - # Validate JWT token from auth data (temporarily allow without token for testing) + # Validate JWT token from auth data - require authentication if not auth or 'token' not in auth: - logger.warning(f"WebSocket connection without auth token - allowing for testing") - # Temporarily allow connections without tokens for testing - self.user_sessions[sid] = { - 'user_id': 'test_user', # Default user for testing - 'connected_at': datetime.utcnow(), - 'focus_groups': set() - } - logger.info(f"WebSocket connected without auth - Session: {sid}") - await self.sio.emit('connected', {'status': 'success', 'session_id': sid}, to=sid) - return True + logger.warning(f"WebSocket connection without auth token - rejecting") + await self.sio.emit('auth_error', { + 'error': 'missing_token', + 'message': 'Authentication token required' + }, to=sid) + await self.sio.disconnect(sid) + return False try: # Decode and validate JWT token with better error handling @@ -84,15 +81,14 @@ class AsyncWebSocketManager: logger.warning(f"JWT decode failed: {jwt_error}") logger.warning(f"Token format: {len(token_parts)} segments, first 20 chars: {token[:20]}...") - # During migration, allow connection with test user instead of disconnecting - logger.info(f"Allowing WebSocket connection with test user due to JWT transition") - self.user_sessions[sid] = { - 'user_id': 'test_user', # Default user during JWT transition - 'connected_at': datetime.utcnow(), - 'focus_groups': set() - } - await self.sio.emit('connected', {'status': 'success', 'session_id': sid, 'auth': 'fallback'}, to=sid) - return True + # Reject connection for expired or invalid tokens + await self.sio.emit('auth_error', { + 'error': 'invalid_token', + 'message': f'Token validation failed: {str(jwt_error)}', + 'expired': 'expired' in str(jwt_error).lower() + }, to=sid) + await self.sio.disconnect(sid) + return False # Store user session info self.user_sessions[sid] = { @@ -108,6 +104,10 @@ class AsyncWebSocketManager: except Exception as e: logger.error(f"Connection authentication failed: {e}") + await self.sio.emit('auth_error', { + 'error': 'authentication_failed', + 'message': 'Authentication error occurred' + }, to=sid) await self.sio.disconnect(sid) return False diff --git a/backend/requirements.txt b/backend/requirements.txt index 424ee1af..741f6678 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -1,17 +1,33 @@ -flask -werkzeug -flask-cors -pymongo -python-dotenv -flask-jwt-extended -bcrypt -pydantic +# Web Framework (Async) +quart +quart-cors hypercorn +werkzeug + +# Database (Async) +motor +pymongo + +# Authentication & Security +bcrypt +PyJWT +msal + +# AI & LLM Services google-genai openai -requests llama-cloud-services -msal -PyJWT -flask-socketio -eventlet \ No newline at end of file + +# WebSocket & Real-time +python-socketio + +# HTTP Clients +httpx +requests + +# Data Validation & Processing +pydantic +pillow + +# Configuration & Utilities +python-dotenv \ No newline at end of file diff --git a/dist/index.html b/dist/index.html index 6c1eb4a8..081a98f6 100644 --- a/dist/index.html +++ b/dist/index.html @@ -7,7 +7,7 @@ - + diff --git a/src/contexts/AuthContext.tsx b/src/contexts/AuthContext.tsx index 00737b5a..40e8cef8 100644 --- a/src/contexts/AuthContext.tsx +++ b/src/contexts/AuthContext.tsx @@ -49,19 +49,56 @@ export function AuthProvider({ children }: { children: ReactNode }) { } // For all other auth errors, clear the session and redirect - setToken(null); - setUser(null); + clearAuthData(); toast.error('Session expired', { description: 'Please log in again' }); navigate('/login'); }; + // Listen for WebSocket authentication errors + const handleWebSocketAuthError = (event: Event) => { + const customEvent = event as CustomEvent; + const errorData = customEvent.detail || {}; + + console.log('WebSocket authentication error:', errorData); + + // Clear auth data and redirect to login + clearAuthData(); + toast.error('Session expired', { + description: errorData.expired ? 'Your session has expired. Please log in again.' : 'Authentication failed. Please log in again.' + }); + navigate('/login'); + }; + window.addEventListener(AUTH_ERROR_EVENT, handleAuthError); + window.addEventListener('ws:auth_error', handleWebSocketAuthError); return () => { window.removeEventListener(AUTH_ERROR_EVENT, handleAuthError); + window.removeEventListener('ws:auth_error', handleWebSocketAuthError); }; }, [navigate]); + // Helper function to check if JWT token is expired + const isTokenExpired = (token: string): boolean => { + try { + const payload = JSON.parse(atob(token.split('.')[1])); + const currentTime = Date.now() / 1000; + return payload.exp < currentTime; + } catch (error) { + console.error('Error parsing JWT token:', error); + return true; // Treat malformed tokens as expired + } + }; + + // Helper function to clear authentication data + const clearAuthData = () => { + localStorage.removeItem('auth_token'); + localStorage.removeItem('user'); + localStorage.removeItem('auth_type'); + setToken(null); + setUser(null); + }; + useEffect(() => { // Check if user is already logged in const storedToken = localStorage.getItem('auth_token'); @@ -73,14 +110,20 @@ export function AuthProvider({ children }: { children: ReactNode }) { }); if (storedToken && storedUser) { - try { - setToken(storedToken); - setUser(JSON.parse(storedUser)); - console.log('User session restored from localStorage'); - } catch (error) { - console.error('Failed to parse stored user data:', error); - localStorage.removeItem('auth_token'); - localStorage.removeItem('user'); + // Check if token is expired + if (isTokenExpired(storedToken)) { + console.log('Stored token is expired, clearing authentication data'); + clearAuthData(); + toast.error('Session expired', { description: 'Please log in again' }); + } else { + try { + setToken(storedToken); + setUser(JSON.parse(storedUser)); + console.log('User session restored from localStorage'); + } catch (error) { + console.error('Failed to parse stored user data:', error); + clearAuthData(); + } } } else { console.log('No stored authentication data found'); @@ -114,12 +157,11 @@ export function AuthProvider({ children }: { children: ReactNode }) { }) .catch(error => { if (error.response && error.response.status === 401) { - // Handle unauthorized - invalid token - console.error('Token invalid (401):', error); - localStorage.removeItem('auth_token'); - localStorage.removeItem('user'); - setToken(null); - setUser(null); + // Handle unauthorized - invalid or expired token + console.error('Token invalid or expired (401):', error); + clearAuthData(); + toast.error('Session expired', { description: 'Please log in again' }); + navigate('/login'); } else { console.warn('Profile validation error (not clearing token):', error); // Mark as validated anyway to prevent repeated retries @@ -219,12 +261,8 @@ export function AuthProvider({ children }: { children: ReactNode }) { const logout = async () => { const authType = localStorage.getItem('auth_type'); - // Clear local storage - localStorage.removeItem('auth_token'); - localStorage.removeItem('user'); - localStorage.removeItem('auth_type'); - setToken(null); - setUser(null); + // Clear local storage using helper function + clearAuthData(); // If user was authenticated with Microsoft, also sign out from Microsoft if (authType === 'microsoft' && accounts.length > 0) { diff --git a/src/contexts/NavigationContext.tsx b/src/contexts/NavigationContext.tsx index daeb4021..cccd8c11 100644 --- a/src/contexts/NavigationContext.tsx +++ b/src/contexts/NavigationContext.tsx @@ -6,6 +6,7 @@ export interface NavigationState { focusGroupTab?: 'setup' | 'review' | 'participants'; isNewFocusGroup?: boolean; focusGroupData?: any; + folderId?: string; // For persona list folder context } interface NavigationContextType { diff --git a/src/hooks/usePersonaDetails.ts b/src/hooks/usePersonaDetails.ts index e7e7f166..3ff1e2c4 100644 --- a/src/hooks/usePersonaDetails.ts +++ b/src/hooks/usePersonaDetails.ts @@ -645,7 +645,16 @@ export function usePersonaDetails() { } // Clear navigation state after using it clearNavigationState(); - } else if (isFromReview) { + } + // Check if we came from synthetic users with a specific folder context + else if (navigationState.previousRoute === '/synthetic-users' && navigationState.folderId) { + // Navigate back to synthetic users with the folder filter applied + // We'll use URL state to restore the folder selection + navigate(`/synthetic-users?folder=${navigationState.folderId}`); + // Clear navigation state after using it + clearNavigationState(); + } + else if (isFromReview) { // Legacy behavior for review mode navigate('/synthetic-users?mode=create&tab=ai&step=review'); } else { diff --git a/src/lib/api.ts b/src/lib/api.ts index 731daf19..410e4bc5 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -14,11 +14,37 @@ const api = axios.create({ timeout: 600000 // 10 minutes default timeout for AI operations }); +// Helper function to check if JWT token is expired +const isTokenExpired = (token: string): boolean => { + try { + const payload = JSON.parse(atob(token.split('.')[1])); + const currentTime = Date.now() / 1000; + return payload.exp < currentTime; + } catch (error) { + console.error('Error parsing JWT token:', error); + return true; // Treat malformed tokens as expired + } +}; + // Request interceptor to add JWT token api.interceptors.request.use( (config) => { const token = localStorage.getItem('auth_token'); if (token) { + // Check if token is expired before making request + if (isTokenExpired(token)) { + console.log('Token expired, clearing auth data before request'); + localStorage.removeItem('auth_token'); + localStorage.removeItem('user'); + localStorage.removeItem('auth_type'); + + // Dispatch auth error to trigger redirect + dispatchAuthError({ source: config.url }); + + // Reject the request + return Promise.reject(new Error('Token expired')); + } + config.headers.Authorization = `Bearer ${token}`; } diff --git a/src/pages/SyntheticUsers.tsx b/src/pages/SyntheticUsers.tsx index 31534eac..1b0ec035 100644 --- a/src/pages/SyntheticUsers.tsx +++ b/src/pages/SyntheticUsers.tsx @@ -78,7 +78,7 @@ const SyntheticUsers = () => { const navigate = useNavigate(); const [searchParams] = useSearchParams(); const { loadPersonas } = usePersonaStorage(); - const { clearNavigationState } = useNavigation(); + const { clearNavigationState, setPreviousRoute } = useNavigation(); const [mode, setMode] = useState<'view' | 'create'>('view'); const [creationMode, setCreationMode] = useState<'manual' | 'ai'>('ai'); @@ -88,12 +88,18 @@ const SyntheticUsers = () => { const [isCreatingFolder, setIsCreatingFolder] = useState(false); const [newFolderName, setNewFolderName] = useState(''); - // Handle URL parameters to set mode + // Handle URL parameters to set mode and folder useEffect(() => { const modeParam = searchParams.get('mode'); if (modeParam === 'view' || modeParam === 'create') { setMode(modeParam); } + + // Handle folder parameter to restore folder selection + const folderParam = searchParams.get('folder'); + if (folderParam) { + setSelectedFolder(folderParam); + } }, [searchParams]); const [allPersonas, setAllPersonas] = useState([]); const [folders, setFolders] = useState([]); @@ -150,6 +156,16 @@ const SyntheticUsers = () => { // Navigate to persona details navigate(`/synthetic-users/${persona._id || persona.id}`); }; + + // Handle navigation to persona details with folder context + const handlePersonaViewDetails = (persona: Persona) => { + // Set navigation context to remember which folder we came from + setPreviousRoute('/synthetic-users', { + folderId: selectedFolder !== DEFAULT_FOLDER_ID ? selectedFolder : undefined + }); + // Navigate to persona details + navigate(`/synthetic-users/${persona._id || persona.id}`); + }; // Function to collect unique filter options from personas const getFilterOptions = (personas: Persona[]) => { @@ -1416,6 +1432,7 @@ const SyntheticUsers = () => { e.stopPropagation(); togglePersonaSelection(persona.id); }} + onViewDetails={handlePersonaViewDetails} showAddToFolderButton={false} folders={folders} /> diff --git a/src/services/websocketServiceNew.ts b/src/services/websocketServiceNew.ts index 4aefb617..09c3df8b 100644 --- a/src/services/websocketServiceNew.ts +++ b/src/services/websocketServiceNew.ts @@ -111,6 +111,11 @@ export function initSocket(getToken: () => string): Socket { case 'error': console.error('🔧 [GPT-5] *** ROUTING error from onAny ***', payload); break; + + case 'auth_error': + console.error('🔧 [GPT-5] *** ROUTING auth_error from onAny ***', payload); + window.dispatchEvent(new CustomEvent("ws:auth_error", { detail: payload })); + break; } }); @@ -251,6 +256,11 @@ function bindCoreListeners() { console.log('🔧 [GPT-5] mode_event_update:', payload); window.dispatchEvent(new CustomEvent("ws:mode_event_update", { detail: payload })); }; + + const onAuthError = (payload: any) => { + console.error('🔧 [GPT-5] auth_error:', payload); + window.dispatchEvent(new CustomEvent("ws:auth_error", { detail: payload })); + }; // Bind all listeners console.log('🔧 [GPT-5] BINDING specific listeners to socket'); @@ -262,6 +272,7 @@ function bindCoreListeners() { socket.on("theme_update", onTheme); socket.on("focus_group_update", onFG); socket.on("mode_event_update", onModeEvent); + socket.on("auth_error", onAuthError); console.log('🔧 [GPT-5] BOUND specific listeners to socket'); // GPT-5 DEBUG: Verify listeners are actually attached