diff --git a/docs/security.md b/docs/security.md new file mode 100644 index 00000000..ab10ebd3 --- /dev/null +++ b/docs/security.md @@ -0,0 +1,267 @@ +# Veritas Kanban Server - Security Guide + +## Overview + +The Veritas Kanban server includes a flexible authentication and authorization system to protect API endpoints and WebSocket connections from unauthorized access. + +## Quick Start + +### Development (Localhost Bypass) + +For local development, enable localhost bypass: + +```bash +# .env +VERITAS_AUTH_ENABLED=true +VERITAS_AUTH_LOCALHOST_BYPASS=true +``` + +This allows unauthenticated requests from `localhost`/`127.0.0.1` while still requiring auth for remote connections. + +### Production + +For production, configure API keys: + +```bash +# .env +VERITAS_AUTH_ENABLED=true +VERITAS_AUTH_LOCALHOST_BYPASS=false +VERITAS_ADMIN_KEY=your-secure-admin-key +VERITAS_API_KEYS=agent1:key1:agent,dashboard:key2:read-only +``` + +## Authentication Methods + +Clients can authenticate using any of these methods: + +### 1. Authorization Header (Recommended) + +```bash +curl -H "Authorization: Bearer your-api-key" \ + http://localhost:3001/api/tasks +``` + +### 2. X-API-Key Header + +```bash +curl -H "X-API-Key: your-api-key" \ + http://localhost:3001/api/tasks +``` + +### 3. Query Parameter (WebSocket) + +```javascript +const ws = new WebSocket('ws://localhost:3001/ws?api_key=your-api-key'); +``` + +## Roles and Permissions + +| Role | Read | Write | Admin Actions | +|------|------|-------|---------------| +| `admin` | ✅ | ✅ | ✅ | +| `agent` | ✅ | ✅ | ❌ | +| `read-only` | ✅ | ❌ | ❌ | + +### Role Details + +- **admin**: Full access to all endpoints including sensitive operations +- **agent**: Can read/write tasks, run agents, manage worktrees. Intended for AI agents like Clawdbot/Veritas +- **read-only**: Can only perform GET requests. Suitable for dashboards and monitoring + +## Configuration Reference + +### Environment Variables + +| Variable | Default | Description | +|----------|---------|-------------| +| `VERITAS_AUTH_ENABLED` | `true` | Enable/disable authentication | +| `VERITAS_AUTH_LOCALHOST_BYPASS` | `false` | Allow unauthenticated localhost requests | +| `VERITAS_ADMIN_KEY` | (none) | Admin API key with full access | +| `VERITAS_API_KEYS` | (none) | Comma-separated API keys (format: `name:key:role`) | + +### API Key Format + +``` +name:key:role,name2:key2:role2 +``` + +Example: +``` +veritas:vk_abc123xyz:agent,dashboard:vk_def456uvw:read-only +``` + +## Generating API Keys + +### Using OpenSSL + +```bash +# Generate a random 32-character key +openssl rand -base64 32 +``` + +### Using the Built-in Function + +```typescript +import { generateApiKey } from './middleware/auth.js'; +const key = generateApiKey('vk'); // e.g., vk_AbCdEf123... +``` + +## API Endpoints + +### Auth Status (Unauthenticated) + +Check the current authentication configuration: + +```bash +curl http://localhost:3001/api/auth/status +``` + +Response: +```json +{ + "enabled": true, + "localhostBypass": false, + "configuredKeys": 2, + "hasAdminKey": true +} +``` + +### Health Check (Unauthenticated) + +```bash +curl http://localhost:3001/health +``` + +## WebSocket Authentication + +WebSocket connections are authenticated on connect: + +```javascript +// With API key +const ws = new WebSocket('ws://localhost:3001/ws?api_key=your-key'); + +ws.onclose = (event) => { + if (event.code === 4001) { + console.error('Authentication failed:', event.reason); + } +}; +``` + +### WebSocket Close Codes + +| Code | Meaning | +|------|---------| +| `1000` | Normal close | +| `4001` | Authentication required/failed | + +## Error Responses + +### 401 Unauthorized + +```json +{ + "error": "Authentication required", + "code": "AUTH_REQUIRED", + "hint": "Provide API key via Authorization header (Bearer ), X-API-Key header, or api_key query parameter" +} +``` + +### 403 Forbidden + +```json +{ + "error": "Write access denied", + "code": "WRITE_FORBIDDEN", + "hint": "Your API key has read-only access" +} +``` + +## Security Best Practices + +1. **Never commit API keys** - Use environment variables or `.env` files (add to `.gitignore`) + +2. **Rotate keys regularly** - Update API keys periodically, especially if compromised + +3. **Use HTTPS in production** - API keys are transmitted in headers/URLs + +4. **Principle of least privilege** - Use `read-only` for dashboards, `agent` for automation + +5. **Monitor access** - The server logs connection attempts with role information + +## Migrating from No Auth + +If you're upgrading from an earlier version without authentication: + +1. **Before upgrading**: Document all clients that access the API + +2. **During upgrade**: + - Start with `VERITAS_AUTH_LOCALHOST_BYPASS=true` for smooth transition + - Generate API keys for each client + - Update clients to include authentication headers + +3. **After testing**: Disable localhost bypass for production + +## Troubleshooting + +### "Authentication required" for localhost + +Check that `VERITAS_AUTH_LOCALHOST_BYPASS=true` is set, or provide an API key. + +### "Invalid API key" + +- Verify the key matches exactly (no extra spaces) +- Check that the key is in the `VERITAS_API_KEYS` or `VERITAS_ADMIN_KEY` variable +- Ensure the format is correct: `name:key:role` + +### WebSocket immediately closes + +- Check browser console for the close reason +- Ensure the API key is passed as a query parameter: `?api_key=...` + +## Architecture + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Request Flow │ +├─────────────────────────────────────────────────────────────┤ +│ │ +│ Client Request │ +│ │ │ +│ ▼ │ +│ ┌──────────────┐ │ +│ │ CORS/JSON │ (express middleware) │ +│ └──────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌──────────────┐ ┌───────────────────────┐ │ +│ │ /health │──▶│ Bypass auth │ │ +│ │ /api/auth/* │ │ (unauthenticated) │ │ +│ └──────────────┘ └───────────────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌──────────────┐ │ +│ │ authenticate │ (middleware/auth.ts) │ +│ │ │ │ +│ │ - Check auth │ │ +│ │ enabled │ │ +│ │ - Localhost │ │ +│ │ bypass? │ │ +│ │ - Validate │ │ +│ │ API key │ │ +│ └──────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌──────────────┐ │ +│ │ Route Handler│ (req.auth available) │ +│ └──────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────┘ +``` + +## Changelog + +- **v1.0.0** (2026-01-28): Initial authentication implementation + - API key authentication for HTTP and WebSocket + - Role-based authorization (admin, agent, read-only) + - Localhost bypass for development + - Configuration via environment variables diff --git a/server/.env.example b/server/.env.example new file mode 100644 index 00000000..0c76884e --- /dev/null +++ b/server/.env.example @@ -0,0 +1,50 @@ +# Veritas Kanban Server - Environment Variables +# Copy to .env and customize + +# Server port +PORT=3001 + +# ═══════════════════════════════════════════════════════════════════════════════ +# AUTHENTICATION SETTINGS +# ═══════════════════════════════════════════════════════════════════════════════ + +# Enable/disable authentication (default: true) +# Set to "false" to disable auth (NOT recommended for production) +VERITAS_AUTH_ENABLED=true + +# Allow unauthenticated requests from localhost (default: false) +# Useful for local development while keeping auth enabled for remote +VERITAS_AUTH_LOCALHOST_BYPASS=true + +# Admin API key - has full access to all endpoints +# Generate a secure key: openssl rand -base64 32 +VERITAS_ADMIN_KEY=your-secret-admin-key-here + +# API keys for agents and services +# Format: name:key:role,name2:key2:role2 +# Roles: admin, agent, read-only +# Example: +VERITAS_API_KEYS=veritas-agent:vk_agent123:agent,dashboard:vk_dashboard456:read-only + +# ═══════════════════════════════════════════════════════════════════════════════ +# ROLE PERMISSIONS +# ═══════════════════════════════════════════════════════════════════════════════ +# +# admin - Full access to all endpoints +# agent - Can read/write tasks, run agents, manage worktrees +# read-only - Can only GET endpoints (view tasks, read config) +# + +# ═══════════════════════════════════════════════════════════════════════════════ +# AUTHENTICATION METHODS +# ═══════════════════════════════════════════════════════════════════════════════ +# +# 1. Authorization header (Bearer token) +# curl -H "Authorization: Bearer your-api-key" http://localhost:3001/api/tasks +# +# 2. X-API-Key header +# curl -H "X-API-Key: your-api-key" http://localhost:3001/api/tasks +# +# 3. Query parameter (for WebSocket) +# ws://localhost:3001/ws?api_key=your-api-key +# diff --git a/server/src/index.ts b/server/src/index.ts index dae5556c..bb8cffe2 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -3,6 +3,10 @@ import cors from 'cors'; import { WebSocketServer, WebSocket } from 'ws'; import { createServer } from 'http'; import { taskRoutes } from './routes/tasks.js'; +import { taskCommentRoutes } from './routes/task-comments.js'; +import { taskSubtaskRoutes } from './routes/task-subtasks.js'; +import { taskTimeRoutes } from './routes/task-time.js'; +import { taskArchiveRoutes } from './routes/task-archive.js'; import { configRoutes } from './routes/config.js'; import { agentRoutes, agentService } from './routes/agents.js'; import { diffRoutes } from './routes/diff.js'; @@ -22,11 +26,13 @@ import metricsRoutes from './routes/metrics.js'; import tracesRoutes from './routes/traces.js'; import attachmentRoutes from './routes/attachments.js'; import { settingsRoutes, syncSettingsToServices } from './routes/settings.js'; +import { agentStatusRoutes, initAgentStatus } from './routes/agent-status.js'; import { getTelemetryService } from './services/telemetry-service.js'; import { ConfigService } from './services/config-service.js'; import { initBroadcast } from './services/broadcast-service.js'; import { runStartupMigrations } from './services/migration-service.js'; import { errorHandler } from './middleware/error-handler.js'; +import { authenticate, authenticateWebSocket, getAuthStatus, type AuthenticatedWebSocket } from './middleware/auth.js'; import type { AgentOutput } from './services/clawdbot-agent-service.js'; const app = express(); @@ -36,13 +42,26 @@ const PORT = process.env.PORT || 3001; app.use(cors()); app.use(express.json()); -// Health check +// Health check (unauthenticated) app.get('/health', (_req, res) => { res.json({ status: 'ok', timestamp: new Date().toISOString() }); }); -// API Routes +// Auth status endpoint (unauthenticated - for diagnostics) +app.get('/api/auth/status', (_req, res) => { + res.json(getAuthStatus()); +}); + +// Apply authentication to all API routes +app.use('/api', authenticate); + +// API Routes - Task routes (split for maintainability) +// Archive and time routes must be mounted before main taskRoutes to handle /archived, /time/summary before /:id +app.use('/api/tasks', taskArchiveRoutes); +app.use('/api/tasks', taskTimeRoutes); app.use('/api/tasks', taskRoutes); +app.use('/api/tasks', taskCommentRoutes); +app.use('/api/tasks', taskSubtaskRoutes); app.use('/api/tasks', attachmentRoutes); app.use('/api/config', configRoutes); app.use('/api/agents', agentRoutes); @@ -62,6 +81,7 @@ app.use('/api/telemetry', telemetryRoutes); app.use('/api/metrics', metricsRoutes); app.use('/api/traces', tracesRoutes); app.use('/api/settings', settingsRoutes); +app.use('/api/agent/status', agentStatusRoutes); // Error handling middleware (must be last) app.use(errorHandler); @@ -91,11 +111,30 @@ const wss = new WebSocketServer({ server, path: '/ws' }); // Initialize broadcast service for task change notifications initBroadcast(wss); +// Initialize agent status service for WebSocket broadcasts +initAgentStatus(wss); + // Track subscriptions: taskId -> Set of WebSocket clients const agentSubscriptions = new Map>(); -wss.on('connection', (ws) => { - console.log('WebSocket client connected'); +wss.on('connection', (ws: AuthenticatedWebSocket, req) => { + // Authenticate WebSocket connection + const authResult = authenticateWebSocket(req); + + if (!authResult.authenticated) { + console.log('WebSocket connection rejected: ' + authResult.error); + ws.close(4001, authResult.error || 'Authentication required'); + return; + } + + // Attach auth info to WebSocket for later use + ws.auth = { + role: authResult.role!, + keyName: authResult.keyName, + isLocalhost: authResult.isLocalhost, + }; + + console.log(`WebSocket client connected (role: ${authResult.role}, localhost: ${authResult.isLocalhost})`); let subscribedTaskId: string | null = null; @@ -233,6 +272,11 @@ process.on('SIGINT', () => gracefulShutdown('SIGINT')); // Start server server.listen(PORT, () => { + const authStatus = getAuthStatus(); + const authLine = authStatus.enabled + ? `Auth: ON (${authStatus.configuredKeys} keys${authStatus.localhostBypass ? ', localhost bypass' : ''})` + : 'Auth: OFF (dev mode)'; + console.log(` ╔═══════════════════════════════════════════════╗ ║ Veritas Kanban Server ║ @@ -240,6 +284,7 @@ server.listen(PORT, () => { ║ API: http://localhost:${PORT} ║ ║ WebSocket: ws://localhost:${PORT}/ws ║ ║ Health: http://localhost:${PORT}/health ║ +║ ${authLine.padEnd(42)}║ ╚═══════════════════════════════════════════════╝ `); }); diff --git a/server/src/middleware/auth.ts b/server/src/middleware/auth.ts new file mode 100644 index 00000000..7c011e0f --- /dev/null +++ b/server/src/middleware/auth.ts @@ -0,0 +1,372 @@ +import { Request, Response, NextFunction } from 'express'; +import { WebSocket } from 'ws'; +import { IncomingMessage } from 'http'; + +// === Types === + +export type AuthRole = 'admin' | 'read-only' | 'agent'; + +export interface AuthConfig { + /** Enable authentication (default: true) */ + enabled: boolean; + /** Allow unauthenticated localhost connections when auth is enabled */ + allowLocalhostBypass: boolean; + /** API keys for agents and services */ + apiKeys: ApiKeyConfig[]; + /** Admin API key (full access) */ + adminKey?: string; +} + +export interface ApiKeyConfig { + /** The API key value */ + key: string; + /** Human-readable name/description */ + name: string; + /** Role assigned to this key */ + role: AuthRole; + /** Optional: restrict to specific routes (regex patterns) */ + allowedRoutes?: string[]; +} + +export interface AuthenticatedRequest extends Request { + auth?: { + role: AuthRole; + keyName?: string; + isLocalhost: boolean; + }; +} + +// === Configuration === + +// Load auth config from environment variables +function loadAuthConfig(): AuthConfig { + const enabled = process.env.VERITAS_AUTH_ENABLED !== 'false'; + const allowLocalhostBypass = process.env.VERITAS_AUTH_LOCALHOST_BYPASS === 'true'; + const adminKey = process.env.VERITAS_ADMIN_KEY; + + // Parse API keys from environment (format: name:key:role,name2:key2:role2) + const apiKeysEnv = process.env.VERITAS_API_KEYS || ''; + const apiKeys: ApiKeyConfig[] = apiKeysEnv + .split(',') + .filter(Boolean) + .map(entry => { + const [name, key, role] = entry.split(':'); + return { + name: name?.trim() || 'unnamed', + key: key?.trim() || '', + role: (role?.trim() as AuthRole) || 'read-only', + }; + }) + .filter(k => k.key); + + return { + enabled, + allowLocalhostBypass, + apiKeys, + adminKey, + }; +} + +// Singleton config instance (reloaded on each request for dev flexibility) +let authConfig: AuthConfig | null = null; + +export function getAuthConfig(): AuthConfig { + if (!authConfig || process.env.NODE_ENV === 'development') { + authConfig = loadAuthConfig(); + } + return authConfig; +} + +// === Helper Functions === + +function isLocalhostRequest(req: Request | IncomingMessage): boolean { + const forwarded = (req.headers['x-forwarded-for'] as string)?.split(',')[0]?.trim(); + let remoteAddr: string; + + if ('socket' in req && req.socket) { + remoteAddr = forwarded || req.socket.remoteAddress || ''; + } else if ('ip' in req) { + remoteAddr = forwarded || (req as Request).ip || ''; + } else { + remoteAddr = forwarded || ''; + } + + return ( + remoteAddr === '127.0.0.1' || + remoteAddr === '::1' || + remoteAddr === '::ffff:127.0.0.1' || + remoteAddr === 'localhost' + ); +} + +function extractApiKey(req: Request | IncomingMessage): string | null { + // Check Authorization header (Bearer token) + const authHeader = req.headers.authorization; + if (authHeader?.startsWith('Bearer ')) { + return authHeader.slice(7); + } + + // Check X-API-Key header + const apiKeyHeader = req.headers['x-api-key']; + if (typeof apiKeyHeader === 'string') { + return apiKeyHeader; + } + + // Check query parameter (for WebSocket connections) + if ('query' in req && typeof req.query === 'object' && req.query !== null) { + const query = req.query as Record; + if (typeof query.api_key === 'string') { + return query.api_key; + } + } + + // For IncomingMessage (WebSocket), parse URL + if ('url' in req && typeof req.url === 'string') { + try { + const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`); + const apiKey = url.searchParams.get('api_key'); + if (apiKey) return apiKey; + } catch { + // Ignore URL parsing errors + } + } + + return null; +} + +function validateApiKey(apiKey: string, config: AuthConfig): { valid: boolean; role?: AuthRole; name?: string } { + // Check admin key first + if (config.adminKey && apiKey === config.adminKey) { + return { valid: true, role: 'admin', name: 'admin' }; + } + + // Check configured API keys + const keyConfig = config.apiKeys.find(k => k.key === apiKey); + if (keyConfig) { + return { valid: true, role: keyConfig.role, name: keyConfig.name }; + } + + return { valid: false }; +} + +// === Express Middleware === + +/** + * Authentication middleware - validates API key and sets auth context + */ +export function authenticate(req: AuthenticatedRequest, res: Response, next: NextFunction): void { + const config = getAuthConfig(); + const isLocalhost = isLocalhostRequest(req); + + // Auth disabled - allow all requests + if (!config.enabled) { + req.auth = { role: 'admin', isLocalhost }; + return next(); + } + + // Localhost bypass + if (config.allowLocalhostBypass && isLocalhost) { + req.auth = { role: 'admin', isLocalhost }; + return next(); + } + + // Extract and validate API key + const apiKey = extractApiKey(req); + + if (!apiKey) { + res.status(401).json({ + error: 'Authentication required', + code: 'AUTH_REQUIRED', + hint: 'Provide API key via Authorization header (Bearer ), X-API-Key header, or api_key query parameter', + }); + return; + } + + const validation = validateApiKey(apiKey, config); + + if (!validation.valid) { + res.status(401).json({ + error: 'Invalid API key', + code: 'INVALID_API_KEY', + }); + return; + } + + req.auth = { + role: validation.role!, + keyName: validation.name, + isLocalhost, + }; + + next(); +} + +/** + * Authorization middleware factory - requires specific roles + */ +export function authorize(...allowedRoles: AuthRole[]) { + return (req: AuthenticatedRequest, res: Response, next: NextFunction): void => { + if (!req.auth) { + res.status(401).json({ + error: 'Authentication required', + code: 'AUTH_REQUIRED', + }); + return; + } + + // Admin can do everything + if (req.auth.role === 'admin') { + return next(); + } + + if (!allowedRoles.includes(req.auth.role)) { + res.status(403).json({ + error: 'Insufficient permissions', + code: 'FORBIDDEN', + required: allowedRoles, + current: req.auth.role, + }); + return; + } + + next(); + }; +} + +/** + * Middleware that allows read operations for read-only users + * but requires admin for write operations + */ +export function authorizeWrite(req: AuthenticatedRequest, res: Response, next: NextFunction): void { + if (!req.auth) { + res.status(401).json({ + error: 'Authentication required', + code: 'AUTH_REQUIRED', + }); + return; + } + + // Admin and agent can write + if (req.auth.role === 'admin' || req.auth.role === 'agent') { + return next(); + } + + // Read-only can only GET + const readMethods = ['GET', 'HEAD', 'OPTIONS']; + if (req.auth.role === 'read-only' && readMethods.includes(req.method)) { + return next(); + } + + res.status(403).json({ + error: 'Write access denied', + code: 'WRITE_FORBIDDEN', + hint: 'Your API key has read-only access', + }); +} + +// === WebSocket Authentication === + +export interface WebSocketAuthResult { + authenticated: boolean; + role?: AuthRole; + keyName?: string; + isLocalhost: boolean; + error?: string; +} + +/** + * Authenticate a WebSocket connection request + */ +export function authenticateWebSocket(req: IncomingMessage): WebSocketAuthResult { + const config = getAuthConfig(); + const isLocalhost = isLocalhostRequest(req); + + // Auth disabled + if (!config.enabled) { + return { authenticated: true, role: 'admin', isLocalhost }; + } + + // Localhost bypass + if (config.allowLocalhostBypass && isLocalhost) { + return { authenticated: true, role: 'admin', isLocalhost }; + } + + // Extract and validate API key + const apiKey = extractApiKey(req); + + if (!apiKey) { + return { + authenticated: false, + isLocalhost, + error: 'Authentication required. Provide api_key query parameter.', + }; + } + + const validation = validateApiKey(apiKey, config); + + if (!validation.valid) { + return { + authenticated: false, + isLocalhost, + error: 'Invalid API key', + }; + } + + return { + authenticated: true, + role: validation.role, + keyName: validation.name, + isLocalhost, + }; +} + +/** + * Attach auth info to WebSocket for later use + */ +export interface AuthenticatedWebSocket extends WebSocket { + auth?: { + role: AuthRole; + keyName?: string; + isLocalhost: boolean; + }; +} + +// === Utility Functions === + +/** + * Generate a secure random API key + */ +export function generateApiKey(prefix = 'vk'): string { + const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; + let key = prefix + '_'; + for (let i = 0; i < 32; i++) { + key += chars.charAt(Math.floor(Math.random() * chars.length)); + } + return key; +} + +/** + * Check if current config requires authentication + */ +export function isAuthRequired(): boolean { + const config = getAuthConfig(); + return config.enabled; +} + +/** + * Get current auth status for diagnostics + */ +export function getAuthStatus(): { + enabled: boolean; + localhostBypass: boolean; + configuredKeys: number; + hasAdminKey: boolean; +} { + const config = getAuthConfig(); + return { + enabled: config.enabled, + localhostBypass: config.allowLocalhostBypass, + configuredKeys: config.apiKeys.length, + hasAdminKey: !!config.adminKey, + }; +}