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:
Alex
2026-05-08 22:11:14 +01:00
committed by GitHub
parent 3305ec9e46
commit 9b8402c9a7
12 changed files with 175 additions and 12 deletions
+35 -1
View File
@@ -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
View File
@@ -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
+19 -1
View File
@@ -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 |",
"|----------|-------------|------|---------|",
+1
View File
@@ -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"))
+12 -6
View File
@@ -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"),
),
]
),
*(
[]
+2 -1
View File
@@ -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)
+6 -2
View File
@@ -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"
+7
View File
@@ -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>
{' '}
+32
View File
@@ -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
+28
View File
@@ -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
):
+27
View File
@@ -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