Skip to content

Commit 77aa0cb

Browse files
committed
feat(auth): add JWT session auth with page nonce
1 parent 477a01f commit 77aa0cb

File tree

4 files changed

+170
-9
lines changed

4 files changed

+170
-9
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: 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: 155 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,15 +9,19 @@
99
* - Contract-compliant API endpoint: POST /api/text-to-speech
1010
* - Accepts text in body and model as query parameter
1111
* - Returns binary audio data (application/octet-stream)
12-
* - Proxies to Vite dev server in development
13-
* - Serves static frontend in production
12+
* - CORS enabled for frontend communication
13+
* - JWT session auth with page nonce (production only)
14+
* - Pure API server (frontend served separately)
1415
*/
1516

1617
require("dotenv").config();
1718

1819
const { createClient } = require("@deepgram/sdk");
1920
const cors = require("cors");
21+
const crypto = require("crypto");
2022
const express = require("express");
23+
const fs = require("fs");
24+
const jwt = require("jsonwebtoken");
2125
const path = require("path");
2226

2327
// ============================================================================
@@ -39,6 +43,105 @@ const CONFIG = {
3943
host: process.env.HOST || "0.0.0.0",
4044
};
4145

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

303+
// ============================================================================
304+
// SESSION ROUTES - Auth endpoints (unprotected)
305+
// ============================================================================
306+
307+
/**
308+
* GET / — Serve index.html with injected session nonce (production only).
309+
* In dev mode, Vite serves the frontend directly.
310+
*/
311+
app.get("/", (req, res) => {
312+
if (!indexHtmlTemplate) {
313+
return res.status(404).send("Frontend not built. Run make build first.");
314+
}
315+
const nonce = generateNonce();
316+
const html = indexHtmlTemplate.replace(
317+
"</head>",
318+
`<meta name="session-nonce" content="${nonce}">\n</head>`
319+
);
320+
res.type("html").send(html);
321+
});
322+
323+
/**
324+
* GET /api/session — Issues a JWT. In production (SESSION_SECRET set),
325+
* requires a valid single-use nonce via X-Session-Nonce header.
326+
*/
327+
app.get("/api/session", (req, res) => {
328+
if (REQUIRE_NONCE) {
329+
const nonce = req.headers["x-session-nonce"];
330+
if (!nonce || !consumeNonce(nonce)) {
331+
return res.status(403).json({
332+
error: {
333+
type: "AuthenticationError",
334+
code: "INVALID_NONCE",
335+
message: "Valid session nonce required. Please refresh the page.",
336+
},
337+
});
338+
}
339+
}
340+
341+
const token = jwt.sign({ iat: Math.floor(Date.now() / 1000) }, SESSION_SECRET, {
342+
expiresIn: JWT_EXPIRY,
343+
});
344+
res.json({ token });
345+
});
346+
200347
// ============================================================================
201348
// API ROUTES - Define your API endpoints here
202349
// ============================================================================
@@ -214,8 +361,10 @@ function formatErrorResponse(error, statusCode = 500, errorCode = null) {
214361
* - Error (4XX): JSON error response matching contract format
215362
*
216363
* This endpoint implements the TTS contract specification.
364+
*
365+
* Protected by JWT session auth (requireSession middleware).
217366
*/
218-
app.post("/api/text-to-speech", async (req, res) => {
367+
app.post("/api/text-to-speech", requireSession, async (req, res) => {
219368
try {
220369
// Get model from query parameter (contract specifies query param, not body)
221370
const model = req.query.model || DEFAULT_MODEL;
@@ -289,7 +438,6 @@ app.post("/api/text-to-speech", async (req, res) => {
289438
*/
290439
app.get("/api/metadata", (req, res) => {
291440
try {
292-
const fs = require("fs");
293441
const toml = require("toml");
294442
const tomlPath = path.join(__dirname, "deepgram.toml");
295443
const tomlContent = fs.readFileSync(tomlPath, "utf-8");
@@ -329,9 +477,9 @@ app.get("/api/metadata", (req, res) => {
329477

330478
app.listen(CONFIG.port, CONFIG.host, () => {
331479
console.log("\n" + "=".repeat(70));
332-
console.log(`🚀 Backend API Server running at http://localhost:${CONFIG.port}`);
333-
console.log("");
334-
console.log(`📡 POST /api/text-to-speech`);
480+
console.log(`🚀 Backend API running at http://localhost:${CONFIG.port}`);
481+
console.log(`📡 GET /api/session${REQUIRE_NONCE ? " (nonce required)" : ""}`);
482+
console.log(`📡 POST /api/text-to-speech (auth required)`);
335483
console.log(`📡 GET /api/metadata`);
336484
console.log("=".repeat(70) + "\n");
337485
});

0 commit comments

Comments
 (0)