Once OAUTH_SINGLE_AUDIENCE was renamed to LOGIN_FLOW and the validation
gate ensured the only meaningful configuration was
`MCP_DEPLOYMENT_MODE=login_flow + ENABLE_LOGIN_FLOW=true`, the two
controls became redundant. Setting the mode is sufficient; the
ENABLE_LOGIN_FLOW env var doesn't add information.
This commit makes the deployment mode the single source of truth for
the Login Flow v2 toggle:
- `nextcloud_mcp_server/config.py`: drop the `ENABLE_LOGIN_FLOW`
dynaconf env-var alias. The `enable_login_flow` field stays as an
internal attribute so the 6 runtime call sites (app.py x4,
context.py, auth/scope_authorization.py) keep working unchanged.
Updated field docstring to flag it as derived.
- `nextcloud_mcp_server/config_validators.py`:
- Drop `enable_login_flow` from `MODE_REQUIREMENTS[LOGIN_FLOW].required`.
- Drop the validation gate that required ENABLE_LOGIN_FLOW=true for
LOGIN_FLOW mode (no longer possible to misconfigure — the flag is
derived, not user input).
- Add `_sync_derived_flags()` helper called at every return path of
`detect_auth_mode` to set `settings.enable_login_flow` from the
resolved mode.
- `tests/unit/test_config_validators.py`: drop `enable_login_flow=True`
from happy-path fixtures (no longer needed — detection sets it).
Repurpose `test_login_flow_requires_enable_login_flow_flag` into
`test_login_flow_mode_auto_derives_enable_login_flow_flag` which
asserts the new auto-derivation behaviour for both LOGIN_FLOW and a
non-LOGIN_FLOW mode.
- `docker-compose.yml`: remove `ENABLE_LOGIN_FLOW=true` from the
`mcp-login-flow` and `mcp-keycloak` profiles.
- `env.sample`: remove the ENABLE_LOGIN_FLOW reference; the comment
on `MCP_DEPLOYMENT_MODE` now notes the derived flag.
- `docs/configuration.md`, `docs/authentication.md`,
`docs/login-flow-v2.md`, `docs/auth-flows.md`,
`docs/troubleshooting.md`, `docs/ADR-025-*.md`: replace
ENABLE_LOGIN_FLOW=true examples and references with
MCP_DEPLOYMENT_MODE=login_flow.
BREAKING CHANGE: `ENABLE_LOGIN_FLOW` is no longer read from the
environment. Anyone who relied on `ENABLE_LOGIN_FLOW=true` to activate
Login Flow v2 should set `MCP_DEPLOYMENT_MODE=login_flow` instead (or
rely on it being the default when no other auth env vars are set).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
409 lines
15 KiB
Python
409 lines
15 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"],
|
|
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}")
|
|
_sync_derived_flags(settings, explicit_mode)
|
|
return explicit_mode
|
|
|
|
# Auto-detection (existing behavior)
|
|
# Check for multi-user BasicAuth
|
|
if settings.enable_multi_user_basic_auth:
|
|
_sync_derived_flags(settings, AuthMode.MULTI_USER_BASIC)
|
|
return AuthMode.MULTI_USER_BASIC
|
|
|
|
# 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.
|
|
|
|
Specifically: `enable_login_flow` is now derived from
|
|
`mode == AuthMode.LOGIN_FLOW`. The ENABLE_LOGIN_FLOW env-var alias was
|
|
removed in the ADR-022 follow-up (PR #787).
|
|
"""
|
|
settings.enable_login_flow = mode == 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 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)
|