feat(server): Add authentication & authorization system

- Add auth middleware (server/src/middleware/auth.ts)
  - API key authentication via Bearer token, X-API-Key header, or query param
  - Role-based authorization (admin, agent, read-only)
  - Localhost bypass option for development
  - WebSocket connection authentication

- Update index.ts to integrate auth middleware
  - Apply authenticate middleware to all /api routes
  - Add /api/auth/status endpoint for diagnostics
  - Protect WebSocket connections with token validation
  - Display auth status in startup banner

- Add configuration via environment variables
  - VERITAS_AUTH_ENABLED (default: true)
  - VERITAS_AUTH_LOCALHOST_BYPASS (default: false)
  - VERITAS_ADMIN_KEY for admin access
  - VERITAS_API_KEYS for named keys with roles

- Add comprehensive security documentation (docs/security.md)
- Add .env.example with all auth configuration options

Closes RF-01
This commit is contained in:
Brad Groux 2026-01-28 07:37:44 -06:00
parent 26431ebc9f
commit 55c742c2df
4 changed files with 738 additions and 4 deletions

267
docs/security.md Normal file
View file

@ -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 <key>), 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

50
server/.env.example Normal file
View file

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

View file

@ -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<string, Set<WebSocket>>();
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)}║
╚═══════════════════════════════════════════════╝
`);
});

View file

@ -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<string, unknown>;
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 <key>), 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,
};
}