Add Chrome extension cookie bridge for session import

Users logged into Alta in Chrome can now send their session cookie
to the running Electron app via a local HTTP server on port 18247,
eliminating the need for re-authentication.

- main.js: HTTP cookie server with CORS, token, domain validation
- preload.js: onExtensionCookie push-pattern IPC bridge
- renderer.js: handleExtensionCookie sets session, fetches devices
- chrome-extension/: Manifest V3 extension with popup UI
- CLAUDE.md: updated architecture docs

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Zac
2026-02-09 20:58:54 -05:00
parent e813607f63
commit 67437a0c46
11 changed files with 470 additions and 9 deletions
+43 -5
View File
@@ -4,7 +4,13 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Project Overview
Alta Video Camera Proxy — an Electron desktop app that authenticates with Avigilon Alta Video deployments, discovers cameras, and launches external proxy executables (`aware-cam-proxy-win.exe`, `aware-cam-proxy.exe`) to establish camera connections. Windows-only due to the proxy executables.
Alta Proxy Tool (APT) — an Electron desktop app that authenticates with Avigilon Alta Video deployments, discovers cameras, and launches external proxy executables (`aware-cam-proxy-win.exe`, `aware-cam-proxy.exe`) to establish camera connections. Windows-only due to the proxy executables.
## Repository
- **GitHub**: https://github.com/PageZ948/Alta-Proxy-Tool (private)
- **Branch**: master
- **Git identity**: Zac <zpage948@gmail.com> (repo-local config)
## Commands
@@ -19,26 +25,43 @@ No test framework is configured. No linter is configured.
## Architecture
This is a vanilla Electron app (no React/Vue/framework). Four files form the entire application:
This is a vanilla Electron app (no React/Vue/framework). Core files:
```
main.js → Electron main process: IPC handlers, API calls (axios), profile CRUD,
camera proxy process spawning, password encryption (CryptoJS + machine-derived key)
camera proxy process spawning, password encryption (CryptoJS + machine-derived key),
local HTTP cookie server for Chrome extension bridge
preload.js → contextBridge exposing window.electronAPI with typed IPC invoke wrappers
renderer.js → All UI logic: DOM manipulation, state management, event handlers
index.html → Static HTML shell, no inline scripts (CSP enforced)
styles.css → Dark theme using CSS custom properties
```
A companion Chrome extension lives in `chrome-extension/`:
```
chrome-extension/
manifest.json → Manifest V3, cookies + activeTab permissions
popup.html → Extension popup UI
popup.css → Dark theme matching the Electron app
popup.js → Tab detection, cookie retrieval, POST to localhost
icon*.png → Placeholder icons
```
### IPC Communication Pattern
All cross-process communication follows one pattern:
Most cross-process communication follows the request/response pattern:
1. `main.js` registers handler: `ipcMain.handle('channel-name', async (event, params) => { ... })`
2. `preload.js` exposes it: `channelName: (params) => ipcRenderer.invoke('channel-name', params)`
3. `renderer.js` calls it: `const result = await window.electronAPI.channelName(params)`
All handlers return `{ success: boolean, message?: string, ...data }`.
There is one **push-pattern** channel for the Chrome extension cookie bridge:
- `main.js` sends: `mainWindow.webContents.send('extension-cookie-received', data)`
- `preload.js` bridges: `ipcRenderer.on('extension-cookie-received', callback)`
- `renderer.js` listens via `window.electronAPI.onExtensionCookie(callback)`
### IPC Channels
| Channel | Purpose |
@@ -52,6 +75,7 @@ All handlers return `{ success: boolean, message?: string, ...data }`.
| `camera-proxy-stop` | Kills all proxy processes via taskkill/powershell |
| `camera-proxy-check` | Checks if proxy executable exists |
| `camera-proxy-version` | Runs proxy with -v flag |
| `extension-cookie-received` | Push channel: cookie data from Chrome extension → renderer |
### State Management (renderer.js)
@@ -68,11 +92,25 @@ Active proxy processes are tracked in two Maps: `activeProxyConnections` and `ac
- Legacy profiles auto-migrate from old static key on first load
- Clipboard is cleared 30 seconds after password copy
- Passwords never written to DOM; kept only in JS variables (`selectedProfile`)
- Local HTTP cookie server (port 18247) bound to `127.0.0.1` only
- Cookie server validates: shared token header, CORS restricted to `chrome-extension://` origins, deployment URL must be `*.avasecurity.com` or `*.avigilon.com` over HTTPS, type/length limits on all inputs, 64KB body size limit
### Profile Storage
Profiles stored at `~/.alta-api-profiles.json`. Passwords encrypted with CryptoJS AES using a machine-derived key. The `profiles-load` handler strips passwords before sending to renderer; `profiles-get` decrypts for a specific profile when needed.
### Chrome Extension Cookie Bridge
Users already logged into Alta in Chrome can send their `va` session cookie to the running Electron app. The flow:
1. Chrome extension popup detects Alta tab (`*.avasecurity.com` / `*.avigilon.com`)
2. User clicks "Send Cookie to APT"
3. Extension POSTs `{deploymentUrl, cookieValue}` to `http://127.0.0.1:18247/cookie` with `X-APT-Token` header
4. `main.js` HTTP server validates and forwards via IPC push to renderer
5. `renderer.js` `handleExtensionCookie()` sets session state, populates cookie key, expands cookie proxy section, fetches devices
The extension is loaded unpacked via `chrome://extensions/` → Developer mode → Load unpacked → select `chrome-extension/`.
## Key Conventions
- No inline event handlers in HTML — all use `addEventListener` in renderer.js
@@ -86,4 +124,4 @@ Profiles stored at `~/.alta-api-profiles.json`. Passwords encrypted with CryptoJ
- `aware-cam-proxy-win.exe` — username/password auth proxy (required)
- `aware-cam-proxy.exe` — cookie-based auth proxy (optional)
These are not bundled via npm. They must be in the app root directory.
These are not bundled via npm. They must be in the app root directory. They are gitignored along with `*.pdf`, `node_modules/`, and `dist/`.
Binary file not shown.

After

Width:  |  Height:  |  Size: 579 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 225 B

+25
View File
@@ -0,0 +1,25 @@
{
"manifest_version": 3,
"name": "Alta Proxy Tool Bridge",
"version": "1.0.0",
"description": "Send Alta session cookies to the Alta Proxy Tool desktop app.",
"permissions": ["cookies", "activeTab"],
"host_permissions": [
"https://*.avasecurity.com/*",
"https://*.avigilon.com/*",
"http://127.0.0.1:18247/*"
],
"action": {
"default_popup": "popup.html",
"default_icon": {
"16": "icon16.png",
"48": "icon48.png",
"128": "icon128.png"
}
},
"icons": {
"16": "icon16.png",
"48": "icon48.png",
"128": "icon128.png"
}
}
+100
View File
@@ -0,0 +1,100 @@
/* Dark theme matching the Electron app */
body {
margin: 0;
padding: 0;
font-family: 'Segoe UI', -apple-system, BlinkMacSystemFont, Roboto, sans-serif;
background: #1E1E1E;
color: #E0E0E0;
font-size: 14px;
min-width: 300px;
}
.popup-container {
padding: 16px;
}
h1 {
font-size: 16px;
font-weight: 600;
color: #0E7AFE;
margin: 0 0 12px 0;
text-align: center;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.tab-info {
background: #2D2D30;
border: 1px solid #3C3C3C;
border-radius: 4px;
padding: 10px 12px;
margin-bottom: 12px;
font-size: 13px;
color: #999999;
word-break: break-all;
}
.tab-info.detected {
color: #4CAF50;
border-color: #4CAF50;
}
.tab-info.not-detected {
color: #F44336;
border-color: #F44336;
}
.send-btn {
display: block;
width: 100%;
padding: 10px 16px;
font-family: inherit;
font-size: 14px;
font-weight: bold;
color: white;
background: #0E7AFE;
border: none;
border-radius: 4px;
cursor: pointer;
transition: background 0.2s ease;
}
.send-btn:hover:not(:disabled) {
background: #0A5FD9;
}
.send-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.status-msg {
margin-top: 10px;
padding: 8px 12px;
border-radius: 4px;
font-size: 13px;
font-weight: bold;
display: none;
border: 1px solid transparent;
}
.status-msg.success {
display: block;
background: rgba(76, 175, 80, 0.1);
color: #4CAF50;
border-color: #4CAF50;
}
.status-msg.error {
display: block;
background: rgba(244, 67, 54, 0.1);
color: #F44336;
border-color: #F44336;
}
.status-msg.info {
display: block;
background: rgba(14, 122, 254, 0.1);
color: #0E7AFE;
border-color: #0E7AFE;
}
+18
View File
@@ -0,0 +1,18 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; connect-src http://127.0.0.1:18247;">
<title>Alta Proxy Tool Bridge</title>
<link rel="stylesheet" href="popup.css">
</head>
<body>
<div class="popup-container">
<h1>Alta Proxy Tool</h1>
<div id="tabInfo" class="tab-info">Checking tab...</div>
<button id="sendBtn" class="send-btn" disabled>Send Cookie to APT</button>
<div id="statusMsg" class="status-msg"></div>
</div>
<script src="popup.js"></script>
</body>
</html>
+100
View File
@@ -0,0 +1,100 @@
const APT_URL = 'http://127.0.0.1:18247/cookie';
const APT_TOKEN = 'apt-local-bridge-token';
const tabInfo = document.getElementById('tabInfo');
const sendBtn = document.getElementById('sendBtn');
const statusMsg = document.getElementById('statusMsg');
let detectedOrigin = null;
function showStatus(message, type) {
statusMsg.textContent = message;
statusMsg.className = 'status-msg ' + type;
}
// Check the active tab on popup open
chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => {
if (!tabs || tabs.length === 0) {
tabInfo.textContent = 'No active tab found.';
tabInfo.className = 'tab-info not-detected';
return;
}
const tab = tabs[0];
let url;
try {
url = new URL(tab.url);
} catch {
tabInfo.textContent = 'Cannot read this tab URL.';
tabInfo.className = 'tab-info not-detected';
return;
}
const hostname = url.hostname;
const isAlta = hostname.endsWith('.avasecurity.com') || hostname.endsWith('.avigilon.com');
if (!isAlta) {
tabInfo.textContent = 'This tab is not an Alta deployment.';
tabInfo.className = 'tab-info not-detected';
return;
}
detectedOrigin = url.origin;
tabInfo.textContent = 'Detected: ' + hostname;
tabInfo.className = 'tab-info detected';
sendBtn.disabled = false;
});
// Send cookie on button click
sendBtn.addEventListener('click', async () => {
if (!detectedOrigin) return;
sendBtn.disabled = true;
showStatus('Retrieving cookie...', 'info');
try {
const cookie = await chrome.cookies.get({ url: detectedOrigin, name: 'va' });
if (!cookie || !cookie.value) {
showStatus('No "va" session cookie found. Are you logged in?', 'error');
sendBtn.disabled = false;
return;
}
if (cookie.expirationDate && cookie.expirationDate < Date.now() / 1000) {
showStatus('Session cookie has expired. Please log in again.', 'error');
sendBtn.disabled = false;
return;
}
showStatus('Sending to Alta Proxy Tool...', 'info');
const response = await fetch(APT_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-APT-Token': APT_TOKEN
},
body: JSON.stringify({
deploymentUrl: detectedOrigin,
cookieValue: cookie.value
})
});
const data = await response.json();
if (data.success) {
showStatus('Cookie sent successfully!', 'success');
} else {
showStatus('Error: ' + (data.message || 'Unknown error'), 'error');
sendBtn.disabled = false;
}
} catch (err) {
if (err.message && err.message.includes('Failed to fetch')) {
showStatus('Alta Proxy Tool is not running.', 'error');
} else {
showStatus('Error: ' + err.message, 'error');
}
sendBtn.disabled = false;
}
});
+129 -1
View File
@@ -6,9 +6,13 @@ const axios = require('axios');
const CryptoJS = require('crypto-js');
const { spawn } = require('child_process');
const crypto = require('crypto');
const http = require('http');
let mainWindow;
let activeProxyProcesses = new Map(); // Track active camera proxy processes
let cookieServer = null;
const COOKIE_SERVER_PORT = 18247;
const COOKIE_SERVER_TOKEN = 'apt-local-bridge-token';
// Sanitize strings before embedding in batch files to prevent command injection
function sanitizeBatchInput(input) {
@@ -113,6 +117,121 @@ function saveProfiles(profiles) {
}
}
function startCookieServer() {
cookieServer = http.createServer((req, res) => {
// CORS headers — only allow Chrome extension origins
const origin = req.headers.origin || '';
if (origin.startsWith('chrome-extension://')) {
res.setHeader('Access-Control-Allow-Origin', origin);
}
res.setHeader('Access-Control-Allow-Methods', 'POST, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, X-APT-Token');
// Handle preflight
if (req.method === 'OPTIONS') {
res.writeHead(204);
res.end();
return;
}
// Only accept POST /cookie
if (req.method !== 'POST' || req.url !== '/cookie') {
res.writeHead(404, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ success: false, message: 'Not found' }));
return;
}
// Verify shared token
if (req.headers['x-apt-token'] !== COOKIE_SERVER_TOKEN) {
res.writeHead(403, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ success: false, message: 'Forbidden' }));
return;
}
// Read body with 64KB size limit
let body = '';
let bodySize = 0;
const MAX_BODY_SIZE = 65536;
req.on('data', (chunk) => {
bodySize += chunk.length;
if (bodySize > MAX_BODY_SIZE) {
res.writeHead(413, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ success: false, message: 'Payload too large' }));
req.destroy();
return;
}
body += chunk;
});
req.on('end', () => {
try {
const data = JSON.parse(body);
const { deploymentUrl, cookieValue } = data;
if (!deploymentUrl || !cookieValue) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ success: false, message: 'Missing deploymentUrl or cookieValue' }));
return;
}
// Validate types and lengths
if (typeof deploymentUrl !== 'string' || typeof cookieValue !== 'string' ||
deploymentUrl.length > 512 || cookieValue.length > 4096) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ success: false, message: 'Invalid parameter types or lengths' }));
return;
}
// Validate deployment URL is an Alta domain
try {
const parsed = new URL(deploymentUrl);
const isAltaDomain = parsed.hostname.endsWith('.avasecurity.com') ||
parsed.hostname.endsWith('.avigilon.com');
if (!isAltaDomain || parsed.protocol !== 'https:') {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ success: false, message: 'Invalid deployment URL domain' }));
return;
}
} catch {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ success: false, message: 'Invalid deployment URL' }));
return;
}
if (mainWindow && !mainWindow.isDestroyed()) {
const cookies = ['va=' + cookieValue];
mainWindow.webContents.send('extension-cookie-received', {
deploymentUrl: deploymentUrl.replace(/\/$/, ''),
cookies,
cookieValue
});
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ success: true, message: 'Cookie received' }));
} else {
res.writeHead(503, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ success: false, message: 'Application window not available' }));
}
} catch (e) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ success: false, message: 'Invalid JSON' }));
}
});
});
cookieServer.listen(COOKIE_SERVER_PORT, '127.0.0.1', () => {
console.log(`Cookie server listening on http://127.0.0.1:${COOKIE_SERVER_PORT}`);
});
cookieServer.on('error', (err) => {
if (err.code === 'EADDRINUSE') {
console.error(`Cookie server error: Port ${COOKIE_SERVER_PORT} is already in use`);
} else {
console.error('Cookie server error:', err.message);
}
});
}
function createWindow() {
mainWindow = new BrowserWindow({
width: 1400,
@@ -134,7 +253,16 @@ function createWindow() {
}
}
app.whenReady().then(createWindow);
app.whenReady().then(() => {
createWindow();
startCookieServer();
});
app.on('before-quit', () => {
if (cookieServer) {
cookieServer.close();
}
});
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
+12 -1
View File
@@ -20,5 +20,16 @@ contextBridge.exposeInMainWorld('electronAPI', {
stopCameraProxy: (processId) => ipcRenderer.invoke('camera-proxy-stop', { processId }),
checkCameraProxy: () => ipcRenderer.invoke('camera-proxy-check'),
getCameraProxyVersion: () => ipcRenderer.invoke('camera-proxy-version'),
listActiveCameraProxies: () => ipcRenderer.invoke('camera-proxy-list-active')
listActiveCameraProxies: () => ipcRenderer.invoke('camera-proxy-list-active'),
// Extension cookie bridge (push from main process)
onExtensionCookie: (callback) => {
ipcRenderer.on('extension-cookie-received', (event, data) => {
try {
callback(data);
} catch (error) {
console.error('Extension cookie handler error:', error);
}
});
}
});
+41
View File
@@ -996,6 +996,44 @@ async function deleteProfile(profileId) {
// toggleCookieSection is attached via addEventListener in the event listeners section above
// Handle cookie received from Chrome extension via local HTTP bridge
async function handleExtensionCookie(data) {
const { deploymentUrl, cookies, cookieValue } = data;
// If already connected, disconnect first
if (sessionData.isConnected) {
handleDisconnect();
}
// Set session state from extension cookie
sessionData.deploymentUrl = deploymentUrl;
sessionData.cookies = cookies;
sessionData.isConnected = true;
showStatus(connectionStatus, `Connected via Chrome extension to ${deploymentUrl}`, 'success');
updateConnectionStatus(true);
updateButtonStates();
// Auto-populate cookie key and expand cookie proxy section
cookieKey.value = cookieValue;
const cookieContent = document.getElementById('cookieProxyContent');
const cookieIcon = document.getElementById('cookieCollapseIcon');
if (cookieContent.style.display === 'none') {
cookieContent.style.display = 'block';
cookieIcon.textContent = '\u25B2';
cookieIcon.classList.add('expanded');
}
updateCookieProxyButtonStates();
// Fetch devices
try {
await handleGetDevices();
} catch (err) {
console.error('Failed to fetch devices after extension cookie:', err);
showStatus(deviceStatus, 'Connected, but failed to load devices.', 'warning');
}
}
// Initialize the app
document.addEventListener('DOMContentLoaded', async () => {
console.log('Alta Video Camera Proxy loaded');
@@ -1004,6 +1042,9 @@ document.addEventListener('DOMContentLoaded', async () => {
updateConnectionStatus(false);
updateButtonStates();
// Listen for cookies pushed from Chrome extension
window.electronAPI.onExtensionCookie(handleExtensionCookie);
// Load saved profiles
await loadProfiles();