Skip to content

Commit bea2896

Browse files
committed
feat(auth): add JWT session auth with page nonce
1 parent 7b9ac98 commit bea2896

File tree

4 files changed

+170
-11
lines changed

4 files changed

+170
-11
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
@@ -34,6 +34,7 @@
3434
"cors": "2.8.5",
3535
"dotenv": "17.2.3",
3636
"express": "5.2.1",
37+
"jsonwebtoken": "9.0.2",
3738
"toml": "3.0.0"
3839
},
3940
"devDependencies": {

sample.env

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,6 @@ DEEPGRAM_API_KEY=%api_key%
66
PORT=8081
77
# Server host
88
HOST=0.0.0.0
9+
10+
# Session auth (optional - enables nonce validation when set)
11+
# SESSION_SECRET=%session_secret%

server.js

Lines changed: 154 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -9,15 +9,18 @@
99
* - Accepts text or URL in JSON body
1010
* - Supports multiple intelligence features: summarization, topics, sentiment, intents
1111
* - CORS-enabled for frontend communication
12+
* - JWT session auth with page nonce (production only)
1213
*/
1314

1415
require("dotenv").config();
1516

16-
const express = require("express");
1717
const { createClient } = require("@deepgram/sdk");
1818
const cors = require("cors");
19-
const path = require("path");
19+
const crypto = require("crypto");
20+
const express = require("express");
2021
const fs = require("fs");
22+
const jwt = require("jsonwebtoken");
23+
const path = require("path");
2124
const toml = require("toml");
2225

2326
// ============================================================================
@@ -29,6 +32,105 @@ const CONFIG = {
2932
host: process.env.HOST || '0.0.0.0',
3033
};
3134

35+
// ============================================================================
36+
// SESSION AUTH - JWT tokens with page nonce for production security
37+
// ============================================================================
38+
39+
/**
40+
* Session secret for signing JWTs. When set (production/Fly.io), nonce
41+
* validation is enforced. When unset (local dev), tokens are issued freely.
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+
/** In-memory nonce store: nonce -> expiry timestamp */
48+
const sessionNonces = new Map();
49+
50+
/** Nonce expiry time (5 minutes) */
51+
const NONCE_TTL_MS = 5 * 60 * 1000;
52+
53+
/** JWT expiry time (1 hour) */
54+
const JWT_EXPIRY = "1h";
55+
56+
/**
57+
* Generates a single-use nonce and stores it with an expiry
58+
* @returns {string} The generated nonce
59+
*/
60+
function generateNonce() {
61+
const nonce = crypto.randomBytes(16).toString("hex");
62+
sessionNonces.set(nonce, Date.now() + NONCE_TTL_MS);
63+
return nonce;
64+
}
65+
66+
/**
67+
* Validates and consumes a nonce (single-use)
68+
* @param {string} nonce - The nonce to validate
69+
* @returns {boolean} True if the nonce was valid and consumed
70+
*/
71+
function consumeNonce(nonce) {
72+
const expiry = sessionNonces.get(nonce);
73+
if (!expiry) return false;
74+
sessionNonces.delete(nonce);
75+
return Date.now() < expiry;
76+
}
77+
78+
/** Periodically clean up expired nonces (every 60 seconds) */
79+
setInterval(() => {
80+
const now = Date.now();
81+
for (const [nonce, expiry] of sessionNonces) {
82+
if (now >= expiry) sessionNonces.delete(nonce);
83+
}
84+
}, 60_000);
85+
86+
/**
87+
* Reads frontend/dist/index.html and injects a session nonce meta tag.
88+
* Returns null in dev mode (no built frontend).
89+
*/
90+
let indexHtmlTemplate = null;
91+
try {
92+
indexHtmlTemplate = fs.readFileSync(
93+
path.join(__dirname, "frontend", "dist", "index.html"),
94+
"utf-8"
95+
);
96+
} catch {
97+
// No built frontend (dev mode) — index.html served by Vite
98+
}
99+
100+
/**
101+
* Express middleware that validates JWT from Authorization header.
102+
* Returns 401 with JSON error if token is missing or invalid.
103+
*/
104+
function requireSession(req, res, next) {
105+
const authHeader = req.headers.authorization;
106+
if (!authHeader || !authHeader.startsWith("Bearer ")) {
107+
return res.status(401).json({
108+
error: {
109+
type: "AuthenticationError",
110+
code: "MISSING_TOKEN",
111+
message: "Authorization header with Bearer token is required",
112+
},
113+
});
114+
}
115+
116+
try {
117+
const token = authHeader.slice(7);
118+
jwt.verify(token, SESSION_SECRET);
119+
next();
120+
} catch (err) {
121+
return res.status(401).json({
122+
error: {
123+
type: "AuthenticationError",
124+
code: "INVALID_TOKEN",
125+
message:
126+
err.name === "TokenExpiredError"
127+
? "Session expired, please refresh the page"
128+
: "Invalid session token",
129+
},
130+
});
131+
}
132+
}
133+
32134
// ============================================================================
33135
// API KEY LOADING
34136
// ============================================================================
@@ -61,6 +163,50 @@ app.use(express.json());
61163
// Enable CORS (wildcard is safe -- same-origin via Vite proxy / Caddy in production)
62164
app.use(cors());
63165

166+
// ============================================================================
167+
// SESSION ROUTES - Auth endpoints (unprotected)
168+
// ============================================================================
169+
170+
/**
171+
* GET / — Serve index.html with injected session nonce (production only).
172+
* In dev mode, Vite serves the frontend directly.
173+
*/
174+
app.get("/", (req, res) => {
175+
if (!indexHtmlTemplate) {
176+
return res.status(404).send("Frontend not built. Run make build first.");
177+
}
178+
const nonce = generateNonce();
179+
const html = indexHtmlTemplate.replace(
180+
"</head>",
181+
`<meta name="session-nonce" content="${nonce}">\n</head>`
182+
);
183+
res.type("html").send(html);
184+
});
185+
186+
/**
187+
* GET /api/session — Issues a JWT. In production (SESSION_SECRET set),
188+
* requires a valid single-use nonce via X-Session-Nonce header.
189+
*/
190+
app.get("/api/session", (req, res) => {
191+
if (REQUIRE_NONCE) {
192+
const nonce = req.headers["x-session-nonce"];
193+
if (!nonce || !consumeNonce(nonce)) {
194+
return res.status(403).json({
195+
error: {
196+
type: "AuthenticationError",
197+
code: "INVALID_NONCE",
198+
message: "Valid session nonce required. Please refresh the page.",
199+
},
200+
});
201+
}
202+
}
203+
204+
const token = jwt.sign({ iat: Math.floor(Date.now() / 1000) }, SESSION_SECRET, {
205+
expiresIn: JWT_EXPIRY,
206+
});
207+
res.json({ token });
208+
});
209+
64210
// ============================================================================
65211
// API ROUTES
66212
// ============================================================================
@@ -77,7 +223,7 @@ app.use(cors());
77223
* - Success (200): JSON with results object containing requested intelligence features
78224
* - Error (4XX): JSON error response matching contract format
79225
*/
80-
app.post('/api/text-intelligence', async (req, res) => {
226+
app.post('/api/text-intelligence', requireSession, async (req, res) => {
81227
try {
82228
// Extract text or url from JSON body
83229
const { text, url } = req.body;
@@ -284,12 +430,10 @@ app.get('/api/metadata', (req, res) => {
284430
// ============================================================================
285431

286432
app.listen(CONFIG.port, CONFIG.host, () => {
287-
console.log('');
288-
console.log('======================================================================');
289-
console.log(`🚀 Backend API Server running at http://localhost:${CONFIG.port}`);
290-
console.log('');
291-
console.log(`📡 POST /api/text-intelligence`);
433+
console.log("\n" + "=".repeat(70));
434+
console.log(`🚀 Backend API running at http://localhost:${CONFIG.port}`);
435+
console.log(`📡 GET /api/session${REQUIRE_NONCE ? " (nonce required)" : ""}`);
436+
console.log(`📡 POST /api/text-intelligence (auth required)`);
292437
console.log(`📡 GET /api/metadata`);
293-
console.log('======================================================================');
294-
console.log('');
438+
console.log("=".repeat(70) + "\n");
295439
});

0 commit comments

Comments
 (0)