Files
mcp-nextcloud/nextcloud_mcp_server/config_validators.py
T
Chris CoutinhoandClaude Opus 4.7 282c245da1 refactor(config)!: drop ENABLE_MULTI_USER_BASIC_AUTH env var, fail loud on legacy aliases
Same pattern as the ENABLE_LOGIN_FLOW removal in the previous commit:
the deployment mode (MCP_DEPLOYMENT_MODE) is the single source of truth
for selecting an auth flow. The ENABLE_MULTI_USER_BASIC_AUTH env-var
alias is redundant with `MCP_DEPLOYMENT_MODE=multi_user_basic`.

Unlike the ENABLE_LOGIN_FLOW removal — where silent removal was safe
because Login Flow v2 is the auto-detection default — silent removal
here would be a surprise: a user with only ENABLE_MULTI_USER_BASIC_AUTH=true
in their .env would auto-detect into LOGIN_FLOW after upgrade (wrong
runtime mode). Mitigation: detect_auth_mode now reads os.environ
directly for both legacy aliases and raises ValueError with a one-line
migration message if either is set. Applied retroactively to
ENABLE_LOGIN_FLOW as well — loud is better than silent.

- nextcloud_mcp_server/config.py:
  - Drop the dynaconf env-var alias entry for ENABLE_MULTI_USER_BASIC_AUTH.
  - Update the `enable_multi_user_basic_auth` field docstring to mark it
    as derived / not user-settable.
  - `_is_multi_user_mode()` (early-config helper, runs before Settings
    is built) switched to checking MCP_DEPLOYMENT_MODE directly. Now
    consistent with the canonical detection in detect_auth_mode.
- nextcloud_mcp_server/config_validators.py:
  - Drop the auto-detection branch (`if settings.enable_multi_user_basic_auth`).
    Selection of MULTI_USER_BASIC is now exclusively via the explicit
    MCP_DEPLOYMENT_MODE branch.
  - Add `enable_multi_user_basic_auth` to `_sync_derived_flags` alongside
    `enable_login_flow` — both flags are now derived from the resolved mode.
  - Drop `enable_multi_user_basic_auth` from
    `MODE_REQUIREMENTS[MULTI_USER_BASIC].required` and from the
    `forbidden` lists of SINGLE_USER_BASIC and LOGIN_FLOW (no longer
    user input → no meaningful forbidden check).
  - Add loud-deprecation `ValueError` block at the top of detect_auth_mode
    that errors with a clear migration message when ENABLE_MULTI_USER_BASIC_AUTH
    or ENABLE_LOGIN_FLOW is found in os.environ.
- tests/unit/test_config_validators.py:
  - Switch ~10 fixtures from `enable_multi_user_basic_auth=True` to
    `deployment_mode="multi_user_basic"` (mirrors `enable_login_flow`
    treatment from the previous commit).
  - Switch two `patch.dict(os.environ, {"ENABLE_MULTI_USER_BASIC_AUTH": "true"})`
    blocks to use MCP_DEPLOYMENT_MODE.
  - Rename `test_forbidden_multi_user_basic_auth` to
    `test_forbidden_multi_user_basic_when_credentials_present` — the
    scenario is now an explicit-mode + credentials conflict, not an
    env-var-flag conflict.
  - Add `test_legacy_enable_multi_user_basic_auth_env_var_errors` and
    `test_legacy_enable_login_flow_env_var_errors` to exercise the new
    loud-deprecation ValueError path.
- docker-compose.yml: mcp-multi-user-basic profile switched to
  `MCP_DEPLOYMENT_MODE=multi_user_basic`.
- env.sample: replaced `#ENABLE_MULTI_USER_BASIC_AUTH=true` example with
  `#MCP_DEPLOYMENT_MODE=multi_user_basic`.
- docs/authentication.md, configuration.md, troubleshooting.md,
  auth-flows.md, webhook-management-guide.md,
  configuration-migration-v2.md, ADR-025: replaced env-var examples
  with the canonical MCP_DEPLOYMENT_MODE form.
- docs/ADR-020: marked partly superseded by ADR-022.
- CLAUDE.md: Multi-User BasicAuth section updated to set
  MCP_DEPLOYMENT_MODE.
- nextcloud_mcp_server/vector/oauth_sync.py: module docstring updated.

BREAKING CHANGE: ENABLE_MULTI_USER_BASIC_AUTH is no longer read from
the environment, and setting it now raises a startup ValueError with
a migration message. Replace `ENABLE_MULTI_USER_BASIC_AUTH=true` with
`MCP_DEPLOYMENT_MODE=multi_user_basic`. The same loud-deprecation
check is also applied to the recently-removed ENABLE_LOGIN_FLOW —
replace with `MCP_DEPLOYMENT_MODE=login_flow` (or drop both;
`login_flow` is the auto-detect default when no other auth env vars
are set).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 20:06:16 +02:00

422 lines
16 KiB
Python

"""Configuration validation and mode detection for the MCP server.
This module provides:
- Mode detection based on configuration
- Configuration validation with clear error messages
- Single source of truth for deployment mode requirements
See ADR-020 for detailed architecture and deployment mode documentation.
"""
import logging
import os
from dataclasses import dataclass
from enum import Enum
from nextcloud_mcp_server.config import Settings
logger = logging.getLogger(__name__)
class AuthMode(Enum):
"""Authentication mode for the MCP server.
Determines how users authenticate and how the server accesses Nextcloud.
"""
SINGLE_USER_BASIC = "single_user_basic"
MULTI_USER_BASIC = "multi_user_basic"
LOGIN_FLOW = "login_flow"
@dataclass
class ModeRequirements:
"""Requirements for a deployment mode.
Attributes:
required: Configuration variables that must be set
optional: Configuration variables that may be set
forbidden: Configuration variables that should not be set
conditional: Additional requirements based on feature flags
Format: {feature_flag: [required_vars]}
description: Human-readable description of the mode
"""
required: list[str]
optional: list[str]
forbidden: list[str]
conditional: dict[str, list[str]]
description: str
# Mode requirements definition
MODE_REQUIREMENTS: dict[AuthMode, ModeRequirements] = {
AuthMode.SINGLE_USER_BASIC: ModeRequirements(
required=["nextcloud_host", "nextcloud_username", "nextcloud_password"],
optional=[
"vector_sync_enabled",
"qdrant_url",
"qdrant_location",
"ollama_base_url",
"ollama_embedding_model",
"openai_api_key",
"openai_embedding_model",
"document_chunk_size",
"document_chunk_overlap",
],
forbidden=[
"oidc_client_id",
"oidc_client_secret",
],
conditional={
"vector_sync_enabled": [
# Either qdrant_url OR qdrant_location (checked in Settings.__post_init__)
# At least one embedding provider (ollama_base_url OR openai_api_key)
],
},
description="Single-user deployment with BasicAuth credentials. "
"Suitable for personal Nextcloud instances and local development.",
),
AuthMode.MULTI_USER_BASIC: ModeRequirements(
required=["nextcloud_host"],
optional=[
# Background sync with app passwords (via Astrolabe)
"enable_offline_access",
"token_encryption_key",
"token_storage_db",
"oidc_client_id",
"oidc_client_secret",
# Vector sync
"vector_sync_enabled",
"qdrant_url",
"qdrant_location",
"ollama_base_url",
"ollama_embedding_model",
"openai_api_key",
"openai_embedding_model",
],
forbidden=[
"nextcloud_username",
"nextcloud_password",
],
conditional={
"enable_offline_access": [
# OAuth credentials validated separately (lines 397-406) with clearer error message
"token_encryption_key",
"token_storage_db",
],
# Note: vector_sync_enabled (now ENABLE_SEMANTIC_SEARCH) automatically
# enables background operations in multi-user modes. No explicit
# enable_offline_access setting required.
},
description="Multi-user deployment with BasicAuth pass-through. "
"Users provide credentials in request headers. "
"Optional background sync using app passwords stored via Astrolabe.",
),
AuthMode.LOGIN_FLOW: ModeRequirements(
required=["nextcloud_host"],
optional=[
# OAuth credentials (uses DCR if not provided)
"oidc_client_id",
"oidc_client_secret",
"oidc_discovery_url",
# Offline access
"enable_offline_access",
"token_encryption_key",
"token_storage_db",
# Vector sync
"vector_sync_enabled",
"qdrant_url",
"qdrant_location",
"ollama_base_url",
"ollama_embedding_model",
"openai_api_key",
"openai_embedding_model",
# Scopes
"nextcloud_oidc_scopes",
],
forbidden=[
"nextcloud_username",
"nextcloud_password",
],
conditional={
"enable_offline_access": [
"token_encryption_key",
"token_storage_db",
],
# Note: vector_sync_enabled (now ENABLE_SEMANTIC_SEARCH) automatically
# enables background operations in multi-user modes. No explicit
# enable_offline_access setting required.
},
description="OAuth multi-user deployment using Login Flow v2 to acquire "
"per-user Nextcloud app passwords via a browser flow. The MCP server "
"is an OIDC relying party of a configurable IdP (Nextcloud's built-in "
"OIDC by default; Keycloak, AWS Cognito, etc. via OIDC_DISCOVERY_URL). "
"Uses Dynamic Client Registration if credentials not provided. "
"Replaces the deprecated direct OAuth bearer-token pass-through which "
"required unmerged user_oidc patches (see ADR-022).",
),
}
def detect_auth_mode(settings: Settings) -> AuthMode:
"""Detect authentication mode from configuration.
Mode detection priority (ADR-021, updated for ADR-022):
0. Explicit MCP_DEPLOYMENT_MODE (if set) - NEW in ADR-021
1. Multi-user BasicAuth
2. Single-user BasicAuth
3. Login Flow v2 (default — was OAuth single-audience pre-ADR-022)
Args:
settings: Application settings
Returns:
Detected AuthMode
Raises:
ValueError: If explicit deployment_mode is invalid or conflicts with detected mode
"""
logger = logging.getLogger(__name__)
# ADR-022 follow-up: fail loudly if a caller is still relying on the
# removed env-var aliases. Bypass dynaconf and read os.environ directly
# so the check survives even though the aliases are gone.
for legacy, replacement in (
("ENABLE_MULTI_USER_BASIC_AUTH", "multi_user_basic"),
("ENABLE_LOGIN_FLOW", "login_flow"),
):
if os.getenv(legacy):
raise ValueError(
f"{legacy} is no longer read from the environment. "
f"Set MCP_DEPLOYMENT_MODE={replacement} instead "
"(ADR-022). The deployment mode is the single source of "
"truth for selecting an auth flow."
)
# ADR-021: Check for explicit deployment mode first
if settings.deployment_mode:
mode_str = settings.deployment_mode.lower().strip()
# Map string to AuthMode enum
mode_map = {
"single_user_basic": AuthMode.SINGLE_USER_BASIC,
"multi_user_basic": AuthMode.MULTI_USER_BASIC,
"login_flow": AuthMode.LOGIN_FLOW,
}
if mode_str not in mode_map:
valid_modes = ", ".join(mode_map.keys())
raise ValueError(
f"Invalid MCP_DEPLOYMENT_MODE: '{settings.deployment_mode}'. "
f"Valid values: {valid_modes}"
)
explicit_mode = mode_map[mode_str]
logger.info(f"Using explicit deployment mode: {explicit_mode.value}")
_sync_derived_flags(settings, explicit_mode)
return explicit_mode
# Auto-detection (no explicit deployment_mode).
# MULTI_USER_BASIC is no longer auto-detectable — the ENABLE_MULTI_USER_BASIC_AUTH
# env-var alias was dropped in the ADR-022 follow-up, so the only way to
# opt into that mode is `MCP_DEPLOYMENT_MODE=multi_user_basic` (handled
# above). The legacy env var fails loudly at the top of this function.
# Check for single-user BasicAuth (explicit credentials)
if settings.nextcloud_username and settings.nextcloud_password:
_sync_derived_flags(settings, AuthMode.SINGLE_USER_BASIC)
return AuthMode.SINGLE_USER_BASIC
# Default: Login Flow v2 multi-user mode (browser-based app-password flow).
# The un-augmented OAuth bearer pass-through it replaced required unmerged
# Nextcloud user_oidc patches (see ADR-022).
_sync_derived_flags(settings, AuthMode.LOGIN_FLOW)
return AuthMode.LOGIN_FLOW
def _sync_derived_flags(settings: Settings, mode: AuthMode) -> None:
"""Derive internal feature flags from the resolved deployment mode.
Some runtime call sites (app.py, context.py, auth/scope_authorization.py)
still read individual boolean flags rather than passing the mode around.
Keep those flags in sync with the mode here so the mode is the single
source of truth and users don't have to set redundant env vars. The
ENABLE_LOGIN_FLOW and ENABLE_MULTI_USER_BASIC_AUTH env-var aliases were
removed in the ADR-022 follow-up (PR #787).
"""
settings.enable_login_flow = mode == AuthMode.LOGIN_FLOW
settings.enable_multi_user_basic_auth = mode == AuthMode.MULTI_USER_BASIC
def validate_configuration(settings: Settings) -> tuple[AuthMode, list[str]]:
"""Validate configuration for detected mode.
Args:
settings: Application settings
Returns:
Tuple of (detected_mode, list_of_errors)
Empty list means valid configuration.
"""
mode = detect_auth_mode(settings)
requirements = MODE_REQUIREMENTS[mode]
errors: list[str] = []
logger.debug(f"Validating configuration for mode: {mode.value}")
# Check required variables
for var in requirements.required:
value = getattr(settings, var, None)
if value is None or (isinstance(value, str) and not value.strip()):
errors.append(
f"[{mode.value}] Missing required configuration: {var.upper()}"
)
# Check forbidden variables
for var in requirements.forbidden:
value = getattr(settings, var, None)
# For bools, check if True (forbidden means must be False/unset)
# For strings, check if non-empty
is_set = False
if isinstance(value, bool):
is_set = value is True
elif isinstance(value, str):
is_set = bool(value.strip())
elif value is not None:
is_set = True
if is_set:
errors.append(
f"[{mode.value}] Forbidden configuration: {var.upper()} "
f"should not be set in this mode"
)
# Check conditional requirements
for condition, required_vars in requirements.conditional.items():
# Check if the condition is enabled
condition_value = getattr(settings, condition, None)
is_enabled = False
if isinstance(condition_value, bool):
is_enabled = condition_value is True
elif isinstance(condition_value, str):
is_enabled = bool(condition_value.strip())
elif condition_value is not None:
is_enabled = True
if is_enabled:
# Check that all required vars for this condition are set
for var in required_vars:
value = getattr(settings, var, None)
# For boolean requirements, check that they are True (not just set)
if hasattr(Settings, var):
field_type = type(getattr(Settings(), var, None))
if field_type is bool:
if value is not True:
errors.append(
f"[{mode.value}] {var.upper()} must be enabled when "
f"{condition.upper()} is enabled"
)
continue
# For non-boolean requirements, check that they are set
if value is None or (isinstance(value, str) and not value.strip()):
errors.append(
f"[{mode.value}] {var.upper()} is required when "
f"{condition.upper()} is enabled"
)
# Special validations for specific modes
if mode == AuthMode.SINGLE_USER_BASIC:
# Validate that NEXTCLOUD_HOST doesn't have trailing slash
if settings.nextcloud_host and settings.nextcloud_host.endswith("/"):
errors.append(
f"[{mode.value}] NEXTCLOUD_HOST should not have trailing slash: "
f"{settings.nextcloud_host}"
)
if mode == AuthMode.LOGIN_FLOW:
# ADR-022 follow-up: the un-augmented OAuth bearer pass-through (the
# old OAUTH_SINGLE_AUDIENCE without ENABLE_LOGIN_FLOW) needed unmerged
# Nextcloud user_oidc patches and is no longer supported. The
# `enable_login_flow` flag is now derived from the resolved mode by
# `_sync_derived_flags`, so users only configure the mode — no
# separate ENABLE_LOGIN_FLOW env var is needed.
# If OAuth credentials not provided, DCR must be available
# (This is a runtime check, not a config check, so we just warn)
if not settings.oidc_client_id or not settings.oidc_client_secret:
logger.info(
f"[{mode.value}] OAuth credentials not configured. "
"Will attempt Dynamic Client Registration (DCR) at startup."
)
if mode == AuthMode.MULTI_USER_BASIC:
# If background operations enabled, check for OAuth credentials (for app password retrieval)
# Allow DCR as fallback, just like OAuth modes
if settings.enable_offline_access:
if not settings.oidc_client_id or not settings.oidc_client_secret:
logger.info(
f"[{mode.value}] OAuth credentials not configured. "
"Will attempt Dynamic Client Registration (DCR) at startup "
"(required for app password retrieval via Astrolabe)."
)
# Note: Vector sync no longer requires explicit ENABLE_OFFLINE_ACCESS setting
# ENABLE_SEMANTIC_SEARCH (formerly VECTOR_SYNC_ENABLED) automatically enables
# background operations in multi-user modes via smart dependency resolution
# in config.py
# Note: Embedding provider validation removed - Simple provider is always
# available as fallback (ADR-015). Users can optionally configure Ollama or OpenAI
# for better quality embeddings.
return mode, errors
def get_mode_summary(mode: AuthMode) -> str:
"""Get human-readable summary of a deployment mode.
Args:
mode: Deployment mode
Returns:
Multi-line string describing the mode
"""
requirements = MODE_REQUIREMENTS[mode]
summary_lines = [
f"Mode: {mode.value}",
f"Description: {requirements.description}",
"",
"Required configuration:",
]
if requirements.required:
for var in requirements.required:
summary_lines.append(f" - {var.upper()}")
else:
summary_lines.append(" (none - configured via session)")
summary_lines.append("")
summary_lines.append("Optional configuration:")
if requirements.optional:
for var in requirements.optional:
summary_lines.append(f" - {var.upper()}")
else:
summary_lines.append(" (none)")
if requirements.conditional:
summary_lines.append("")
summary_lines.append("Conditional requirements:")
for condition, vars in requirements.conditional.items():
summary_lines.append(f" When {condition.upper()} is enabled:")
for var in vars:
summary_lines.append(f" - {var.upper()}")
return "\n".join(summary_lines)