diff --git a/opax-web/README.md b/opax-web/README.md new file mode 100644 index 0000000..b5a1e8d --- /dev/null +++ b/opax-web/README.md @@ -0,0 +1,41 @@ +# OPAX Web TUI + +This project provides a starter scaffold for a Google-login-gated web control plane. It consists of a React frontend and a Node.js backend. + +The frontend provides a terminal-like interface for interacting with the system. The backend handles user authentication via Google OAuth2 and provides a secure boundary for potential agent integrations. + +## Local Development + +To run the application locally, first install the dependencies: +```bash +npm install +``` + +Then, start the development server: +```bash +npm run dev +``` + +### Configuration + +The backend requires several environment variables for configuration. For local development, you can create a `.env` file in the `opax-web/backend/` directory. + +- Do not commit `.env` files to Git. +- **NEVER** use or copy production secrets into a local `.env` file. +- For configuration that requires secrets, use local, rotatable test values. +- Production secrets must be stored and delivered via the dedicated secrets solution described in `SECURITY.md`. + +Required configuration keys: +- `FRONTEND_URL` +- `ALLOWED_EMAILS` +- `GOOGLE_CLIENT_ID` +- `GOOGLE_CLIENT_SECRET` +- `SESSION_SECRET` + +**Note:** The outbound agent integration is currently disabled by default (fail-closed). The backend will not attempt to connect to any external agent services. + +## Security + +**IMPORTANT:** Never commit passwords, tokens, OAuth client secrets, or service account keys to the Git repository. If any credential is accidentally exposed, it **MUST** be revoked and rotated immediately. + +For more details, see `SECURITY.md`. diff --git a/opax-web/SECURITY.md b/opax-web/SECURITY.md new file mode 100644 index 0000000..d9d4d01 --- /dev/null +++ b/opax-web/SECURITY.md @@ -0,0 +1,14 @@ +# Security Policy + +## Responsible Disclosure + +To report a security vulnerability, please send a private email to `chris.christiansen@vauco.no`. + +Do not include sensitive information, customer data, or secrets in public GitHub issues or chat discussions. + +## Credential Security + +- **NEVER** commit credentials, secrets, or tokens to the Git repository. +- **NEVER** include credentials in logs, frontend code, or any other uncontrolled files. +- If a credential is accidentally exposed, it **MUST** be rotated immediately. +- For production environments, all secrets must be stored in a dedicated secret management service (e.g., Google Secret Manager) and accessed using least-privilege IAM principles. diff --git a/opax-web/backend/server.js b/opax-web/backend/server.js new file mode 100644 index 0000000..c2dc456 --- /dev/null +++ b/opax-web/backend/server.js @@ -0,0 +1,109 @@ +import { randomUUID } from 'node:crypto'; +import { callAgent, AgentNotConfiguredError } from './agentClient.js'; +import 'dotenv/config'; +import express from 'express'; +import cors from 'cors'; +import session from 'express-session'; +import { OAuth2Client } from 'google-auth-library'; +import { createServer } from 'node:http'; +import { Server } from 'socket.io'; + +const app = express(); +const http = createServer(app); +const front = process.env.FRONTEND_URL || 'http://localhost:5173'; +const emails = new Set((process.env.ALLOWED_EMAILS || '').split(',').filter(Boolean)); +const oauth = new OAuth2Client(process.env.GOOGLE_CLIENT_ID, process.env.GOOGLE_CLIENT_SECRET, `${front}/auth/callback`); + +if (process.env.NODE_ENV === 'production') { + app.set('trust proxy', 1); +} + +const sessionSecret = process.env.SESSION_SECRET; +if (!sessionSecret || (sessionSecret === 'CHANGE_ME' && process.env.NODE_ENV !== 'production')) { + throw new Error('A valid SESSION_SECRET must be set.'); +} + +const sm = session({ + secret: sessionSecret, + resave: false, + saveUninitialized: false, + cookie: { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax' + }, +}); +const io = new Server(http, { cors: { origin: front, credentials: true } }); +const allowed = new Set(['get_health', 'get_build_status', 'get_state', 'get_telemetry', 'get_git_status', 'get_git_branch', 'get_git_log', 'get_git_diff', 'list_repo_files', 'read_repo_file']); + +app.use(cors({ origin: front, credentials: true })); +app.use(sm); +io.use((s, n) => sm(s.request, {}, n)); + +app.get('/health', (_, r) => r.json({ status: 'ok' })); + +app.get('/auth/google', (q, r) => r.redirect(oauth.generateAuthUrl({ scope: ['openid', 'email', 'profile'], prompt: 'consent' }))); + +app.get('/auth/callback', async (q, r) => { + try { + let { tokens } = await oauth.getToken(q.query.code); + let p = (await oauth.verifyIdToken({ idToken: tokens.id_token, audience: process.env.GOOGLE_CLIENT_ID })).getPayload(); + if (!p?.email || !emails.has(p.email)) return r.status(403).send('Not authorised'); + q.session.user = { email: p.email, name: p.name || p.email }; + r.redirect('/'); + } catch (e) { + r.status(500).send('Login failed'); + } +}); + +app.get('/auth/me', (q, r) => (q.session.user ? r.json(q.session.user) : r.status(401).end())); +app.post('/auth/logout', (q, r) => q.session.destroy(() => r.json({ ok: true }))); + +io.on('connection', (s) => { + let u = s.request.session?.user; + if (!u) return s.disconnect(); + + s.on('mcp-call', async ({ id, tool, args }) => { + if (typeof id !== 'string' || id.length > 128) { + return s.emit('mcp-result', { id, error: 'Invalid request id' }); + } + if (typeof tool !== 'string' || !allowed.has(tool)) { + return s.emit('mcp-result', { id, error: 'Tool denied by server allowlist' }); + } + try { + if (typeof args !== 'object' || args === null || Array.isArray(args)) { + return s.emit('mcp-result', { id, error: 'Invalid arguments' }); + } + const argsString = JSON.stringify(args); + if (argsString.length > 16384) { + return s.emit('mcp-result', { id, error: 'Invalid arguments' }); + } + } catch { + return s.emit('mcp-result', { id, error: 'Invalid arguments' }); + } + + const request_id = randomUUID(); + try { + const result = await callAgent(tool, args); + s.emit('mcp-result', { id, result }); + } catch (e) { + if (e instanceof AgentNotConfiguredError) { + console.log(JSON.stringify({ + request_id, + event: 'agent_not_configured', + category: 'agent_not_configured', + })); + s.emit('mcp-result', { id, error: 'Agent integration is not configured.' }); + } else { + console.log(JSON.stringify({ + request_id, + event: 'agent_request_failed', + category: 'agent_request_failed', + })); + s.emit('mcp-result', { id, error: 'An unexpected error occurred.' }); + } + } + }); +}); + +http.listen(Number(process.env.PORT || 8080), '0.0.0.0');