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
# Environment variables
ENV NODE_ENV=production
ENV PORT=3008
ENV DATA_DIR=/data

View File

@@ -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<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
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(() => {

View File

@@ -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<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 {
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<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 =
| 'agent:stream'
| 'auto-mode:event'

View File

@@ -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' });
}
}
};

View File

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