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
1415require ( "dotenv" ) . config ( ) ;
1516
16- const express = require ( "express" ) ;
1717const { createClient } = require ( "@deepgram/sdk" ) ;
1818const cors = require ( "cors" ) ;
19- const path = require ( "path" ) ;
19+ const crypto = require ( "crypto" ) ;
20+ const express = require ( "express" ) ;
2021const fs = require ( "fs" ) ;
22+ const jwt = require ( "jsonwebtoken" ) ;
23+ const path = require ( "path" ) ;
2124const 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)
62164app . 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
286432app . 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