diff --git a/.env.example b/.env.example index 32810b3..4d7b4d2 100644 --- a/.env.example +++ b/.env.example @@ -11,3 +11,7 @@ FRESHRSS_HOST=http://freshrss FRESHRSS_USER=your-freshrss-username FRESHRSS_PASS=your-freshrss-password + +# Optional bearer token — when set, all protected endpoints require +# Authorization: Bearer . Leave blank or omit to skip auth. +RSS_API_TOKEN=replace-with-a-long-random-secret diff --git a/README.md b/README.md index 1296336..a250cb7 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ Simple API to fetch rss feeds for github releases using fresh rss (powered by Fa ![alt text](example/image.png) This allows you to easily integrate it with [https://gethomepage.dev/](https://gethomepage.dev/) -Using their [Custom API integration](https://gethomepage.dev/widgets/services/customapi/) +Using their [Custom API integration](https://gethomepage.dev/widgets/services/customapi/) ## API Documentation @@ -19,6 +19,31 @@ The API provides interactive documentation at: - Optional query parameters: - `n` (integer, default `10`, valid range `1`–`100`) - Number of unread items to return - `category` (string) - FreshRSS category label to scope unread items, e.g. `/freshrss/unread?category=Tech` +- `GET /health` - Container health endpoint (does not require authentication) + +## Authentication + +Bearer authentication is **optional**. When `RSS_API_TOKEN` is not set the API +accepts all requests — convenient for trusted/internal networks. Set +`RSS_API_TOKEN` to a long random secret to require an `Authorization: Bearer ` +header on every protected endpoint. `/health` always remains unauthenticated. + +```bash +# With auth enabled (token set): +curl \ + -H "Authorization: Bearer your-secret-token" \ + "http://localhost:5000/freshrss/unread?n=10&category=Tech" + +# Without auth (token not set): +curl "http://localhost:5000/freshrss/unread?n=10&category=Tech" +``` + +When auth is enabled, clients such as Homepage must send the same header: + +```yaml +headers: + Authorization: Bearer your-secret-token +``` ## Testing @@ -42,6 +67,8 @@ Example of services.yaml: type: customapi name: Unread RSS url: http://192.168.0.11:5000/freshrss/unread + headers: + Authorization: Bearer your-secret-token display: dynamic-list mappings: name: feed @@ -50,7 +77,7 @@ Example of services.yaml: # Docker -Copy the example environment file and set all three required values: +Copy the example environment file and set the required values: ```bash cp .env.example .env diff --git a/docker-compose.yml b/docker-compose.yml index 289a638..5696742 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -11,4 +11,7 @@ services: FRESHRSS_HOST: "${FRESHRSS_HOST:?Set FRESHRSS_HOST in .env (see README.md)}" FRESHRSS_USER: "${FRESHRSS_USER:?Set FRESHRSS_USER in .env}" FRESHRSS_PASS: "${FRESHRSS_PASS:?Set FRESHRSS_PASS in .env}" + # Optional — when unset the API runs without bearer authentication. + # Set this to a long random secret to require Authorization: Bearer . + RSS_API_TOKEN: "${RSS_API_TOKEN:-}" restart: unless-stopped diff --git a/main.py b/main.py index 728981d..3ca3974 100644 --- a/main.py +++ b/main.py @@ -1,9 +1,11 @@ import logging import os +import secrets import threading from urllib.parse import quote, urlsplit -from fastapi import FastAPI, HTTPException, Query +from fastapi import Depends, FastAPI, HTTPException, Query, status +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer import requests import humanize from datetime import datetime, timezone @@ -34,12 +36,22 @@ FRESHRSS_HOST = os.environ.get("FRESHRSS_HOST") FRESHRSS_USERNAME = os.environ.get("FRESHRSS_USER") FRESHRSS_PASSWORD = os.environ.get("FRESHRSS_PASS") - -_missing = [k for k, v in {"FRESHRSS_HOST": FRESHRSS_HOST, "FRESHRSS_USER": FRESHRSS_USERNAME, "FRESHRSS_PASS": FRESHRSS_PASSWORD}.items() if not v] +RSS_API_TOKEN = os.environ.get("RSS_API_TOKEN") # optional — when unset, auth is skipped + +_missing = [ + key + for key, value in { + "FRESHRSS_HOST": FRESHRSS_HOST, + "FRESHRSS_USER": FRESHRSS_USERNAME, + "FRESHRSS_PASS": FRESHRSS_PASSWORD, + }.items() + if not value +] if _missing: raise RuntimeError(f"Missing required environment variables: {', '.join(_missing)}") app = FastAPI() +bearer_scheme = HTTPBearer(auto_error=False) AUTH_TOKEN = None AUTH_TOKEN_LOCK = threading.Lock() @@ -101,6 +113,25 @@ def validate_freshrss_response(raw): raise HTTPException(status_code=502, detail="FreshRSS returned an invalid unread response") from exc +def require_api_token( + credentials: HTTPAuthorizationCredentials | None = Depends(bearer_scheme), +): + """Require the configured bearer token when RSS_API_TOKEN is set. + + When RSS_API_TOKEN is not configured, all requests pass through without + authentication — use this for trusted/internal networks. When set, every + protected endpoint demands a matching ``Authorization: Bearer `` header. + """ + if not RSS_API_TOKEN: + return # auth is disabled + if credentials is None or not secrets.compare_digest(credentials.credentials, RSS_API_TOKEN): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid or missing API token", + headers={"WWW-Authenticate": "Bearer"}, + ) + + def get_greader_token(): global AUTH_TOKEN with AUTH_TOKEN_LOCK: @@ -157,7 +188,7 @@ def health(): return {"status": "ok"} -@app.get("/freshrss/unread") +@app.get("/freshrss/unread", dependencies=[Depends(require_api_token)]) def freshrss_unread( n: int = Query(default=10, ge=1, le=100), category: str | None = Query(default=None, max_length=200), diff --git a/tests/test_main.py b/tests/test_main.py index 0c55507..09d7c40 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -21,6 +21,8 @@ "FRESHRSS_PASS": "secret", } +AUTH_TOKEN_VALUE = "api-test-token" + class FakeResponse: def __init__(self, status_code=200, text="", payload=None, raise_error=None): @@ -84,7 +86,11 @@ def test_import_requires_freshrss_environment(monkeypatch): with pytest.raises(RuntimeError) as excinfo: importlib.import_module("main") - assert all(name in str(excinfo.value) for name in REQUIRED_ENV) + message = str(excinfo.value) + assert "FRESHRSS_HOST" in message + assert "FRESHRSS_USER" in message + assert "FRESHRSS_PASS" in message + # RSS_API_TOKEN is optional — not required for import # ── Health endpoint ─────────────────────────────────────────────────── @@ -98,6 +104,77 @@ def test_health_endpoint_returns_json_over_http(test_client): assert response.json() == {"status": "ok"} +# ── Authentication ──────────────────────────────────────────────────── + + +@pytest.mark.parametrize( + "headers", + [{}, {"Authorization": "Bearer wrong-token"}], + ids=["missing", "invalid"], +) +def test_unread_rejects_unauthorized_requests_without_contacting_freshrss( + test_client, monkeypatch, main_module, headers +): + main_module.RSS_API_TOKEN = AUTH_TOKEN_VALUE # enable auth for this test + monkeypatch.setattr(main_module, "get_greader_token", lambda: "unreachable") + + def unexpected_request(*args, **kwargs): + pytest.fail("unauthorized requests must not contact FreshRSS") + + monkeypatch.setattr(main_module.requests, "post", unexpected_request) + monkeypatch.setattr(main_module.requests, "get", unexpected_request) + + response = test_client.get("/freshrss/unread", headers=headers) + + assert response.status_code == 401 + assert response.json() == {"detail": "Invalid or missing API token"} + assert response.headers["www-authenticate"] == "Bearer" + + +def test_unread_accepts_valid_credentials(test_client, monkeypatch, main_module): + main_module.RSS_API_TOKEN = AUTH_TOKEN_VALUE # enable auth for this test + calls = install_freshrss_transport(monkeypatch, main_module) + + response = test_client.get( + "/freshrss/unread", + headers={"Authorization": "Bearer api-test-token"}, + ) + + assert response.status_code == 200 + assert response.json() == [] + assert len(calls["post"]) == 1 # authenticated and logged in + + +def test_unread_allows_requests_when_token_not_configured(monkeypatch): + """When RSS_API_TOKEN is unset, auth is skipped entirely.""" + env_without_token = {k: v for k, v in REQUIRED_ENV.items() if k != "RSS_API_TOKEN"} + for name in REQUIRED_ENV: + monkeypatch.delenv(name, raising=False) + for name, value in env_without_token.items(): + monkeypatch.setenv(name, value) + sys.modules.pop("main", None) + module = importlib.import_module("main") + + # Stub FreshRSS transport so we don't need a real server + monkeypatch.setattr( + module.requests, "post", + lambda *args, **kwargs: FakeResponse(text="Auth=no-auth-token\n"), + ) + monkeypatch.setattr( + module.requests, "get", + lambda *args, **kwargs: FakeResponse(payload={"items": []}), + ) + + with TestClient(module.app) as client: + # No Authorization header at all — should still work + response = client.get("/freshrss/unread") + + assert response.status_code == 200 + assert response.json() == [] + + sys.modules.pop("main", None) + + # ── Unread endpoint (via TestClient) ──────────────────────────────────