refactor(config): centralize configuration validation and simplify startup

Implement centralized configuration validation (ADR-020) to simplify
deployment mode detection and improve error messages.

Changes:
- Create ADR-020 documenting 5 deployment modes with required/optional config
- Add config_validators.py with validate_configuration() and mode detection
- Simplify app.py startup with single validation point at get_app()
- Remove duplicate is_oauth_mode() function (43 lines)
- Fix DeploymentMode mapping (only SELF_HOSTED and SMITHERY_STATELESS exist)
- Add comprehensive unit tests (41 tests covering all modes and edge cases)
- Add enable_multi_user_basic_auth to Settings and BasicAuthMiddleware

Docker Compose:
- Remove conflicting ENABLE_MULTI_USER_BASIC_AUTH from mcp-oauth service
- Add dedicated mcp-multi-user-basic service on port 8003

Test Results:
- 237/237 integration tests PASSED
- All deployment modes verified: single-user BasicAuth, multi-user BasicAuth,
  OAuth single-audience, OAuth token exchange (Keycloak), Smithery stateless

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Chris Coutinho
2025-12-20 20:49:28 +01:00
co-authored by Claude Sonnet 4.5
parent 8682fa4f88
commit 4507359760
6 changed files with 1565 additions and 89 deletions
+171 -88
View File
@@ -41,10 +41,14 @@ from nextcloud_mcp_server.auth.unified_verifier import UnifiedTokenVerifier
from nextcloud_mcp_server.client import NextcloudClient
from nextcloud_mcp_server.config import (
DeploymentMode,
get_deployment_mode,
get_document_processor_config,
get_settings,
)
from nextcloud_mcp_server.config_validators import (
AuthMode,
get_mode_summary,
validate_configuration,
)
from nextcloud_mcp_server.context import get_client as get_nextcloud_client
from nextcloud_mcp_server.document_processors import get_registry
from nextcloud_mcp_server.observability import (
@@ -351,6 +355,52 @@ def get_smithery_session_config() -> dict | None:
return _smithery_session_config.get()
class BasicAuthMiddleware:
"""Middleware to extract BasicAuth credentials from Authorization header.
For multi-user BasicAuth pass-through mode, this middleware extracts
username/password from the Authorization: Basic header and stores them
in the request state for use by the context layer.
The credentials are NOT stored persistently - they are passed through
directly to Nextcloud APIs for each request (stateless).
"""
def __init__(self, app: ASGIApp):
self.app = app
async def __call__(
self, scope: StarletteScope, receive: Receive, send: Send
) -> None:
if scope["type"] == "http":
# Extract Authorization header
headers = dict(scope.get("headers", []))
auth_header = headers.get(b"authorization", b"")
if auth_header.startswith(b"Basic "):
try:
import base64
# Decode base64(username:password)
encoded = auth_header[6:] # Skip "Basic "
decoded = base64.b64decode(encoded).decode("utf-8")
username, password = decoded.split(":", 1)
# Store in request state
scope.setdefault("state", {})
scope["state"]["basic_auth"] = {
"username": username,
"password": password,
}
logger.debug(
f"BasicAuth credentials extracted for user: {username}"
)
except Exception as e:
logger.warning(f"Failed to extract BasicAuth credentials: {e}")
await self.app(scope, receive, send)
class SmitheryConfigMiddleware:
"""Middleware to extract Smithery config from URL query parameters.
@@ -423,41 +473,6 @@ async def app_lifespan_smithery(server: FastMCP) -> AsyncIterator[SmitheryAppCon
logger.info("Shutting down Smithery stateless mode")
def is_oauth_mode() -> bool:
"""
Determine if OAuth mode should be used.
OAuth mode is enabled when:
- NEXTCLOUD_USERNAME and NEXTCLOUD_PASSWORD are NOT set
- AND we are NOT in Smithery stateless mode
- Or explicitly enabled via configuration
Returns:
True if OAuth mode, False if BasicAuth mode
"""
# ADR-016: Smithery stateless mode uses per-request BasicAuth from session config
# It's not OAuth mode even though env credentials aren't set
deployment_mode = get_deployment_mode()
if deployment_mode == DeploymentMode.SMITHERY_STATELESS:
logger.info(
"BasicAuth mode (Smithery stateless - credentials from session config)"
)
return False
username = os.getenv("NEXTCLOUD_USERNAME")
password = os.getenv("NEXTCLOUD_PASSWORD")
# If both username and password are set, use BasicAuth
if username and password:
logger.info(
"BasicAuth mode detected (NEXTCLOUD_USERNAME and NEXTCLOUD_PASSWORD set)"
)
return False
logger.info("OAuth mode detected (NEXTCLOUD_USERNAME/PASSWORD not set)")
return True
async def load_oauth_client_credentials(
nextcloud_host: str, registration_endpoint: str | None
) -> tuple[str, str]:
@@ -578,17 +593,31 @@ async def app_lifespan_basic(server: FastMCP) -> AsyncIterator[AppContext]:
"""
Manage application lifecycle for BasicAuth mode (FastMCP session lifespan).
Creates a single Nextcloud client with basic authentication
For single-user mode: Creates a single Nextcloud client with basic authentication
that is shared across all requests within a session.
For multi-user mode: No shared client - clients created per-request by BasicAuthMiddleware.
Note: Background tasks (scanner, processor) are started at server level
in starlette_lifespan, not here. This lifespan runs per-session.
"""
logger.info("Starting MCP session in BasicAuth mode")
logger.info("Creating Nextcloud client with BasicAuth")
settings = get_settings()
is_multi_user = settings.enable_multi_user_basic_auth
client = NextcloudClient.from_env()
logger.info("Client initialization complete")
logger.info(
f"Starting MCP session in {'multi-user' if is_multi_user else 'single-user'} BasicAuth mode"
)
# Only create shared client for single-user mode
client = None
if not is_multi_user:
logger.info("Creating shared Nextcloud client with BasicAuth")
client = NextcloudClient.from_env()
logger.info("Client initialization complete")
else:
logger.info(
"Multi-user mode - clients created per-request from BasicAuth headers"
)
# Initialize persistent storage (for webhook tracking and future features)
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
@@ -604,7 +633,7 @@ async def app_lifespan_basic(server: FastMCP) -> AsyncIterator[AppContext]:
# Include vector sync state from module singleton (set by starlette_lifespan)
try:
yield AppContext(
client=client,
client=client, # type: ignore[arg-type] # None in multi-user mode
storage=storage,
document_send_stream=_vector_sync_state.document_send_stream,
document_receive_stream=_vector_sync_state.document_receive_stream,
@@ -613,7 +642,8 @@ async def app_lifespan_basic(server: FastMCP) -> AsyncIterator[AppContext]:
)
finally:
logger.info("Shutting down BasicAuth session")
await client.close()
if client is not None:
await client.close()
async def setup_oauth_config():
@@ -985,6 +1015,33 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
# Initialize observability (logging will be configured by uvicorn)
settings = get_settings()
# Validate configuration and detect deployment mode
mode, config_errors = validate_configuration(settings)
if config_errors:
error_msg = (
f"Configuration validation failed for {mode.value} mode:\n"
+ "\n".join(f" - {err}" for err in config_errors)
+ "\n\n"
+ get_mode_summary(mode)
)
logger.error(error_msg)
raise ValueError(error_msg)
logger.info(f"✅ Configuration validated successfully for {mode.value} mode")
logger.debug(f"Mode details:\n{get_mode_summary(mode)}")
# Derive helper variables for backward compatibility with existing code
oauth_enabled = mode in (
AuthMode.OAUTH_SINGLE_AUDIENCE,
AuthMode.OAUTH_TOKEN_EXCHANGE,
)
deployment_mode = (
DeploymentMode.SMITHERY_STATELESS
if mode == AuthMode.SMITHERY_STATELESS
else DeploymentMode.SELF_HOSTED
)
# Setup Prometheus metrics (always enabled by default)
if settings.metrics_enabled:
setup_metrics(port=settings.metrics_port)
@@ -1008,11 +1065,8 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
"OpenTelemetry tracing disabled (set OTEL_EXPORTER_OTLP_ENDPOINT to enable)"
)
# Determine authentication mode and deployment mode
oauth_enabled = is_oauth_mode()
deployment_mode = get_deployment_mode()
if oauth_enabled:
# Create MCP server based on detected mode
if mode in (AuthMode.OAUTH_SINGLE_AUDIENCE, AuthMode.OAUTH_TOKEN_EXCHANGE):
logger.info("Configuring MCP server for OAuth mode")
# Asynchronously get the OAuth configuration
import anyio
@@ -1075,33 +1129,32 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
enable_dns_rebinding_protection=False
),
)
elif mode == AuthMode.SMITHERY_STATELESS:
logger.info("Configuring MCP server for Smithery stateless mode")
# json_response=True returns plain JSON-RPC instead of SSE format,
# required for Smithery scanner compatibility
mcp = FastMCP(
"Nextcloud MCP",
lifespan=app_lifespan_smithery,
json_response=True,
# Disable DNS rebinding protection for containerized deployments (k8s, Docker)
# MCP 1.23+ auto-enables this for localhost, breaking k8s service DNS names
transport_security=TransportSecuritySettings(
enable_dns_rebinding_protection=False
),
)
else:
# ADR-016: Use Smithery lifespan for stateless mode, BasicAuth otherwise
if deployment_mode == DeploymentMode.SMITHERY_STATELESS:
logger.info("Configuring MCP server for Smithery stateless mode")
# json_response=True returns plain JSON-RPC instead of SSE format,
# required for Smithery scanner compatibility
mcp = FastMCP(
"Nextcloud MCP",
lifespan=app_lifespan_smithery,
json_response=True,
# Disable DNS rebinding protection for containerized deployments (k8s, Docker)
# MCP 1.23+ auto-enables this for localhost, breaking k8s service DNS names
transport_security=TransportSecuritySettings(
enable_dns_rebinding_protection=False
),
)
else:
logger.info("Configuring MCP server for BasicAuth mode")
mcp = FastMCP(
"Nextcloud MCP",
lifespan=app_lifespan_basic,
# Disable DNS rebinding protection for containerized deployments (k8s, Docker)
# MCP 1.23+ auto-enables this for localhost, breaking k8s service DNS names
transport_security=TransportSecuritySettings(
enable_dns_rebinding_protection=False
),
)
# BasicAuth modes (single-user or multi-user)
logger.info(f"Configuring MCP server for {mode.value} mode")
mcp = FastMCP(
"Nextcloud MCP",
lifespan=app_lifespan_basic,
# Disable DNS rebinding protection for containerized deployments (k8s, Docker)
# MCP 1.23+ auto-enables this for localhost, breaking k8s service DNS names
transport_security=TransportSecuritySettings(
enable_dns_rebinding_protection=False
),
)
@mcp.resource("nc://capabilities")
async def nc_get_capabilities():
@@ -1139,8 +1192,6 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
# Register semantic search tools (cross-app feature)
# ADR-016: Skip in Smithery stateless mode (no vector database)
settings = get_settings()
deployment_mode = get_deployment_mode()
if deployment_mode == DeploymentMode.SMITHERY_STATELESS:
logger.info("Skipping semantic search tools (Smithery stateless mode)")
elif settings.vector_sync_enabled:
@@ -1227,13 +1278,20 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
# Set OAuth context for OAuth login routes (ADR-004)
if oauth_enabled:
# Prepare OAuth config from setup_oauth_config closure variables
# Get nextcloud_host from settings (it was validated as required)
nextcloud_host_for_context = settings.nextcloud_host
if not nextcloud_host_for_context:
raise ValueError("NEXTCLOUD_HOST is required for OAuth mode")
mcp_server_url = os.getenv(
"NEXTCLOUD_MCP_SERVER_URL", "http://localhost:8000"
)
nextcloud_resource_uri = os.getenv("NEXTCLOUD_RESOURCE_URI", nextcloud_host)
nextcloud_resource_uri = os.getenv(
"NEXTCLOUD_RESOURCE_URI", nextcloud_host_for_context
)
discovery_url = os.getenv(
"OIDC_DISCOVERY_URL",
f"{nextcloud_host}/.well-known/openid-configuration",
f"{nextcloud_host_for_context}/.well-known/openid-configuration",
)
scopes = os.getenv("NEXTCLOUD_OIDC_SCOPES", "")
@@ -1247,7 +1305,7 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
"client_id": client_id, # From setup_oauth_config (DCR or static)
"client_secret": client_secret, # From setup_oauth_config (DCR or static)
"scopes": scopes,
"nextcloud_host": nextcloud_host,
"nextcloud_host": nextcloud_host_for_context,
"nextcloud_resource_uri": nextcloud_resource_uri,
"oauth_provider": oauth_provider,
},
@@ -1273,16 +1331,16 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
# BasicAuth mode - share storage with browser_app for webhook management
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
storage = RefreshTokenStorage.from_env()
await storage.initialize()
basic_auth_storage = RefreshTokenStorage.from_env()
await basic_auth_storage.initialize()
app.state.storage = storage
app.state.storage = basic_auth_storage
# Also share with browser_app for webhook routes
for route in app.routes:
if isinstance(route, Mount) and route.path == "/app":
browser_app = cast(Starlette, route.app)
browser_app.state.storage = storage
browser_app.state.storage = basic_auth_storage
logger.info(
"Storage shared with browser_app for webhook management"
)
@@ -1292,7 +1350,7 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
# Scanner runs at server-level (once), not per-session
import anyio as anyio_module
settings = get_settings()
# Re-use settings from outer scope (already validated)
# Check if vector sync is enabled and determine the mode
enable_offline_access_for_sync = os.getenv(
@@ -1300,7 +1358,13 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
).lower() in ("true", "1", "yes")
encryption_key = os.getenv("TOKEN_ENCRYPTION_KEY")
if settings.vector_sync_enabled and not oauth_enabled:
# Multi-user BasicAuth uses OAuth-style background sync (with app passwords)
# So skip single-user BasicAuth vector sync if in multi-user mode
if (
settings.vector_sync_enabled
and not oauth_enabled
and not settings.enable_multi_user_basic_auth
):
# BasicAuth mode - single user sync
logger.info("Starting background vector sync tasks for BasicAuth mode")
@@ -1400,13 +1464,15 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
elif (
settings.vector_sync_enabled
and oauth_enabled
and (oauth_enabled or settings.enable_multi_user_basic_auth)
and enable_offline_access_for_sync
and refresh_token_storage
and encryption_key
):
# OAuth mode with offline access - multi-user sync
logger.info("Starting background vector sync tasks for OAuth mode")
# Also used for multi-user BasicAuth mode (client auth is BasicAuth, background sync uses app passwords)
mode_desc = "OAuth mode" if oauth_enabled else "Multi-user BasicAuth mode"
logger.info(f"Starting background vector sync tasks for {mode_desc}")
from nextcloud_mcp_server.auth.token_broker import TokenBrokerService
from nextcloud_mcp_server.vector.oauth_sync import (
@@ -1414,10 +1480,15 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
user_manager_task,
)
# Get nextcloud_host (from settings - already validated)
nextcloud_host_for_sync = settings.nextcloud_host
if not nextcloud_host_for_sync:
raise ValueError("NEXTCLOUD_HOST required for vector sync")
# Get OIDC discovery URL (same as used for OAuth setup)
discovery_url = os.getenv(
"OIDC_DISCOVERY_URL",
f"{nextcloud_host}/.well-known/openid-configuration",
f"{nextcloud_host_for_sync}/.well-known/openid-configuration",
)
# Get client credentials from oauth_context (set by setup_oauth_config)
@@ -1428,6 +1499,11 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
sync_client_id = oauth_config.get("client_id")
sync_client_secret = oauth_config.get("client_secret")
# For multi-user BasicAuth mode, get OIDC credentials from environment
if not sync_client_id or not sync_client_secret:
sync_client_id = settings.oidc_client_id
sync_client_secret = settings.oidc_client_secret
if not sync_client_id or not sync_client_secret:
logger.error(
"Cannot start OAuth vector sync: client credentials not found in oauth_context"
@@ -2141,4 +2217,11 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
app = SmitheryConfigMiddleware(app)
logger.info("SmitheryConfigMiddleware enabled for query parameter config")
# Apply BasicAuthMiddleware for multi-user BasicAuth pass-through mode
if settings.enable_multi_user_basic_auth:
app = BasicAuthMiddleware(app)
logger.info(
"BasicAuthMiddleware enabled - multi-user BasicAuth pass-through mode active"
)
return app
+9
View File
@@ -187,6 +187,11 @@ class Settings:
enable_token_exchange: bool = False
enable_offline_access: bool = False
# Multi-user BasicAuth pass-through mode (ADR-019 interim solution)
# When enabled, MCP server extracts BasicAuth credentials from request headers
# and passes them through to Nextcloud APIs (no storage, stateless)
enable_multi_user_basic_auth: bool = False
# Token exchange cache settings
token_exchange_cache_ttl: int = 300 # seconds (5 minutes default)
@@ -376,6 +381,10 @@ def get_settings() -> Settings:
enable_offline_access=(
os.getenv("ENABLE_OFFLINE_ACCESS", "false").lower() == "true"
),
# Multi-user BasicAuth pass-through mode
enable_multi_user_basic_auth=(
os.getenv("ENABLE_MULTI_USER_BASIC_AUTH", "false").lower() == "true"
),
# Token exchange cache settings
token_exchange_cache_ttl=int(os.getenv("TOKEN_EXCHANGE_CACHE_TTL", "300")),
# Token and webhook storage settings (encryption key optional for webhook-only usage)
+432
View File
@@ -0,0 +1,432 @@
"""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 plan in /home/chris/.claude/plans/prancy-chasing-gizmo.md for architecture details.
"""
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": [
"oidc_client_id",
"oidc_client_secret",
"token_encryption_key",
"token_storage_db",
],
"vector_sync_enabled": [
# Requires offline access for background sync
"enable_offline_access",
],
},
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",
],
"vector_sync_enabled": [
"enable_offline_access", # Background sync requires refresh tokens
],
},
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",
],
"vector_sync_enabled": [
"enable_offline_access",
],
},
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 (most specific to most general):
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
"""
# Check for Smithery mode (explicit environment variable)
# Note: This checks the environment directly, not settings
# because Smithery mode has no settings-based config
import os
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:
# Validate that if offline access enabled, we have OAuth credentials
if settings.enable_offline_access:
if not settings.oidc_client_id or not settings.oidc_client_secret:
errors.append(
f"[{mode.value}] NEXTCLOUD_OIDC_CLIENT_ID and "
"NEXTCLOUD_OIDC_CLIENT_SECRET are required when "
"ENABLE_OFFLINE_ACCESS is enabled (for app password retrieval)"
)
# Validate vector sync requirements
if settings.vector_sync_enabled and not settings.enable_offline_access:
errors.append(
f"[{mode.value}] ENABLE_OFFLINE_ACCESS must be enabled when "
"VECTOR_SYNC_ENABLED is true (background sync requires "
"app passwords or refresh tokens)"
)
# 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)