Files
mcp-nextcloud/nextcloud_mcp_server/config_validators.py
T
Chris CoutinhoandClaude Opus 4.7 cafd318f36 refactor(config)!: rename OAUTH_SINGLE_AUDIENCE to LOGIN_FLOW, gate on ENABLE_LOGIN_FLOW
The AuthMode.OAUTH_SINGLE_AUDIENCE enum was a vestige of ADR-021's
original design where it co-existed with OAUTH_TOKEN_EXCHANGE. The
un-augmented OAuth bearer pass-through it represented relied on
Nextcloud-side patches to user_oidc (Bearer token validation on
non-OCS endpoints) that were never merged upstream (see
docs/authentication.md, docs/login-flow-v2.md). The working path —
mcp-login-flow profile — sets ENABLE_LOGIN_FLOW=true on top of this
mode so Login Flow v2 acquires per-user Nextcloud app passwords via
a browser flow. With OAUTH_TOKEN_EXCHANGE removed in 57303135, the
_AUDIENCE suffix in the Python name no longer disambiguates anything,
and the enum value diverged from the env-var spelling. ADR-022 (now
accepted) called for this rename as step 1 of consolidation.

- nextcloud_mcp_server/config_validators.py: rename enum to LOGIN_FLOW
  with value "login_flow". The mode_map key is now "login_flow"; the
  MODE_REQUIREMENTS entry requires `enable_login_flow=True`. Added a
  validation gate so MCP_DEPLOYMENT_MODE=login_flow without
  ENABLE_LOGIN_FLOW=true errors with a clear message pointing at
  ADR-022. Default auto-detection fallback returns LOGIN_FLOW.
- nextcloud_mcp_server/app.py: renamed three identifier references and
  switched the "Configuring MCP server for OAuth mode" log line to
  the uniform `mode.value` shape used by the other modes.
- nextcloud_mcp_server/api/management.py: renamed identifier in the
  /api/v1/status mapping. The user-visible "auth_mode": "oauth" string
  is preserved — that's a stable Astrolabe contract.
- nextcloud_mcp_server/config.py: updated Settings docstring.
- tests/unit/test_config_validators.py: renamed class
  TestOAuthSingleAudienceValidation → TestLoginFlowValidation,
  individual test methods, env-var strings; added enable_login_flow=True
  to fixtures expecting success; added a new test
  (test_login_flow_requires_enable_login_flow_flag) that exercises the
  validation gate.
- tests/unit/test_management_status_endpoint.py: renamed identifier.

BREAKING CHANGE: MCP_DEPLOYMENT_MODE=oauth_single_audience is no longer
accepted. Set MCP_DEPLOYMENT_MODE=login_flow (and keep
ENABLE_LOGIN_FLOW=true) for the same deployment. The un-augmented
OAuth path is no longer supported; if you previously ran the broken
path, you can either configure Login Flow v2 (recommended) or switch
to multi_user_basic / single_user_basic.

Dead-code pruning of `oauth_enabled and not enable_login_flow`
branches in app.py (lifespan, background sync) is deferred to a
separate follow-up PR per the consolidation plan in ADR-022.

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

395 lines
14 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
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=[
"enable_multi_user_basic_auth",
"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", "enable_multi_user_basic_auth"],
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", "enable_login_flow"],
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",
"enable_multi_user_basic_auth",
],
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-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}")
return explicit_mode
# Auto-detection (existing behavior)
# Check for multi-user BasicAuth
if settings.enable_multi_user_basic_auth:
return AuthMode.MULTI_USER_BASIC
# Check for single-user BasicAuth (explicit credentials)
if settings.nextcloud_username and settings.nextcloud_password:
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); selecting LOGIN_FLOW without
# ENABLE_LOGIN_FLOW=true fails validation below.
return AuthMode.LOGIN_FLOW
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: LOGIN_FLOW requires the Login Flow v2 layer. The un-augmented
# OAuth bearer pass-through (formerly OAUTH_SINGLE_AUDIENCE without
# ENABLE_LOGIN_FLOW) needed unmerged Nextcloud user_oidc patches and
# is no longer supported.
if not settings.enable_login_flow:
errors.append(
f"[{mode.value}] ENABLE_LOGIN_FLOW=true is required for "
"login_flow mode. The un-augmented OAuth path is no longer "
"supported — see ADR-022."
)
# 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)