mirror of
https://github.com/calibrain/shelfmark.git
synced 2026-09-24 09:10:23 +01:00
fix(auth): rename the API_KEY env var to SHELFMARK_API_KEY (#1374)
This commit is contained in:
+8
-8
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"))
|
||||
|
||||
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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 (
|
||||
|
||||
Reference in New Issue
Block a user