4a5766b84e
Allows multi-user BasicAuth mode to use Dynamic Client Registration (DCR) for OAuth credentials when ENABLE_OFFLINE_ACCESS is enabled, making it consistent with OAuth modes and reducing configuration burden. **Changes:** Configuration Validation: - Relaxed OAuth credential requirements for multi-user BasicAuth - OAuth credentials now optional when offline access enabled - Will use DCR as fallback if NEXTCLOUD_OIDC_CLIENT_ID/SECRET not set - Updated validation to log info instead of error when DCR will be used Startup Logic (app.py): - Added DCR workflow for multi-user BasicAuth before uvicorn starts - Creates oauth_context for management APIs when offline access enabled - Allows Astrolabe to authenticate management API calls with OAuth - DCR runs synchronously at same lifecycle point as OAuth modes - Added traceback import for better error logging - Fixed type assertions for nextcloud_host - Fixed undefined variable references in vector sync logging Management API: - Improved auth mode detection using proper detect_auth_mode() - Added auth_mode field to /status endpoint: * "basic" - Single-user BasicAuth * "multi_user_basic" - Multi-user BasicAuth * "oauth" - OAuth modes * "smithery" - Smithery stateless - Added supports_app_passwords indicator for multi-user BasicAuth Docker Compose: - Updated mcp-multi-user-basic service configuration: * Enabled vector sync (VECTOR_SYNC_ENABLED=true) * Added ENABLE_OFFLINE_ACCESS=true for app password support * Added NEXTCLOUD_MCP_SERVER_URL for Astrolabe integration * Documented optional static OAuth credentials Testing: - Updated test_config_validators.py to expect DCR fallback - Enhanced configure_astrolabe_for_mcp_server fixture with verification - Added debug logging to test_users_setup fixture **Workflow:** 1. User configures ENABLE_OFFLINE_ACCESS=true 2. Server checks for static NEXTCLOUD_OIDC_CLIENT_ID/SECRET 3. If not found, performs DCR before uvicorn starts 4. DCR registers client with Nextcloud OIDC provider 5. OAuth credentials used for Astrolabe management API auth 6. Background sync can retrieve user app passwords via Astrolabe 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
461 lines
16 KiB
Python
461 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
|
|
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"
|
|
OAUTH_SINGLE_AUDIENCE = "oauth_single"
|
|
OAUTH_TOKEN_EXCHANGE = "oauth_exchange"
|
|
SMITHERY_STATELESS = "smithery"
|
|
|
|
|
|
@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",
|
|
"enable_token_exchange",
|
|
"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",
|
|
"enable_token_exchange",
|
|
],
|
|
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.OAUTH_SINGLE_AUDIENCE: 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_token_exchange",
|
|
"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 with single-audience tokens. "
|
|
"Tokens work for both MCP server and Nextcloud APIs (pass-through). "
|
|
"Uses Dynamic Client Registration if credentials not provided.",
|
|
),
|
|
AuthMode.OAUTH_TOKEN_EXCHANGE: ModeRequirements(
|
|
required=["nextcloud_host", "enable_token_exchange"],
|
|
optional=[
|
|
# OAuth credentials
|
|
"oidc_client_id",
|
|
"oidc_client_secret",
|
|
"oidc_discovery_url",
|
|
# Token exchange settings
|
|
"token_exchange_cache_ttl",
|
|
# 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",
|
|
],
|
|
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 with token exchange (RFC 8693). "
|
|
"MCP tokens are separate from Nextcloud tokens. "
|
|
"Server exchanges MCP token for Nextcloud token on each request.",
|
|
),
|
|
AuthMode.SMITHERY_STATELESS: ModeRequirements(
|
|
required=[], # All config from session URL params
|
|
optional=[],
|
|
forbidden=[
|
|
"nextcloud_host",
|
|
"nextcloud_username",
|
|
"nextcloud_password",
|
|
"enable_multi_user_basic_auth",
|
|
"enable_token_exchange",
|
|
"enable_offline_access",
|
|
"vector_sync_enabled",
|
|
"oidc_client_id",
|
|
"oidc_client_secret",
|
|
],
|
|
conditional={},
|
|
description="Stateless multi-tenant deployment for Smithery platform. "
|
|
"Configuration comes from session URL parameters. "
|
|
"No persistent storage, no OAuth, no vector sync.",
|
|
),
|
|
}
|
|
|
|
|
|
def detect_auth_mode(settings: Settings) -> AuthMode:
|
|
"""Detect authentication mode from configuration.
|
|
|
|
Mode detection priority (ADR-021):
|
|
0. Explicit MCP_DEPLOYMENT_MODE (if set) - NEW in ADR-021
|
|
1. Smithery (explicit flag)
|
|
2. Token exchange (most specific OAuth mode)
|
|
3. Multi-user BasicAuth
|
|
4. Single-user BasicAuth
|
|
5. OAuth single-audience (default OAuth mode)
|
|
|
|
Args:
|
|
settings: Application settings
|
|
|
|
Returns:
|
|
Detected AuthMode
|
|
|
|
Raises:
|
|
ValueError: If explicit deployment_mode is invalid or conflicts with detected mode
|
|
"""
|
|
import logging
|
|
import os
|
|
|
|
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,
|
|
"oauth_single_audience": AuthMode.OAUTH_SINGLE_AUDIENCE,
|
|
"oauth_token_exchange": AuthMode.OAUTH_TOKEN_EXCHANGE,
|
|
"smithery": AuthMode.SMITHERY_STATELESS,
|
|
}
|
|
|
|
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 Smithery mode (explicit environment variable)
|
|
# Note: This checks the environment directly, not settings
|
|
# because Smithery mode has no settings-based config
|
|
if os.getenv("SMITHERY_DEPLOYMENT", "false").lower() == "true":
|
|
return AuthMode.SMITHERY_STATELESS
|
|
|
|
# Check for token exchange (most specific OAuth mode)
|
|
if settings.enable_token_exchange:
|
|
return AuthMode.OAUTH_TOKEN_EXCHANGE
|
|
|
|
# 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: OAuth single-audience mode
|
|
# This is the safest multi-user mode (no credential storage)
|
|
return AuthMode.OAUTH_SINGLE_AUDIENCE
|
|
|
|
|
|
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 in [
|
|
AuthMode.OAUTH_SINGLE_AUDIENCE,
|
|
AuthMode.OAUTH_TOKEN_EXCHANGE,
|
|
]:
|
|
# 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)
|