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:
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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}"},
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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}/"
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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()
|
||||||
|
|||||||
@@ -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)
|
||||||
Reference in New Issue
Block a user