feat(auth): static API_KEY (env) accepted as Bearer or X-Api-Key, cookie or key (#1366)

Supersedes #1353, per the discussion in #1352: one `API_KEY` environment
variable; when set, a request carrying it is authenticated as the first
admin, and cookie sessions keep working exactly as before (cookie **or**
key). Nothing else changes. No table, no UI, no settings-tab switch, no
per-user keys.

## What

- `API_KEY` (env). Unset → the feature is off and none of the new code
runs.
- `Authorization: Bearer <key>` or `X-Api-Key: <key>` on any existing
`/api/*` route authenticates that request as the first admin in
`users.db` (`ORDER BY id`), or as a bare admin identity
(`user_id="api"`, `is_admin=True`, no local user row) if the install has
no admin yet. Per request only; nothing is persisted; the admin's role
is read live, so deleting or demoting that user takes effect on the next
request.
- Both headers are checked and either may match. That is what makes the
key usable behind a reverse proxy that injects its own `Authorization`
header (oauth2-proxy, Authelia, forwardAuth): send the key in
`X-Api-Key`.
- A credential that is **not** the key is ignored and the request
continues on the normal session path, so proxy-forwarded tokens are
unaffected. Without a valid session such a request gets the usual `401
{"error": "Unauthorized"}`, identical to a request with no credential,
so there is nothing to probe.

## How

- `shelfmark/config/env.py`: `API_KEY = os.getenv("API_KEY",
"").strip()`.
- `shelfmark/core/api_key.py`: `extract_api_key_candidates()` (Bearer
token if the scheme is Bearer, then `X-Api-Key`) and `matches_api_key()`
using `hmac.compare_digest` on bytes.
- `shelfmark/core/user_db.py`: `UserDB.get_first_admin()`.
- `shelfmark/main.py`: `api_key_auth_middleware` (`before_request`,
registered before `proxy_auth_middleware`, which early-returns for keyed
requests). Only `/api/` paths; `/api/health` and `/api/auth/*` exempt;
no-op when `API_KEY` is unset or the auth mode is `none`. On a match it
mirrors the proxy-auth pattern: `session.clear()` then populate
`user_id` / `is_admin` / `db_user_id` for this request, `permanent =
False`, `modified = False`, `g.api_key_auth = True`. An `after_request`
hook guarantees no `Set-Cookie` is written for a keyed request even if a
handler dirties the session.
- `docs/api-access.md` (new), the `API_KEY` entry in
`docs/environment-variables.md`, and a README link.

## Security

- Constant-time compare; the key is never logged or echoed.
- Keyed requests never mint or refresh a session cookie and ignore any
cookie sent with them (a non-admin cookie plus the key yields admin for
that request; the browser's own session is left untouched and usable).
- The mismatch path touches neither the session nor `g`, so a stray
bearer on a browser request can neither log the user out nor change how
their cookie is refreshed.
- Store errors during the admin lookup fail closed (`500 {"error":
"Authentication error"}`), never to anonymous.
- Verified against Flask's `save_session` / `should_set_cookie`
ordering, and under auth modes `none`, `builtin`, `proxy`.

## Tests

`tests/core/test_api_key_env.py` (36): extraction and matching;
first-admin lookup; middleware behaviour on a guarded route and an admin
route, with and without a user_db, `X-Api-Key`, both-headers
combinations, no `Set-Cookie` when a handler dirties the session,
incoming non-admin cookie ignored, browser cookie still usable after a
keyed request, security headers, store error → 500, mismatch → guard's
401 / cookie path / permanent cookie untouched, unset → off, exempt
paths and path probes, `none` and `proxy` modes, deleted and demoted
first admin, a keyed write passing the guard. Existing auth suites
unchanged. All CI gates green on the fork:
https://github.com/gavinmcfall/shelfmark/pull/2 (CI-only draft).

Also exercised against a running instance: 47 scripted checks including
150 concurrent requests, proxy-mode switching through the key, an
unset-key restart, and a log scan for the key.

## Naming

`API_KEY` as discussed. If you'd rather namespace it
(`SHELFMARK_API_KEY`) to avoid clashing with other tools' env vars in
shared compose files, it is a one-line change; say the word.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Gavin McFall
2026-09-21 00:00:42 -04:00
committed by GitHub
co-authored by Claude Fable 5.1
parent 1a5b37d9d3
commit 3b280009ae
10 changed files with 638 additions and 2 deletions
+72
View File
@@ -0,0 +1,72 @@
# 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
call the same API without a browser session. Browser logins keep working
exactly as before: it is cookie **or** key.
## Set the key
```yaml
environment:
API_KEY: "a-long-random-secret"
```
Generate something long and random (for example `openssl rand -base64 32`).
A request carrying the key acts as an **admin**: the first admin user in
Shelfmark's user database. Create an admin before relying on the key in any
install that has none yet (for example an OIDC-only install). Without an
admin user, the key still authenticates as an admin identity with no user
row, and routes that need one (requests, activity) answer 403. To rotate,
change the variable and restart. Unset it and the feature is off. When the
instance runs with no authentication configured (`AUTH_METHOD=none`), the
key is simply unnecessary.
## Send the key
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
```
A request that carries the key is authenticated by the key alone. Session
cookies are ignored and none are set. A bearer value that is not the configured
key is ignored and the request continues with normal session authentication,
so reverse proxies that forward their own tokens are unaffected; without a valid
session such a request gets the usual `401 {"error": "Unauthorized"}`. A
database error while resolving the admin returns
`500 {"error": "Authentication error"}` — never anonymous access.
`/api/auth/check` reflects the browser session only and ignores the key, so
use `/api/status` to verify a key.
## Examples
Search, then look up releases, then queue one (the same calls the web UI makes):
```bash
curl -s -H "Authorization: Bearer $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" \
"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" \
-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
```
## Security notes
- The key is compared in constant time and is never logged.
- Keyed requests never set cookies and ignore any cookie sent with them.
- WebSocket (live activity) connections do not accept the key; poll `/api/status` instead.
- The key is a root-equivalent credential: an admin can configure a custom
post-download script that the server executes, so treat it like a root
password and send it only over HTTPS.
+8
View File
@@ -47,6 +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` | | `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` | | `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` | | `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` |
| `OIDC_AUTO_REDIRECT` | Automatically redirect to the OIDC provider instead of showing the login page. | boolean | `false` | | `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` | | `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` | | `ONBOARDING` | Show the onboarding wizard on first run. Set to false to skip (useful for ephemeral storage). | boolean | `true` |
@@ -124,6 +125,13 @@ Disable username/password login and remove the local-admin prerequisite for OIDC
- **Type:** boolean - **Type:** boolean
- **Default:** `false` - **Default:** `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.
- **Type:** string
- **Default:** `unset`
#### `OIDC_AUTO_REDIRECT` #### `OIDC_AUTO_REDIRECT`
Automatically redirect to the OIDC provider instead of showing the login page. Automatically redirect to the OIDC provider instead of showing the login page.
+1
View File
@@ -15,6 +15,7 @@ Use the guides below to set up the app, connect your library tools, and understa
- [Users & Requests](users-and-requests.md) - [Users & Requests](users-and-requests.md)
- [Reverse Proxy](reverse-proxy.md) - [Reverse Proxy](reverse-proxy.md)
- [OIDC](oidc.md) - [OIDC](oidc.md)
- [API Access](api-access.md)
- [URL Search Parameters](url-search-parameters.md) - [URL Search Parameters](url-search-parameters.md)
- [Custom Scripts](custom-scripts.md) - [Custom Scripts](custom-scripts.md)
+2
View File
@@ -248,6 +248,8 @@ volumes:
With any authentication method enabled, Shelfmark supports multi-user management with admin/user roles. Users can have per-user settings for download destinations, email recipients, and notification preferences. Non-admin users only see their own downloads and can submit book requests for admin review. Admins can configure request policies per source to control whether users can download directly, must submit a request, or are blocked entirely. With any authentication method enabled, Shelfmark supports multi-user management with admin/user roles. Users can have per-user settings for download destinations, email recipients, and notification preferences. Non-admin users only see their own downloads and can submit book requests for admin review. Admins can configure request policies per source to control whether users can download directly, must submit a request, or are blocked entirely.
See [API Access](docs/api-access.md) to call the API with a static key from scripts and integrations.
## Project Scope ## Project Scope
Shelfmark is a manual search and download tool, the entry point to your book library, not a library manager. It finds books, downloads them, and sends them to a configured destination. That's the full scope. Shelfmark is a manual search and download tool, the entry point to your book library, not a library manager. It finds books, downloads them, and sends them to a configured destination. That's the full scope.
+6
View File
@@ -184,6 +184,12 @@ def _generate_bootstrap_env_docs() -> list[str]:
"type": "boolean", "type": "boolean",
"default": "false", "default": "false",
}, },
{
"name": "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",
},
{ {
"name": "OIDC_AUTO_REDIRECT", "name": "OIDC_AUTO_REDIRECT",
"description": "Automatically redirect to the OIDC provider instead of showing the login page.", "description": "Automatically redirect to the OIDC provider instead of showing the login page.",
+3
View File
@@ -166,6 +166,9 @@ SESSION_COOKIE_NAME = "shelfmark_session"
CWA_DB_PATH = _resolve_cwa_db_path() CWA_DB_PATH = _resolve_cwa_db_path()
HIDE_LOCAL_AUTH = string_to_bool(os.getenv("HIDE_LOCAL_AUTH", "false")) HIDE_LOCAL_AUTH = string_to_bool(os.getenv("HIDE_LOCAL_AUTH", "false"))
DISABLE_LOCAL_AUTH = string_to_bool(os.getenv("DISABLE_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()
OIDC_AUTO_REDIRECT = string_to_bool(os.getenv("OIDC_AUTO_REDIRECT", "false")) OIDC_AUTO_REDIRECT = string_to_bool(os.getenv("OIDC_AUTO_REDIRECT", "false"))
+39
View File
@@ -0,0 +1,39 @@
"""Static API-key authentication backed by the API_KEY environment variable.
When ``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
``X-Api-Key``. A candidate that matches neither is ignored so that bearer
tokens forwarded by reverse proxies keep working. The key is never logged.
"""
from __future__ import annotations
import hmac
from shelfmark.config.env import API_KEY
def extract_api_key_candidates(
authorization_header: str | None, api_key_header: str | None
) -> list[str]:
"""Return the non-empty credentials a client presented, Bearer token first."""
candidates: list[str] = []
if authorization_header:
scheme, _, token = authorization_header.strip().partition(" ")
token = token.strip()
if scheme.lower() == "bearer" and token:
candidates.append(token)
if api_key_header:
token = api_key_header.strip()
if token:
candidates.append(token)
return 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:
return False
return hmac.compare_digest(candidate.encode("utf-8"), API_KEY.encode("utf-8"))
+11
View File
@@ -433,6 +433,17 @@ class UserDB:
finally: finally:
conn.close() 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."""
conn = self._connect()
try:
row = conn.execute(
"SELECT * FROM users WHERE role = 'admin' ORDER BY id LIMIT 1"
).fetchone()
return dict(row) if row else None
finally:
conn.close()
def has_admin(self) -> bool: def has_admin(self) -> bool:
"""Return True when at least one admin user exists.""" """Return True when at least one admin user exists."""
conn = self._connect() conn = self._connect()
+73 -2
View File
@@ -14,7 +14,7 @@ from importlib import import_module
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING, Any, Callable, NoReturn, cast from typing import TYPE_CHECKING, Any, Callable, NoReturn, cast
from flask import Flask, jsonify, request, send_file, send_from_directory, session from flask import Flask, g, jsonify, request, send_file, send_from_directory, session
from flask_cors import CORS from flask_cors import CORS
from flask_socketio import SocketIO, emit from flask_socketio import SocketIO, emit
from werkzeug.middleware.proxy_fix import ProxyFix from werkzeug.middleware.proxy_fix import ProxyFix
@@ -42,6 +42,7 @@ from shelfmark.config.settings import (
_SUPPORTED_BOOK_LANGUAGE, _SUPPORTED_BOOK_LANGUAGE,
migrate_audiobook_format_settings, 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 search_deadline from shelfmark.core import search_deadline
from shelfmark.core.activity_view_state_service import ActivityViewStateService from shelfmark.core.activity_view_state_service import ActivityViewStateService
from shelfmark.core.auth_modes import ( from shelfmark.core.auth_modes import (
@@ -550,7 +551,7 @@ if _is_debug_enabled():
r"/*": { r"/*": {
"origins": ["http://localhost:5173", "http://127.0.0.1:5173"], "origins": ["http://localhost:5173", "http://127.0.0.1:5173"],
"supports_credentials": True, "supports_credentials": True,
"allow_headers": ["Content-Type", "Authorization"], "allow_headers": ["Content-Type", "Authorization", "X-Api-Key"],
"methods": ["GET", "POST", "PUT", "DELETE", "OPTIONS"], "methods": ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
} }
}, },
@@ -670,6 +671,61 @@ def _proxy_default_is_admin(db: UserDB) -> bool:
return role == "admin" return role == "admin"
_API_KEY_EXEMPT_PREFIXES = ("/api/auth/",)
_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.
Both Authorization: Bearer and X-Api-Key are checked, and either
matching authenticates the request as an admin for this request only:
any session cookie is ignored and none is written back. Checking both
means a reverse proxy's own Authorization header never shadows an
operator-supplied X-Api-Key. No matching candidate is ignored so bearer
tokens forwarded by reverse proxies keep working; the request then
continues on the normal session path.
"""
if not request.path.startswith("/api/"):
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:
return None
candidates = api_key_module.extract_api_key_candidates(
request.headers.get("Authorization"), request.headers.get("X-Api-Key")
)
if not candidates:
return None
if not any(api_key_module.matches_api_key(candidate) for candidate in candidates):
return None
if get_auth_mode() == "none":
return None
# Mark the request as keyed before the lookup so the after-request cookie
# reset also covers the error path below.
g.api_key_auth = True
try:
admin = user_db.get_first_admin() if user_db is not None else None
except _OPERATIONAL_ERRORS:
logger.exception("API key auth middleware error")
return jsonify({"error": "Authentication error"}), 500
session.clear()
session["user_id"] = admin["username"] if admin else "api"
session["is_admin"] = True
if admin:
session["db_user_id"] = admin["id"]
session.permanent = False
# Identity is per request; never persist it as a cookie.
session.modified = False
return None
@app.before_request @app.before_request
def proxy_auth_middleware() -> Response | tuple[Response, int] | None: def proxy_auth_middleware() -> Response | tuple[Response, int] | None:
"""Middleware to handle proxy authentication. """Middleware to handle proxy authentication.
@@ -683,6 +739,10 @@ def proxy_auth_middleware() -> Response | tuple[Response, int] | None:
if auth_mode != "proxy": if auth_mode != "proxy":
return None return None
# A request already authenticated by API_KEY needs no proxy headers.
if g.get("api_key_auth"):
return None
# Skip for public endpoints that don't need auth # Skip for public endpoints that don't need auth
if request.path == "/api/health": if request.path == "/api/health":
return None return None
@@ -815,6 +875,17 @@ def set_security_headers(response: Response) -> Response:
return response return response
@app.after_request
def strip_cookie_for_api_key_requests(response: Response) -> Response:
"""Keyed requests never mint or refresh a session cookie, even if a handler dirties the session."""
if g.get("api_key_auth"):
# Setting `permanent` mutates the session dict (re-marking it
# modified), so it must be reset before `modified`, not after.
session.permanent = False
session.modified = False
return response
def login_required( def login_required(
f: Callable[..., Response | tuple[Response, int]], f: Callable[..., Response | tuple[Response, int]],
) -> Callable[..., Response | tuple[Response, int]]: ) -> Callable[..., Response | tuple[Response, int]]:
+423
View File
@@ -0,0 +1,423 @@
"""Tests for the API_KEY environment-variable authentication."""
from __future__ import annotations
import importlib
import logging
import os
import sqlite3
import tempfile
from unittest.mock import patch
import pytest
from shelfmark.core import api_key
@pytest.fixture
def user_db():
from shelfmark.core.user_db import UserDB
with tempfile.TemporaryDirectory() as tmpdir:
db = UserDB(os.path.join(tmpdir, "users.db"))
db.initialize()
yield db
class TestExtractCandidates:
def test_bearer_only(self):
assert api_key.extract_api_key_candidates("Bearer a", None) == ["a"]
def test_bearer_case_insensitive_and_trimmed(self):
assert api_key.extract_api_key_candidates("bearer a ", None) == ["a"]
def test_x_api_key_only(self):
assert api_key.extract_api_key_candidates(None, "b") == ["b"]
def test_both_present(self):
assert api_key.extract_api_key_candidates("Bearer a", "b") == ["a", "b"]
def test_non_bearer_scheme_plus_x_api_key(self):
assert api_key.extract_api_key_candidates("Basic dXNlcjpwYXNz", "b") == ["b"]
def test_empty(self):
assert api_key.extract_api_key_candidates("Bearer ", "") == []
assert api_key.extract_api_key_candidates(None, None) == []
class TestMatches:
def test_unset_never_matches(self, monkeypatch):
monkeypatch.setattr(api_key, "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")
assert api_key.matches_api_key("s3cret") is True
def test_mismatch_and_prefix(self, monkeypatch):
monkeypatch.setattr(api_key, "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
class TestFirstAdmin:
def test_none_when_no_admin(self, user_db):
user_db.create_user(username="alice")
assert user_db.get_first_admin() is None
def test_first_admin_by_id(self, user_db):
user_db.create_user(username="alice")
root = user_db.create_user(username="root", role="admin")
user_db.create_user(username="root2", role="admin")
assert user_db.get_first_admin()["id"] == root["id"]
@pytest.fixture(scope="module")
def main_module():
"""Import `shelfmark.main` with background startup disabled."""
with patch("shelfmark.download.orchestrator.start"):
import shelfmark.main as main
importlib.reload(main)
return main
@pytest.fixture
def wired(main_module, user_db, monkeypatch):
monkeypatch.setattr(main_module, "user_db", user_db)
monkeypatch.setattr(api_key, "API_KEY", "s3cret")
with patch.object(main_module, "get_auth_mode", return_value="builtin"):
yield main_module
def _bearer(value):
return {"Authorization": f"Bearer {value}"}
def _x_api_key(value):
return {"X-Api-Key": value}
def _identity_for_key(wired):
"""Run the middleware directly against the isolated `user_db` and return the resulting session.
Going through a real request/response round trip would need a route
that reveals identity, and the only such route (`/api/users/me/edit-context`)
is closed over the real per-worker `users.db` at registration time, not
the isolated temp DB the `wired` fixture monkeypatches onto
`main_module.user_db` -- so it can't see admins created here. Calling
the middleware in a request context sidesteps that entirely.
"""
with wired.app.test_request_context("/api/downloads/active", headers=_bearer("s3cret")):
assert wired.api_key_auth_middleware() is None
from flask import session
return dict(session)
def _cookie_client(app, user, *, is_admin=False, permanent=False):
client = app.test_client()
with client.session_transaction() as sess:
sess["user_id"] = user["username"]
sess["is_admin"] = is_admin
sess["db_user_id"] = user["id"]
sess.permanent = permanent
return client
class TestKeyedRequests:
def test_match_reaches_login_required_route(self, wired, user_db):
user_db.create_user(username="root", role="admin")
assert (
wired.app.test_client()
.get("/api/downloads/active", headers=_bearer("s3cret"))
.status_code
== 200
)
def test_match_is_admin(self, wired, user_db):
user_db.create_user(username="root", role="admin")
assert (
wired.app.test_client().get("/api/settings", headers=_bearer("s3cret")).status_code
== 200
)
def test_match_without_any_admin_user_is_still_admin(self, wired):
assert (
wired.app.test_client().get("/api/settings", headers=_bearer("s3cret")).status_code
== 200
)
def test_match_no_user_db(self, main_module, monkeypatch):
monkeypatch.setattr(main_module, "user_db", None)
monkeypatch.setattr(api_key, "API_KEY", "s3cret")
with patch.object(main_module, "get_auth_mode", return_value="builtin"):
assert (
main_module.app.test_client()
.get("/api/downloads/active", headers=_bearer("s3cret"))
.status_code
== 200
)
def test_x_api_key(self, wired, user_db):
user_db.create_user(username="root", role="admin")
assert (
wired.app.test_client()
.get("/api/downloads/active", headers={"X-Api-Key": "s3cret"})
.status_code
== 200
)
def test_wrong_bearer_plus_correct_x_api_key_authenticates(self, wired, user_db):
"""A proxy's own Authorization header must not shadow a correct X-Api-Key."""
user_db.create_user(username="root", role="admin")
headers = {**_bearer("wrong"), **_x_api_key("s3cret")}
assert wired.app.test_client().get("/api/settings", headers=headers).status_code == 200
def test_correct_bearer_plus_wrong_x_api_key_authenticates(self, wired, user_db):
user_db.create_user(username="root", role="admin")
headers = {**_bearer("s3cret"), **_x_api_key("wrong")}
assert wired.app.test_client().get("/api/settings", headers=headers).status_code == 200
def test_no_set_cookie_even_when_handler_dirties_session(self, wired, user_db, monkeypatch):
user_db.create_user(username="root", role="admin")
original = wired.app.view_functions["api_active_downloads"]
def dirty(*args, **kwargs):
from flask import session
session["dirty"] = True
return original(*args, **kwargs)
monkeypatch.setitem(wired.app.view_functions, "api_active_downloads", dirty)
response = wired.app.test_client().get("/api/downloads/active", headers=_bearer("s3cret"))
assert response.status_code == 200
assert "Set-Cookie" not in response.headers
def test_incoming_non_admin_cookie_is_ignored(self, wired, user_db):
user_db.create_user(username="root", role="admin")
alice = user_db.create_user(username="alice")
client = _cookie_client(wired.app, alice)
assert client.get("/api/settings", headers=_bearer("s3cret")).status_code == 200
def test_matched_key_leaves_browser_cookie_usable(self, wired, user_db):
user_db.create_user(username="root", role="admin")
alice = user_db.create_user(username="alice")
client = _cookie_client(wired.app, alice)
assert client.get("/api/settings", headers=_bearer("s3cret")).status_code == 200
assert client.get("/api/downloads/active").status_code == 200
assert client.get("/api/settings").status_code == 403
def test_security_headers_present(self, wired, user_db):
user_db.create_user(username="root", role="admin")
response = wired.app.test_client().get("/api/downloads/active", headers=_bearer("s3cret"))
assert response.headers.get("X-Content-Type-Options") == "nosniff"
def test_store_error_is_500_not_anonymous(self, wired, user_db, monkeypatch):
monkeypatch.setattr(
user_db,
"get_first_admin",
lambda: (_ for _ in ()).throw(sqlite3.OperationalError("boom")),
)
response = wired.app.test_client().get("/api/downloads/active", headers=_bearer("s3cret"))
assert response.status_code == 500
assert response.get_json() == {"error": "Authentication error"}
def test_store_error_does_not_refresh_browser_cookie(self, wired, user_db, monkeypatch):
alice = user_db.create_user(username="alice")
monkeypatch.setattr(
user_db,
"get_first_admin",
lambda: (_ for _ in ()).throw(sqlite3.OperationalError("boom")),
)
client = _cookie_client(wired.app, alice, permanent=True)
response = client.get("/api/downloads/active", headers=_bearer("s3cret"))
assert response.status_code == 500
assert "Set-Cookie" not in response.headers
# The browser's own session is untouched and still usable afterwards.
assert client.get("/api/downloads/active").status_code == 200
class TestMismatchFallsThrough:
def test_mismatch_no_cookie_is_plain_unauthorized(self, wired):
response = wired.app.test_client().get("/api/downloads/active", headers=_bearer("wrong"))
assert response.status_code == 401
assert response.get_json() == {"error": "Unauthorized"}
def test_mismatch_is_indistinguishable_from_no_credential(self, wired):
client = wired.app.test_client()
no_credential = client.get("/api/downloads/active")
with_wrong_bearer = client.get("/api/downloads/active", headers=_bearer("wrong"))
assert no_credential.status_code == with_wrong_bearer.status_code
assert no_credential.get_json() == with_wrong_bearer.get_json()
ignored_headers = {"date", "content-length", "server"}
def header_names(response):
return {name.lower() for name in response.headers.keys()} - ignored_headers
assert header_names(no_credential) == header_names(with_wrong_bearer)
assert "WWW-Authenticate" not in no_credential.headers
assert "WWW-Authenticate" not in with_wrong_bearer.headers
def test_mismatch_writes_no_log(self, wired, caplog):
with caplog.at_level(logging.INFO, logger="shelfmark"):
wired.app.test_client().get("/api/downloads/active", headers=_bearer("wrong"))
for record in caplog.records:
message = record.getMessage()
assert "API key" not in message
assert "wrong" not in message
def test_mismatch_with_cookie_uses_cookie(self, wired, user_db):
alice = user_db.create_user(username="alice")
client = _cookie_client(wired.app, alice)
assert client.get("/api/downloads/active", headers=_bearer("wrong")).status_code == 200
assert client.get("/api/settings", headers=_bearer("wrong")).status_code == 403
def test_mismatch_leaves_browser_session_untouched(self, wired, user_db):
alice = user_db.create_user(username="alice")
client = _cookie_client(wired.app, alice, permanent=True)
response = client.get("/api/downloads/active", headers=_bearer("wrong"))
assert response.status_code == 200
assert client.get("/api/downloads/active").status_code == 200
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", "")
with patch.object(main_module, "get_auth_mode", return_value="builtin"):
response = main_module.app.test_client().get(
"/api/downloads/active", headers=_bearer("s3cret")
)
assert response.status_code == 401
assert response.get_json() == {"error": "Unauthorized"}
alice = user_db.create_user(username="alice")
client = _cookie_client(main_module.app, alice, permanent=True)
cookie_response = client.get("/api/downloads/active", headers=_bearer("s3cret"))
assert cookie_response.status_code == 200
assert "Set-Cookie" in cookie_response.headers
class TestScopeAndModes:
def test_health_and_auth_paths_and_root_ignore_key(self, wired):
client = wired.app.test_client()
assert client.get("/api/health", headers=_bearer("s3cret")).status_code == 200
assert (
client.get("/api/auth/check", headers=_bearer("s3cret")).get_json()["authenticated"]
is False
)
assert client.get("/", headers=_bearer("s3cret")).status_code != 401
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")
with patch.object(main_module, "get_auth_mode", return_value="none"):
assert (
main_module.app.test_client()
.get("/api/downloads/active", headers=_bearer("s3cret"))
.status_code
== 200
)
def test_proxy_mode_keyed_request_needs_no_proxy_header(
self, main_module, user_db, monkeypatch
):
monkeypatch.setattr(main_module, "user_db", user_db)
monkeypatch.setattr(api_key, "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 (
main_module.app.test_client()
.get("/api/downloads/active", headers=_bearer("s3cret"))
.status_code
== 200
)
class TestIdentityAndRouting:
def test_deleted_first_admin_changes_identity(self, wired, user_db):
root = user_db.create_user(username="root", role="admin")
root2 = user_db.create_user(username="root2", role="admin")
identity = _identity_for_key(wired)
assert identity["user_id"] == "root"
assert identity["db_user_id"] == root["id"]
assert identity["is_admin"] is True
user_db.delete_user(root["id"])
identity = _identity_for_key(wired)
assert identity["user_id"] == "root2"
assert identity["db_user_id"] == root2["id"]
user_db.delete_user(root2["id"])
# No admin row left at all; the key still authenticates a bare
# admin identity for routes that don't need a local user row.
identity = _identity_for_key(wired)
assert identity["user_id"] == "api"
assert identity["is_admin"] is True
assert "db_user_id" not in identity
def test_demoted_first_admin_changes_identity(self, wired, user_db):
root = user_db.create_user(username="root", role="admin")
identity = _identity_for_key(wired)
assert identity["user_id"] == "root"
user_db.update_user(root["id"], role="user")
# No admin row left to match; the key falls back to a bare identity.
identity = _identity_for_key(wired)
assert identity["user_id"] == "api"
assert identity["is_admin"] is True
assert "db_user_id" not in identity
# Bare admin identity still reaches a route that doesn't need a
# local user row.
response = wired.app.test_client().get("/api/settings", headers=_bearer("s3cret"))
assert response.status_code == 200
def test_path_probes(self, wired, user_db):
user_db.create_user(username="root", role="admin")
client = wired.app.test_client()
# Flask routing is case-sensitive: /API/... doesn't match the /api/
# prefix the middleware checks, so it falls through to the SPA
# catch-all route instead of the JSON handler with elevated state.
response = client.get("/API/downloads/active", headers=_bearer("s3cret"))
assert response.status_code != 200 or response.content_type != "application/json"
# A trailing slash must not silently reach the handler either.
response = client.get("/api/downloads/active/", headers=_bearer("s3cret"))
assert response.status_code != 200 or not response.data
# A dot-segment path trick must not resolve to a live route.
response = client.get("/api/auth/../downloads/active", headers=_bearer("s3cret"))
assert response.status_code == 404
def test_keyed_post_reaches_guarded_route(self, wired, user_db):
"""A keyed POST passes the login_required guard and reaches the handler's own validation.
Uses /api/releases/inspect rather than a settings-save route: that
handler does no persistence at all, so this proves the request got
past the auth guard into real handler logic without touching the
process-wide Config singleton or the on-disk settings files other
tests (and other workers, under xdist) share.
"""
user_db.create_user(username="root", role="admin")
response = wired.app.test_client().post(
"/api/releases/inspect",
json={},
headers=_bearer("s3cret"),
)
assert response.status_code == 400
assert response.get_json() == {"error": "source_id is required"}
assert "Set-Cookie" not in response.headers