mirror of
https://github.com/BradGroux/veritas-kanban.git
synced 2026-10-09 05:07:53 +00:00
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:
parent
26431ebc9f
commit
55c742c2df
4 changed files with 738 additions and 4 deletions
267
docs/security.md
Normal file
267
docs/security.md
Normal 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
50
server/.env.example
Normal 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
|
||||
#
|
||||
|
|
@ -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)}║
|
||||
╚═══════════════════════════════════════════════╝
|
||||
`);
|
||||
});
|
||||
|
|
|
|||
372
server/src/middleware/auth.ts
Normal file
372
server/src/middleware/auth.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue