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:
co-authored by
Claude Sonnet 4.5
parent
8682fa4f88
commit
4507359760
+171
-88
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user