feat: enhance authentication and session management

- Added NODE_ENV variable for development in docker-compose.override.yml.example.
- Changed default NODE_ENV to development in Dockerfile.
- Implemented fetchWsToken function to retrieve short-lived WebSocket tokens for secure authentication in TerminalPanel.
- Updated connect function to use wsToken for WebSocket connections when API key is not available.
- Introduced verifySession function to validate session status after login and on app load, ensuring session integrity.
- Modified RootLayoutContent to verify session cookie validity and redirect to login if the session is invalid or expired.

These changes improve the security and reliability of the authentication process.
This commit is contained in:
Test User
2025-12-29 19:06:11 -05:00
parent 469ee5ff85
commit d66259b411
5 changed files with 136 additions and 30 deletions

View File

@@ -102,7 +102,6 @@ RUN git config --system --add safe.directory '*' && \
USER automaker USER automaker
# Environment variables # Environment variables
ENV NODE_ENV=production
ENV PORT=3008 ENV PORT=3008
ENV DATA_DIR=/data ENV DATA_DIR=/data

View File

@@ -40,7 +40,7 @@ import {
} from '@/config/terminal-themes'; } from '@/config/terminal-themes';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { getElectronAPI } from '@/lib/electron'; import { getElectronAPI } from '@/lib/electron';
import { getApiKey } from '@/lib/http-api-client'; import { getApiKey, getSessionToken } from '@/lib/http-api-client';
// Font size constraints // Font size constraints
const MIN_FONT_SIZE = 8; const MIN_FONT_SIZE = 8;
@@ -486,6 +486,40 @@ export function TerminalPanel({
const serverUrl = import.meta.env.VITE_SERVER_URL || 'http://localhost:3008'; const serverUrl = import.meta.env.VITE_SERVER_URL || 'http://localhost:3008';
const wsUrl = serverUrl.replace(/^http/, 'ws'); const wsUrl = serverUrl.replace(/^http/, 'ws');
// Fetch a short-lived WebSocket token for secure authentication
const fetchWsToken = useCallback(async (): Promise<string | null> => {
try {
const headers: Record<string, string> = {
'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 // Draggable - only the drag handle triggers drag
const { const {
attributes: dragAttributes, attributes: dragAttributes,
@@ -940,7 +974,7 @@ export function TerminalPanel({
const terminal = xtermRef.current; const terminal = xtermRef.current;
if (!terminal) return; if (!terminal) return;
const connect = () => { const connect = async () => {
// Build WebSocket URL with auth params // Build WebSocket URL with auth params
let url = `${wsUrl}/api/terminal/ws?sessionId=${sessionId}`; let url = `${wsUrl}/api/terminal/ws?sessionId=${sessionId}`;
@@ -948,8 +982,14 @@ export function TerminalPanel({
const apiKey = getApiKey(); const apiKey = getApiKey();
if (apiKey) { if (apiKey) {
url += `&apiKey=${encodeURIComponent(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 // Add terminal password token if required
if (authToken) { if (authToken) {
@@ -1164,7 +1204,7 @@ export function TerminalPanel({
wsRef.current = null; wsRef.current = null;
} }
}; };
}, [sessionId, authToken, wsUrl, isTerminalReady]); }, [sessionId, authToken, wsUrl, isTerminalReady, fetchWsToken]);
// Handle resize with debouncing // Handle resize with debouncing
const handleResize = useCallback(() => { const handleResize = useCallback(() => {

View File

@@ -124,6 +124,8 @@ export const checkAuthStatus = async (): Promise<{
/** /**
* Login with API key (for web mode) * 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 ( export const login = async (
apiKey: string apiKey: string
@@ -141,6 +143,17 @@ export const login = async (
if (data.success && data.token) { if (data.success && data.token) {
setSessionToken(data.token); setSessionToken(data.token);
console.log('[HTTP Client] Session token stored after login'); 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; return data;
@@ -151,30 +164,34 @@ export const login = async (
}; };
/** /**
* Fetch session token from server (for page refresh when cookie exists) * Check if the session cookie is still valid by making a request to an authenticated endpoint.
* This retrieves the session token so it can be used for explicit header-based auth. * 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<boolean> => { export const fetchSessionToken = async (): Promise<boolean> => {
// 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 { try {
const response = await fetch(`${getServerUrl()}/api/auth/token`, { const response = await fetch(`${getServerUrl()}/api/auth/status`, {
credentials: 'include', // Send the session cookie credentials: 'include', // Send the session cookie
}); });
if (!response.ok) { 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; return false;
} }
const data = await response.json(); const data = await response.json();
if (data.success && data.token) { if (data.success && data.authenticated) {
setSessionToken(data.token); console.log('[HTTP Client] Session cookie is valid');
console.log('[HTTP Client] Session token retrieved from cookie session');
return true; return true;
} }
console.log('[HTTP Client] Session cookie is not authenticated');
return false; return false;
} catch (error) { } catch (error) {
console.error('[HTTP Client] Failed to fetch session token:', error); console.error('[HTTP Client] Failed to check session:', error);
return false; 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<boolean> => {
try {
const headers: Record<string, string> = {
'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 = type EventType =
| 'agent:stream' | 'agent:stream'
| 'auto-mode:event' | 'auto-mode:event'

View File

@@ -9,12 +9,7 @@ import {
import { useAppStore } from '@/store/app-store'; import { useAppStore } from '@/store/app-store';
import { useSetupStore } from '@/store/setup-store'; import { useSetupStore } from '@/store/setup-store';
import { getElectronAPI } from '@/lib/electron'; import { getElectronAPI } from '@/lib/electron';
import { import { initApiKey, isElectronMode, verifySession } from '@/lib/http-api-client';
initApiKey,
checkAuthStatus,
isElectronMode,
fetchSessionToken,
} from '@/lib/http-api-client';
import { Toaster } from 'sonner'; import { Toaster } from 'sonner';
import { ThemeOption, themeOptions } from '@/config/theme-options'; import { ThemeOption, themeOptions } from '@/config/theme-options';
@@ -80,7 +75,7 @@ function RootLayoutContent() {
// Initialize authentication // Initialize authentication
// - Electron mode: Uses API key from IPC (header-based auth) // - 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(() => { useEffect(() => {
const initAuth = async () => { const initAuth = async () => {
try { try {
@@ -94,29 +89,31 @@ function RootLayoutContent() {
return; return;
} }
// In web mode, try to fetch session token (works if cookie is valid) // In web mode, verify the session cookie is still valid
// This allows explicit header-based auth which works better cross-origin // by making a request to an authenticated endpoint
const tokenFetched = await fetchSessionToken(); const isValid = await verifySession();
if (tokenFetched) { if (isValid) {
// We have a valid session - token is now stored in memory
setIsAuthenticated(true); setIsAuthenticated(true);
setAuthChecked(true); setAuthChecked(true);
return; return;
} }
// Fallback: check auth status via cookie // Session is invalid or expired - redirect to login
const status = await checkAuthStatus(); console.log('Session invalid or expired - redirecting to login');
setIsAuthenticated(status.authenticated); setIsAuthenticated(false);
setAuthChecked(true); setAuthChecked(true);
// Redirect to login if not authenticated and not already on login page if (location.pathname !== '/login') {
if (!status.authenticated && location.pathname !== '/login') {
navigate({ to: '/login' }); navigate({ to: '/login' });
} }
} catch (error) { } catch (error) {
console.error('Failed to initialize auth:', error); console.error('Failed to initialize auth:', error);
setAuthChecked(true); setAuthChecked(true);
// On error, redirect to login to be safe
if (location.pathname !== '/login') {
navigate({ to: '/login' });
}
} }
}; };

View File

@@ -8,3 +8,4 @@ services:
# Set root directory for all projects and file operations # Set root directory for all projects and file operations
# Users can only create/open projects within this directory # Users can only create/open projects within this directory
- ALLOWED_ROOT_DIRECTORY=/projects - ALLOWED_ROOT_DIRECTORY=/projects
- NODE_ENV=development