graceful handling of expired JWT token

This commit is contained in:
michael 2025-09-03 13:15:00 -05:00
parent 6a40936508
commit 1b977ec517
14 changed files with 355 additions and 64 deletions

BIN
.DS_Store vendored

Binary file not shown.

175
.gitignore vendored
View file

@ -1,7 +1,180 @@
# Dependencies
node_modules/
venv/
env/
# Build outputs
dist/
build/
*.tsbuildinfo
# Environment variables
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Coverage directory used by tools like istanbul
coverage/
*.lcov
# nyc test coverage
.nyc_output
# Dependency directories
node_modules/
jspm_packages/
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# TypeScript cache
*.tsbuildinfo
# Optional eslint cache
.eslintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Ignore Python cache files
__pycache__/
*.py[cod]
*pycache*
*.pyo
*.pyd
.Python
*.so
.pytest_cache/
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
*.manifest
*.spec
# Unit test / coverage reports
htmlcov/
.tox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
.hypothesis/
.pytest_cache/
# Jupyter Notebook
.ipynb_checkpoints
# pyenv
.python-version
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# IDE and editor files
.vscode/
.idea/
*.swp
*.swo
*~
.DS_Store
Thumbs.db
# Temporary files
*.tmp
*.temp
temp/
tmp/
uploads/
# OS generated files
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
ehthumbs.db
Thumbs.db
# Database files
*.db
*.sqlite
*.sqlite3
# Backup files
*.bak
*.backup

BIN
backend/.DS_Store vendored

Binary file not shown.

View file

@ -888,7 +888,7 @@ async def get_autonomous_conversation_status(focus_group_id):
@focus_group_ai_bp.route('/conversation/state/<focus_group_id>', 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/<focus_group_id>', 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

View file

@ -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

View file

@ -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
# WebSocket & Real-time
python-socketio
# HTTP Clients
httpx
requests
# Data Validation & Processing
pydantic
pillow
# Configuration & Utilities
python-dotenv

2
dist/index.html vendored
View file

@ -7,7 +7,7 @@
<meta name="description" content="Lovable Generated Project" />
<meta name="author" content="Lovable" />
<meta property="og:image" content="/og-image.png" />
<script type="module" crossorigin src="/semblance/assets/index-BXPouqjR.js"></script>
<script type="module" crossorigin src="/semblance/assets/index-CLQA4rqQ.js"></script>
<link rel="stylesheet" crossorigin href="/semblance/assets/index-8o0iGAjY.css">
</head>

View file

@ -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<any>;
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) {

View file

@ -6,6 +6,7 @@ export interface NavigationState {
focusGroupTab?: 'setup' | 'review' | 'participants';
isNewFocusGroup?: boolean;
focusGroupData?: any;
folderId?: string; // For persona list folder context
}
interface NavigationContextType {

View file

@ -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 {

View file

@ -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}`;
}

View file

@ -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<Persona[]>([]);
const [folders, setFolders] = useState<Folder[]>([]);
@ -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}
/>

View file

@ -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