simplefe

Log | Files | Refs

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:
Msrc/simplefe/app.py | 18+++++++++++++++++-
Asrc/simplefe/auth.py | 25+++++++++++++++++++++++++
Mtests/conftest.py | 7++++++-
Mtests/test_app.py | 21++++++++++++---------
Atests/test_auth.py | 27+++++++++++++++++++++++++++
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()