Skip to content

Commit 36de7dd

Browse files
committed
feat(auth): add JWT session auth with page nonce
1 parent 4a67901 commit 36de7dd

File tree

4 files changed

+226
-66
lines changed

4 files changed

+226
-66
lines changed

deploy/Caddyfile

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,22 @@
11
:8080 {
2+
# HTML page served by backend (injects session nonce)
3+
@htmlpage {
4+
path /
5+
path /index.html
6+
}
7+
handle @htmlpage {
8+
reverse_proxy localhost:{$BACKEND_PORT:8081}
9+
}
10+
11+
# API endpoints proxied to backend
212
handle /api/* {
313
reverse_proxy localhost:{$BACKEND_PORT:8081}
414
}
515

16+
# Static assets served by Caddy
617
handle {
718
root * /app/frontend/dist
8-
try_files {path} /index.html
919
file_server
1020
}
1121
}
22+

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
"cors": "2.8.5",
2929
"dotenv": "17.2.3",
3030
"express": "5.2.1",
31+
"jsonwebtoken": "9.0.2",
3132
"toml": "3.0.0",
3233
"ws": "8.18.0"
3334
},

sample.env

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,4 +5,5 @@ DEEPGRAM_API_KEY=%api_key%
55
# Backend API server port
66
PORT=8081
77
# Server host
8-
HOST=0.0.0.0
8+
HOST=0.0.0.0
9+
# SESSION_SECRET=%session_secret%

server.js

Lines changed: 211 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -5,19 +5,28 @@
55
* Forwards all messages (JSON and binary) bidirectionally between client and Deepgram.
66
*
77
* Routes:
8-
* WS /api/voice-agent - WebSocket proxy to Deepgram Agent API
9-
* GET /api/metadata - Project metadata from deepgram.toml
8+
* GET /api/session - Issue JWT session token
9+
* GET /api/metadata - Project metadata from deepgram.toml
10+
* WS /api/voice-agent - WebSocket proxy to Deepgram Agent API (auth required)
1011
*/
1112

1213
const { WebSocketServer, WebSocket } = require('ws');
1314
const express = require('express');
1415
const { createServer } = require('http');
1516
const cors = require('cors');
17+
const crypto = require('crypto');
18+
const jwt = require('jsonwebtoken');
1619
require('dotenv').config();
1720
const path = require('path');
1821
const fs = require('fs');
1922
const toml = require('toml');
20-
// Native __dirname support in CommonJS
23+
24+
// Validate required environment variables
25+
if (!process.env.DEEPGRAM_API_KEY) {
26+
console.error('ERROR: DEEPGRAM_API_KEY environment variable is required');
27+
console.error('Please copy sample.env to .env and add your API key');
28+
process.exit(1);
29+
}
2130

2231
// Configuration
2332
const CONFIG = {
@@ -27,24 +36,130 @@ const CONFIG = {
2736
host: process.env.HOST || '0.0.0.0',
2837
};
2938

30-
// Validate required environment variables
31-
if (!CONFIG.deepgramApiKey) {
32-
console.error('Error: DEEPGRAM_API_KEY not found in environment variables');
33-
process.exit(1);
39+
// ============================================================================
40+
// SESSION AUTH - JWT tokens with page nonce for production security
41+
// ============================================================================
42+
43+
const SESSION_SECRET =
44+
process.env.SESSION_SECRET || crypto.randomBytes(32).toString('hex');
45+
const REQUIRE_NONCE = !!process.env.SESSION_SECRET;
46+
47+
const sessionNonces = new Map();
48+
const NONCE_TTL_MS = 5 * 60 * 1000;
49+
const JWT_EXPIRY = '1h';
50+
51+
function generateNonce() {
52+
const nonce = crypto.randomBytes(16).toString('hex');
53+
sessionNonces.set(nonce, Date.now() + NONCE_TTL_MS);
54+
return nonce;
55+
}
56+
57+
function consumeNonce(nonce) {
58+
const expiry = sessionNonces.get(nonce);
59+
if (!expiry) return false;
60+
sessionNonces.delete(nonce);
61+
return Date.now() < expiry;
62+
}
63+
64+
setInterval(() => {
65+
const now = Date.now();
66+
for (const [nonce, expiry] of sessionNonces) {
67+
if (now >= expiry) sessionNonces.delete(nonce);
68+
}
69+
}, 60_000);
70+
71+
let indexHtmlTemplate = null;
72+
try {
73+
indexHtmlTemplate = fs.readFileSync(
74+
path.join(__dirname, 'frontend', 'dist', 'index.html'),
75+
'utf-8'
76+
);
77+
} catch {
78+
// No built frontend (dev mode)
79+
}
80+
81+
/**
82+
* Validates JWT from WebSocket subprotocol: access_token.<jwt>
83+
* Returns the token string if valid, null if invalid.
84+
*/
85+
function validateWsToken(protocols) {
86+
if (!protocols) return null;
87+
const list = Array.isArray(protocols) ? protocols : protocols.split(',').map(s => s.trim());
88+
const tokenProto = list.find(p => p.startsWith('access_token.'));
89+
if (!tokenProto) return null;
90+
const token = tokenProto.slice('access_token.'.length);
91+
try {
92+
jwt.verify(token, SESSION_SECRET);
93+
return tokenProto;
94+
} catch {
95+
return null;
96+
}
3497
}
3598

36-
// Initialize Express
3799
const app = express();
38-
app.use(express.json());
100+
const server = createServer(app);
101+
const wss = new WebSocketServer({
102+
noServer: true,
103+
handleProtocols: (protocols) => {
104+
// Accept the access_token.* subprotocol so the client sees it echoed back
105+
for (const proto of protocols) {
106+
if (proto.startsWith('access_token.')) return proto;
107+
}
108+
return false;
109+
},
110+
});
111+
112+
// Track all active WebSocket connections for graceful shutdown
113+
const activeConnections = new Set();
39114

40-
// Enable CORS (wildcard is safe -- same-origin via Vite proxy / Caddy in production)
115+
// Enable CORS
41116
app.use(cors());
42117

43118
// ============================================================================
44-
// API ROUTES
119+
// SESSION ROUTES - Auth endpoints (unprotected)
45120
// ============================================================================
46121

47-
// Metadata endpoint - required for standardization compliance
122+
/**
123+
* GET / — Serve index.html with injected session nonce (production only)
124+
*/
125+
app.get('/', (req, res) => {
126+
if (!indexHtmlTemplate) {
127+
return res.status(404).send('Frontend not built. Run make build first.');
128+
}
129+
const nonce = generateNonce();
130+
const html = indexHtmlTemplate.replace(
131+
'</head>',
132+
`<meta name="session-nonce" content="${nonce}">\n</head>`
133+
);
134+
res.type('html').send(html);
135+
});
136+
137+
/**
138+
* GET /api/session — Issues a JWT. In production, requires valid nonce.
139+
*/
140+
app.get('/api/session', (req, res) => {
141+
if (REQUIRE_NONCE) {
142+
const nonce = req.headers['x-session-nonce'];
143+
if (!nonce || !consumeNonce(nonce)) {
144+
return res.status(403).json({
145+
error: {
146+
type: 'AuthenticationError',
147+
code: 'INVALID_NONCE',
148+
message: 'Valid session nonce required. Please refresh the page.',
149+
},
150+
});
151+
}
152+
}
153+
154+
const token = jwt.sign({ iat: Math.floor(Date.now() / 1000) }, SESSION_SECRET, {
155+
expiresIn: JWT_EXPIRY,
156+
});
157+
res.json({ token });
158+
});
159+
160+
/**
161+
* Metadata endpoint - required for standardization compliance
162+
*/
48163
app.get('/api/metadata', (req, res) => {
49164
try {
50165
const tomlPath = path.join(__dirname, 'deepgram.toml');
@@ -68,40 +183,20 @@ app.get('/api/metadata', (req, res) => {
68183
}
69184
});
70185

71-
// Create HTTP server
72-
const server = createServer(app);
73-
74-
// Create WebSocket server for agent endpoint
75-
const wss = new WebSocketServer({
76-
server,
77-
path: '/api/voice-agent'
78-
});
79-
80-
// Handle WebSocket connections - simple pass-through proxy
186+
/**
187+
* WebSocket proxy handler
188+
* Forwards all messages bidirectionally between client and Deepgram Agent API
189+
*/
81190
wss.on('connection', async (clientWs, request) => {
82191
console.log('Client connected to /api/voice-agent');
192+
activeConnections.add(clientWs);
83193

84194
try {
85-
// Extract API key from Sec-WebSocket-Protocol header or use server's key
86-
const protocol = request.headers['sec-websocket-protocol'];
87-
const apiKey = protocol || CONFIG.deepgramApiKey;
88-
89-
if (!apiKey) {
90-
clientWs.send(JSON.stringify({
91-
type: 'Error',
92-
description: 'Missing API key',
93-
code: 'MISSING_API_KEY'
94-
}));
95-
clientWs.close();
96-
return;
97-
}
98-
99-
// Create raw WebSocket connection to Deepgram Agent API
100-
// Send API key via Authorization header
195+
// Always use server-side API key for Deepgram connection
101196
console.log('Initiating Deepgram connection...');
102197
const deepgramWs = new WebSocket(CONFIG.deepgramAgentUrl, {
103198
headers: {
104-
'Authorization': `Token ${apiKey}`
199+
'Authorization': `Token ${CONFIG.deepgramApiKey}`
105200
}
106201
});
107202

@@ -152,6 +247,7 @@ wss.on('connection', async (clientWs, request) => {
152247
if (deepgramWs.readyState === WebSocket.OPEN) {
153248
deepgramWs.close();
154249
}
250+
activeConnections.delete(clientWs);
155251
});
156252

157253
// Handle client errors
@@ -175,44 +271,95 @@ wss.on('connection', async (clientWs, request) => {
175271
}
176272
});
177273

178-
// Start the server
179-
server.listen(CONFIG.port, CONFIG.host, () => {
180-
console.log('');
181-
console.log('======================================================================');
182-
console.log(`🚀 Backend API Server running at http://localhost:${CONFIG.port}`);
183-
console.log(`📡 WS /api/voice-agent`);
184-
console.log(`📡 GET /api/metadata`);
185-
console.log('======================================================================');
186-
console.log('');
187-
});
274+
/**
275+
* Handle WebSocket upgrade requests for /api/voice-agent.
276+
* Validates JWT from access_token.<jwt> subprotocol before upgrading.
277+
*/
278+
server.on('upgrade', (request, socket, head) => {
279+
const pathname = new URL(request.url, 'http://localhost').pathname;
188280

189-
// Graceful shutdown
190-
function shutdown() {
191-
console.log('\nShutting down server...');
281+
console.log(`WebSocket upgrade request for: ${pathname}`);
192282

193-
wss.clients.forEach((client) => {
194-
try {
195-
client.close();
196-
} catch (err) {
197-
console.error('Error closing client:', err);
283+
if (pathname === '/api/voice-agent') {
284+
// Validate JWT from subprotocol
285+
const protocols = request.headers['sec-websocket-protocol'];
286+
const validProto = validateWsToken(protocols);
287+
if (!validProto) {
288+
console.log('WebSocket auth failed: invalid or missing token');
289+
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
290+
socket.destroy();
291+
return;
198292
}
199-
});
200293

294+
console.log('Backend handling /api/voice-agent WebSocket (authenticated)');
295+
wss.handleUpgrade(request, socket, head, (ws) => {
296+
wss.emit('connection', ws, request);
297+
});
298+
return;
299+
}
300+
301+
// Unknown WebSocket path - reject
302+
console.log(`Unknown WebSocket path: ${pathname}`);
303+
socket.destroy();
304+
});
305+
306+
/**
307+
* Graceful shutdown handler
308+
*/
309+
function gracefulShutdown(signal) {
310+
console.log(`\n${signal} signal received: starting graceful shutdown...`);
311+
312+
// Stop accepting new connections
201313
wss.close(() => {
202-
console.log('WebSocket server closed');
314+
console.log('WebSocket server closed to new connections');
315+
});
316+
317+
// Close all active WebSocket connections
318+
console.log(`Closing ${activeConnections.size} active WebSocket connection(s)...`);
319+
activeConnections.forEach((ws) => {
320+
try {
321+
ws.close(1001, 'Server shutting down');
322+
} catch (error) {
323+
console.error('Error closing WebSocket:', error);
324+
}
203325
});
204326

327+
// Close the HTTP server
205328
server.close(() => {
206329
console.log('HTTP server closed');
330+
console.log('Shutdown complete');
207331
process.exit(0);
208332
});
209333

210-
// Force exit after 5 seconds
334+
// Force shutdown after 10 seconds if graceful shutdown fails
211335
setTimeout(() => {
212-
console.error('Force closing');
336+
console.error('Could not close connections in time, forcefully shutting down');
213337
process.exit(1);
214-
}, 5000);
338+
}, 10000);
215339
}
216340

217-
process.on('SIGINT', shutdown);
218-
process.on('SIGTERM', shutdown);
341+
// Handle shutdown signals
342+
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
343+
process.on('SIGINT', () => gracefulShutdown('SIGINT'));
344+
345+
// Handle uncaught errors
346+
process.on('uncaughtException', (error) => {
347+
console.error('Uncaught Exception:', error);
348+
gracefulShutdown('UNCAUGHT_EXCEPTION');
349+
});
350+
351+
process.on('unhandledRejection', (reason, promise) => {
352+
console.error('Unhandled Rejection at:', promise, 'reason:', reason);
353+
gracefulShutdown('UNHANDLED_REJECTION');
354+
});
355+
356+
// Start server
357+
server.listen(CONFIG.port, CONFIG.host, () => {
358+
console.log("\n" + "=".repeat(70));
359+
console.log(`🚀 Backend API Server running at http://localhost:${CONFIG.port}`);
360+
console.log("");
361+
console.log(`📡 GET /api/session${REQUIRE_NONCE ? ' (nonce required)' : ''}`);
362+
console.log(`📡 WS /api/voice-agent (auth required)`);
363+
console.log(`📡 GET /api/metadata`);
364+
console.log("=".repeat(70) + "\n");
365+
});

0 commit comments

Comments
 (0)