Skip to content

Commit b9b1e30

Browse files
committed
refactor(auth): remove nonce, rely on rate limiting + JWT
1 parent dda3562 commit b9b1e30

3 files changed

Lines changed: 12 additions & 68 deletions

File tree

app.py

Lines changed: 9 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
- POST endpoint: /api/text-to-speech
99
- Accepts JSON text input
1010
- Returns raw audio bytes (application/octet-stream)
11-
- JWT session auth with page nonce (production only)
11+
- JWT session auth with rate limiting (production only)
1212
- Serves built frontend from frontend/dist/
1313
"""
1414

@@ -18,7 +18,7 @@
1818
import time
1919

2020
import jwt
21-
from flask import Flask, request, jsonify, make_response
21+
from flask import Flask, request, jsonify, make_response, send_from_directory
2222
from flask_cors import CORS
2323
from deepgram import DeepgramClient
2424
from dotenv import load_dotenv
@@ -40,50 +40,13 @@
4040
}
4141

4242
# ============================================================================
43-
# SESSION AUTH - JWT tokens with page nonce for production security
43+
# SESSION AUTH - JWT tokens with rate limiting for production security
4444
# ============================================================================
4545

4646
SESSION_SECRET = os.environ.get("SESSION_SECRET") or secrets.token_hex(32)
47-
REQUIRE_NONCE = bool(os.environ.get("SESSION_SECRET"))
48-
49-
# In-memory nonce store: nonce -> expiry timestamp
50-
session_nonces = {}
51-
NONCE_TTL = 5 * 60 # 5 minutes
5247
JWT_EXPIRY = 3600 # 1 hour
5348

5449

55-
def generate_nonce():
56-
"""Generates a single-use nonce and stores it with an expiry."""
57-
nonce = secrets.token_hex(16)
58-
session_nonces[nonce] = time.time() + NONCE_TTL
59-
return nonce
60-
61-
62-
def consume_nonce(nonce):
63-
"""Validates and consumes a nonce (single-use). Returns True if valid."""
64-
expiry = session_nonces.pop(nonce, None)
65-
if expiry is None:
66-
return False
67-
return time.time() < expiry
68-
69-
70-
def cleanup_nonces():
71-
"""Remove expired nonces."""
72-
now = time.time()
73-
expired = [k for k, v in session_nonces.items() if now >= v]
74-
for k in expired:
75-
del session_nonces[k]
76-
77-
78-
# Read frontend/dist/index.html template for nonce injection
79-
_index_html_template = None
80-
try:
81-
with open(os.path.join(os.path.dirname(__file__), "frontend", "dist", "index.html")) as f:
82-
_index_html_template = f.read()
83-
except FileNotFoundError:
84-
pass # No built frontend (dev mode)
85-
86-
8750
def require_session(f):
8851
"""Decorator that validates JWT from Authorization header."""
8952
@functools.wraps(f)
@@ -162,34 +125,16 @@ def validate_api_key():
162125

163126
@app.route("/", methods=["GET"])
164127
def serve_index():
165-
"""Serve index.html with injected session nonce (production only)."""
166-
if not _index_html_template:
128+
"""Serve the built frontend index.html."""
129+
frontend_dir = os.path.join(os.path.dirname(__file__), "frontend", "dist")
130+
if not os.path.isfile(os.path.join(frontend_dir, "index.html")):
167131
return "Frontend not built. Run make build first.", 404
168-
cleanup_nonces()
169-
nonce = generate_nonce()
170-
html = _index_html_template.replace(
171-
"</head>",
172-
f'<meta name="session-nonce" content="{nonce}">\n</head>'
173-
)
174-
response = make_response(html)
175-
response.headers["Content-Type"] = "text/html"
176-
return response
132+
return send_from_directory(frontend_dir, "index.html")
177133

178134

179135
@app.route("/api/session", methods=["GET"])
180136
def get_session():
181-
"""Issues a JWT. In production, requires valid nonce via X-Session-Nonce header."""
182-
if REQUIRE_NONCE:
183-
nonce = request.headers.get("X-Session-Nonce")
184-
if not nonce or not consume_nonce(nonce):
185-
return jsonify({
186-
"error": {
187-
"type": "AuthenticationError",
188-
"code": "INVALID_NONCE",
189-
"message": "Valid session nonce required. Please refresh the page.",
190-
}
191-
}), 403
192-
137+
"""Issues a JWT for session authentication."""
193138
token = jwt.encode(
194139
{"iat": int(time.time()), "exp": int(time.time()) + JWT_EXPIRY},
195140
SESSION_SECRET,
@@ -365,13 +310,12 @@ def get_metadata():
365310
host = CONFIG["host"]
366311
debug = os.environ.get("FLASK_DEBUG", "0") == "1"
367312

368-
nonce_status = " (nonce required)" if REQUIRE_NONCE else ""
369313
print("\n" + "=" * 70)
370314
print(f"🚀 Flask Text To Speech Server (Backend API)")
371315
print("=" * 70)
372316
print(f"🚀 Backend API Server running at http://localhost:{port}")
373317
print(f"")
374-
print(f"📡 GET /api/session{nonce_status}")
318+
print(f"📡 GET /api/session")
375319
print(f"📡 POST /api/text-to-speech (auth required)")
376320
print(f"📡 GET /api/metadata")
377321
print(f"Debug: {'ON' if debug else 'OFF'}")

deploy/Caddyfile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
}
44

55
:8080 {
6-
# HTML page: Caddy templates inject base path + session nonce
6+
# HTML page: Caddy templates inject base path
77
@htmlpage {
88
path /
99
path /index.html

deploy/Dockerfile

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,8 @@ COPY frontend/package.json frontend/pnpm-lock.yaml ./frontend/
1313
RUN cd frontend && pnpm install --frozen-lockfile
1414
COPY frontend/ ./frontend/
1515
RUN cd frontend && pnpm build
16-
# Inject Caddy template directives for subpath base path + session nonce
17-
RUN sed -i 's|<head>|<head>\n <base href="{{ .Req.Header.Get "X-Base-Path" }}">\n <meta name="session-nonce" content="{{ placeholder "http.request.uuid" }}">|' ./frontend/dist/index.html
16+
# Inject Caddy template directive for subpath base path
17+
RUN sed -i 's|<head>|<head>\n <base href="{{ .Req.Header.Get "X-Base-Path" }}">|' ./frontend/dist/index.html
1818

1919
# Stage 3: Runtime
2020
FROM python:3.13-slim

0 commit comments

Comments
 (0)