From 9b8402c9a78a1a7e35562dc32cd80710ce4cd0d2 Mon Sep 17 00:00:00 2001 From: Alex <25013571+alexhb1@users.noreply.github.com> Date: Fri, 8 May 2026 22:11:14 +0100 Subject: [PATCH] Add DISABLE_LOCAL_AUTH env variable (#962) Adds a new env var to disable local auth entirely when using OIDC authentication Fixes #922 #834 --- docs/environment-variables.md | 36 ++++++++++++++++++- docs/oidc.md | 3 +- scripts/generate_env_docs.py | 20 ++++++++++- shelfmark/config/env.py | 1 + shelfmark/config/security.py | 18 ++++++---- shelfmark/config/security_handlers.py | 3 +- shelfmark/core/auth_modes.py | 8 +++-- shelfmark/main.py | 7 ++++ .../settings/customFields/OidcEnvInfo.tsx | 4 +++ tests/config/test_security.py | 32 +++++++++++++++++ tests/core/test_auth_api.py | 28 +++++++++++++++ tests/core/test_oidc_integration.py | 27 ++++++++++++++ 12 files changed, 175 insertions(+), 12 deletions(-) diff --git a/docs/environment-variables.md b/docs/environment-variables.md index bd3a05b0..5b61ad0e 100644 --- a/docs/environment-variables.md +++ b/docs/environment-variables.md @@ -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` |
Detailed descriptions @@ -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` +
## Newznab diff --git a/docs/oidc.md b/docs/oidc.md index ec5ecfd1..0c3982ed 100644 --- a/docs/oidc.md +++ b/docs/oidc.md @@ -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 diff --git a/scripts/generate_env_docs.py b/scripts/generate_env_docs.py index 2907166e..bbee1cbf 100755 --- a/scripts/generate_env_docs.py +++ b/scripts/generate_env_docs.py @@ -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 |", "|----------|-------------|------|---------|", diff --git a/shelfmark/config/env.py b/shelfmark/config/env.py index 7900df5c..8eda0486 100644 --- a/shelfmark/config/env.py +++ b/shelfmark/config/env.py @@ -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")) diff --git a/shelfmark/config/security.py b/shelfmark/config/security.py index 8a094fed..10f4cba5 100644 --- a/shelfmark/config/security.py +++ b/shelfmark/config/security.py @@ -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"), + ), + ] ), *( [] diff --git a/shelfmark/config/security_handlers.py b/shelfmark/config/security_handlers.py index df857810..990e25e3 100644 --- a/shelfmark/config/security_handlers.py +++ b/shelfmark/config/security_handlers.py @@ -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) diff --git a/shelfmark/core/auth_modes.py b/shelfmark/core/auth_modes.py index fe4ce155..81c81948 100644 --- a/shelfmark/core/auth_modes.py +++ b/shelfmark/core/auth_modes.py @@ -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" diff --git a/shelfmark/main.py b/shelfmark/main.py index 81d0a692..85a125a5 100644 --- a/shelfmark/main.py +++ b/shelfmark/main.py @@ -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", "") diff --git a/src/frontend/src/components/settings/customFields/OidcEnvInfo.tsx b/src/frontend/src/components/settings/customFields/OidcEnvInfo.tsx index 57c71475..50dfd7d0 100644 --- a/src/frontend/src/components/settings/customFields/OidcEnvInfo.tsx +++ b/src/frontend/src/components/settings/customFields/OidcEnvInfo.tsx @@ -18,6 +18,10 @@ export const OidcEnvInfo = (_props: CustomSettingsFieldRendererProps) => { {' '} # Hide the local login form {'\n'} + {' '}- DISABLE_LOCAL_AUTH= + true{' '} + # Disable username/password login + {'\n'} {' '}- OIDC_AUTO_REDIRECT= true {' '} diff --git a/tests/config/test_security.py b/tests/config/test_security.py index 3226f35a..e2766ed0 100644 --- a/tests/config/test_security.py +++ b/tests/config/test_security.py @@ -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 diff --git a/tests/core/test_auth_api.py b/tests/core/test_auth_api.py index 4ee00560..dd30fc36 100644 --- a/tests/core/test_auth_api.py +++ b/tests/core/test_auth_api.py @@ -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 ): diff --git a/tests/core/test_oidc_integration.py b/tests/core/test_oidc_integration.py index ef841f64..8a26b901 100644 --- a/tests/core/test_oidc_integration.py +++ b/tests/core/test_oidc_integration.py @@ -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