feat: add self-signed SSL certificate support for Nextcloud connections

Add NEXTCLOUD_VERIFY_SSL and NEXTCLOUD_CA_BUNDLE env vars to configure
TLS certificate verification for all outbound Nextcloud connections.
Centralizes SSL config via a new HTTP client factory (http.py) used by
all 27 Nextcloud-bound call sites, including API clients, OIDC endpoints,
OAuth flows, and health checks.

Closes #560

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Chris Coutinho
2026-02-16 09:21:21 +01:00
parent df3cce4370
commit 1707b2e6e1
21 changed files with 383 additions and 38 deletions
+52
View File
@@ -171,6 +171,58 @@ NEXTCLOUD_PASSWORD=your_app_password_or_password
--- ---
## SSL/TLS Configuration (Optional)
If your Nextcloud instance uses a self-signed certificate or a private CA (common with reverse proxies like Traefik or Caddy), the MCP server will reject the connection by default. Use these settings to configure certificate verification.
### Custom CA Bundle (Recommended)
Point the server at your CA certificate file:
```dotenv
NEXTCLOUD_CA_BUNDLE=/etc/ssl/certs/my-ca.pem
```
With Docker, mount the certificate as a read-only volume:
```bash
docker run \
-v /path/to/my-ca.pem:/etc/ssl/certs/my-ca.pem:ro \
-e NEXTCLOUD_CA_BUNDLE=/etc/ssl/certs/my-ca.pem \
-e NEXTCLOUD_HOST=https://nextcloud.local \
--env-file .env \
ghcr.io/cbcoutinho/nextcloud-mcp-server:latest
```
### Disable Verification (Development Only)
> [!WARNING]
> Disabling TLS verification is insecure. Only use this for local development or testing.
```dotenv
NEXTCLOUD_VERIFY_SSL=false
```
### Environment Variables Reference
| Variable | Required | Default | Description |
|----------|----------|---------|-------------|
| `NEXTCLOUD_VERIFY_SSL` | ⚠️ Optional | `true` | Set to `false` to disable TLS certificate verification |
| `NEXTCLOUD_CA_BUNDLE` | ⚠️ Optional | - | Path to a PEM CA bundle file for custom certificate authorities |
### Scope
These settings apply to **all** outbound connections to Nextcloud and its OIDC endpoints, including:
- Nextcloud API calls (Notes, Calendar, Contacts, WebDAV, etc.)
- OIDC discovery and token endpoints
- OAuth client registration (DCR)
- Health checks
They do **not** affect connections to internal services (Ollama, Qdrant, Unstructured) which have their own SSL configuration.
---
## Semantic Search Configuration (Optional) ## Semantic Search Configuration (Optional)
**New in v0.58.0:** Simplified semantic search configuration with automatic dependency resolution. **New in v0.58.0:** Simplified semantic search configuration with automatic dependency resolution.
+13
View File
@@ -217,6 +217,19 @@ NEXTCLOUD_PASSWORD=
#CUSTOM_PROCESSOR_TIMEOUT=60 #CUSTOM_PROCESSOR_TIMEOUT=60
#CUSTOM_PROCESSOR_TYPES=application/pdf,image/jpeg,image/png #CUSTOM_PROCESSOR_TYPES=application/pdf,image/jpeg,image/png
# ===== SSL/TLS =====
# For Nextcloud behind reverse proxies with self-signed or private CA certificates
#
# Disable TLS certificate verification (insecure, development only):
#NEXTCLOUD_VERIFY_SSL=false
#
# Use a custom CA bundle (path to PEM file):
#NEXTCLOUD_CA_BUNDLE=/etc/ssl/certs/my-ca.pem
#
# Docker example: mount the CA bundle as a volume
# docker run -v /path/to/ca.pem:/etc/ssl/certs/my-ca.pem:ro \
# -e NEXTCLOUD_CA_BUNDLE=/etc/ssl/certs/my-ca.pem ...
# ===== SECURITY & ADVANCED ===== # ===== SECURITY & ADVANCED =====
# Cookie security (browser UI) # Cookie security (browser UI)
# Auto-detects from NEXTCLOUD_HOST protocol if not set # Auto-detects from NEXTCLOUD_HOST protocol if not set
+8 -2
View File
@@ -21,6 +21,8 @@ import httpx
from starlette.requests import Request from starlette.requests import Request
from starlette.responses import JSONResponse from starlette.responses import JSONResponse
from ..http import nextcloud_httpx_client
if TYPE_CHECKING: if TYPE_CHECKING:
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
@@ -252,7 +254,9 @@ async def provision_app_password(request: Request) -> JSONResponse:
# Validate app password against Nextcloud # Validate app password against Nextcloud
try: try:
async with httpx.AsyncClient(timeout=NEXTCLOUD_VALIDATION_TIMEOUT) as client: async with nextcloud_httpx_client(
timeout=NEXTCLOUD_VALIDATION_TIMEOUT
) as client:
# Use OCS API to verify credentials # Use OCS API to verify credentials
test_url = f"{nextcloud_host}/ocs/v1.php/cloud/user" test_url = f"{nextcloud_host}/ocs/v1.php/cloud/user"
response = await client.get( response = await client.get(
@@ -380,7 +384,9 @@ async def delete_app_password(request: Request) -> JSONResponse:
nextcloud_host = settings.nextcloud_host nextcloud_host = settings.nextcloud_host
try: try:
async with httpx.AsyncClient(timeout=NEXTCLOUD_VALIDATION_TIMEOUT) as client: async with nextcloud_httpx_client(
timeout=NEXTCLOUD_VALIDATION_TIMEOUT
) as client:
test_url = f"{nextcloud_host}/ocs/v1.php/cloud/user" test_url = f"{nextcloud_host}/ocs/v1.php/cloud/user"
response = await client.get( response = await client.get(
test_url, test_url,
+6 -5
View File
@@ -10,7 +10,6 @@ All endpoints require OAuth bearer token authentication via UnifiedTokenVerifier
import logging import logging
import httpx
from starlette.requests import Request from starlette.requests import Request
from starlette.responses import JSONResponse from starlette.responses import JSONResponse
@@ -20,6 +19,8 @@ from nextcloud_mcp_server.api.management import (
validate_token_and_get_user, validate_token_and_get_user,
) )
from ..http import nextcloud_httpx_client
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -57,7 +58,7 @@ async def get_installed_apps(request: Request) -> JSONResponse:
raise ValueError("Nextcloud host not configured") raise ValueError("Nextcloud host not configured")
# Create authenticated HTTP client # Create authenticated HTTP client
async with httpx.AsyncClient( async with nextcloud_httpx_client(
base_url=nextcloud_host, base_url=nextcloud_host,
headers={"Authorization": f"Bearer {token}"}, headers={"Authorization": f"Bearer {token}"},
timeout=30.0, timeout=30.0,
@@ -129,7 +130,7 @@ async def list_webhooks(request: Request) -> JSONResponse:
raise ValueError("Nextcloud host not configured") raise ValueError("Nextcloud host not configured")
# Create authenticated HTTP client # Create authenticated HTTP client
async with httpx.AsyncClient( async with nextcloud_httpx_client(
base_url=nextcloud_host, base_url=nextcloud_host,
headers={"Authorization": f"Bearer {token}"}, headers={"Authorization": f"Bearer {token}"},
timeout=30.0, timeout=30.0,
@@ -210,7 +211,7 @@ async def create_webhook(request: Request) -> JSONResponse:
raise ValueError("Nextcloud host not configured") raise ValueError("Nextcloud host not configured")
# Create authenticated HTTP client # Create authenticated HTTP client
async with httpx.AsyncClient( async with nextcloud_httpx_client(
base_url=nextcloud_host, base_url=nextcloud_host,
headers={"Authorization": f"Bearer {token}"}, headers={"Authorization": f"Bearer {token}"},
timeout=30.0, timeout=30.0,
@@ -286,7 +287,7 @@ async def delete_webhook(request: Request) -> JSONResponse:
raise ValueError("Nextcloud host not configured") raise ValueError("Nextcloud host not configured")
# Create authenticated HTTP client # Create authenticated HTTP client
async with httpx.AsyncClient( async with nextcloud_httpx_client(
base_url=nextcloud_host, base_url=nextcloud_host,
headers={"Authorization": f"Bearer {token}"}, headers={"Authorization": f"Bearer {token}"},
timeout=30.0, timeout=30.0,
+4 -3
View File
@@ -58,6 +58,7 @@ from nextcloud_mcp_server.config_validators import (
) )
from nextcloud_mcp_server.context import get_client as get_nextcloud_client 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.document_processors import get_registry
from nextcloud_mcp_server.http import nextcloud_httpx_client
from nextcloud_mcp_server.observability import ( from nextcloud_mcp_server.observability import (
ObservabilityMiddleware, ObservabilityMiddleware,
setup_metrics, setup_metrics,
@@ -690,7 +691,7 @@ async def setup_oauth_config():
logger.info(f"Performing OIDC discovery: {discovery_url}") logger.info(f"Performing OIDC discovery: {discovery_url}")
# Perform OIDC discovery # Perform OIDC discovery
async with httpx.AsyncClient(follow_redirects=True) as client: async with nextcloud_httpx_client(follow_redirects=True) as client:
response = await client.get(discovery_url) response = await client.get(discovery_url)
response.raise_for_status() response.raise_for_status()
discovery = response.json() discovery = response.json()
@@ -994,7 +995,7 @@ async def setup_oauth_config_for_multi_user_basic(
# Perform OIDC discovery # Perform OIDC discovery
try: try:
async with httpx.AsyncClient( async with nextcloud_httpx_client(
timeout=30.0, follow_redirects=True timeout=30.0, follow_redirects=True
) as http_client: ) as http_client:
response = await http_client.get(discovery_url) response = await http_client.get(discovery_url)
@@ -1975,7 +1976,7 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
# Try to connect to Nextcloud # Try to connect to Nextcloud
start_time = time.time() start_time = time.time()
try: try:
async with httpx.AsyncClient(timeout=2.0) as client: async with nextcloud_httpx_client(timeout=2.0) as client:
response = await client.get(f"{nextcloud_host}/status.php") response = await client.get(f"{nextcloud_host}/status.php")
duration = time.time() - start_time duration = time.time() - start_time
if response.status_code == 200: if response.status_code == 200:
@@ -9,7 +9,7 @@ import logging
import time import time
from typing import Optional from typing import Optional
import httpx from ..http import nextcloud_httpx_client
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -60,7 +60,7 @@ class AstrolabeClient:
# Discover token endpoint # Discover token endpoint
discovery_url = f"{self.nextcloud_host}/.well-known/openid-configuration" discovery_url = f"{self.nextcloud_host}/.well-known/openid-configuration"
async with httpx.AsyncClient() as client: async with nextcloud_httpx_client() as client:
logger.debug(f"Discovering token endpoint from {discovery_url}") logger.debug(f"Discovering token endpoint from {discovery_url}")
discovery_resp = await client.get(discovery_url) discovery_resp = await client.get(discovery_url)
discovery_resp.raise_for_status() discovery_resp.raise_for_status()
@@ -107,7 +107,7 @@ class AstrolabeClient:
token = await self.get_access_token() token = await self.get_access_token()
url = f"{self.nextcloud_host}/apps/astrolabe/api/v1/background-sync/credentials/{user_id}" url = f"{self.nextcloud_host}/apps/astrolabe/api/v1/background-sync/credentials/{user_id}"
async with httpx.AsyncClient() as client: async with nextcloud_httpx_client() as client:
logger.debug(f"Retrieving app password for user: {user_id}") logger.debug(f"Retrieving app password for user: {user_id}")
response = await client.get( response = await client.get(
@@ -22,6 +22,8 @@ from nextcloud_mcp_server.auth.userinfo_routes import (
_query_idp_userinfo, _query_idp_userinfo,
) )
from ..http import nextcloud_httpx_client
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -142,7 +144,7 @@ async def oauth_login(request: Request) -> RedirectResponse | JSONResponse:
) )
# Fetch authorization endpoint # Fetch authorization endpoint
async with httpx.AsyncClient() as http_client: async with nextcloud_httpx_client() as http_client:
response = await http_client.get(discovery_url) response = await http_client.get(discovery_url)
response.raise_for_status() response.raise_for_status()
discovery = response.json() discovery = response.json()
@@ -286,7 +288,7 @@ async def oauth_login_callback(request: Request) -> RedirectResponse | HTMLRespo
if code_verifier: if code_verifier:
token_params["code_verifier"] = code_verifier token_params["code_verifier"] = code_verifier
async with httpx.AsyncClient() as http_client: async with nextcloud_httpx_client() as http_client:
response = await http_client.post( response = await http_client.post(
oauth_client.token_endpoint, oauth_client.token_endpoint,
data=token_params, data=token_params,
@@ -296,7 +298,7 @@ async def oauth_login_callback(request: Request) -> RedirectResponse | HTMLRespo
else: else:
# Integrated mode (Nextcloud OIDC) # Integrated mode (Nextcloud OIDC)
discovery_url = oauth_config.get("discovery_url") discovery_url = oauth_config.get("discovery_url")
async with httpx.AsyncClient() as http_client: async with nextcloud_httpx_client() as http_client:
response = await http_client.get(discovery_url) response = await http_client.get(discovery_url)
response.raise_for_status() response.raise_for_status()
discovery = response.json() discovery = response.json()
@@ -314,7 +316,7 @@ async def oauth_login_callback(request: Request) -> RedirectResponse | HTMLRespo
if code_verifier: if code_verifier:
token_params["code_verifier"] = code_verifier token_params["code_verifier"] = code_verifier
async with httpx.AsyncClient() as http_client: async with nextcloud_httpx_client() as http_client:
response = await http_client.post( response = await http_client.post(
token_endpoint, token_endpoint,
data=token_params, data=token_params,
@@ -10,6 +10,8 @@ import httpx
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
from ..http import nextcloud_httpx_client
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -132,7 +134,7 @@ async def register_client(
logger.info(f"Registering OAuth client with Nextcloud: {client_name}") logger.info(f"Registering OAuth client with Nextcloud: {client_name}")
logger.debug(f"Registration endpoint: {registration_endpoint}") logger.debug(f"Registration endpoint: {registration_endpoint}")
async with httpx.AsyncClient(timeout=30.0) as client: async with nextcloud_httpx_client(timeout=30.0) as client:
try: try:
response = await client.post( response = await client.post(
registration_endpoint, registration_endpoint,
@@ -229,7 +231,7 @@ async def delete_client(
logger.info(f"Deleting OAuth client: {client_id[:16]}...") logger.info(f"Deleting OAuth client: {client_id[:16]}...")
logger.debug(f"Deletion endpoint: {deletion_endpoint}") logger.debug(f"Deletion endpoint: {deletion_endpoint}")
async with httpx.AsyncClient(timeout=30.0) as http_client: async with nextcloud_httpx_client(timeout=30.0) as http_client:
for attempt in range(max_retries): for attempt in range(max_retries):
try: try:
# Prefer RFC 7592 Bearer token authentication # Prefer RFC 7592 Bearer token authentication
+3 -1
View File
@@ -18,6 +18,8 @@ from urllib.parse import urlencode, urlparse
import httpx import httpx
from ..http import nextcloud_httpx_client
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -107,7 +109,7 @@ class KeycloakOAuthClient:
async def _get_http_client(self) -> httpx.AsyncClient: async def _get_http_client(self) -> httpx.AsyncClient:
"""Get or create HTTP client""" """Get or create HTTP client"""
if self._http_client is None: if self._http_client is None:
self._http_client = httpx.AsyncClient(timeout=30.0) self._http_client = nextcloud_httpx_client(timeout=30.0)
return self._http_client return self._http_client
async def close(self) -> None: async def close(self) -> None:
+6 -5
View File
@@ -27,7 +27,6 @@ import time
from base64 import urlsafe_b64encode from base64 import urlsafe_b64encode
from urllib.parse import urlencode from urllib.parse import urlencode
import httpx
import jwt import jwt
from starlette.requests import Request from starlette.requests import Request
from starlette.responses import JSONResponse, RedirectResponse from starlette.responses import JSONResponse, RedirectResponse
@@ -35,6 +34,8 @@ from starlette.responses import JSONResponse, RedirectResponse
from nextcloud_mcp_server.auth.client_registry import get_client_registry from nextcloud_mcp_server.auth.client_registry import get_client_registry
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
from ..http import nextcloud_httpx_client
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -218,7 +219,7 @@ async def oauth_authorize(request: Request) -> RedirectResponse | JSONResponse:
) )
# Fetch authorization endpoint from discovery # Fetch authorization endpoint from discovery
async with httpx.AsyncClient() as http_client: async with nextcloud_httpx_client() as http_client:
response = await http_client.get(discovery_url) response = await http_client.get(discovery_url)
response.raise_for_status() response.raise_for_status()
discovery = response.json() discovery = response.json()
@@ -354,7 +355,7 @@ async def oauth_authorize_nextcloud(
status_code=500, status_code=500,
) )
async with httpx.AsyncClient() as http_client: async with nextcloud_httpx_client() as http_client:
response = await http_client.get(discovery_url) response = await http_client.get(discovery_url)
response.raise_for_status() response.raise_for_status()
discovery = response.json() discovery = response.json()
@@ -462,7 +463,7 @@ async def oauth_callback_nextcloud(request: Request):
callback_uri = f"{mcp_server_url}/oauth/callback" callback_uri = f"{mcp_server_url}/oauth/callback"
discovery_url = oauth_config.get("discovery_url") discovery_url = oauth_config.get("discovery_url")
async with httpx.AsyncClient() as http_client: async with nextcloud_httpx_client() as http_client:
response = await http_client.get(discovery_url) response = await http_client.get(discovery_url)
response.raise_for_status() response.raise_for_status()
discovery = response.json() discovery = response.json()
@@ -482,7 +483,7 @@ async def oauth_callback_nextcloud(request: Request):
token_params["code_verifier"] = code_verifier token_params["code_verifier"] = code_verifier
# Exchange code for tokens # Exchange code for tokens
async with httpx.AsyncClient() as http_client: async with nextcloud_httpx_client() as http_client:
response = await http_client.post( response = await http_client.post(
token_endpoint, token_endpoint,
data=token_params, data=token_params,
+3 -1
View File
@@ -25,6 +25,8 @@ import jwt
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
from nextcloud_mcp_server.auth.token_exchange import exchange_token_for_delegation from nextcloud_mcp_server.auth.token_exchange import exchange_token_for_delegation
from ..http import nextcloud_httpx_client
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -136,7 +138,7 @@ class TokenBrokerService:
async def _get_http_client(self) -> httpx.AsyncClient: async def _get_http_client(self) -> httpx.AsyncClient:
"""Get or create HTTP client.""" """Get or create HTTP client."""
if self._http_client is None: if self._http_client is None:
self._http_client = httpx.AsyncClient( self._http_client = nextcloud_httpx_client(
timeout=httpx.Timeout(30.0), follow_redirects=True timeout=httpx.Timeout(30.0), follow_redirects=True
) )
return self._http_client return self._http_client
+2 -1
View File
@@ -20,6 +20,7 @@ import httpx
import jwt import jwt
from ..config import get_settings from ..config import get_settings
from ..http import nextcloud_httpx_client
from .storage import RefreshTokenStorage from .storage import RefreshTokenStorage
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -68,7 +69,7 @@ class TokenExchangeService:
self.storage: Optional[RefreshTokenStorage] = None self.storage: Optional[RefreshTokenStorage] = None
# Create HTTP client # Create HTTP client
self.http_client = httpx.AsyncClient( self.http_client = nextcloud_httpx_client(
timeout=30.0, timeout=30.0,
follow_redirects=True, follow_redirects=True,
) )
@@ -31,6 +31,8 @@ from nextcloud_mcp_server.observability.metrics import (
record_oauth_token_validation, record_oauth_token_validation,
) )
from ..http import nextcloud_httpx_client
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -61,7 +63,7 @@ class UnifiedTokenVerifier(TokenVerifier):
self.mode = "exchange" if settings.enable_token_exchange else "multi-audience" self.mode = "exchange" if settings.enable_token_exchange else "multi-audience"
# Common components for all modes # Common components for all modes
self.http_client = httpx.AsyncClient(timeout=10.0) self.http_client = nextcloud_httpx_client(timeout=10.0)
# JWT verification support # JWT verification support
self.jwks_client: PyJWKClient | None = None self.jwks_client: PyJWKClient | None = None
+4 -3
View File
@@ -13,7 +13,6 @@ import traceback
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
import httpx
from jinja2 import Environment, FileSystemLoader from jinja2 import Environment, FileSystemLoader
from starlette.authentication import requires from starlette.authentication import requires
from starlette.requests import Request from starlette.requests import Request
@@ -22,6 +21,8 @@ from starlette.responses import HTMLResponse, JSONResponse
from nextcloud_mcp_server.client import NextcloudClient from nextcloud_mcp_server.client import NextcloudClient
from nextcloud_mcp_server.config import get_settings from nextcloud_mcp_server.config import get_settings
from ..http import nextcloud_httpx_client
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Setup Jinja2 environment for templates # Setup Jinja2 environment for templates
@@ -257,7 +258,7 @@ async def _get_userinfo_endpoint(oauth_ctx: dict[str, Any]) -> str | None:
return None return None
try: try:
async with httpx.AsyncClient(timeout=10.0) as client: async with nextcloud_httpx_client(timeout=10.0) as client:
response = await client.get(discovery_url) response = await client.get(discovery_url)
response.raise_for_status() response.raise_for_status()
discovery = response.json() discovery = response.json()
@@ -290,7 +291,7 @@ async def _query_idp_userinfo(
User info dictionary from IdP, or None if query fails User info dictionary from IdP, or None if query fails
""" """
try: try:
async with httpx.AsyncClient(timeout=10.0) as client: async with nextcloud_httpx_client(timeout=10.0) as client:
response = await client.get( response = await client.get(
userinfo_uri, userinfo_uri,
headers={"Authorization": f"Bearer {access_token_str}"}, headers={"Authorization": f"Bearer {access_token_str}"},
+4 -2
View File
@@ -20,6 +20,8 @@ from nextcloud_mcp_server.server.webhook_presets import (
get_preset, get_preset,
) )
from ..http import nextcloud_httpx_client
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -140,7 +142,7 @@ async def _get_authenticated_client(request: Request) -> httpx.AsyncClient:
assert nextcloud_host is not None # Type narrowing for type checker assert nextcloud_host is not None # Type narrowing for type checker
assert username is not None and password is not None # Type narrowing assert username is not None and password is not None # Type narrowing
return httpx.AsyncClient( return nextcloud_httpx_client(
base_url=nextcloud_host, base_url=nextcloud_host,
auth=(username, password), auth=(username, password),
timeout=30.0, timeout=30.0,
@@ -163,7 +165,7 @@ async def _get_authenticated_client(request: Request) -> httpx.AsyncClient:
if not nextcloud_host: if not nextcloud_host:
raise RuntimeError("Nextcloud host not configured") raise RuntimeError("Nextcloud host not configured")
return httpx.AsyncClient( return nextcloud_httpx_client(
base_url=nextcloud_host, base_url=nextcloud_host,
headers={"Authorization": f"Bearer {access_token}"}, headers={"Authorization": f"Bearer {access_token}"},
timeout=30.0, timeout=30.0,
+2 -2
View File
@@ -4,7 +4,6 @@ import os
from httpx import ( from httpx import (
AsyncBaseTransport, AsyncBaseTransport,
AsyncClient, AsyncClient,
AsyncHTTPTransport,
Auth, Auth,
BasicAuth, BasicAuth,
Request, Request,
@@ -13,6 +12,7 @@ from httpx import (
) )
from ..controllers.notes_search import NotesSearchController from ..controllers.notes_search import NotesSearchController
from ..http import nextcloud_httpx_transport
from .calendar import CalendarClient from .calendar import CalendarClient
from .contacts import ContactsClient from .contacts import ContactsClient
from .cookbook import CookbookClient from .cookbook import CookbookClient
@@ -67,7 +67,7 @@ class NextcloudClient:
self._client = AsyncClient( self._client = AsyncClient(
base_url=base_url, base_url=base_url,
auth=auth, auth=auth,
transport=AsyncDisableCookieTransport(AsyncHTTPTransport()), transport=AsyncDisableCookieTransport(nextcloud_httpx_transport()),
event_hooks={"request": [log_request], "response": [log_response]}, event_hooks={"request": [log_request], "response": [log_response]},
timeout=Timeout(timeout=30, connect=5), timeout=Timeout(timeout=30, connect=5),
) )
+3
View File
@@ -13,6 +13,8 @@ from icalendar import Alarm, Calendar, vRecur
from icalendar import Event as ICalEvent from icalendar import Event as ICalEvent
from icalendar import Todo as ICalTodo from icalendar import Todo as ICalTodo
from ..config import get_nextcloud_ssl_verify
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -34,6 +36,7 @@ class CalendarClient:
url=f"{base_url}/remote.php/dav/", url=f"{base_url}/remote.php/dav/",
username=username, username=username,
auth=auth, auth=auth,
ssl_verify_cert=get_nextcloud_ssl_verify(),
) )
self._calendar_home_url = f"{base_url}/remote.php/dav/calendars/{username}/" self._calendar_home_url = f"{base_url}/remote.php/dav/calendars/{username}/"
+42 -1
View File
@@ -181,6 +181,10 @@ class Settings:
nextcloud_username: Optional[str] = None nextcloud_username: Optional[str] = None
nextcloud_password: Optional[str] = None nextcloud_password: Optional[str] = None
# Nextcloud SSL/TLS settings
nextcloud_verify_ssl: bool = True
nextcloud_ca_bundle: Optional[str] = None
# ADR-005: Token Audience Validation (required for OAuth mode) # ADR-005: Token Audience Validation (required for OAuth mode)
nextcloud_mcp_server_url: Optional[str] = None # MCP server URL (used as audience) nextcloud_mcp_server_url: Optional[str] = None # MCP server URL (used as audience)
nextcloud_resource_uri: Optional[str] = None # Nextcloud resource identifier nextcloud_resource_uri: Optional[str] = None # Nextcloud resource identifier
@@ -252,9 +256,25 @@ class Settings:
log_include_trace_context: bool = True log_include_trace_context: bool = True
def __post_init__(self): def __post_init__(self):
"""Validate Qdrant configuration and set defaults.""" """Validate configuration and set defaults."""
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Validate SSL/TLS configuration
if not self.nextcloud_verify_ssl:
logger.warning(
"NEXTCLOUD_VERIFY_SSL is disabled. "
"TLS certificate verification is turned off for all Nextcloud connections. "
"This is insecure and should only be used for development/testing."
)
if self.nextcloud_ca_bundle:
import os as _os
if not _os.path.isfile(self.nextcloud_ca_bundle):
raise ValueError(
f"NEXTCLOUD_CA_BUNDLE path does not exist: {self.nextcloud_ca_bundle}"
)
logger.info("Using custom CA bundle: %s", self.nextcloud_ca_bundle)
# Ensure mutual exclusivity # Ensure mutual exclusivity
if self.qdrant_url and self.qdrant_location: if self.qdrant_url and self.qdrant_location:
raise ValueError( raise ValueError(
@@ -504,6 +524,11 @@ def get_settings() -> Settings:
nextcloud_host=os.getenv("NEXTCLOUD_HOST"), nextcloud_host=os.getenv("NEXTCLOUD_HOST"),
nextcloud_username=os.getenv("NEXTCLOUD_USERNAME"), nextcloud_username=os.getenv("NEXTCLOUD_USERNAME"),
nextcloud_password=os.getenv("NEXTCLOUD_PASSWORD"), nextcloud_password=os.getenv("NEXTCLOUD_PASSWORD"),
# Nextcloud SSL/TLS settings
nextcloud_verify_ssl=(
os.getenv("NEXTCLOUD_VERIFY_SSL", "true").lower() == "true"
),
nextcloud_ca_bundle=os.getenv("NEXTCLOUD_CA_BUNDLE"),
# ADR-005: Token Audience Validation # ADR-005: Token Audience Validation
nextcloud_mcp_server_url=os.getenv("NEXTCLOUD_MCP_SERVER_URL"), nextcloud_mcp_server_url=os.getenv("NEXTCLOUD_MCP_SERVER_URL"),
nextcloud_resource_uri=os.getenv("NEXTCLOUD_RESOURCE_URI"), nextcloud_resource_uri=os.getenv("NEXTCLOUD_RESOURCE_URI"),
@@ -569,3 +594,19 @@ def get_settings() -> Settings:
log_include_trace_context=os.getenv("LOG_INCLUDE_TRACE_CONTEXT", "true").lower() log_include_trace_context=os.getenv("LOG_INCLUDE_TRACE_CONTEXT", "true").lower()
== "true", == "true",
) )
def get_nextcloud_ssl_verify() -> bool | str:
"""Return the SSL verification setting for Nextcloud connections.
Returns:
- False if NEXTCLOUD_VERIFY_SSL=false (disable verification)
- CA bundle path if NEXTCLOUD_CA_BUNDLE is set (custom CA)
- True otherwise (default system CA verification)
"""
settings = get_settings()
if not settings.nextcloud_verify_ssl:
return False
if settings.nextcloud_ca_bundle:
return settings.nextcloud_ca_bundle
return True
+45
View File
@@ -0,0 +1,45 @@
"""Centralized HTTP client factory for Nextcloud connections.
All outbound connections to Nextcloud (API calls, OIDC endpoints) should use
these factories to ensure consistent SSL/TLS configuration from environment
variables (NEXTCLOUD_VERIFY_SSL, NEXTCLOUD_CA_BUNDLE).
"""
from typing import Any
import httpx
from .config import get_nextcloud_ssl_verify
def nextcloud_httpx_client(**kwargs: Any) -> httpx.AsyncClient:
"""Create an httpx.AsyncClient with Nextcloud SSL settings applied.
Reads NEXTCLOUD_VERIFY_SSL and NEXTCLOUD_CA_BUNDLE from the environment
via ``get_nextcloud_ssl_verify()``. Caller-supplied ``verify`` kwarg
takes precedence if explicitly provided.
Args:
**kwargs: Forwarded to ``httpx.AsyncClient()``.
Returns:
Configured ``httpx.AsyncClient``.
"""
kwargs.setdefault("verify", get_nextcloud_ssl_verify())
return httpx.AsyncClient(**kwargs)
def nextcloud_httpx_transport(**kwargs: Any) -> httpx.AsyncHTTPTransport:
"""Create an httpx.AsyncHTTPTransport with Nextcloud SSL settings applied.
Used by ``NextcloudClient`` which wraps the transport in
``AsyncDisableCookieTransport``.
Args:
**kwargs: Forwarded to ``httpx.AsyncHTTPTransport()``.
Returns:
Configured ``httpx.AsyncHTTPTransport``.
"""
kwargs.setdefault("verify", get_nextcloud_ssl_verify())
return httpx.AsyncHTTPTransport(**kwargs)
+3 -2
View File
@@ -11,7 +11,6 @@ import secrets
from typing import Optional from typing import Optional
from urllib.parse import urlencode from urllib.parse import urlencode
import httpx
import jwt import jwt
from mcp.server.auth.middleware.auth_context import get_access_token from mcp.server.auth.middleware.auth_context import get_access_token
from mcp.server.auth.provider import AccessToken from mcp.server.auth.provider import AccessToken
@@ -24,6 +23,8 @@ from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
from nextcloud_mcp_server.auth.token_broker import TokenBrokerService from nextcloud_mcp_server.auth.token_broker import TokenBrokerService
from nextcloud_mcp_server.auth.userinfo_routes import _query_idp_userinfo from nextcloud_mcp_server.auth.userinfo_routes import _query_idp_userinfo
from ..http import nextcloud_httpx_client
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -69,7 +70,7 @@ async def extract_user_id_from_token(ctx: Context) -> str:
"OIDC_DISCOVERY_URI", "OIDC_DISCOVERY_URI",
"http://localhost:8080/.well-known/openid-configuration", "http://localhost:8080/.well-known/openid-configuration",
) )
async with httpx.AsyncClient() as http_client: async with nextcloud_httpx_client() as http_client:
discovery_response = await http_client.get(oidc_discovery_uri) discovery_response = await http_client.get(oidc_discovery_uri)
discovery_response.raise_for_status() discovery_response.raise_for_status()
discovery = discovery_response.json() discovery = discovery_response.json()
+167
View File
@@ -0,0 +1,167 @@
"""Tests for SSL/TLS configuration (NEXTCLOUD_VERIFY_SSL, NEXTCLOUD_CA_BUNDLE)."""
import logging
import os
from unittest.mock import patch
import httpx
import pytest
from nextcloud_mcp_server.config import Settings, get_nextcloud_ssl_verify, get_settings
from nextcloud_mcp_server.http import nextcloud_httpx_client, nextcloud_httpx_transport
class TestSSLSettings:
"""Test SSL/TLS fields on Settings dataclass."""
def test_defaults(self):
"""verify_ssl defaults to True, ca_bundle defaults to None."""
settings = Settings()
assert settings.nextcloud_verify_ssl is True
assert settings.nextcloud_ca_bundle is None
def test_verify_ssl_false_logs_warning(self, caplog):
caplog.set_level(logging.WARNING, logger="nextcloud_mcp_server.config")
Settings(nextcloud_verify_ssl=False)
assert "NEXTCLOUD_VERIFY_SSL is disabled" in caplog.text
def test_ca_bundle_nonexistent_path_raises(self):
with pytest.raises(ValueError, match="does not exist"):
Settings(nextcloud_ca_bundle="/nonexistent/path/ca.pem")
def test_ca_bundle_existing_path_logs_info(self, caplog, tmp_path):
ca_file = tmp_path / "ca.pem"
ca_file.write_text(
"-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----\n"
)
caplog.set_level(logging.INFO, logger="nextcloud_mcp_server.config")
settings = Settings(nextcloud_ca_bundle=str(ca_file))
assert settings.nextcloud_ca_bundle == str(ca_file)
assert "Using custom CA bundle" in caplog.text
class TestGetNextcloudSSLVerify:
"""Test the get_nextcloud_ssl_verify() helper function."""
def test_default_returns_true(self):
env = {
"NEXTCLOUD_VERIFY_SSL": "true",
}
with patch.dict(os.environ, env, clear=False):
# Clear any cached settings
result = get_nextcloud_ssl_verify()
assert result is True
def test_verify_false_returns_false(self):
env = {
"NEXTCLOUD_VERIFY_SSL": "false",
}
with patch.dict(os.environ, env, clear=False):
with patch(
"nextcloud_mcp_server.config.get_settings",
return_value=Settings(nextcloud_verify_ssl=False),
):
result = get_nextcloud_ssl_verify()
assert result is False
def test_ca_bundle_returns_path(self, tmp_path):
ca_file = tmp_path / "ca.pem"
ca_file.write_text(
"-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----\n"
)
with patch(
"nextcloud_mcp_server.config.get_settings",
return_value=Settings(nextcloud_ca_bundle=str(ca_file)),
):
result = get_nextcloud_ssl_verify()
assert result == str(ca_file)
def test_verify_false_takes_precedence_over_ca_bundle(self, tmp_path):
"""When verify_ssl=False, ca_bundle is ignored (False wins)."""
ca_file = tmp_path / "ca.pem"
ca_file.write_text(
"-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----\n"
)
with patch(
"nextcloud_mcp_server.config.get_settings",
return_value=Settings(
nextcloud_verify_ssl=False,
nextcloud_ca_bundle=str(ca_file),
),
):
result = get_nextcloud_ssl_verify()
assert result is False
class TestGetSettingsSSLEnvVars:
"""Test that get_settings() reads SSL env vars correctly."""
def test_verify_ssl_env_true(self):
env = {"NEXTCLOUD_VERIFY_SSL": "true"}
with patch.dict(os.environ, env, clear=False):
settings = get_settings()
assert settings.nextcloud_verify_ssl is True
def test_verify_ssl_env_false(self):
env = {"NEXTCLOUD_VERIFY_SSL": "false"}
with patch.dict(os.environ, env, clear=False):
settings = get_settings()
assert settings.nextcloud_verify_ssl is False
def test_verify_ssl_env_missing_defaults_true(self):
with patch.dict(os.environ, {}, clear=False):
# Remove NEXTCLOUD_VERIFY_SSL if it exists
os.environ.pop("NEXTCLOUD_VERIFY_SSL", None)
settings = get_settings()
assert settings.nextcloud_verify_ssl is True
def test_ca_bundle_env(self, tmp_path):
ca_file = tmp_path / "ca.pem"
ca_file.write_text(
"-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----\n"
)
env = {"NEXTCLOUD_CA_BUNDLE": str(ca_file)}
with patch.dict(os.environ, env, clear=False):
settings = get_settings()
assert settings.nextcloud_ca_bundle == str(ca_file)
class TestHTTPClientFactory:
"""Test that factory functions apply verify correctly."""
def test_client_applies_verify_true(self):
with patch(
"nextcloud_mcp_server.http.get_nextcloud_ssl_verify", return_value=True
):
client = nextcloud_httpx_client()
# httpx stores verify as an SSLConfig; check the _transport
assert isinstance(client, httpx.AsyncClient)
def test_client_applies_verify_false(self):
with patch(
"nextcloud_mcp_server.http.get_nextcloud_ssl_verify", return_value=False
):
client = nextcloud_httpx_client()
assert isinstance(client, httpx.AsyncClient)
def test_client_caller_override_takes_precedence(self):
"""Caller-supplied verify kwarg should not be overridden."""
with patch(
"nextcloud_mcp_server.http.get_nextcloud_ssl_verify", return_value=True
):
client = nextcloud_httpx_client(verify=False)
assert isinstance(client, httpx.AsyncClient)
def test_transport_applies_verify(self):
with patch(
"nextcloud_mcp_server.http.get_nextcloud_ssl_verify", return_value=False
):
transport = nextcloud_httpx_transport()
assert isinstance(transport, httpx.AsyncHTTPTransport)
def test_client_passes_extra_kwargs(self):
with patch(
"nextcloud_mcp_server.http.get_nextcloud_ssl_verify", return_value=True
):
client = nextcloud_httpx_client(timeout=5.0, follow_redirects=True)
assert isinstance(client, httpx.AsyncClient)