graceful handling of expired JWT token
This commit is contained in:
parent
6a40936508
commit
1b977ec517
14 changed files with 355 additions and 64 deletions
BIN
.DS_Store
vendored
BIN
.DS_Store
vendored
Binary file not shown.
175
.gitignore
vendored
175
.gitignore
vendored
|
|
@ -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
BIN
backend/.DS_Store
vendored
Binary file not shown.
Binary file not shown.
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
2
dist/index.html
vendored
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ export interface NavigationState {
|
|||
focusGroupTab?: 'setup' | 'review' | 'participants';
|
||||
isNewFocusGroup?: boolean;
|
||||
focusGroupData?: any;
|
||||
folderId?: string; // For persona list folder context
|
||||
}
|
||||
|
||||
interface NavigationContextType {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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}`;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue