adding more security to api endpoints to require api token for all access, no by passing

This commit is contained in:
Test User
2025-12-29 16:16:28 -05:00
parent dd822c41c5
commit d68de99c15
26 changed files with 1347 additions and 184 deletions

View File

@@ -5,6 +5,7 @@ import { RefreshCw, AlertTriangle, CheckCircle, XCircle, Clock, ExternalLink } f
import { cn } from '@/lib/utils';
import { getElectronAPI } from '@/lib/electron';
import { useAppStore } from '@/store/app-store';
import { useSetupStore } from '@/store/setup-store';
// Error codes for distinguishing failure modes
const ERROR_CODES = {
@@ -25,10 +26,15 @@ const REFRESH_INTERVAL_SECONDS = 45;
export function ClaudeUsagePopover() {
const { claudeUsage, claudeUsageLastUpdated, setClaudeUsage } = useAppStore();
const claudeAuthStatus = useSetupStore((state) => state.claudeAuthStatus);
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<UsageError | null>(null);
// Check if CLI is verified/authenticated
const isCliVerified =
claudeAuthStatus?.authenticated && claudeAuthStatus?.method === 'cli_authenticated';
// Check if data is stale (older than 2 minutes) - recalculates when claudeUsageLastUpdated changes
const isStale = useMemo(() => {
return !claudeUsageLastUpdated || Date.now() - claudeUsageLastUpdated > 2 * 60 * 1000;
@@ -68,14 +74,17 @@ export function ClaudeUsagePopover() {
[setClaudeUsage]
);
// Auto-fetch on mount if data is stale
// Auto-fetch on mount if data is stale (only if CLI is verified)
useEffect(() => {
if (isStale) {
if (isStale && isCliVerified) {
fetchUsage(true);
}
}, [isStale, fetchUsage]);
}, [isStale, isCliVerified, fetchUsage]);
useEffect(() => {
// Skip if CLI is not verified
if (!isCliVerified) return;
// Initial fetch when opened
if (open) {
if (!claudeUsage || isStale) {
@@ -94,7 +103,7 @@ export function ClaudeUsagePopover() {
return () => {
if (intervalId) clearInterval(intervalId);
};
}, [open, claudeUsage, isStale, fetchUsage]);
}, [open, claudeUsage, isStale, isCliVerified, fetchUsage]);
// Derived status color/icon helper
const getStatusInfo = (percentage: number) => {

View File

@@ -14,6 +14,7 @@ import { Kbd, KbdGroup } from '@/components/ui/kbd';
import { getJSON, setJSON } from '@/lib/storage';
import { getDefaultWorkspaceDirectory, saveLastProjectDirectory } from '@/lib/workspace-config';
import { useOSDetection } from '@/hooks';
import { apiPost } from '@/lib/api-fetch';
interface DirectoryEntry {
name: string;
@@ -98,16 +99,7 @@ export function FileBrowserDialog({
setWarning('');
try {
// Get server URL from environment or default
const serverUrl = import.meta.env.VITE_SERVER_URL || 'http://localhost:3008';
const response = await fetch(`${serverUrl}/api/fs/browse`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ dirPath }),
});
const result: BrowseResult = await response.json();
const result = await apiPost<BrowseResult>('/api/fs/browse', { dirPath });
if (result.success) {
setCurrentPath(result.currentPath);

View File

@@ -7,6 +7,7 @@ import { Plus, Bot, Wand2 } from 'lucide-react';
import { KeyboardShortcut } from '@/hooks/use-keyboard-shortcuts';
import { ClaudeUsagePopover } from '@/components/claude-usage-popover';
import { useAppStore } from '@/store/app-store';
import { useSetupStore } from '@/store/setup-store';
interface BoardHeaderProps {
projectName: string;
@@ -34,12 +35,18 @@ export function BoardHeader({
isMounted,
}: BoardHeaderProps) {
const apiKeys = useAppStore((state) => state.apiKeys);
const claudeAuthStatus = useSetupStore((state) => state.claudeAuthStatus);
// Hide usage tracking when using API key (only show for Claude Code CLI users)
// Check both user-entered API key and environment variable ANTHROPIC_API_KEY
// Also hide on Windows for now (CLI usage command not supported)
// Only show if CLI has been verified/authenticated
const isWindows =
typeof navigator !== 'undefined' && navigator.platform?.toLowerCase().includes('win');
const showUsageTracking = !apiKeys.anthropic && !isWindows;
const hasApiKey = !!apiKeys.anthropic || !!claudeAuthStatus?.hasEnvApiKey;
const isCliVerified =
claudeAuthStatus?.authenticated && claudeAuthStatus?.method === 'cli_authenticated';
const showUsageTracking = !hasApiKey && !isWindows && isCliVerified;
return (
<div className="flex items-center justify-between p-4 border-b border-border bg-glass backdrop-blur-md">

View File

@@ -0,0 +1,104 @@
/**
* Login View - Web mode authentication
*
* Prompts user to enter the API key shown in server console.
* On successful login, sets an HTTP-only session cookie.
*/
import { useState } from 'react';
import { useNavigate } from '@tanstack/react-router';
import { login } from '@/lib/http-api-client';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { KeyRound, AlertCircle, Loader2 } from 'lucide-react';
export function LoginView() {
const navigate = useNavigate();
const [apiKey, setApiKey] = useState('');
const [error, setError] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
setIsLoading(true);
try {
const result = await login(apiKey.trim());
if (result.success) {
// Redirect to home/board on success
navigate({ to: '/' });
} else {
setError(result.error || 'Invalid API key');
}
} catch (err) {
setError('Failed to connect to server');
} finally {
setIsLoading(false);
}
};
return (
<div className="flex min-h-screen items-center justify-center bg-background p-4">
<div className="w-full max-w-md space-y-8">
{/* Header */}
<div className="text-center">
<div className="mx-auto flex h-16 w-16 items-center justify-center rounded-full bg-primary/10">
<KeyRound className="h-8 w-8 text-primary" />
</div>
<h1 className="mt-6 text-2xl font-bold tracking-tight">Authentication Required</h1>
<p className="mt-2 text-sm text-muted-foreground">
Enter the API key shown in the server console to continue.
</p>
</div>
{/* Login Form */}
<form onSubmit={handleSubmit} className="space-y-6">
<div className="space-y-2">
<label htmlFor="apiKey" className="text-sm font-medium">
API Key
</label>
<Input
id="apiKey"
type="password"
placeholder="Enter API key..."
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
disabled={isLoading}
autoFocus
className="font-mono"
/>
</div>
{error && (
<div className="flex items-center gap-2 rounded-md bg-destructive/10 p-3 text-sm text-destructive">
<AlertCircle className="h-4 w-4 flex-shrink-0" />
<span>{error}</span>
</div>
)}
<Button type="submit" className="w-full" disabled={isLoading || !apiKey.trim()}>
{isLoading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Authenticating...
</>
) : (
'Login'
)}
</Button>
</form>
{/* Help Text */}
<div className="rounded-lg border bg-muted/50 p-4 text-sm">
<p className="font-medium">Where to find the API key:</p>
<ol className="mt-2 list-inside list-decimal space-y-1 text-muted-foreground">
<li>Look at the server terminal/console output</li>
<li>Find the box labeled "API Key for Web Mode Authentication"</li>
<li>Copy the UUID displayed there</li>
</ol>
</div>
</div>
</div>
);
}

View File

@@ -1,5 +1,6 @@
import { useState } from 'react';
import { useAppStore } from '@/store/app-store';
import { useSetupStore } from '@/store/setup-store';
import { useCliStatus, useSettingsView } from './settings-view/hooks';
import { NAV_ITEMS } from './settings-view/config/navigation';
@@ -55,11 +56,15 @@ export function SettingsView() {
setEnableSandboxMode,
} = useAppStore();
const claudeAuthStatus = useSetupStore((state) => state.claudeAuthStatus);
// Hide usage tracking when using API key (only show for Claude Code CLI users)
// Check both user-entered API key and environment variable ANTHROPIC_API_KEY
// Also hide on Windows for now (CLI usage command not supported)
const isWindows =
typeof navigator !== 'undefined' && navigator.platform?.toLowerCase().includes('win');
const showUsageTracking = !apiKeys.anthropic && !isWindows;
const hasApiKey = !!apiKeys.anthropic || !!claudeAuthStatus?.hasEnvApiKey;
const showUsageTracking = !hasApiKey && !isWindows;
// Convert electron Project to settings-view Project type
const convertProject = (project: ElectronProject | null): SettingsProject | null => {

View File

@@ -46,6 +46,8 @@ import {
defaultDropAnimationSideEffects,
} from '@dnd-kit/core';
import { cn } from '@/lib/utils';
import { apiFetch, apiGet, apiPost, apiDeleteRaw, getAuthHeaders } from '@/lib/api-fetch';
import { getApiKey } from '@/lib/http-api-client';
interface TerminalStatus {
enabled: boolean;
@@ -304,16 +306,13 @@ export function TerminalView() {
await Promise.allSettled(
sessionIds.map(async (sessionId) => {
try {
await fetch(`${serverUrl}/api/terminal/sessions/${sessionId}`, {
method: 'DELETE',
headers,
});
await apiDeleteRaw(`/api/terminal/sessions/${sessionId}`, { headers });
} catch (err) {
console.error(`[Terminal] Failed to kill session ${sessionId}:`, err);
}
})
);
}, [collectAllSessionIds, terminalState.authToken, serverUrl]);
}, [collectAllSessionIds, terminalState.authToken]);
const CREATE_COOLDOWN_MS = 500; // Prevent rapid terminal creation
// Helper to check if terminal creation should be debounced
@@ -434,9 +433,10 @@ export function TerminalView() {
try {
setLoading(true);
setError(null);
const response = await fetch(`${serverUrl}/api/terminal/status`);
const data = await response.json();
if (data.success) {
const data = await apiGet<{ success: boolean; data?: TerminalStatus; error?: string }>(
'/api/terminal/status'
);
if (data.success && data.data) {
setStatus(data.data);
if (!data.data.passwordRequired) {
setTerminalUnlocked(true);
@@ -450,7 +450,7 @@ export function TerminalView() {
} finally {
setLoading(false);
}
}, [serverUrl, setTerminalUnlocked]);
}, [setTerminalUnlocked]);
// Fetch server session settings
const fetchServerSettings = useCallback(async () => {
@@ -460,15 +460,17 @@ export function TerminalView() {
if (terminalState.authToken) {
headers['X-Terminal-Token'] = terminalState.authToken;
}
const response = await fetch(`${serverUrl}/api/terminal/settings`, { headers });
const data = await response.json();
if (data.success) {
const data = await apiGet<{
success: boolean;
data?: { currentSessions: number; maxSessions: number };
}>('/api/terminal/settings', { headers });
if (data.success && data.data) {
setServerSessionInfo({ current: data.data.currentSessions, max: data.data.maxSessions });
}
} catch (err) {
console.error('[Terminal] Failed to fetch server settings:', err);
}
}, [serverUrl, terminalState.isUnlocked, terminalState.authToken]);
}, [terminalState.isUnlocked, terminalState.authToken]);
useEffect(() => {
fetchStatus();
@@ -483,22 +485,20 @@ export function TerminalView() {
const sessionIds = collectAllSessionIds();
if (sessionIds.length === 0) return;
const headers: Record<string, string> = {
'Content-Type': 'application/json',
};
if (terminalState.authToken) {
headers['X-Terminal-Token'] = terminalState.authToken;
}
// Try to use the bulk delete endpoint if available, otherwise delete individually
// Using sendBeacon for reliability during page unload
// Using sync XMLHttpRequest for reliability during page unload (async doesn't complete)
sessionIds.forEach((sessionId) => {
const url = `${serverUrl}/api/terminal/sessions/${sessionId}`;
// sendBeacon doesn't support DELETE method, so we'll use a sync XMLHttpRequest
// which is more reliable during page unload than fetch
try {
const xhr = new XMLHttpRequest();
xhr.open('DELETE', url, false); // synchronous
xhr.withCredentials = true; // Include cookies for session auth
// Add API auth header
const apiKey = getApiKey();
if (apiKey) {
xhr.setRequestHeader('X-API-Key', apiKey);
}
// Add terminal-specific auth
if (terminalState.authToken) {
xhr.setRequestHeader('X-Terminal-Token', terminalState.authToken);
}
@@ -593,9 +593,7 @@ export function TerminalView() {
let reconnectedSessions = 0;
try {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
};
const headers: Record<string, string> = {};
// Get fresh auth token from store
const authToken = useAppStore.getState().terminalState.authToken;
if (authToken) {
@@ -605,11 +603,9 @@ export function TerminalView() {
// Helper to check if a session still exists on server
const checkSessionExists = async (sessionId: string): Promise<boolean> => {
try {
const response = await fetch(`${serverUrl}/api/terminal/sessions/${sessionId}`, {
method: 'GET',
const data = await apiGet<{ success: boolean }>(`/api/terminal/sessions/${sessionId}`, {
headers,
});
const data = await response.json();
return data.success === true;
} catch {
return false;
@@ -619,17 +615,12 @@ export function TerminalView() {
// Helper to create a new terminal session
const createSession = async (): Promise<string | null> => {
try {
const response = await fetch(`${serverUrl}/api/terminal/sessions`, {
method: 'POST',
headers,
body: JSON.stringify({
cwd: currentPath,
cols: 80,
rows: 24,
}),
});
const data = await response.json();
return data.success ? data.data.id : null;
const data = await apiPost<{ success: boolean; data?: { id: string } }>(
'/api/terminal/sessions',
{ cwd: currentPath, cols: 80, rows: 24 },
{ headers }
);
return data.success && data.data ? data.data.id : null;
} catch (err) {
console.error('[Terminal] Failed to create terminal session:', err);
return null;
@@ -801,14 +792,12 @@ export function TerminalView() {
setAuthError(null);
try {
const response = await fetch(`${serverUrl}/api/terminal/auth`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password }),
});
const data = await response.json();
const data = await apiPost<{ success: boolean; data?: { token: string }; error?: string }>(
'/api/terminal/auth',
{ password }
);
if (data.success) {
if (data.success && data.data) {
setTerminalUnlocked(true, data.data.token);
setPassword('');
} else {
@@ -833,21 +822,14 @@ export function TerminalView() {
}
try {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
};
const headers: Record<string, string> = {};
if (terminalState.authToken) {
headers['X-Terminal-Token'] = terminalState.authToken;
}
const response = await fetch(`${serverUrl}/api/terminal/sessions`, {
method: 'POST',
const response = await apiFetch('/api/terminal/sessions', 'POST', {
headers,
body: JSON.stringify({
cwd: currentProject?.path || undefined,
cols: 80,
rows: 24,
}),
body: { cwd: currentProject?.path || undefined, cols: 80, rows: 24 },
});
const data = await response.json();
@@ -892,21 +874,14 @@ export function TerminalView() {
const tabId = addTerminalTab();
try {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
};
const headers: Record<string, string> = {};
if (terminalState.authToken) {
headers['X-Terminal-Token'] = terminalState.authToken;
}
const response = await fetch(`${serverUrl}/api/terminal/sessions`, {
method: 'POST',
const response = await apiFetch('/api/terminal/sessions', 'POST', {
headers,
body: JSON.stringify({
cwd: currentProject?.path || undefined,
cols: 80,
rows: 24,
}),
body: { cwd: currentProject?.path || undefined, cols: 80, rows: 24 },
});
const data = await response.json();
@@ -959,10 +934,7 @@ export function TerminalView() {
headers['X-Terminal-Token'] = terminalState.authToken;
}
const response = await fetch(`${serverUrl}/api/terminal/sessions/${sessionId}`, {
method: 'DELETE',
headers,
});
const response = await apiDeleteRaw(`/api/terminal/sessions/${sessionId}`, { headers });
// Always remove from UI - even if server says 404 (session may have already exited)
removeTerminalFromLayout(sessionId);
@@ -1008,10 +980,7 @@ export function TerminalView() {
await Promise.all(
sessionIds.map(async (sessionId) => {
try {
await fetch(`${serverUrl}/api/terminal/sessions/${sessionId}`, {
method: 'DELETE',
headers,
});
await apiDeleteRaw(`/api/terminal/sessions/${sessionId}`, { headers });
} catch (err) {
console.error(`[Terminal] Failed to kill session ${sessionId}:`, err);
}

View File

@@ -40,6 +40,7 @@ import {
} from '@/config/terminal-themes';
import { toast } from 'sonner';
import { getElectronAPI } from '@/lib/electron';
import { getApiKey } from '@/lib/http-api-client';
// Font size constraints
const MIN_FONT_SIZE = 8;
@@ -940,8 +941,17 @@ export function TerminalPanel({
if (!terminal) return;
const connect = () => {
// Build WebSocket URL with token
// Build WebSocket URL with auth params
let url = `${wsUrl}/api/terminal/ws?sessionId=${sessionId}`;
// Add API key for Electron mode auth
const apiKey = getApiKey();
if (apiKey) {
url += `&apiKey=${encodeURIComponent(apiKey)}`;
}
// In web mode, cookies are sent automatically with same-origin WebSocket
// Add terminal password token if required
if (authToken) {
url += `&token=${encodeURIComponent(authToken)}`;
}

View File

@@ -0,0 +1,161 @@
/**
* Authenticated fetch utility
*
* Provides a wrapper around fetch that automatically includes:
* - X-API-Key header (for Electron mode)
* - X-Session-Token header (for web mode with explicit token)
* - credentials: 'include' (fallback for web mode session cookies)
*
* Use this instead of raw fetch() for all authenticated API calls.
*/
import { getApiKey, getSessionToken } from './http-api-client';
// Server URL - configurable via environment variable
const getServerUrl = (): string => {
if (typeof window !== 'undefined') {
const envUrl = import.meta.env.VITE_SERVER_URL;
if (envUrl) return envUrl;
}
return 'http://localhost:3008';
};
export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH';
export interface ApiFetchOptions extends Omit<RequestInit, 'method' | 'headers' | 'body'> {
/** Additional headers to include (merged with auth headers) */
headers?: Record<string, string>;
/** Request body - will be JSON stringified if object */
body?: unknown;
/** Skip authentication headers (for public endpoints like /api/health) */
skipAuth?: boolean;
}
/**
* Build headers for an authenticated request
*/
export function getAuthHeaders(additionalHeaders?: Record<string, string>): Record<string, string> {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
...additionalHeaders,
};
// Electron mode: use API key
const apiKey = getApiKey();
if (apiKey) {
headers['X-API-Key'] = apiKey;
return headers;
}
// Web mode: use session token if available
const sessionToken = getSessionToken();
if (sessionToken) {
headers['X-Session-Token'] = sessionToken;
}
return headers;
}
/**
* Make an authenticated fetch request to the API
*
* @param endpoint - API endpoint (e.g., '/api/fs/browse')
* @param method - HTTP method
* @param options - Additional options
* @returns Response from fetch
*
* @example
* ```ts
* // Simple GET
* const response = await apiFetch('/api/terminal/status', 'GET');
*
* // POST with body
* const response = await apiFetch('/api/fs/browse', 'POST', {
* body: { dirPath: '/home/user' }
* });
*
* // With additional headers
* const response = await apiFetch('/api/terminal/sessions', 'POST', {
* headers: { 'X-Terminal-Token': token },
* body: { cwd: '/home/user' }
* });
* ```
*/
export async function apiFetch(
endpoint: string,
method: HttpMethod = 'GET',
options: ApiFetchOptions = {}
): Promise<Response> {
const { headers: additionalHeaders, body, skipAuth, ...restOptions } = options;
const headers = skipAuth
? { 'Content-Type': 'application/json', ...additionalHeaders }
: getAuthHeaders(additionalHeaders);
const fetchOptions: RequestInit = {
method,
headers,
credentials: 'include',
...restOptions,
};
if (body !== undefined) {
fetchOptions.body = typeof body === 'string' ? body : JSON.stringify(body);
}
const url = endpoint.startsWith('http') ? endpoint : `${getServerUrl()}${endpoint}`;
return fetch(url, fetchOptions);
}
/**
* Make an authenticated GET request
*/
export async function apiGet<T>(
endpoint: string,
options: Omit<ApiFetchOptions, 'body'> = {}
): Promise<T> {
const response = await apiFetch(endpoint, 'GET', options);
return response.json();
}
/**
* Make an authenticated POST request
*/
export async function apiPost<T>(
endpoint: string,
body?: unknown,
options: ApiFetchOptions = {}
): Promise<T> {
const response = await apiFetch(endpoint, 'POST', { ...options, body });
return response.json();
}
/**
* Make an authenticated PUT request
*/
export async function apiPut<T>(
endpoint: string,
body?: unknown,
options: ApiFetchOptions = {}
): Promise<T> {
const response = await apiFetch(endpoint, 'PUT', { ...options, body });
return response.json();
}
/**
* Make an authenticated DELETE request
*/
export async function apiDelete<T>(endpoint: string, options: ApiFetchOptions = {}): Promise<T> {
const response = await apiFetch(endpoint, 'DELETE', options);
return response.json();
}
/**
* Make an authenticated DELETE request (returns raw response for status checking)
*/
export async function apiDeleteRaw(
endpoint: string,
options: ApiFetchOptions = {}
): Promise<Response> {
return apiFetch(endpoint, 'DELETE', options);
}

View File

@@ -431,6 +431,7 @@ export interface SaveImageResult {
export interface ElectronAPI {
ping: () => Promise<string>;
getApiKey?: () => Promise<string | null>;
openExternalLink: (url: string) => Promise<{ success: boolean; error?: string }>;
openDirectory: () => Promise<DialogResult>;
openFile: (options?: object) => Promise<DialogResult>;

View File

@@ -41,12 +41,163 @@ const getServerUrl = (): string => {
return 'http://localhost:3008';
};
// Get API key from environment variable
const getApiKey = (): string | null => {
if (typeof window !== 'undefined') {
return import.meta.env.VITE_AUTOMAKER_API_KEY || null;
// Cached API key for authentication (Electron mode only)
let cachedApiKey: string | null = null;
let apiKeyInitialized = false;
// Cached session token for authentication (Web mode - explicit header auth)
let cachedSessionToken: string | null = null;
// Get API key for Electron mode (returns cached value after initialization)
// Exported for use in WebSocket connections that need auth
export const getApiKey = (): string | null => cachedApiKey;
// Get session token for Web mode (returns cached value after login or token fetch)
export const getSessionToken = (): string | null => cachedSessionToken;
// Set session token (called after login or token fetch)
export const setSessionToken = (token: string | null): void => {
cachedSessionToken = token;
};
// Clear session token (called on logout)
export const clearSessionToken = (): void => {
cachedSessionToken = null;
};
/**
* Check if we're running in Electron mode
*/
export const isElectronMode = (): boolean => {
return typeof window !== 'undefined' && !!window.electronAPI?.getApiKey;
};
/**
* Initialize API key for Electron mode authentication.
* In web mode, authentication uses HTTP-only cookies instead.
*
* This should be called early in app initialization.
*/
export const initApiKey = async (): Promise<void> => {
if (apiKeyInitialized) return;
apiKeyInitialized = true;
// Only Electron mode uses API key header auth
if (typeof window !== 'undefined' && window.electronAPI?.getApiKey) {
try {
cachedApiKey = await window.electronAPI.getApiKey();
if (cachedApiKey) {
console.log('[HTTP Client] Using API key from Electron');
return;
}
} catch (error) {
console.warn('[HTTP Client] Failed to get API key from Electron:', error);
}
}
// In web mode, authentication is handled via HTTP-only cookies
console.log('[HTTP Client] Web mode - using cookie-based authentication');
};
/**
* Check authentication status with the server
*/
export const checkAuthStatus = async (): Promise<{
authenticated: boolean;
required: boolean;
}> => {
try {
const response = await fetch(`${getServerUrl()}/api/auth/status`, {
credentials: 'include',
headers: getApiKey() ? { 'X-API-Key': getApiKey()! } : undefined,
});
const data = await response.json();
return {
authenticated: data.authenticated ?? false,
required: data.required ?? true,
};
} catch (error) {
console.error('[HTTP Client] Failed to check auth status:', error);
return { authenticated: false, required: true };
}
};
/**
* Login with API key (for web mode)
*/
export const login = async (
apiKey: string
): Promise<{ success: boolean; error?: string; token?: string }> => {
try {
const response = await fetch(`${getServerUrl()}/api/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ apiKey }),
});
const data = await response.json();
// Store the session token if login succeeded
if (data.success && data.token) {
setSessionToken(data.token);
console.log('[HTTP Client] Session token stored after login');
}
return data;
} catch (error) {
console.error('[HTTP Client] Login failed:', error);
return { success: false, error: 'Network error' };
}
};
/**
* 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.
*/
export const fetchSessionToken = async (): Promise<boolean> => {
try {
const response = await fetch(`${getServerUrl()}/api/auth/token`, {
credentials: 'include', // Send the session cookie
});
if (!response.ok) {
console.log('[HTTP Client] No valid session to get token from');
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');
return true;
}
return false;
} catch (error) {
console.error('[HTTP Client] Failed to fetch session token:', error);
return false;
}
};
/**
* Logout (for web mode)
*/
export const logout = async (): Promise<{ success: boolean }> => {
try {
const response = await fetch(`${getServerUrl()}/api/auth/logout`, {
method: 'POST',
credentials: 'include',
});
// Clear the cached session token
clearSessionToken();
console.log('[HTTP Client] Session token cleared on logout');
return await response.json();
} catch (error) {
console.error('[HTTP Client] Logout failed:', error);
return { success: false };
}
return null;
};
type EventType =
@@ -87,7 +238,22 @@ export class HttpApiClient implements ElectronAPI {
this.isConnecting = true;
try {
const wsUrl = this.serverUrl.replace(/^http/, 'ws') + '/api/events';
let wsUrl = this.serverUrl.replace(/^http/, 'ws') + '/api/events';
// In Electron mode, add API key as query param for WebSocket auth
// (WebSocket doesn't support custom headers in browser)
const apiKey = getApiKey();
if (apiKey) {
wsUrl += `?apiKey=${encodeURIComponent(apiKey)}`;
} else {
// In web mode, add session token as query param
// (cookies may not work cross-origin, so use explicit token)
const sessionToken = getSessionToken();
if (sessionToken) {
wsUrl += `?sessionToken=${encodeURIComponent(sessionToken)}`;
}
}
this.ws = new WebSocket(wsUrl);
this.ws.onopen = () => {
@@ -155,10 +321,20 @@ export class HttpApiClient implements ElectronAPI {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
};
// Electron mode: use API key
const apiKey = getApiKey();
if (apiKey) {
headers['X-API-Key'] = apiKey;
return headers;
}
// Web mode: use session token if available
const sessionToken = getSessionToken();
if (sessionToken) {
headers['X-Session-Token'] = sessionToken;
}
return headers;
}
@@ -166,14 +342,17 @@ export class HttpApiClient implements ElectronAPI {
const response = await fetch(`${this.serverUrl}${endpoint}`, {
method: 'POST',
headers: this.getHeaders(),
credentials: 'include', // Include cookies for session auth
body: body ? JSON.stringify(body) : undefined,
});
return response.json();
}
private async get<T>(endpoint: string): Promise<T> {
const headers = this.getHeaders();
const response = await fetch(`${this.serverUrl}${endpoint}`, { headers });
const response = await fetch(`${this.serverUrl}${endpoint}`, {
headers: this.getHeaders(),
credentials: 'include', // Include cookies for session auth
});
return response.json();
}
@@ -181,6 +360,7 @@ export class HttpApiClient implements ElectronAPI {
const response = await fetch(`${this.serverUrl}${endpoint}`, {
method: 'PUT',
headers: this.getHeaders(),
credentials: 'include', // Include cookies for session auth
body: body ? JSON.stringify(body) : undefined,
});
return response.json();
@@ -190,6 +370,7 @@ export class HttpApiClient implements ElectronAPI {
const response = await fetch(`${this.serverUrl}${endpoint}`, {
method: 'DELETE',
headers: this.getHeaders(),
credentials: 'include', // Include cookies for session auth
});
return response.json();
}

View File

@@ -8,6 +8,7 @@
import path from 'path';
import { spawn, ChildProcess } from 'child_process';
import fs from 'fs';
import crypto from 'crypto';
import http, { Server } from 'http';
import { app, BrowserWindow, ipcMain, dialog, shell, screen } from 'electron';
import { findNodeExecutable, buildEnhancedPath } from '@automaker/platform';
@@ -59,6 +60,46 @@ interface WindowBounds {
// Debounce timer for saving window bounds
let saveWindowBoundsTimeout: ReturnType<typeof setTimeout> | null = null;
// API key for CSRF protection
let apiKey: string | null = null;
/**
* Get path to API key file in user data directory
*/
function getApiKeyPath(): string {
return path.join(app.getPath('userData'), '.api-key');
}
/**
* Ensure an API key exists - load from file or generate new one.
* This key is passed to the server for CSRF protection.
*/
function ensureApiKey(): string {
const keyPath = getApiKeyPath();
try {
if (fs.existsSync(keyPath)) {
const key = fs.readFileSync(keyPath, 'utf-8').trim();
if (key) {
apiKey = key;
console.log('[Electron] Loaded existing API key');
return apiKey;
}
}
} catch (error) {
console.warn('[Electron] Error reading API key:', error);
}
// Generate new key
apiKey = crypto.randomUUID();
try {
fs.writeFileSync(keyPath, apiKey, { encoding: 'utf-8', mode: 0o600 });
console.log('[Electron] Generated new API key');
} catch (error) {
console.error('[Electron] Failed to save API key:', error);
}
return apiKey;
}
/**
* Get icon path - works in both dev and production, cross-platform
*/
@@ -331,6 +372,8 @@ async function startServer(): Promise<void> {
PORT: SERVER_PORT.toString(),
DATA_DIR: app.getPath('userData'),
NODE_PATH: serverNodeModules,
// Pass API key to server for CSRF protection
AUTOMAKER_API_KEY: apiKey!,
// Only set ALLOWED_ROOT_DIRECTORY if explicitly provided in environment
// If not set, server will allow access to all paths
...(process.env.ALLOWED_ROOT_DIRECTORY && {
@@ -509,6 +552,9 @@ app.whenReady().then(async () => {
}
}
// Generate or load API key for CSRF protection (before starting server)
ensureApiKey();
try {
// Start static file server in production
if (app.isPackaged) {
@@ -666,6 +712,11 @@ ipcMain.handle('server:getUrl', async () => {
return `http://localhost:${SERVER_PORT}`;
});
// Get API key for authentication
ipcMain.handle('auth:getApiKey', () => {
return apiKey;
});
// Window management - update minimum width based on sidebar state
// Now uses a fixed small minimum since horizontal scrolling handles overflow
ipcMain.handle('window:updateMinWidth', (_, _sidebarExpanded: boolean) => {

View File

@@ -19,6 +19,9 @@ contextBridge.exposeInMainWorld('electronAPI', {
// Get server URL for HTTP client
getServerUrl: (): Promise<string> => ipcRenderer.invoke('server:getUrl'),
// Get API key for authentication
getApiKey: (): Promise<string | null> => ipcRenderer.invoke('auth:getApiKey'),
// Native dialogs - better UX than prompt()
openDirectory: (): Promise<Electron.OpenDialogReturnValue> =>
ipcRenderer.invoke('dialog:openDirectory'),

View File

@@ -9,6 +9,12 @@ 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 { Toaster } from 'sonner';
import { ThemeOption, themeOptions } from '@/config/theme-options';
@@ -22,6 +28,8 @@ function RootLayoutContent() {
const [setupHydrated, setSetupHydrated] = useState(
() => useSetupStore.persist?.hasHydrated?.() ?? false
);
const [authChecked, setAuthChecked] = useState(false);
const [isAuthenticated, setIsAuthenticated] = useState(false);
const { openFileBrowser } = useFileBrowser();
// Hidden streamer panel - opens with "\" key
@@ -70,6 +78,51 @@ function RootLayoutContent() {
setIsMounted(true);
}, []);
// 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)
useEffect(() => {
const initAuth = async () => {
try {
// Initialize API key for Electron mode
await initApiKey();
// In Electron mode, we're always authenticated via header
if (isElectronMode()) {
setIsAuthenticated(true);
setAuthChecked(true);
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();
if (tokenFetched) {
// We have a valid session - token is now stored in memory
setIsAuthenticated(true);
setAuthChecked(true);
return;
}
// Fallback: check auth status via cookie
const status = await checkAuthStatus();
setIsAuthenticated(status.authenticated);
setAuthChecked(true);
// Redirect to login if not authenticated and not already on login page
if (!status.authenticated && location.pathname !== '/login') {
navigate({ to: '/login' });
}
} catch (error) {
console.error('Failed to initialize auth:', error);
setAuthChecked(true);
}
};
initAuth();
}, [location.pathname, navigate]);
// Wait for setup store hydration before enforcing routing rules
useEffect(() => {
if (useSetupStore.persist?.hasHydrated?.()) {
@@ -147,8 +200,32 @@ function RootLayoutContent() {
}
}, [deferredTheme]);
// Setup view is full-screen without sidebar
// Login and setup views are full-screen without sidebar
const isSetupRoute = location.pathname === '/setup';
const isLoginRoute = location.pathname === '/login';
// Show login page (full screen, no sidebar)
if (isLoginRoute) {
return (
<main className="h-screen overflow-hidden" data-testid="app-container">
<Outlet />
</main>
);
}
// Wait for auth check before rendering protected routes (web mode only)
if (!isElectronMode() && !authChecked) {
return (
<main className="flex h-screen items-center justify-center" data-testid="app-container">
<div className="text-muted-foreground">Loading...</div>
</main>
);
}
// Redirect to login if not authenticated (web mode)
if (!isElectronMode() && !isAuthenticated) {
return null; // Will redirect via useEffect
}
if (isSetupRoute) {
return (

View File

@@ -0,0 +1,6 @@
import { createFileRoute } from '@tanstack/react-router';
import { LoginView } from '@/components/views/login-view';
export const Route = createFileRoute('/login')({
component: LoginView,
});

View File

@@ -176,6 +176,7 @@ export const useSetupStore = create<SetupState & SetupActions>()(
isFirstRun: state.isFirstRun,
setupComplete: state.setupComplete,
skipClaudeSetup: state.skipClaudeSetup,
claudeAuthStatus: state.claudeAuthStatus,
}),
}
)

View File

@@ -464,6 +464,7 @@ export interface AutoModeAPI {
export interface ElectronAPI {
ping: () => Promise<string>;
getApiKey?: () => Promise<string | null>;
openExternalLink: (url: string) => Promise<{ success: boolean; error?: string }>;
// Dialog APIs