diff --git a/Dockerfile b/Dockerfile index e7a0237a..3f110451 100644 --- a/Dockerfile +++ b/Dockerfile @@ -102,7 +102,6 @@ RUN git config --system --add safe.directory '*' && \ USER automaker # Environment variables -ENV NODE_ENV=production ENV PORT=3008 ENV DATA_DIR=/data diff --git a/apps/ui/src/components/views/terminal-view/terminal-panel.tsx b/apps/ui/src/components/views/terminal-view/terminal-panel.tsx index 13117624..f7991873 100644 --- a/apps/ui/src/components/views/terminal-view/terminal-panel.tsx +++ b/apps/ui/src/components/views/terminal-view/terminal-panel.tsx @@ -40,7 +40,7 @@ import { } from '@/config/terminal-themes'; import { toast } from 'sonner'; import { getElectronAPI } from '@/lib/electron'; -import { getApiKey } from '@/lib/http-api-client'; +import { getApiKey, getSessionToken } from '@/lib/http-api-client'; // Font size constraints const MIN_FONT_SIZE = 8; @@ -486,6 +486,40 @@ export function TerminalPanel({ const serverUrl = import.meta.env.VITE_SERVER_URL || 'http://localhost:3008'; const wsUrl = serverUrl.replace(/^http/, 'ws'); + // Fetch a short-lived WebSocket token for secure authentication + const fetchWsToken = useCallback(async (): Promise => { + try { + const headers: Record = { + 'Content-Type': 'application/json', + }; + + const sessionToken = getSessionToken(); + if (sessionToken) { + headers['X-Session-Token'] = sessionToken; + } + + const response = await fetch(`${serverUrl}/api/auth/token`, { + headers, + credentials: 'include', + }); + + if (!response.ok) { + console.warn('[Terminal] Failed to fetch wsToken:', response.status); + return null; + } + + const data = await response.json(); + if (data.success && data.token) { + return data.token; + } + + return null; + } catch (error) { + console.error('[Terminal] Error fetching wsToken:', error); + return null; + } + }, [serverUrl]); + // Draggable - only the drag handle triggers drag const { attributes: dragAttributes, @@ -940,7 +974,7 @@ export function TerminalPanel({ const terminal = xtermRef.current; if (!terminal) return; - const connect = () => { + const connect = async () => { // Build WebSocket URL with auth params let url = `${wsUrl}/api/terminal/ws?sessionId=${sessionId}`; @@ -948,8 +982,14 @@ export function TerminalPanel({ const apiKey = getApiKey(); if (apiKey) { url += `&apiKey=${encodeURIComponent(apiKey)}`; + } else { + // In web mode, fetch a short-lived wsToken for secure authentication + const wsToken = await fetchWsToken(); + if (wsToken) { + url += `&wsToken=${encodeURIComponent(wsToken)}`; + } + // Cookies are also sent automatically with same-origin WebSocket } - // In web mode, cookies are sent automatically with same-origin WebSocket // Add terminal password token if required if (authToken) { @@ -1164,7 +1204,7 @@ export function TerminalPanel({ wsRef.current = null; } }; - }, [sessionId, authToken, wsUrl, isTerminalReady]); + }, [sessionId, authToken, wsUrl, isTerminalReady, fetchWsToken]); // Handle resize with debouncing const handleResize = useCallback(() => { diff --git a/apps/ui/src/lib/http-api-client.ts b/apps/ui/src/lib/http-api-client.ts index 44a4f459..b856bd51 100644 --- a/apps/ui/src/lib/http-api-client.ts +++ b/apps/ui/src/lib/http-api-client.ts @@ -124,6 +124,8 @@ export const checkAuthStatus = async (): Promise<{ /** * Login with API key (for web mode) + * After login succeeds, verifies the session is actually working by making + * a request to an authenticated endpoint. */ export const login = async ( apiKey: string @@ -141,6 +143,17 @@ export const login = async ( if (data.success && data.token) { setSessionToken(data.token); console.log('[HTTP Client] Session token stored after login'); + + // Verify the session is actually working by making a request to an authenticated endpoint + const verified = await verifySession(); + if (!verified) { + console.error('[HTTP Client] Login appeared successful but session verification failed'); + return { + success: false, + error: 'Session verification failed. Please try again.', + }; + } + console.log('[HTTP Client] Login verified successfully'); } return data; @@ -151,30 +164,34 @@ export const login = async ( }; /** - * Fetch session token from server (for page refresh when cookie exists) - * This retrieves the session token so it can be used for explicit header-based auth. + * Check if the session cookie is still valid by making a request to an authenticated endpoint. + * Note: This does NOT retrieve the session token - on page refresh we rely on cookies alone. + * The session token is only available after a fresh login. */ export const fetchSessionToken = async (): Promise => { + // On page refresh, we can't retrieve the session token (it's stored in HTTP-only cookie). + // We just verify the cookie is valid by checking auth status. + // The session token is only stored in memory after a fresh login. try { - const response = await fetch(`${getServerUrl()}/api/auth/token`, { + const response = await fetch(`${getServerUrl()}/api/auth/status`, { credentials: 'include', // Send the session cookie }); if (!response.ok) { - console.log('[HTTP Client] No valid session to get token from'); + console.log('[HTTP Client] Failed to check auth status'); return false; } const data = await response.json(); - if (data.success && data.token) { - setSessionToken(data.token); - console.log('[HTTP Client] Session token retrieved from cookie session'); + if (data.success && data.authenticated) { + console.log('[HTTP Client] Session cookie is valid'); return true; } + console.log('[HTTP Client] Session cookie is not authenticated'); return false; } catch (error) { - console.error('[HTTP Client] Failed to fetch session token:', error); + console.error('[HTTP Client] Failed to check session:', error); return false; } }; @@ -200,6 +217,58 @@ export const logout = async (): Promise<{ success: boolean }> => { } }; +/** + * Verify that the current session is still valid by making a request to an authenticated endpoint. + * If the session has expired or is invalid, clears the session and returns false. + * This should be called: + * 1. After login to verify the cookie was set correctly + * 2. On app load to verify the session hasn't expired + */ +export const verifySession = async (): Promise => { + try { + const headers: Record = { + 'Content-Type': 'application/json', + }; + + // Add session token header if available + const sessionToken = getSessionToken(); + if (sessionToken) { + headers['X-Session-Token'] = sessionToken; + } + + // Make a request to an authenticated endpoint to verify the session + // We use /api/settings/status as it requires authentication and is lightweight + const response = await fetch(`${getServerUrl()}/api/settings/status`, { + headers, + credentials: 'include', + }); + + // Check for authentication errors + if (response.status === 401 || response.status === 403) { + console.warn('[HTTP Client] Session verification failed - session expired or invalid'); + // Clear the session since it's no longer valid + clearSessionToken(); + // Try to clear the cookie via logout (fire and forget) + fetch(`${getServerUrl()}/api/auth/logout`, { + method: 'POST', + credentials: 'include', + }).catch(() => {}); + return false; + } + + if (!response.ok) { + console.warn('[HTTP Client] Session verification failed with status:', response.status); + return false; + } + + console.log('[HTTP Client] Session verified successfully'); + return true; + } catch (error) { + console.error('[HTTP Client] Session verification error:', error); + return false; + } +}; + type EventType = | 'agent:stream' | 'auto-mode:event' diff --git a/apps/ui/src/routes/__root.tsx b/apps/ui/src/routes/__root.tsx index 94bfe5e0..23a4fa30 100644 --- a/apps/ui/src/routes/__root.tsx +++ b/apps/ui/src/routes/__root.tsx @@ -9,12 +9,7 @@ import { import { useAppStore } from '@/store/app-store'; import { useSetupStore } from '@/store/setup-store'; import { getElectronAPI } from '@/lib/electron'; -import { - initApiKey, - checkAuthStatus, - isElectronMode, - fetchSessionToken, -} from '@/lib/http-api-client'; +import { initApiKey, isElectronMode, verifySession } from '@/lib/http-api-client'; import { Toaster } from 'sonner'; import { ThemeOption, themeOptions } from '@/config/theme-options'; @@ -80,7 +75,7 @@ function RootLayoutContent() { // Initialize authentication // - Electron mode: Uses API key from IPC (header-based auth) - // - Web mode: Uses session token (fetched from cookie session for explicit header auth) + // - Web mode: Uses HTTP-only session cookie useEffect(() => { const initAuth = async () => { try { @@ -94,29 +89,31 @@ function RootLayoutContent() { return; } - // In web mode, try to fetch session token (works if cookie is valid) - // This allows explicit header-based auth which works better cross-origin - const tokenFetched = await fetchSessionToken(); + // In web mode, verify the session cookie is still valid + // by making a request to an authenticated endpoint + const isValid = await verifySession(); - if (tokenFetched) { - // We have a valid session - token is now stored in memory + if (isValid) { setIsAuthenticated(true); setAuthChecked(true); return; } - // Fallback: check auth status via cookie - const status = await checkAuthStatus(); - setIsAuthenticated(status.authenticated); + // Session is invalid or expired - redirect to login + console.log('Session invalid or expired - redirecting to login'); + setIsAuthenticated(false); setAuthChecked(true); - // Redirect to login if not authenticated and not already on login page - if (!status.authenticated && location.pathname !== '/login') { + if (location.pathname !== '/login') { navigate({ to: '/login' }); } } catch (error) { console.error('Failed to initialize auth:', error); setAuthChecked(true); + // On error, redirect to login to be safe + if (location.pathname !== '/login') { + navigate({ to: '/login' }); + } } }; diff --git a/docker-compose.override.yml.example b/docker-compose.override.yml.example index 99d7a5dd..611ff588 100644 --- a/docker-compose.override.yml.example +++ b/docker-compose.override.yml.example @@ -8,3 +8,4 @@ services: # Set root directory for all projects and file operations # Users can only create/open projects within this directory - ALLOWED_ROOT_DIRECTORY=/projects + - NODE_ENV=development