fix(auth): rename the API_KEY env var to SHELFMARK_API_KEY (#1374)

This commit is contained in:
CaliBrain
2026-09-21 00:10:10 -04:00
committed by GitHub
parent 3b280009ae
commit d978896142
8 changed files with 31 additions and 31 deletions
+8 -8
View File
@@ -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
+2 -2
View File
@@ -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 <key>' (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 <key>' (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 <key>' (or X-Api-Key) are authenticated as an admin; browser sessions keep working. Unset = off.
+1 -1
View File
@@ -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 <key>' (or X-Api-Key) are authenticated as an admin; browser sessions keep working. Unset = off.",
"type": "string",
"default": "unset",
+1 -1
View File
@@ -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"))
+5 -5
View File
@@ -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"))
+1 -1
View File
@@ -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(
+4 -4
View File
@@ -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
+9 -9
View File
@@ -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 (