From c325b9c6364e8c41cb2349cb9986acba938de737 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 26 Apr 2026 17:15:20 +0000 Subject: [PATCH 1/2] Harden default authentication secrets Co-authored-by: Aliasocracy --- .env.example | 6 ++- database/schema.sql | 7 ---- docker-compose.yml | 5 ++- docs/QUICK_START.md | 2 +- src/api/__tests__/gateway.test.ts | 6 +-- src/api/gateway.ts | 26 ++++++------- src/dashboard/admin-server.ts | 17 +++++++-- src/dashboard/public/admin.js | 12 +----- src/ui/interface.ts | 61 ++++++++++++++++++++++++++----- 9 files changed, 91 insertions(+), 51 deletions(-) diff --git a/.env.example b/.env.example index 61747f7..092645a 100644 --- a/.env.example +++ b/.env.example @@ -59,7 +59,11 @@ API_HOST=0.0.0.0 # JWT Secret for API authentication (REQUIRED in production) # Generate a secure random string: openssl rand -base64 32 -JWT_SECRET=change-this-to-a-secure-random-string-in-production +JWT_SECRET= + +# Admin dashboard token for server-side API Gateway proxying +# Generate a secure random string: openssl rand -base64 32 +ADMIN_API_TOKEN= # ---------------------------------------------------------------------------- # AI Provider API Keys diff --git a/database/schema.sql b/database/schema.sql index 46bbe6d..57c80f8 100644 --- a/database/schema.sql +++ b/database/schema.sql @@ -261,13 +261,6 @@ CREATE INDEX idx_api_keys_key_hash ON api_keys(key_hash); CREATE INDEX idx_api_keys_user_id ON api_keys(user_id); CREATE INDEX idx_api_keys_active ON api_keys(active); --- Seed demo API key for UI (development/testing only) --- Key: demo-api-key-for-testing-purposes-only-12345678901234567890 --- Hash: 52346957575b04c715942a324887efde06f034ca62893fa6a76064d7f65f8e43 -INSERT INTO api_keys (user_id, key_hash, active) -VALUES ('demo-user', '52346957575b04c715942a324887efde06f034ca62893fa6a76064d7f65f8e43', true) -ON CONFLICT (key_hash) DO NOTHING; - -- ============================================================================ -- Iterative Consensus tables -- ============================================================================ diff --git a/docker-compose.yml b/docker-compose.yml index 85a66dd..5b77df4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -85,8 +85,8 @@ services: # API Configuration API_PORT: ${API_PORT:-3000} API_HOST: ${API_HOST:-0.0.0.0} - JWT_SECRET: ${JWT_SECRET:-change-this-in-production} - ADMIN_API_TOKEN: ${ADMIN_API_TOKEN:-admin-test-key} + JWT_SECRET: ${JWT_SECRET:?JWT_SECRET is required} + ADMIN_API_TOKEN: ${ADMIN_API_TOKEN:?ADMIN_API_TOKEN is required} # Provider API Keys (required) OPENAI_API_KEY: ${OPENAI_API_KEY} @@ -190,6 +190,7 @@ services: # API Gateway connection (for proxying /api/v1/* requests) API_INTERNAL_HOST: api API_PORT: 3000 + ADMIN_API_TOKEN: ${ADMIN_API_TOKEN:?ADMIN_API_TOKEN is required} # Provider API Keys (for health checks) OPENAI_API_KEY: ${OPENAI_API_KEY} diff --git a/docs/QUICK_START.md b/docs/QUICK_START.md index 5e4000c..cafb345 100644 --- a/docs/QUICK_START.md +++ b/docs/QUICK_START.md @@ -24,7 +24,7 @@ Edit `.env` and add your OpenRouter API key: ```bash # REQUIRED: OpenRouter API key (unified access to all providers) -OPENROUTER_API_KEY=sk-or-v1-your-openrouter-key-here +OPENROUTER_API_KEY=your-openrouter-key-here # Get your key at: https://openrouter.ai/keys diff --git a/src/api/__tests__/gateway.test.ts b/src/api/__tests__/gateway.test.ts index 179dcf7..1ab8ee5 100644 --- a/src/api/__tests__/gateway.test.ts +++ b/src/api/__tests__/gateway.test.ts @@ -101,7 +101,7 @@ describe('APIGateway - Error Paths and Edge Cases', () => { mockRedis, mockDbPool ); - }).toThrow('JWT_SECRET environment variable is required in production'); + }).toThrow('JWT_SECRET environment variable or constructor parameter is required'); }); it('should use environment JWT_SECRET if provided', () => { @@ -118,7 +118,7 @@ describe('APIGateway - Error Paths and Edge Cases', () => { expect(gatewayInstance).toBeDefined(); }); - it('should warn and use default JWT_SECRET in development when not provided', () => { + it('should warn and generate a development JWT_SECRET when not provided', () => { const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(); process.env.NODE_ENV = 'development'; @@ -131,7 +131,7 @@ describe('APIGateway - Error Paths and Edge Cases', () => { ); expect(consoleWarnSpy).toHaveBeenCalledWith( - expect.stringContaining('WARNING: Using default JWT_SECRET') + expect.stringContaining('WARNING: Generated ephemeral JWT_SECRET') ); expect(gatewayInstance).toBeDefined(); diff --git a/src/api/gateway.ts b/src/api/gateway.ts index be11c4c..a560547 100644 --- a/src/api/gateway.ts +++ b/src/api/gateway.ts @@ -7,7 +7,7 @@ import express, { Express, Request, Response, NextFunction } from 'express'; import cors from 'cors'; import rateLimit from 'express-rate-limit'; import jwt from 'jsonwebtoken'; -import { randomUUID, createHash } from 'crypto'; +import { randomUUID, createHash, randomBytes } from 'crypto'; import { Server } from 'http'; import { RedisClientType } from 'redis'; import { Pool } from 'pg'; @@ -94,18 +94,17 @@ export class APIGateway implements IAPIGateway { this.idempotencyCache = idempotencyCache; this.modelRegistry = modelRegistry; - // Require JWT_SECRET environment variable in production + if (!jwtSecret && !process.env.JWT_SECRET && process.env.NODE_ENV === 'production') { + throw new Error( + 'JWT_SECRET environment variable or constructor parameter is required' + ); + } + if (!jwtSecret && !process.env.JWT_SECRET) { - if (process.env.NODE_ENV === 'production') { - throw new Error( - 'JWT_SECRET environment variable is required in production' - ); - } - // Only allow default in development console.warn( - 'WARNING: Using default JWT_SECRET. Set JWT_SECRET environment variable in production!' + 'WARNING: Generated ephemeral JWT_SECRET for development/test use. Set JWT_SECRET in persistent environments.' ); - this.jwtSecret = 'default-secret-change-in-production'; + this.jwtSecret = randomBytes(32).toString('hex'); } else { this.jwtSecret = jwtSecret || process.env.JWT_SECRET!; } @@ -313,9 +312,10 @@ export class APIGateway implements IAPIGateway { return; } - // Check for admin dashboard token (internal use only) - const adminToken = process.env.ADMIN_API_TOKEN || 'admin-test-key'; - if (apiKey === adminToken) { + // Check for admin dashboard token (internal use only). There is no + // fallback token: deployments must opt in by setting ADMIN_API_TOKEN. + const adminToken = process.env.ADMIN_API_TOKEN?.trim(); + if (adminToken && apiKey === adminToken) { req.userId = 'admin-dashboard'; next(); return; diff --git a/src/dashboard/admin-server.ts b/src/dashboard/admin-server.ts index 4f7d714..9563e34 100644 --- a/src/dashboard/admin-server.ts +++ b/src/dashboard/admin-server.ts @@ -30,6 +30,7 @@ export class AdminServer { private syncScheduler?: ISyncScheduler; private db: Pool; private redis: RedisClientType; + private adminApiToken?: string; constructor( dashboard: IDashboard, @@ -46,6 +47,7 @@ export class AdminServer { this.syncScheduler = syncScheduler; this.db = db; this.redis = redis; + this.adminApiToken = process.env.ADMIN_API_TOKEN?.trim(); this.setupMiddleware(); this.setupRoutes(); @@ -154,15 +156,22 @@ export class AdminServer { // For Docker internal networking, use service name const targetHost = process.env.API_INTERNAL_HOST || apiHost; + const headers: http.OutgoingHttpHeaders = { + ...req.headers, + host: `${targetHost}:${apiPort}` + }; + + // Keep the admin token server-side instead of exposing it in browser JS. + if (this.adminApiToken && !req.headers.authorization) { + headers.authorization = `ApiKey ${this.adminApiToken}`; + } + const options: http.RequestOptions = { hostname: targetHost, port: parseInt(apiPort), path: `/api/v1${req.url}`, method: req.method, - headers: { - ...req.headers, - host: `${targetHost}:${apiPort}` - } + headers }; const proxyReq = http.request(options, (proxyRes) => { diff --git a/src/dashboard/public/admin.js b/src/dashboard/public/admin.js index 072f1f1..375473a 100644 --- a/src/dashboard/public/admin.js +++ b/src/dashboard/public/admin.js @@ -1244,7 +1244,6 @@ async function sendTestQuery() { method: "POST", headers: { "Content-Type": "application/json", - Authorization: "ApiKey admin-test-key", }, body: JSON.stringify(requestBody), }); @@ -1310,11 +1309,7 @@ async function sendTestQuery() { : `Using active council configuration (${elapsedSeconds}s elapsed)`; try { - const statusResponse = await fetch(`/api/v1/requests/${requestId}`, { - headers: { - Authorization: "ApiKey admin-test-key", - }, - }); + const statusResponse = await fetch(`/api/v1/requests/${requestId}`); if (statusResponse.ok) { const contentType = statusResponse.headers.get("content-type"); @@ -1416,11 +1411,6 @@ async function displayQueryResponse(result, requestId, showDetails) { try { const deliberationResponse = await fetch( `/api/v1/requests/${requestId}/deliberation`, - { - headers: { - Authorization: "ApiKey admin-test-key", - }, - }, ); if (deliberationResponse.ok) { diff --git a/src/ui/interface.ts b/src/ui/interface.ts index e0a3efa..23da565 100644 --- a/src/ui/interface.ts +++ b/src/ui/interface.ts @@ -304,9 +304,9 @@ export class UserInterface { box-shadow: 0 0 30px rgba(0, 212, 255, 0.2); } - .query-textarea { + .query-textarea, + .api-key-input { width: 100%; - min-height: 140px; padding: 20px; background: var(--bg-secondary); border: none; @@ -314,12 +314,19 @@ export class UserInterface { font-family: inherit; font-size: 16px; color: var(--text-primary); - resize: vertical; transition: all var(--transition-medium); } - .query-textarea::placeholder { color: var(--text-muted); } - .query-textarea:focus { outline: none; background: var(--bg-tertiary); } + .query-textarea { + min-height: 140px; + resize: vertical; + } + + .api-key-input { height: 58px; } + .query-textarea::placeholder, + .api-key-input::placeholder { color: var(--text-muted); } + .query-textarea:focus, + .api-key-input:focus { outline: none; background: var(--bg-tertiary); } .button-group { display: flex; @@ -774,6 +781,23 @@ export class UserInterface {
+
+
+ 🔐 + API Key +
+
+ +
+
+
@@ -846,6 +870,17 @@ export class UserInterface { let currentRequestId = null; let deliberationVisible = false; + function getApiKey() { + const apiKeyInput = document.getElementById('apiKey'); + const apiKey = apiKeyInput ? apiKeyInput.value.trim() : ''; + if (!apiKey) { + showStatus('Please enter an API key', 'error'); + if (apiKeyInput) apiKeyInput.focus(); + return null; + } + return apiKey; + } + async function loadConfig() { try { const response = await fetch('/api/ui/config'); @@ -869,6 +904,9 @@ export class UserInterface { return; } + const apiKey = getApiKey(); + if (!apiKey) return; + const submitBtn = document.getElementById('submitBtn'); submitBtn.disabled = true; document.getElementById('submitBtnText').innerHTML = 'Processing...'; @@ -878,7 +916,6 @@ export class UserInterface { deliberationVisible = false; try { - const apiKey = localStorage.getItem('apiKey') || 'demo-api-key-for-testing-purposes-only-12345678901234567890'; const response = await fetch(\`\${config.apiBaseUrl}/api/v1/requests\`, { method: 'POST', headers: { @@ -912,7 +949,11 @@ export class UserInterface { } async function pollForResponse(requestId) { - const apiKey = localStorage.getItem('apiKey') || 'demo-api-key-for-testing-purposes-only-12345678901234567890'; + const apiKey = getApiKey(); + if (!apiKey) { + enableNewRequest(); + return; + } const maxAttempts = 120; let attempts = 0; @@ -1044,7 +1085,8 @@ export class UserInterface { async function loadNegotiationDetails(requestId) { try { - const apiKey = localStorage.getItem('apiKey') || 'demo-api-key-for-testing-purposes-only-12345678901234567890'; + const apiKey = getApiKey(); + if (!apiKey) return; const response = await fetch(\`\${config.apiBaseUrl}/api/v1/requests/\${requestId}/negotiation\`, { headers: { 'Authorization': \`ApiKey \${apiKey}\` } }); @@ -1085,7 +1127,8 @@ export class UserInterface { async function loadDeliberationData(requestId) { try { - const apiKey = localStorage.getItem('apiKey') || 'demo-api-key-for-testing-purposes-only-12345678901234567890'; + const apiKey = getApiKey(); + if (!apiKey) return; const response = await fetch(\`\${config.apiBaseUrl}/api/v1/requests/\${requestId}/deliberation\`, { headers: { 'Authorization': \`ApiKey \${apiKey}\` } }); From 8a0806c7ed98bae635a26c9aeae537a6d0dadaee Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 26 Apr 2026 17:27:10 +0000 Subject: [PATCH 2/2] Address PR auth review feedback Co-authored-by: Aliasocracy --- src/dashboard/admin-server.ts | 40 ++++++++++++++++++++++++++++----- src/dashboard/public/admin.html | 4 ++++ src/dashboard/public/admin.js | 32 +++++++++++++++++++++----- src/ui/interface.ts | 28 ++++++++++------------- 4 files changed, 76 insertions(+), 28 deletions(-) diff --git a/src/dashboard/admin-server.ts b/src/dashboard/admin-server.ts index 9563e34..f3bb5bc 100644 --- a/src/dashboard/admin-server.ts +++ b/src/dashboard/admin-server.ts @@ -83,7 +83,11 @@ export class AdminServer { }); // Proxy /api/v1/* requests to the API gateway - this.app.use('/api/v1', this.proxyToApiGateway.bind(this)); + this.app.use( + '/api/v1', + this.authenticateAdminProxy.bind(this), + this.proxyToApiGateway.bind(this) + ); // Overview metrics this.app.get('/api/admin/overview', this.getOverview.bind(this)); @@ -145,6 +149,35 @@ export class AdminServer { * Proxy requests to the API Gateway for /api/v1/* endpoints * This allows the Test Query feature to work from the admin dashboard */ + private authenticateAdminProxy( + req: Request, + res: Response, + next: NextFunction + ): void { + if (!this.adminApiToken) { + res.status(503).json({ + error: { + code: 'ADMIN_API_TOKEN_NOT_CONFIGURED', + message: 'Admin API token is not configured' + } + }); + return; + } + + const authHeader = req.headers.authorization?.trim(); + if (authHeader !== `ApiKey ${this.adminApiToken}`) { + res.status(401).json({ + error: { + code: 'ADMIN_AUTHENTICATION_REQUIRED', + message: 'Admin authorization is required' + } + }); + return; + } + + next(); + } + private proxyToApiGateway( req: Request, res: Response, @@ -161,11 +194,6 @@ export class AdminServer { host: `${targetHost}:${apiPort}` }; - // Keep the admin token server-side instead of exposing it in browser JS. - if (this.adminApiToken && !req.headers.authorization) { - headers.authorization = `ApiKey ${this.adminApiToken}`; - } - const options: http.RequestOptions = { hostname: targetHost, port: parseInt(apiPort), diff --git a/src/dashboard/public/admin.html b/src/dashboard/public/admin.html index 7039600..04dd154 100644 --- a/src/dashboard/public/admin.html +++ b/src/dashboard/public/admin.html @@ -853,6 +853,10 @@

+
+ + +
diff --git a/src/dashboard/public/admin.js b/src/dashboard/public/admin.js index 375473a..6f375ad 100644 --- a/src/dashboard/public/admin.js +++ b/src/dashboard/public/admin.js @@ -11,6 +11,22 @@ let currentTab = "overview"; let refreshInterval = null; +function getAdminProxyHeaders(extraHeaders = {}) { + const tokenInput = document.getElementById("query-admin-token"); + const token = tokenInput?.value || ""; + + if (!token || !token.trim()) { + if (tokenInput) tokenInput.focus(); + throw new Error("Admin API token is required for test queries"); + } + + return { + ...extraHeaders, + Authorization: `ApiKey ${token.trim()}`, + }; +} + + // Initialize on page load document.addEventListener("DOMContentLoaded", () => { setupTabs(); @@ -1229,6 +1245,9 @@ async function sendTestQuery() { try { // Build request body + const proxyHeaders = getAdminProxyHeaders({ + "Content-Type": "application/json", + }); const requestBody = { query, transparency, @@ -1242,9 +1261,7 @@ async function sendTestQuery() { try { submitResponse = await fetch("/api/v1/requests", { method: "POST", - headers: { - "Content-Type": "application/json", - }, + headers: proxyHeaders, body: JSON.stringify(requestBody), }); } catch (fetchError) { @@ -1309,7 +1326,9 @@ async function sendTestQuery() { : `Using active council configuration (${elapsedSeconds}s elapsed)`; try { - const statusResponse = await fetch(`/api/v1/requests/${requestId}`); + const statusResponse = await fetch(`/api/v1/requests/${requestId}`, { + headers: proxyHeaders, + }); if (statusResponse.ok) { const contentType = statusResponse.headers.get("content-type"); @@ -1365,7 +1384,7 @@ async function sendTestQuery() { } // Display response - displayQueryResponse(result, requestId, transparency); + displayQueryResponse(result, requestId, transparency, proxyHeaders); } catch (error) { console.error("Error sending query:", error); document.getElementById("query-loading").style.display = "none"; @@ -1381,7 +1400,7 @@ async function sendTestQuery() { /** * Display query response */ -async function displayQueryResponse(result, requestId, showDetails) { +async function displayQueryResponse(result, requestId, showDetails, proxyHeaders) { const responseDiv = document.getElementById("query-response"); const statusDiv = document.getElementById("query-status"); const consensusDiv = document.getElementById("query-consensus"); @@ -1411,6 +1430,7 @@ async function displayQueryResponse(result, requestId, showDetails) { try { const deliberationResponse = await fetch( `/api/v1/requests/${requestId}/deliberation`, + { headers: proxyHeaders }, ); if (deliberationResponse.ok) { diff --git a/src/ui/interface.ts b/src/ui/interface.ts index 23da565..d08a98d 100644 --- a/src/ui/interface.ts +++ b/src/ui/interface.ts @@ -869,6 +869,7 @@ export class UserInterface { let currentRequestId = null; let deliberationVisible = false; + let activeApiKey = null; function getApiKey() { const apiKeyInput = document.getElementById('apiKey'); @@ -906,6 +907,7 @@ export class UserInterface { const apiKey = getApiKey(); if (!apiKey) return; + activeApiKey = apiKey; const submitBtn = document.getElementById('submitBtn'); submitBtn.disabled = true; @@ -940,7 +942,7 @@ export class UserInterface { const data = await response.json(); currentRequestId = data.requestId; showStatus('Processing your request...', 'processing'); - pollForResponse(currentRequestId); + pollForResponse(currentRequestId, apiKey); } catch (error) { showStatus(\`Error: \${error.message}\`, 'error'); submitBtn.disabled = false; @@ -948,12 +950,7 @@ export class UserInterface { } } - async function pollForResponse(requestId) { - const apiKey = getApiKey(); - if (!apiKey) { - enableNewRequest(); - return; - } + async function pollForResponse(requestId, apiKey) { const maxAttempts = 120; let attempts = 0; @@ -977,7 +974,7 @@ export class UserInterface { if (data.status === 'completed') { if (!currentRequestId) currentRequestId = requestId; - displayResponse(data.consensusDecision); + displayResponse(data.consensusDecision, apiKey); hideStatus(); enableNewRequest(); } else if (data.status === 'failed') { @@ -1019,7 +1016,7 @@ export class UserInterface { return '

' + formatted + '

'; } - function displayResponse(decision) { + function displayResponse(decision, apiKey = activeApiKey) { const responseSection = document.getElementById('responseSection'); const responseContent = document.getElementById('responseContent'); @@ -1039,10 +1036,10 @@ export class UserInterface { if (decision && typeof decision === 'object' && decision.iterativeConsensusMetadata) { displayConsensusMetadata(decision.iterativeConsensusMetadata); - if (currentRequestId) setTimeout(() => loadNegotiationDetails(currentRequestId), 100); + if (currentRequestId && apiKey) setTimeout(() => loadNegotiationDetails(currentRequestId, apiKey), 100); } - if (currentRequestId) setTimeout(() => loadDeliberationData(currentRequestId), 100); + if (currentRequestId && apiKey) setTimeout(() => loadDeliberationData(currentRequestId, apiKey), 100); } function displayConsensusMetadata(metadata) { @@ -1083,9 +1080,8 @@ export class UserInterface { metadataSection.classList.add('visible'); } - async function loadNegotiationDetails(requestId) { + async function loadNegotiationDetails(requestId, apiKey) { try { - const apiKey = getApiKey(); if (!apiKey) return; const response = await fetch(\`\${config.apiBaseUrl}/api/v1/requests/\${requestId}/negotiation\`, { headers: { 'Authorization': \`ApiKey \${apiKey}\` } @@ -1125,9 +1121,8 @@ export class UserInterface { negotiationSection.classList.add('visible'); } - async function loadDeliberationData(requestId) { + async function loadDeliberationData(requestId, apiKey) { try { - const apiKey = getApiKey(); if (!apiKey) return; const response = await fetch(\`\${config.apiBaseUrl}/api/v1/requests/\${requestId}/deliberation\`, { headers: { 'Authorization': \`ApiKey \${apiKey}\` } @@ -1203,7 +1198,7 @@ export class UserInterface { if (deliberationVisible) { deliberationSection.classList.add('visible'); transparencyBtn.textContent = 'Hide Deliberation'; - if (currentRequestId) loadDeliberationData(currentRequestId); + if (currentRequestId && activeApiKey) loadDeliberationData(currentRequestId, activeApiKey); } else { deliberationSection.classList.remove('visible'); transparencyBtn.textContent = 'Show Deliberation'; @@ -1240,6 +1235,7 @@ export class UserInterface { document.getElementById('newRequestBtn').classList.add('hidden'); hideStatus(); currentRequestId = null; + activeApiKey = null; deliberationVisible = false; }