Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 40 additions & 12 deletions backend/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,10 +37,34 @@
app = Flask(__name__)
CORS(app, resources={r"/*": {"origins": "*" }})

from flask_jwt_extended import JWTManager, jwt_required, get_jwt_identity
from functools import wraps
from flask_jwt_extended import JWTManager, jwt_required, get_jwt_identity, verify_jwt_in_request
app.config["JWT_SECRET_KEY"] = os.getenv("JWT_SECRET_KEY", "super-secret")
jwt = JWTManager(app)

def jwt_or_secret_required():
def decorator(fn):
@wraps(fn)
def wrapper(*args, **kwargs):
secret = request.headers.get("X-Internal-Secret")
expected_secret = os.getenv("INTERNAL_SECRET", "super-secret-internal-key")
if secret and secret == expected_secret:
return fn(*args, **kwargs)
verify_jwt_in_request()
return fn(*args, **kwargs)
return wrapper
return decorator

def get_current_user_identity():
secret = request.headers.get("X-Internal-Secret")
expected_secret = os.getenv("INTERNAL_SECRET", "super-secret-internal-key")
if secret and secret == expected_secret:
return request.headers.get("X-User-Username")
try:
return get_jwt_identity()
except Exception:
return None

BASE_DIR = Path(__file__).resolve().parent

def resolve_path(env_var, default_filename):
Expand Down Expand Up @@ -401,11 +425,11 @@ def gmail_auth_url():
return jsonify({"auth_url": url})

@app.route("/gmail/callback", methods=["GET"])
@jwt_required()
@jwt_or_secret_required()
def gmail_callback():
code = request.args.get("code")
redirect_uri = request.args.get("redirect_uri") or "http://localhost:3000/gmail/callback"
username = get_jwt_identity()
username = get_current_user_identity()

if not code:
return jsonify({"error": "Authorization code is missing"}), 400
Expand All @@ -420,9 +444,9 @@ def gmail_callback():
return jsonify({"error": f"Failed to exchange Google code: {str(e)}"}), 500

@app.route("/gmail/emails", methods=["GET"])
@jwt_required()
@jwt_or_secret_required()
def gmail_emails():
username = get_jwt_identity()
username = get_current_user_identity()
user_tokens = TOKEN_STORE.get(username, {}).get("gmail")

if not user_tokens:
Expand Down Expand Up @@ -451,11 +475,11 @@ def outlook_auth_url():
return jsonify({"auth_url": url})

@app.route("/outlook/callback", methods=["GET"])
@jwt_required()
@jwt_or_secret_required()
def outlook_callback():
code = request.args.get("code")
redirect_uri = request.args.get("redirect_uri") or "http://localhost:3000/outlook/callback"
username = get_jwt_identity()
username = get_current_user_identity()

if not code:
return jsonify({"error": "Authorization code is missing"}), 400
Expand All @@ -470,9 +494,9 @@ def outlook_callback():
return jsonify({"error": f"Failed to exchange Outlook code: {str(e)}"}), 500

@app.route("/outlook/emails", methods=["GET"])
@jwt_required()
@jwt_or_secret_required()
def outlook_emails():
username = get_jwt_identity()
username = get_current_user_identity()
user_tokens = TOKEN_STORE.get(username, {}).get("outlook")

if not user_tokens:
Expand All @@ -495,11 +519,11 @@ def outlook_emails():
return jsonify({"error": f"Failed to fetch Outlook emails: {str(e)}"}), 500

@app.route("/scan-emails", methods=["POST"])
@jwt_required()
@jwt_or_secret_required()
def scan_emails_route():
data = request.get_json(silent=True) or {}
provider = data.get("provider", "").lower()
username = get_jwt_identity()
username = get_current_user_identity()

if provider not in ("gmail", "outlook"):
return jsonify({"error": "Invalid provider. Must be 'gmail' or 'outlook'."}), 400
Expand Down Expand Up @@ -577,8 +601,12 @@ def _schedule_user_job(username, interval_minutes):

def _require_username():
"""The Node gateway authenticates the user and forwards their identity via this
header (it does not forward a Flask-issued JWT, so jwt_required() can't be used here).
header. We also verify the internal secret for security.
"""
secret = request.headers.get("X-Internal-Secret")
expected_secret = os.getenv("INTERNAL_SECRET", "super-secret-internal-key")
if not secret or secret != expected_secret:
return None
username = request.headers.get("X-User-Username")
if not username:
return None
Expand Down
12 changes: 12 additions & 0 deletions backend/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,18 @@ dns.setServers(["8.8.8.8", "1.1.1.1"]); // ensure SRV records resolve on all net
const express = require("express");
const cors = require("cors");
const axios = require("axios");

// Configure global request interceptor to append the internal secret API key
axios.interceptors.request.use(
(config) => {
const internalSecret = process.env.INTERNAL_SECRET || "super-secret-internal-key";
config.headers["X-Internal-Secret"] = internalSecret;
return config;
},
(error) => {
return Promise.reject(error);
}
);
const mongoose = require("mongoose");

const History = require("./models/History");
Expand Down
93 changes: 93 additions & 0 deletions backend/tests/test_internal_secret.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import os
import sys
from pathlib import Path
import pytest
from unittest.mock import patch

BASE_DIR = Path(__file__).resolve().parents[2]
BACKEND_DIR = BASE_DIR / "backend"

os.environ.setdefault("MODEL_PATH", str(BASE_DIR / "linear_svm_model.pkl"))
os.environ.setdefault("VECTORIZER_PATH", str(BACKEND_DIR / "tfidf_vectorizer.pkl"))
os.environ.setdefault("LABEL_ENCODER_PATH", str(BASE_DIR / "label_encoder.pkl"))
os.environ.setdefault("URL_MODEL_PATH", str(BACKEND_DIR / "url_detector.pkl"))
os.environ.setdefault("URL_VECTORIZER_PATH", str(BACKEND_DIR / "url_vectorizer.pkl"))

sys.path.insert(0, str(BACKEND_DIR))

import api as api_module
from flask_jwt_extended import create_access_token

@pytest.fixture
def client():
api_module.app.config["TESTING"] = True
api_module.app.config["JWT_SECRET_KEY"] = "super-secret"
with api_module.app.test_client() as c:
yield c

def test_jwt_or_secret_required_with_valid_secret(client):
headers = {
"X-Internal-Secret": "super-secret-internal-key",
"X-User-Username": "test_user"
}
api_module.TOKEN_STORE["test_user"] = {
"gmail": {
"access_token": "mock_gmail_access_token"
}
}

with patch("api.fetch_gmail_emails") as mock_fetch:
mock_fetch.return_value = []
res = client.get("/gmail/emails", headers=headers)
assert res.status_code == 200

def test_jwt_or_secret_required_with_valid_jwt(client):
with api_module.app.app_context():
token = create_access_token(identity="test_user")

headers = {
"Authorization": f"Bearer {token}"
}

api_module.TOKEN_STORE["test_user"] = {
"gmail": {
"access_token": "mock_gmail_access_token"
}
}

with patch("api.fetch_gmail_emails") as mock_fetch:
mock_fetch.return_value = []
res = client.get("/gmail/emails", headers=headers)
assert res.status_code == 200

def test_jwt_or_secret_required_missing_auth(client):
res = client.get("/gmail/emails")
assert res.status_code == 401

def test_jwt_or_secret_required_invalid_secret(client):
headers = {
"X-Internal-Secret": "wrong-secret",
"X-User-Username": "test_user"
}
res = client.get("/gmail/emails", headers=headers)
assert res.status_code == 401

def test_imap_require_username_with_secret(client):
headers = {
"X-Internal-Secret": "super-secret-internal-key",
"X-User-Username": "test_user"
}

with patch("imap_store.get_connection") as mock_get_conn:
mock_get_conn.return_value = None
res = client.get("/imap/status", headers=headers)
assert res.status_code == 200
assert res.get_json() == {"connected": False}

def test_imap_require_username_missing_secret(client):
headers = {
"X-User-Username": "test_user"
}
res = client.get("/imap/status", headers=headers)
assert res.status_code == 401
assert res.get_json() == {"error": "Missing X-User-Username header"}
Loading