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
co-authored by Claude Opus 4.6
parent df3cce4370
commit 1707b2e6e1
21 changed files with 383 additions and 38 deletions
@@ -9,7 +9,7 @@ import logging
import time
from typing import Optional
import httpx
from ..http import nextcloud_httpx_client
logger = logging.getLogger(__name__)
@@ -60,7 +60,7 @@ class AstrolabeClient:
# Discover token endpoint
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}")
discovery_resp = await client.get(discovery_url)
discovery_resp.raise_for_status()
@@ -107,7 +107,7 @@ class AstrolabeClient:
token = await self.get_access_token()
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}")
response = await client.get(
@@ -22,6 +22,8 @@ from nextcloud_mcp_server.auth.userinfo_routes import (
_query_idp_userinfo,
)
from ..http import nextcloud_httpx_client
logger = logging.getLogger(__name__)
@@ -142,7 +144,7 @@ async def oauth_login(request: Request) -> RedirectResponse | JSONResponse:
)
# 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.raise_for_status()
discovery = response.json()
@@ -286,7 +288,7 @@ async def oauth_login_callback(request: Request) -> RedirectResponse | HTMLRespo
if 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(
oauth_client.token_endpoint,
data=token_params,
@@ -296,7 +298,7 @@ async def oauth_login_callback(request: Request) -> RedirectResponse | HTMLRespo
else:
# Integrated mode (Nextcloud OIDC)
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.raise_for_status()
discovery = response.json()
@@ -314,7 +316,7 @@ async def oauth_login_callback(request: Request) -> RedirectResponse | HTMLRespo
if 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(
token_endpoint,
data=token_params,
@@ -10,6 +10,8 @@ import httpx
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
from ..http import nextcloud_httpx_client
logger = logging.getLogger(__name__)
@@ -132,7 +134,7 @@ async def register_client(
logger.info(f"Registering OAuth client with Nextcloud: {client_name}")
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:
response = await client.post(
registration_endpoint,
@@ -229,7 +231,7 @@ async def delete_client(
logger.info(f"Deleting OAuth client: {client_id[:16]}...")
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):
try:
# Prefer RFC 7592 Bearer token authentication
+3 -1
View File
@@ -18,6 +18,8 @@ from urllib.parse import urlencode, urlparse
import httpx
from ..http import nextcloud_httpx_client
logger = logging.getLogger(__name__)
@@ -107,7 +109,7 @@ class KeycloakOAuthClient:
async def _get_http_client(self) -> httpx.AsyncClient:
"""Get or create HTTP client"""
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
async def close(self) -> None:
+6 -5
View File
@@ -27,7 +27,6 @@ import time
from base64 import urlsafe_b64encode
from urllib.parse import urlencode
import httpx
import jwt
from starlette.requests import Request
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.storage import RefreshTokenStorage
from ..http import nextcloud_httpx_client
logger = logging.getLogger(__name__)
@@ -218,7 +219,7 @@ async def oauth_authorize(request: Request) -> RedirectResponse | JSONResponse:
)
# 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.raise_for_status()
discovery = response.json()
@@ -354,7 +355,7 @@ async def oauth_authorize_nextcloud(
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.raise_for_status()
discovery = response.json()
@@ -462,7 +463,7 @@ async def oauth_callback_nextcloud(request: Request):
callback_uri = f"{mcp_server_url}/oauth/callback"
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.raise_for_status()
discovery = response.json()
@@ -482,7 +483,7 @@ async def oauth_callback_nextcloud(request: Request):
token_params["code_verifier"] = code_verifier
# Exchange code for tokens
async with httpx.AsyncClient() as http_client:
async with nextcloud_httpx_client() as http_client:
response = await http_client.post(
token_endpoint,
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.token_exchange import exchange_token_for_delegation
from ..http import nextcloud_httpx_client
logger = logging.getLogger(__name__)
@@ -136,7 +138,7 @@ class TokenBrokerService:
async def _get_http_client(self) -> httpx.AsyncClient:
"""Get or create HTTP client."""
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
)
return self._http_client
+2 -1
View File
@@ -20,6 +20,7 @@ import httpx
import jwt
from ..config import get_settings
from ..http import nextcloud_httpx_client
from .storage import RefreshTokenStorage
logger = logging.getLogger(__name__)
@@ -68,7 +69,7 @@ class TokenExchangeService:
self.storage: Optional[RefreshTokenStorage] = None
# Create HTTP client
self.http_client = httpx.AsyncClient(
self.http_client = nextcloud_httpx_client(
timeout=30.0,
follow_redirects=True,
)
@@ -31,6 +31,8 @@ from nextcloud_mcp_server.observability.metrics import (
record_oauth_token_validation,
)
from ..http import nextcloud_httpx_client
logger = logging.getLogger(__name__)
@@ -61,7 +63,7 @@ class UnifiedTokenVerifier(TokenVerifier):
self.mode = "exchange" if settings.enable_token_exchange else "multi-audience"
# 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
self.jwks_client: PyJWKClient | None = None
+4 -3
View File
@@ -13,7 +13,6 @@ import traceback
from pathlib import Path
from typing import Any
import httpx
from jinja2 import Environment, FileSystemLoader
from starlette.authentication import requires
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.config import get_settings
from ..http import nextcloud_httpx_client
logger = logging.getLogger(__name__)
# Setup Jinja2 environment for templates
@@ -257,7 +258,7 @@ async def _get_userinfo_endpoint(oauth_ctx: dict[str, Any]) -> str | None:
return None
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.raise_for_status()
discovery = response.json()
@@ -290,7 +291,7 @@ async def _query_idp_userinfo(
User info dictionary from IdP, or None if query fails
"""
try:
async with httpx.AsyncClient(timeout=10.0) as client:
async with nextcloud_httpx_client(timeout=10.0) as client:
response = await client.get(
userinfo_uri,
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,
)
from ..http import nextcloud_httpx_client
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 username is not None and password is not None # Type narrowing
return httpx.AsyncClient(
return nextcloud_httpx_client(
base_url=nextcloud_host,
auth=(username, password),
timeout=30.0,
@@ -163,7 +165,7 @@ async def _get_authenticated_client(request: Request) -> httpx.AsyncClient:
if not nextcloud_host:
raise RuntimeError("Nextcloud host not configured")
return httpx.AsyncClient(
return nextcloud_httpx_client(
base_url=nextcloud_host,
headers={"Authorization": f"Bearer {access_token}"},
timeout=30.0,