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