From d978896142d147781fb7805e2840a418e6043060 Mon Sep 17 00:00:00 2001 From: CaliBrain Date: Mon, 21 Sep 2026 00:10:10 -0400 Subject: [PATCH] fix(auth): rename the API_KEY env var to SHELFMARK_API_KEY (#1374) --- docs/api-access.md | 16 ++++++++-------- docs/environment-variables.md | 4 ++-- scripts/generate_env_docs.py | 2 +- shelfmark/config/env.py | 2 +- shelfmark/core/api_key.py | 10 +++++----- shelfmark/core/user_db.py | 2 +- shelfmark/main.py | 8 ++++---- tests/core/test_api_key_env.py | 18 +++++++++--------- 8 files changed, 31 insertions(+), 31 deletions(-) diff --git a/docs/api-access.md b/docs/api-access.md index e58d3758..7ff73f1d 100644 --- a/docs/api-access.md +++ b/docs/api-access.md @@ -1,7 +1,7 @@ # API access with an API key Shelfmark's web interface is driven entirely by a JSON API under `/api/`. Set -the `API_KEY` environment variable and scripts, dashboards and assistants can +the `SHELFMARK_API_KEY` environment variable and scripts, dashboards and assistants can call the same API without a browser session. Browser logins keep working exactly as before: it is cookie **or** key. @@ -9,7 +9,7 @@ exactly as before: it is cookie **or** key. ```yaml environment: - API_KEY: "a-long-random-secret" + SHELFMARK_API_KEY: "a-long-random-secret" ``` Generate something long and random (for example `openssl rand -base64 32`). @@ -28,8 +28,8 @@ Either header works, and both are checked, so the key can be sent in `X-Api-Key` behind a reverse proxy that sets its own `Authorization` header. ```bash -curl -s -H "Authorization: Bearer $API_KEY" https://shelfmark.example.com/api/downloads/active -curl -s -H "X-Api-Key: $API_KEY" https://shelfmark.example.com/api/downloads/active +curl -s -H "Authorization: Bearer $SHELFMARK_API_KEY" https://shelfmark.example.com/api/downloads/active +curl -s -H "X-Api-Key: $SHELFMARK_API_KEY" https://shelfmark.example.com/api/downloads/active ``` A request that carries the key is authenticated by the key alone. Session @@ -47,19 +47,19 @@ use `/api/status` to verify a key. Search, then look up releases, then queue one (the same calls the web UI makes): ```bash -curl -s -H "Authorization: Bearer $API_KEY" \ +curl -s -H "Authorization: Bearer $SHELFMARK_API_KEY" \ "https://shelfmark.example.com/api/metadata/search?query=dune%20frank%20herbert" # -> {"books":[{"provider":"hardcover","provider_id":"427363", ...}]} -curl -s -H "Authorization: Bearer $API_KEY" \ +curl -s -H "Authorization: Bearer $SHELFMARK_API_KEY" \ "https://shelfmark.example.com/api/releases?provider=hardcover&book_id=427363&content_type=ebook" # -> {"releases":[{"source":"direct_download","source_id":"...", ...}], ...} -curl -s -X POST -H "Authorization: Bearer $API_KEY" -H "Content-Type: application/json" \ +curl -s -X POST -H "Authorization: Bearer $SHELFMARK_API_KEY" -H "Content-Type: application/json" \ -d @release.json https://shelfmark.example.com/api/releases/download # release.json = one object from "releases" (source and source_id are required) -curl -s -H "Authorization: Bearer $API_KEY" https://shelfmark.example.com/api/status +curl -s -H "Authorization: Bearer $SHELFMARK_API_KEY" https://shelfmark.example.com/api/status ``` ## Security notes diff --git a/docs/environment-variables.md b/docs/environment-variables.md index 5b467d45..a4c0f395 100644 --- a/docs/environment-variables.md +++ b/docs/environment-variables.md @@ -47,7 +47,7 @@ These environment variables are used at startup before the settings system loads | `CWA_DB_PATH` | Path to the Calibre-Web database for authentication integration. | string (path) | `/auth/app.db` | | `HIDE_LOCAL_AUTH` | Hide the username/password login form when OIDC is active. | boolean | `false` | | `DISABLE_LOCAL_AUTH` | Disable username/password login and remove the local-admin prerequisite for OIDC. Implies HIDE_LOCAL_AUTH; with AUTH_METHOD=builtin, everyone is locked out until auth env vars are changed. | boolean | `false` | -| `API_KEY` | Optional static API key. When set, requests carrying it as 'Authorization: Bearer ' (or X-Api-Key) are authenticated as an admin; browser sessions keep working. Unset = off. | string | `unset` | +| `SHELFMARK_API_KEY` | Optional static API key. When set, requests carrying it as 'Authorization: Bearer ' (or X-Api-Key) are authenticated as an admin; browser sessions keep working. Unset = off. | string | `unset` | | `OIDC_AUTO_REDIRECT` | Automatically redirect to the OIDC provider instead of showing the login page. | boolean | `false` | | `DOCKERMODE` | Indicates the application is running inside a Docker container. | boolean | `false` | | `ONBOARDING` | Show the onboarding wizard on first run. Set to false to skip (useful for ephemeral storage). | boolean | `true` | @@ -125,7 +125,7 @@ Disable username/password login and remove the local-admin prerequisite for OIDC - **Type:** boolean - **Default:** `false` -#### `API_KEY` +#### `SHELFMARK_API_KEY` Optional static API key. When set, requests carrying it as 'Authorization: Bearer ' (or X-Api-Key) are authenticated as an admin; browser sessions keep working. Unset = off. diff --git a/scripts/generate_env_docs.py b/scripts/generate_env_docs.py index 23d7fd21..ec11e6b6 100755 --- a/scripts/generate_env_docs.py +++ b/scripts/generate_env_docs.py @@ -185,7 +185,7 @@ def _generate_bootstrap_env_docs() -> list[str]: "default": "false", }, { - "name": "API_KEY", + "name": "SHELFMARK_API_KEY", "description": "Optional static API key. When set, requests carrying it as 'Authorization: Bearer ' (or X-Api-Key) are authenticated as an admin; browser sessions keep working. Unset = off.", "type": "string", "default": "unset", diff --git a/shelfmark/config/env.py b/shelfmark/config/env.py index 2244c683..a7554c56 100644 --- a/shelfmark/config/env.py +++ b/shelfmark/config/env.py @@ -168,7 +168,7 @@ HIDE_LOCAL_AUTH = string_to_bool(os.getenv("HIDE_LOCAL_AUTH", "false")) DISABLE_LOCAL_AUTH = string_to_bool(os.getenv("DISABLE_LOCAL_AUTH", "false")) # Optional static API key. When set, requests carrying it as a Bearer token # (or X-Api-Key) are authenticated as an admin for that request only. -API_KEY = os.getenv("API_KEY", "").strip() +SHELFMARK_API_KEY = os.getenv("SHELFMARK_API_KEY", "").strip() OIDC_AUTO_REDIRECT = string_to_bool(os.getenv("OIDC_AUTO_REDIRECT", "false")) diff --git a/shelfmark/core/api_key.py b/shelfmark/core/api_key.py index 4004f415..5f2aae10 100644 --- a/shelfmark/core/api_key.py +++ b/shelfmark/core/api_key.py @@ -1,6 +1,6 @@ -"""Static API-key authentication backed by the API_KEY environment variable. +"""Static API-key authentication backed by the SHELFMARK_API_KEY environment variable. -When ``API_KEY`` is set, a request carrying that value as a Bearer token or in +When ``SHELFMARK_API_KEY`` is set, a request carrying that value as a Bearer token or in ``X-Api-Key`` is treated as an admin for that request only. Both headers are checked, since a reverse proxy in front of Shelfmark may set its own ``Authorization`` header, which would otherwise shadow an operator-supplied @@ -12,7 +12,7 @@ from __future__ import annotations import hmac -from shelfmark.config.env import API_KEY +from shelfmark.config.env import SHELFMARK_API_KEY def extract_api_key_candidates( @@ -34,6 +34,6 @@ def extract_api_key_candidates( def matches_api_key(candidate: str) -> bool: """Constant-time comparison against the configured key. False when unset.""" - if not API_KEY or not candidate: + if not SHELFMARK_API_KEY or not candidate: return False - return hmac.compare_digest(candidate.encode("utf-8"), API_KEY.encode("utf-8")) + return hmac.compare_digest(candidate.encode("utf-8"), SHELFMARK_API_KEY.encode("utf-8")) diff --git a/shelfmark/core/user_db.py b/shelfmark/core/user_db.py index 072b3469..62070c51 100644 --- a/shelfmark/core/user_db.py +++ b/shelfmark/core/user_db.py @@ -434,7 +434,7 @@ class UserDB: conn.close() def get_first_admin(self) -> dict[str, Any] | None: - """Return the lowest-id admin user, or None. Used as the identity for API_KEY requests.""" + """Return the lowest-id admin user, or None. Used as the identity for SHELFMARK_API_KEY requests.""" conn = self._connect() try: row = conn.execute( diff --git a/shelfmark/main.py b/shelfmark/main.py index cefefa06..4c6b640a 100644 --- a/shelfmark/main.py +++ b/shelfmark/main.py @@ -42,7 +42,7 @@ from shelfmark.config.settings import ( _SUPPORTED_BOOK_LANGUAGE, migrate_audiobook_format_settings, ) -from shelfmark.core import api_key as api_key_module # module access lets tests monkeypatch API_KEY +from shelfmark.core import api_key as api_key_module # module access lets tests monkeypatch the key from shelfmark.core import search_deadline from shelfmark.core.activity_view_state_service import ActivityViewStateService from shelfmark.core.auth_modes import ( @@ -677,7 +677,7 @@ _API_KEY_EXEMPT_PATHS = frozenset({"/api/health"}) @app.before_request def api_key_auth_middleware() -> Response | tuple[Response, int] | None: - """Authenticate requests that present the configured API_KEY. + """Authenticate requests that present the configured SHELFMARK_API_KEY. Both Authorization: Bearer and X-Api-Key are checked, and either matching authenticates the request as an admin for this request only: @@ -691,7 +691,7 @@ def api_key_auth_middleware() -> Response | tuple[Response, int] | None: return None if request.path in _API_KEY_EXEMPT_PATHS or request.path.startswith(_API_KEY_EXEMPT_PREFIXES): return None - if not api_key_module.API_KEY: + if not api_key_module.SHELFMARK_API_KEY: return None candidates = api_key_module.extract_api_key_candidates( @@ -739,7 +739,7 @@ def proxy_auth_middleware() -> Response | tuple[Response, int] | None: if auth_mode != "proxy": return None - # A request already authenticated by API_KEY needs no proxy headers. + # A request already authenticated by SHELFMARK_API_KEY needs no proxy headers. if g.get("api_key_auth"): return None diff --git a/tests/core/test_api_key_env.py b/tests/core/test_api_key_env.py index 26b3fda9..8ffd63b0 100644 --- a/tests/core/test_api_key_env.py +++ b/tests/core/test_api_key_env.py @@ -1,4 +1,4 @@ -"""Tests for the API_KEY environment-variable authentication.""" +"""Tests for the SHELFMARK_API_KEY environment-variable authentication.""" from __future__ import annotations @@ -47,16 +47,16 @@ class TestExtractCandidates: class TestMatches: def test_unset_never_matches(self, monkeypatch): - monkeypatch.setattr(api_key, "API_KEY", "") + monkeypatch.setattr(api_key, "SHELFMARK_API_KEY", "") assert api_key.matches_api_key("anything") is False assert api_key.matches_api_key("") is False def test_match(self, monkeypatch): - monkeypatch.setattr(api_key, "API_KEY", "s3cret") + monkeypatch.setattr(api_key, "SHELFMARK_API_KEY", "s3cret") assert api_key.matches_api_key("s3cret") is True def test_mismatch_and_prefix(self, monkeypatch): - monkeypatch.setattr(api_key, "API_KEY", "s3cret") + monkeypatch.setattr(api_key, "SHELFMARK_API_KEY", "s3cret") assert api_key.matches_api_key("s3cre") is False assert api_key.matches_api_key("s3cret ") is False assert api_key.matches_api_key("") is False @@ -87,7 +87,7 @@ def main_module(): @pytest.fixture def wired(main_module, user_db, monkeypatch): monkeypatch.setattr(main_module, "user_db", user_db) - monkeypatch.setattr(api_key, "API_KEY", "s3cret") + monkeypatch.setattr(api_key, "SHELFMARK_API_KEY", "s3cret") with patch.object(main_module, "get_auth_mode", return_value="builtin"): yield main_module @@ -152,7 +152,7 @@ class TestKeyedRequests: def test_match_no_user_db(self, main_module, monkeypatch): monkeypatch.setattr(main_module, "user_db", None) - monkeypatch.setattr(api_key, "API_KEY", "s3cret") + monkeypatch.setattr(api_key, "SHELFMARK_API_KEY", "s3cret") with patch.object(main_module, "get_auth_mode", return_value="builtin"): assert ( main_module.app.test_client() @@ -289,7 +289,7 @@ class TestMismatchFallsThrough: def test_unset_key_is_noop(self, main_module, user_db, monkeypatch): monkeypatch.setattr(main_module, "user_db", user_db) - monkeypatch.setattr(api_key, "API_KEY", "") + monkeypatch.setattr(api_key, "SHELFMARK_API_KEY", "") with patch.object(main_module, "get_auth_mode", return_value="builtin"): response = main_module.app.test_client().get( "/api/downloads/active", headers=_bearer("s3cret") @@ -316,7 +316,7 @@ class TestScopeAndModes: def test_none_mode_noop(self, main_module, user_db, monkeypatch): monkeypatch.setattr(main_module, "user_db", user_db) - monkeypatch.setattr(api_key, "API_KEY", "s3cret") + monkeypatch.setattr(api_key, "SHELFMARK_API_KEY", "s3cret") with patch.object(main_module, "get_auth_mode", return_value="none"): assert ( main_module.app.test_client() @@ -329,7 +329,7 @@ class TestScopeAndModes: self, main_module, user_db, monkeypatch ): monkeypatch.setattr(main_module, "user_db", user_db) - monkeypatch.setattr(api_key, "API_KEY", "s3cret") + monkeypatch.setattr(api_key, "SHELFMARK_API_KEY", "s3cret") user_db.create_user(username="root", role="admin", auth_source="proxy") with patch.object(main_module, "get_auth_mode", return_value="proxy"): assert (