commit ba532710a4d069f459d29c35855364192d21a5b5
parent 711c3f2365611c33374430fd49a3efa62b299e73
Author: Chris Roberts <chris.roberts@learningunix.net>
Date: Fri, 17 Jul 2026 09:06:14 -0500
Add shared-token auth, gated on SIMPLEFE_TOKEN
Fails closed at startup if no token is configured, checks Authorization: Bearer <token> via timing-safe compare on every route except the health check.
Diffstat:
5 files changed, 87 insertions(+), 11 deletions(-)
diff --git a/src/simplefe/app.py b/src/simplefe/app.py
@@ -1,17 +1,33 @@
from flask import Flask, abort, request
+from simplefe.auth import check_token, load_token
+
from simplefe.config import load_jobs
from simplefe.runner import run_job
+EXEMPT_PATHS = {"/"}
+
-def create_app(jobs=None):
+def create_app(jobs=None, token=None):
app = Flask(__name__)
if jobs is None:
jobs = load_jobs("jobs.toml")
app.config["JOBS"] = jobs
+ app.config["TOKEN"] = load_token(token)
+
+ @app.before_request
+ def enforce_auth():
+ if request.path in EXEMPT_PATHS:
+ return
+ auth_header = request.headers.get("Authorization", "")
+ if not auth_header.startswith("Bearer "):
+ abort(401)
+ candidate = auth_header.removeprefix("Bearer ")
+ if not check_token(candidate, app.config["TOKEN"]):
+ abort(401)
@app.route("/")
def index():
diff --git a/src/simplefe/auth.py b/src/simplefe/auth.py
@@ -0,0 +1,25 @@
+import hmac
+import os
+
+
+class AuthConfigError(Exception):
+ pass
+
+
+def load_token(token=None):
+ """Return the shared token: an explicit arg wins, else SIMPLEFE_TOKEN.
+
+ Raise AuthConfigError if neither is set -- we fail closed rather than
+ letting the app start up unauthenticated.
+ """
+ if token is not None:
+ return token
+ env_token = os.environ.get("SIMPLEFE_TOKEN")
+ if env_token is None:
+ raise AuthConfigError("SIMPLEFE_TOKEN is not set")
+ return env_token
+
+
+def check_token(candidate, expected):
+ """Timing-safe comparison of the candidate token against the expected one."""
+ return hmac.compare_digest(candidate, expected)
diff --git a/tests/conftest.py b/tests/conftest.py
@@ -5,7 +5,7 @@ from simplefe.app import create_app
@pytest.fixture()
def app():
- app = create_app(jobs={"rip": {"path": "/fake/path"}})
+ app = create_app(jobs={"rip": {"path": "/fake/path"}}, token="test-token")
app.config.update({"TESTING": True})
yield app
@@ -13,3 +13,8 @@ def app():
@pytest.fixture()
def client(app):
return app.test_client()
+
+
+@pytest.fixture()
+def auth_headers():
+ return {"Authorization": "Bearer test-token"}
diff --git a/tests/test_app.py b/tests/test_app.py
@@ -5,22 +5,22 @@ from simplefe.app import create_app
FIXTURES = Path(__file__).parent / "fixtures"
-def test_list_jobs(client):
- response = client.get("/jobs")
+def test_list_jobs(client, auth_headers):
+ response = client.get("/jobs", headers=auth_headers)
assert response.json == {"jobs": ["rip"]}
-def test_get_job(client):
- response = client.get("/jobs/rip")
+def test_get_job(client, auth_headers):
+ response = client.get("/jobs/rip", headers=auth_headers)
assert response.json == {"path": "/fake/path"}
-def test_get_job_not_found(client):
- response = client.get("/jobs/nonexistent")
+def test_get_job_not_found(client, auth_headers):
+ response = client.get("/jobs/nonexistent", headers=auth_headers)
assert response.status_code == 404
-def test_trigger_job():
+def test_trigger_job(auth_headers):
fake_job = FIXTURES / "fake_job.py"
app = create_app(
jobs={
@@ -28,12 +28,15 @@ def test_trigger_job():
"path": f"python3 {fake_job}",
"prompt": "Enter movie name:",
}
- }
+ },
+ token="test-token",
)
app.config.update({"TESTING": True})
client = app.test_client()
- response = client.post("/jobs/rip/run", json={"response": "The Matrix"})
+ response = client.post(
+ "/jobs/rip/run", json={"response": "The Matrix"}, headers=auth_headers
+ )
assert response.status_code == 200
assert "Got: The Matrix" in response.json["output"]
diff --git a/tests/test_auth.py b/tests/test_auth.py
@@ -0,0 +1,27 @@
+import pytest
+
+
+from simplefe.auth import AuthConfigError, check_token, load_token
+
+
+def test_check_token_matching():
+ assert check_token("secret", "secret") is True
+
+
+def test_check_token_mismatch():
+ assert check_token("secret", "wrong") is False
+
+
+def test_load_token_explicit_arg():
+ assert load_token("explicit") == "explicit"
+
+
+def test_load_token_from_env(monkeypatch):
+ monkeypatch.setenv("SIMPLEFE_TOKEN", "from-env")
+ assert load_token() == "from-env"
+
+
+def test_load_token_missing_raises(monkeypatch):
+ monkeypatch.delenv("SIMPLEFE_TOKEN", raising=False)
+ with pytest.raises(AuthConfigError):
+ load_token()