mirror of
https://github.com/calibrain/shelfmark.git
synced 2026-10-04 10:21:11 +01:00
Add DISABLE_LOCAL_AUTH env variable (#962)
Adds a new env var to disable local auth entirely when using OIDC authentication Fixes #922 #834
This commit is contained in:
@@ -31,7 +31,7 @@ This document lists all configuration options that can be set via environment va
|
||||
|
||||
## Bootstrap Configuration
|
||||
|
||||
These environment variables are used at startup before the settings system loads. They typically configure paths and server settings.
|
||||
These environment variables are used at startup before the settings system loads. They typically configure paths, server settings, and authentication startup behavior.
|
||||
|
||||
| Variable | Description | Type | Default |
|
||||
|----------|-------------|------|---------|
|
||||
@@ -43,6 +43,9 @@ These environment variables are used at startup before the settings system loads
|
||||
| `FLASK_PORT` | Port number for the Flask web server. | number | `8084` |
|
||||
| `SESSION_COOKIE_SECURE` | Enable secure cookies (requires HTTPS). | boolean | `false` |
|
||||
| `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` |
|
||||
| `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` |
|
||||
|
||||
@@ -105,6 +108,27 @@ Path to the Calibre-Web database for authentication integration.
|
||||
- **Type:** string (path)
|
||||
- **Default:** `/auth/app.db`
|
||||
|
||||
#### `HIDE_LOCAL_AUTH`
|
||||
|
||||
Hide the username/password login form when OIDC is active.
|
||||
|
||||
- **Type:** boolean
|
||||
- **Default:** `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.
|
||||
|
||||
- **Type:** boolean
|
||||
- **Default:** `false`
|
||||
|
||||
#### `OIDC_AUTO_REDIRECT`
|
||||
|
||||
Automatically redirect to the OIDC provider instead of showing the login page.
|
||||
|
||||
- **Type:** boolean
|
||||
- **Default:** `false`
|
||||
|
||||
#### `DOCKERMODE`
|
||||
|
||||
Indicates the application is running inside a Docker container.
|
||||
@@ -1073,6 +1097,7 @@ How long to cache individual book details. Default: 600 (10 minutes). Max: 60480
|
||||
| `PROWLARR_API_KEY` | Found in Prowlarr: Settings > General > API Key | string (secret) | _none_ |
|
||||
| `PROWLARR_INDEXERS` | Select which indexers to search. 📚 = has book categories. Leave empty to search all. | string (comma-separated) | _empty list_ |
|
||||
| `PROWLARR_AUTO_EXPAND` | Automatically retry search without category filtering if no results are found | boolean | `false` |
|
||||
| `PROWLARR_USE_SEED_PREFERENCES` | Apply per-indexer seed time and ratio preferences from Prowlarr when sending torrents to the download client | boolean | `false` |
|
||||
|
||||
<details>
|
||||
<summary>Detailed descriptions</summary>
|
||||
@@ -1124,6 +1149,15 @@ Automatically retry search without category filtering if no results are found
|
||||
- **Type:** boolean
|
||||
- **Default:** `false`
|
||||
|
||||
#### `PROWLARR_USE_SEED_PREFERENCES`
|
||||
|
||||
**Use Prowlarr seed preferences**
|
||||
|
||||
Apply per-indexer seed time and ratio preferences from Prowlarr when sending torrents to the download client
|
||||
|
||||
- **Type:** boolean
|
||||
- **Default:** `false`
|
||||
|
||||
</details>
|
||||
|
||||
## Newznab
|
||||
|
||||
+2
-1
@@ -39,9 +39,10 @@ These optional environment variables control login page behavior when OIDC is en
|
||||
| Variable | Description | Default |
|
||||
|----------|-------------|---------|
|
||||
| `HIDE_LOCAL_AUTH` | Hide the username/password login option, so only the OIDC button is shown | `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. | `false` |
|
||||
| `OIDC_AUTO_REDIRECT` | Automatically redirect to the OIDC provider instead of showing the login page | `false` |
|
||||
|
||||
If both are enabled, users are redirected straight to the OIDC provider. On failure they return to the login page with an error message but no password fallback.
|
||||
If `DISABLE_LOCAL_AUTH` and `OIDC_AUTO_REDIRECT` are both enabled, users are redirected straight to the OIDC provider. On failure they return to the login page with an error message but no password fallback.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
|
||||
@@ -172,6 +172,24 @@ def _generate_bootstrap_env_docs() -> list[str]:
|
||||
"type": "string (path)",
|
||||
"default": "/auth/app.db",
|
||||
},
|
||||
{
|
||||
"name": "HIDE_LOCAL_AUTH",
|
||||
"description": "Hide the username/password login form when OIDC is active.",
|
||||
"type": "boolean",
|
||||
"default": "false",
|
||||
},
|
||||
{
|
||||
"name": "DISABLE_LOCAL_AUTH",
|
||||
"description": "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.",
|
||||
"type": "boolean",
|
||||
"default": "false",
|
||||
},
|
||||
{
|
||||
"name": "OIDC_AUTO_REDIRECT",
|
||||
"description": "Automatically redirect to the OIDC provider instead of showing the login page.",
|
||||
"type": "boolean",
|
||||
"default": "false",
|
||||
},
|
||||
{
|
||||
"name": "DOCKERMODE",
|
||||
"description": "Indicates the application is running inside a Docker container.",
|
||||
@@ -189,7 +207,7 @@ def _generate_bootstrap_env_docs() -> list[str]:
|
||||
lines = [
|
||||
"## Bootstrap Configuration",
|
||||
"",
|
||||
"These environment variables are used at startup before the settings system loads. They typically configure paths and server settings.",
|
||||
"These environment variables are used at startup before the settings system loads. They typically configure paths, server settings, and authentication startup behavior.",
|
||||
"",
|
||||
"| Variable | Description | Type | Default |",
|
||||
"|----------|-------------|------|---------|",
|
||||
|
||||
@@ -121,6 +121,7 @@ SESSION_COOKIE_SECURE_ENV = os.getenv("SESSION_COOKIE_SECURE", "false")
|
||||
SESSION_COOKIE_NAME = "shelfmark_session"
|
||||
CWA_DB_PATH = _resolve_cwa_db_path()
|
||||
HIDE_LOCAL_AUTH = string_to_bool(os.getenv("HIDE_LOCAL_AUTH", "false"))
|
||||
DISABLE_LOCAL_AUTH = string_to_bool(os.getenv("DISABLE_LOCAL_AUTH", "false"))
|
||||
OIDC_AUTO_REDIRECT = string_to_bool(os.getenv("OIDC_AUTO_REDIRECT", "false"))
|
||||
|
||||
|
||||
|
||||
@@ -76,7 +76,7 @@ def _test_oidc_connection(current_values: dict[str, Any] | None = None) -> dict[
|
||||
@register_settings("security", "Security", icon="shield", order=5)
|
||||
def security_settings() -> list[SettingsField]:
|
||||
"""Security and authentication settings."""
|
||||
from shelfmark.config.env import CWA_DB_PATH
|
||||
from shelfmark.config.env import CWA_DB_PATH, DISABLE_LOCAL_AUTH
|
||||
|
||||
cwa_db_available = CWA_DB_PATH is not None and CWA_DB_PATH.exists()
|
||||
|
||||
@@ -108,11 +108,17 @@ def security_settings() -> list[SettingsField]:
|
||||
),
|
||||
show_when=_auth_condition("builtin"),
|
||||
),
|
||||
CustomComponentField(
|
||||
key="oidc_admin_requirement",
|
||||
component="oidc_admin_hint",
|
||||
label="A local admin account is required before OIDC can be enabled.",
|
||||
show_when=_auth_condition("oidc"),
|
||||
*(
|
||||
[]
|
||||
if DISABLE_LOCAL_AUTH
|
||||
else [
|
||||
CustomComponentField(
|
||||
key="oidc_admin_requirement",
|
||||
component="oidc_admin_hint",
|
||||
label="A local admin account is required before OIDC can be enabled.",
|
||||
show_when=_auth_condition("oidc"),
|
||||
),
|
||||
]
|
||||
),
|
||||
*(
|
||||
[]
|
||||
|
||||
@@ -4,6 +4,7 @@ import os
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from shelfmark.config.env import DISABLE_LOCAL_AUTH
|
||||
from shelfmark.core.user_db import UserDB
|
||||
from shelfmark.core.utils import normalize_http_url
|
||||
from shelfmark.download.network import get_ssl_verify
|
||||
@@ -78,7 +79,7 @@ def on_save_security(
|
||||
auth_method = str(effective_values.get("AUTH_METHOD", "") or "").strip().lower()
|
||||
|
||||
if auth_method == "oidc":
|
||||
if not _has_local_password_admin():
|
||||
if not DISABLE_LOCAL_AUTH and not _has_local_password_admin():
|
||||
return {"error": True, "message": _OIDC_LOCKOUT_MESSAGE, "values": normalized_values}
|
||||
|
||||
missing_fields = _get_missing_oidc_required_fields(effective_values)
|
||||
|
||||
@@ -71,14 +71,16 @@ def determine_auth_mode(
|
||||
cwa_db_path: object | None,
|
||||
*,
|
||||
has_local_admin: bool = True,
|
||||
disable_local_auth: bool = False,
|
||||
) -> str:
|
||||
"""Determine active auth mode from security config and runtime prerequisites."""
|
||||
auth_mode = security_config.get("AUTH_METHOD", "none")
|
||||
local_admin_available = has_local_admin or disable_local_auth
|
||||
|
||||
if auth_mode == AUTH_SOURCE_CWA and cwa_db_path:
|
||||
return AUTH_SOURCE_CWA
|
||||
|
||||
if auth_mode == AUTH_SOURCE_BUILTIN and has_local_admin:
|
||||
if auth_mode == AUTH_SOURCE_BUILTIN and local_admin_available:
|
||||
return AUTH_SOURCE_BUILTIN
|
||||
|
||||
if auth_mode == AUTH_SOURCE_PROXY and security_config.get("PROXY_AUTH_USER_HEADER"):
|
||||
@@ -86,7 +88,7 @@ def determine_auth_mode(
|
||||
|
||||
if (
|
||||
auth_mode == AUTH_SOURCE_OIDC
|
||||
and has_local_admin
|
||||
and local_admin_available
|
||||
and security_config.get("OIDC_DISCOVERY_URL")
|
||||
and security_config.get("OIDC_CLIENT_ID")
|
||||
):
|
||||
@@ -102,6 +104,7 @@ def load_active_auth_mode(
|
||||
) -> str:
|
||||
"""Resolve active auth mode using current security config and runtime prerequisites."""
|
||||
try:
|
||||
from shelfmark.config.env import DISABLE_LOCAL_AUTH
|
||||
from shelfmark.core.config import config as app_config
|
||||
|
||||
security_config = {
|
||||
@@ -114,6 +117,7 @@ def load_active_auth_mode(
|
||||
security_config,
|
||||
cwa_db_path,
|
||||
has_local_admin=has_local_password_admin(user_db),
|
||||
disable_local_auth=DISABLE_LOCAL_AUTH,
|
||||
)
|
||||
except ImportError, OSError, RuntimeError, TypeError, ValueError, sqlite3.Error:
|
||||
return "none"
|
||||
|
||||
@@ -26,6 +26,7 @@ from shelfmark.config.env import (
|
||||
BUILD_VERSION,
|
||||
CONFIG_DIR,
|
||||
CWA_DB_PATH,
|
||||
DISABLE_LOCAL_AUTH,
|
||||
FLASK_HOST,
|
||||
FLASK_PORT,
|
||||
HIDE_LOCAL_AUTH,
|
||||
@@ -1956,6 +1957,9 @@ def api_login() -> Response | tuple[Response, int]:
|
||||
if auth_mode == "proxy":
|
||||
return jsonify({"error": "Proxy authentication is enabled"}), 401
|
||||
|
||||
if auth_mode in ("builtin", "oidc") and DISABLE_LOCAL_AUTH:
|
||||
return jsonify({"error": "Local authentication is disabled"}), 403
|
||||
|
||||
if auth_mode == "oidc" and HIDE_LOCAL_AUTH:
|
||||
return jsonify({"error": "Local authentication is disabled"}), 403
|
||||
|
||||
@@ -2185,6 +2189,9 @@ def api_auth_check() -> Response | tuple[Response, int]:
|
||||
if logout_url:
|
||||
response_data["logout_url"] = logout_url
|
||||
|
||||
if auth_mode in ("builtin", "oidc") and DISABLE_LOCAL_AUTH:
|
||||
response_data["hide_local_auth"] = True
|
||||
|
||||
# Add custom OIDC button label and SSO enforcement flags if configured
|
||||
if auth_mode == "oidc":
|
||||
oidc_button_label = app_config.get("OIDC_BUTTON_LABEL", "")
|
||||
|
||||
@@ -18,6 +18,10 @@ export const OidcEnvInfo = (_props: CustomSettingsFieldRendererProps) => {
|
||||
{' '}
|
||||
<span className="opacity-40"># Hide the local login form</span>
|
||||
{'\n'}
|
||||
{' '}- <span className="text-blue-400">DISABLE_LOCAL_AUTH</span>=
|
||||
<span className="text-green-400">true</span>{' '}
|
||||
<span className="opacity-40"># Disable username/password login</span>
|
||||
{'\n'}
|
||||
{' '}- <span className="text-blue-400">OIDC_AUTO_REDIRECT</span>=
|
||||
<span className="text-green-400">true</span>
|
||||
{' '}
|
||||
|
||||
@@ -381,6 +381,16 @@ class TestSecuritySettings:
|
||||
assert "inactive" in hint.label.lower()
|
||||
assert "local admin" in hint.label.lower()
|
||||
|
||||
def test_oidc_admin_requirement_hint_absent_when_local_auth_is_disabled(self):
|
||||
"""OIDC mode should not show the local-admin warning when local auth is disabled."""
|
||||
from shelfmark.config.security import security_settings
|
||||
|
||||
with patch("shelfmark.config.env.DISABLE_LOCAL_AUTH", True):
|
||||
fields = security_settings()
|
||||
|
||||
hint = next((f for f in fields if f.key == "oidc_admin_requirement"), None)
|
||||
assert hint is None
|
||||
|
||||
def test_builtin_option_label_is_local(self):
|
||||
"""Builtin auth option should be labeled Local."""
|
||||
from shelfmark.config.security import security_settings
|
||||
@@ -429,6 +439,28 @@ class TestSecurityOnSave:
|
||||
assert result["error"] is True
|
||||
assert "local admin" in result["message"].lower()
|
||||
|
||||
def test_on_save_allows_oidc_without_local_admin_when_local_auth_is_disabled(
|
||||
self, tmp_path, monkeypatch
|
||||
):
|
||||
from shelfmark.config.security import _on_save_security
|
||||
|
||||
_set_config_dir(monkeypatch, tmp_path)
|
||||
UserDB(str(tmp_path / "users.db")).initialize()
|
||||
|
||||
with patch("shelfmark.config.security_handlers.DISABLE_LOCAL_AUTH", True):
|
||||
result = _on_save_security(
|
||||
{
|
||||
"AUTH_METHOD": "oidc",
|
||||
"OIDC_DISCOVERY_URL": (
|
||||
"https://auth.example.com/.well-known/openid-configuration"
|
||||
),
|
||||
"OIDC_CLIENT_ID": "shelfmark",
|
||||
"OIDC_CLIENT_SECRET": "secret123",
|
||||
}
|
||||
)
|
||||
|
||||
assert result["error"] is False
|
||||
|
||||
def test_on_save_blocks_oidc_when_client_id_is_missing(self, tmp_path, monkeypatch):
|
||||
from shelfmark.config.security import _on_save_security
|
||||
|
||||
|
||||
@@ -126,6 +126,21 @@ class TestLoginSemantics:
|
||||
assert response.status_code == 403
|
||||
assert response.get_json()["error"] == "Local authentication is disabled"
|
||||
|
||||
@pytest.mark.parametrize("auth_mode", ["builtin", "oidc"])
|
||||
def test_login_rejects_password_auth_when_local_auth_is_disabled(
|
||||
self, main_module, client, auth_mode
|
||||
):
|
||||
with patch.object(main_module, "get_auth_mode", return_value=auth_mode):
|
||||
with patch.object(main_module, "DISABLE_LOCAL_AUTH", True):
|
||||
response = client.post(
|
||||
"/api/auth/login",
|
||||
json={"username": "alice", "password": "wrong", "remember_me": False},
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert response.get_json()["error"] == "Local authentication is disabled"
|
||||
assert main_module.failed_login_attempts == {}
|
||||
|
||||
def test_auth_check_none_mode_reports_full_access(self, main_module, client):
|
||||
with patch.object(main_module, "get_auth_mode", return_value="none"):
|
||||
response = client.get("/api/auth/check")
|
||||
@@ -138,6 +153,19 @@ class TestLoginSemantics:
|
||||
"is_admin": True,
|
||||
}
|
||||
|
||||
@pytest.mark.parametrize("auth_mode", ["builtin", "oidc"])
|
||||
def test_auth_check_hides_local_auth_when_disabled(self, main_module, client, auth_mode):
|
||||
with patch.object(main_module, "get_auth_mode", return_value=auth_mode):
|
||||
with patch.object(main_module, "DISABLE_LOCAL_AUTH", True):
|
||||
response = client.get("/api/auth/check")
|
||||
|
||||
assert response.status_code == 200
|
||||
body = response.get_json()
|
||||
assert body["auth_mode"] == auth_mode
|
||||
assert body["auth_required"] is True
|
||||
assert body["authenticated"] is False
|
||||
assert body["hide_local_auth"] is True
|
||||
|
||||
def test_auth_check_includes_display_name_for_authenticated_user(
|
||||
self, main_module, client, temp_user_db, monkeypatch
|
||||
):
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
import sqlite3
|
||||
|
||||
import pytest
|
||||
|
||||
from shelfmark.core.auth_modes import (
|
||||
determine_auth_mode,
|
||||
get_auth_check_admin_status,
|
||||
@@ -63,6 +65,31 @@ class TestDetermineAuthMode:
|
||||
}
|
||||
assert determine_auth_mode(config, cwa_db_path=None, has_local_admin=False) == "none"
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("auth_mode", "config"),
|
||||
[
|
||||
("builtin", {"AUTH_METHOD": "builtin"}),
|
||||
(
|
||||
"oidc",
|
||||
{
|
||||
"AUTH_METHOD": "oidc",
|
||||
"OIDC_DISCOVERY_URL": "https://auth.example.com/.well-known/openid-configuration",
|
||||
"OIDC_CLIENT_ID": "shelfmark",
|
||||
},
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_disable_local_auth_keeps_configured_mode_without_admin(self, auth_mode, config):
|
||||
assert (
|
||||
determine_auth_mode(
|
||||
config,
|
||||
cwa_db_path=None,
|
||||
has_local_admin=False,
|
||||
disable_local_auth=True,
|
||||
)
|
||||
== auth_mode
|
||||
)
|
||||
|
||||
def test_load_active_auth_mode_reads_env_backed_cwa_setting(self, monkeypatch, tmp_path):
|
||||
from shelfmark.core.config import config as app_config
|
||||
|
||||
|
||||
Reference in New Issue
Block a user