Merge remote-tracking branch 'origin/master' into chore/lazy-logging-g004-sweep

# Conflicts:
#	nextcloud_mcp_server/vector/oauth_sync.py
This commit is contained in:
Chris Coutinho
2026-05-13 01:40:32 +02:00
7 changed files with 76 additions and 317 deletions
+12
View File
@@ -5,6 +5,18 @@ All notable changes to the Nextcloud MCP Server will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [PEP 440](https://peps.python.org/pep-0440/). and this project adheres to [PEP 440](https://peps.python.org/pep-0440/).
## v0.86.2 (2026-05-12)
### Refactor
- drop OAuth-refresh background-sync path from oauth_sync.py
## v0.86.1 (2026-05-12)
### Refactor
- prune dead pre-LOGIN_FLOW config/runtime branches
## v0.86.0 (2026-05-12) ## v0.86.0 (2026-05-12)
### BREAKING CHANGE ### BREAKING CHANGE
+9 -11
View File
@@ -1811,12 +1811,14 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
) )
break break
# Determine authentication mode for background sync # Background sync authenticates as each provisioned user via
# Login Flow v2 and multi-user BasicAuth: use app passwords # locally-stored Nextcloud app passwords (Login Flow v2 /
# OAuth mode (without Login Flow): use OAuth refresh tokens # multi-user BasicAuth). The earlier OAuth refresh-token
use_basic_auth = not oauth_enabled or settings.enable_login_flow # path in vector/oauth_sync.py was removed in the ADR-022
# cleanup — it relied on unmerged user_oidc patches and was
# Start background tasks using anyio TaskGroup # never reachable from any supported deployment mode. The
# `token_broker` constructed above is still used by the
# management API revoke endpoint (via app.state.oauth_context).
async with anyio.create_task_group() as tg: async with anyio.create_task_group() as tg:
# Start user manager task (supervises per-user scanners) # Start user manager task (supervises per-user scanners)
await tg.start( await tg.start(
@@ -1824,12 +1826,10 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
send_stream, send_stream,
shutdown_event, shutdown_event,
scanner_wake_event, scanner_wake_event,
token_broker if not use_basic_auth else None, token_storage,
token_storage, # Use token_storage (works for both OAuth and multi-user BasicAuth)
nextcloud_host_for_sync, nextcloud_host_for_sync,
user_states, user_states,
tg, tg,
use_basic_auth, # Pass as positional arg (before task_status)
) )
# Start processor pool (each gets a cloned receive stream) # Start processor pool (each gets a cloned receive stream)
@@ -1839,9 +1839,7 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
i, i,
receive_stream.clone(), receive_stream.clone(),
shutdown_event, shutdown_event,
token_broker if not use_basic_auth else None,
nextcloud_host_for_sync, nextcloud_host_for_sync,
use_basic_auth, # Pass as positional arg (before task_status)
) )
# Expose this long-lived task group to request-path code # Expose this long-lived task group to request-path code
+3 -2
View File
@@ -43,8 +43,9 @@ _DEFAULTS: dict[str, Any] = {
"userinfo_uri": None, "userinfo_uri": None,
"oidc_resource_server_id": None, "oidc_resource_server_id": None,
# Mode flags # Mode flags
"enable_multi_user_basic_auth": False, # NOTE: `enable_multi_user_basic_auth` and `enable_login_flow` are
"enable_login_flow": False, # intentionally absent — they are derived from MCP_DEPLOYMENT_MODE in
# Settings.__post_init__ (ADR-022) and not read from the dynaconf store.
"enable_semantic_search": False, "enable_semantic_search": False,
"enable_background_operations": False, "enable_background_operations": False,
"vector_sync_enabled": False, "vector_sync_enabled": False,
+43 -182
View File
@@ -5,25 +5,22 @@ Manages background vector sync for multi-user deployments:
- Per-User Scanners: One scanner task per provisioned user - Per-User Scanners: One scanner task per provisioned user
- Shared Processor Pool: Processes documents from all users - Shared Processor Pool: Processes documents from all users
Authentication strategies are mutually exclusive by deployment mode: Background sync authenticates as each provisioned user via locally-stored
Nextcloud app passwords (BasicAuth), retrieved through the management API
after the user completes Login Flow v2 (or, in multi-user BasicAuth mode,
the per-user Astrolabe provisioning flow).
Multi-user BasicAuth mode (MCP_DEPLOYMENT_MODE=multi_user_basic): The earlier OAuth refresh-token path was removed in the ADR-022 follow-up:
- Uses app passwords stored locally in MCP server's database it depended on unmerged Nextcloud `user_oidc` patches for Bearer-token
- Users provision via Astrolabe personal settings, which sends to MCP API validation on non-OCS endpoints, and was never reachable from any
- OAuth is NOT used supported deployment mode. The `TokenBrokerService` constructed in
`app.py` is retained for the management API revoke endpoint, not for
OAuth mode (with external IdP like Keycloak): background sync.
- Uses OAuth refresh tokens via TokenBrokerService
- Users provision via browser OAuth flow
- App passwords are NOT used
These are separate concerns - no fallback between them.
""" """
import logging import logging
import time import time
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import TYPE_CHECKING
import anyio import anyio
from anyio.abc import TaskGroup, TaskStatus from anyio.abc import TaskGroup, TaskStatus
@@ -39,19 +36,8 @@ from nextcloud_mcp_server.config import get_settings
from nextcloud_mcp_server.vector.processor import process_document from nextcloud_mcp_server.vector.processor import process_document
from nextcloud_mcp_server.vector.scanner import DocumentTask, scan_user_documents from nextcloud_mcp_server.vector.scanner import DocumentTask, scan_user_documents
if TYPE_CHECKING:
from nextcloud_mcp_server.auth.token_broker import TokenBrokerService
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Scopes required for vector sync operations
VECTOR_SYNC_SCOPES = [
"notes.read",
"files.read",
"deck.read",
# "news.read", # News app may not be installed
]
class NotProvisionedError(Exception): class NotProvisionedError(Exception):
"""User has not provisioned offline access or has revoked it.""" """User has not provisioned offline access or has revoked it."""
@@ -113,84 +99,13 @@ async def get_user_client_basic_auth(
) )
async def get_user_client_oauth(
user_id: str,
token_broker: "TokenBrokerService",
nextcloud_host: str,
) -> NextcloudClient:
"""Get an authenticated NextcloudClient using OAuth refresh token.
For OAuth deployments with external IdP where users provision via
browser OAuth flow. App passwords are NOT used in this mode.
Args:
user_id: User identifier
token_broker: Token broker for obtaining access tokens
nextcloud_host: Nextcloud base URL
Returns:
Authenticated NextcloudClient with Bearer token
Raises:
NotProvisionedError: If user has not provisioned offline access
"""
token = await token_broker.get_background_token(user_id, VECTOR_SYNC_SCOPES)
if not token:
raise NotProvisionedError(
f"User {user_id} has not provisioned offline access. "
f"User must complete the OAuth provisioning flow."
)
logger.info("Using OAuth refresh token for background sync: %s", user_id)
return NextcloudClient.from_token(
base_url=nextcloud_host,
token=token,
username=user_id,
)
async def get_user_client(
user_id: str,
token_broker: "TokenBrokerService | None",
nextcloud_host: str,
*,
use_basic_auth: bool = False,
) -> NextcloudClient:
"""Get an authenticated NextcloudClient for a user.
Dispatches to the appropriate authentication strategy based on mode.
These are mutually exclusive - no fallback between them.
Args:
user_id: User identifier
token_broker: Token broker for OAuth mode (can be None for BasicAuth mode)
nextcloud_host: Nextcloud base URL
use_basic_auth: If True, use app passwords via Astrolabe (BasicAuth mode).
If False, use OAuth refresh tokens (OAuth mode).
Returns:
Authenticated NextcloudClient
Raises:
NotProvisionedError: If user has not provisioned access for the mode
"""
if use_basic_auth:
return await get_user_client_basic_auth(user_id, nextcloud_host)
else:
if token_broker is None:
raise ValueError("token_broker required for OAuth mode")
return await get_user_client_oauth(user_id, token_broker, nextcloud_host)
async def user_scanner_task( async def user_scanner_task(
user_id: str, user_id: str,
send_stream: MemoryObjectSendStream[DocumentTask], send_stream: MemoryObjectSendStream[DocumentTask],
shutdown_event: anyio.Event, shutdown_event: anyio.Event,
wake_event: anyio.Event, wake_event: anyio.Event,
token_broker: "TokenBrokerService | None",
nextcloud_host: str, nextcloud_host: str,
*, *,
use_basic_auth: bool = False,
task_status: TaskStatus = anyio.TASK_STATUS_IGNORED, task_status: TaskStatus = anyio.TASK_STATUS_IGNORED,
) -> None: ) -> None:
"""Scanner task for a single user. """Scanner task for a single user.
@@ -202,13 +117,10 @@ async def user_scanner_task(
send_stream: Stream to send changed documents to processors send_stream: Stream to send changed documents to processors
shutdown_event: Event signaling shutdown shutdown_event: Event signaling shutdown
wake_event: Event to trigger immediate scan wake_event: Event to trigger immediate scan
token_broker: Token broker for OAuth mode (None for BasicAuth mode)
nextcloud_host: Nextcloud base URL nextcloud_host: Nextcloud base URL
use_basic_auth: If True, use app passwords; if False, use OAuth tokens
task_status: Status object for signaling task readiness task_status: Status object for signaling task readiness
""" """
mode_label = "BasicAuth" if use_basic_auth else "OAuth" logger.info("[BasicAuth] Scanner started for user: %s", user_id)
logger.info("[%s] Scanner started for user: %s", mode_label, user_id)
settings = get_settings() settings = get_settings()
max_consecutive_errors = 5 max_consecutive_errors = 5
@@ -216,17 +128,14 @@ async def user_scanner_task(
# Pre-validate credentials before entering scan loop # Pre-validate credentials before entering scan loop
try: try:
nc_client = await get_user_client( nc_client = await get_user_client_basic_auth(user_id, nextcloud_host)
user_id, token_broker, nextcloud_host, use_basic_auth=use_basic_auth
)
try: try:
await nc_client.capabilities() # Lightweight OCS call to validate creds await nc_client.capabilities() # Lightweight OCS call to validate creds
logger.info("[%s] Credentials validated for %s", mode_label, user_id) logger.info("[BasicAuth] Credentials validated for %s", user_id)
except HTTPStatusError as e: except HTTPStatusError as e:
if e.response.status_code in (401, 403): if e.response.status_code in (401, 403):
logger.warning( logger.warning(
"[%s] Credential validation failed for %s (HTTP %s), not starting scan loop", "[BasicAuth] Credential validation failed for %s (HTTP %s), not starting scan loop",
mode_label,
user_id, user_id,
e.response.status_code, e.response.status_code,
) )
@@ -236,13 +145,12 @@ async def user_scanner_task(
await nc_client.close() await nc_client.close()
except NotProvisionedError: except NotProvisionedError:
logger.warning( logger.warning(
"[%s] User %s not provisioned, not starting scan loop", mode_label, user_id "[BasicAuth] User %s not provisioned, not starting scan loop", user_id
) )
return return
except Exception as e: except Exception as e:
logger.warning( logger.warning(
"[%s] Pre-validation failed for %s: %s. Proceeding to scan loop (has its own error handling).", "[BasicAuth] Pre-validation failed for %s: %s. Proceeding to scan loop (has its own error handling).",
mode_label,
user_id, user_id,
e, e,
) )
@@ -253,9 +161,7 @@ async def user_scanner_task(
nc_client = None nc_client = None
try: try:
# Get fresh credentials for this scan cycle # Get fresh credentials for this scan cycle
nc_client = await get_user_client( nc_client = await get_user_client_basic_auth(user_id, nextcloud_host)
user_id, token_broker, nextcloud_host, use_basic_auth=use_basic_auth
)
# Scan user's documents # Scan user's documents
await scan_user_documents( await scan_user_documents(
@@ -268,9 +174,7 @@ async def user_scanner_task(
except NotProvisionedError: except NotProvisionedError:
logger.warning( logger.warning(
"[%s] User %s no longer provisioned, stopping scanner", "[BasicAuth] User %s no longer provisioned, stopping scanner", user_id
mode_label,
user_id,
) )
break break
@@ -278,8 +182,7 @@ async def user_scanner_task(
status_code = e.response.status_code status_code = e.response.status_code
if status_code in (401, 403): if status_code in (401, 403):
logger.warning( logger.warning(
"[%s] Scanner auth failed for %s (HTTP %s), stopping scanner. User may need to re-provision credentials.", "[BasicAuth] Scanner auth failed for %s (HTTP %s), stopping scanner. User may need to re-provision credentials.",
mode_label,
user_id, user_id,
status_code, status_code,
) )
@@ -287,8 +190,7 @@ async def user_scanner_task(
elif status_code == 429: elif status_code == 429:
retry_after = min(int(e.response.headers.get("Retry-After", "60")), 300) retry_after = min(int(e.response.headers.get("Retry-After", "60")), 300)
logger.warning( logger.warning(
"[%s] Scanner rate-limited for %s, backing off %ss", "[BasicAuth] Scanner rate-limited for %s, backing off %ss",
mode_label,
user_id, user_id,
retry_after, retry_after,
) )
@@ -303,8 +205,7 @@ async def user_scanner_task(
else: else:
consecutive_errors += 1 consecutive_errors += 1
logger.error( logger.error(
"[%s] Scanner HTTP error for %s: %s (%s/%s)", "[BasicAuth] Scanner HTTP error for %s: %s (%s/%s)",
mode_label,
user_id, user_id,
e, e,
consecutive_errors, consecutive_errors,
@@ -315,8 +216,7 @@ async def user_scanner_task(
except Exception as e: except Exception as e:
consecutive_errors += 1 consecutive_errors += 1
logger.error( logger.error(
"[%s] Scanner error for %s: %s (%s/%s)", "[BasicAuth] Scanner error for %s: %s (%s/%s)",
mode_label,
user_id, user_id,
e, e,
consecutive_errors, consecutive_errors,
@@ -330,8 +230,7 @@ async def user_scanner_task(
if consecutive_errors >= max_consecutive_errors: if consecutive_errors >= max_consecutive_errors:
logger.error( logger.error(
"[%s] Scanner for %s hit %s consecutive errors, stopping scanner", "[BasicAuth] Scanner for %s hit %s consecutive errors, stopping scanner",
mode_label,
user_id, user_id,
max_consecutive_errors, max_consecutive_errors,
) )
@@ -344,16 +243,14 @@ async def user_scanner_task(
except anyio.get_cancelled_exc_class(): except anyio.get_cancelled_exc_class():
break break
logger.info("[%s] Scanner stopped for user: %s", mode_label, user_id) logger.info("[BasicAuth] Scanner stopped for user: %s", user_id)
async def multi_user_processor_task( async def multi_user_processor_task(
worker_id: int, worker_id: int,
receive_stream: MemoryObjectReceiveStream[DocumentTask], receive_stream: MemoryObjectReceiveStream[DocumentTask],
shutdown_event: anyio.Event, shutdown_event: anyio.Event,
token_broker: "TokenBrokerService | None",
nextcloud_host: str, nextcloud_host: str,
use_basic_auth: bool = False,
*, *,
task_status: TaskStatus = anyio.TASK_STATUS_IGNORED, task_status: TaskStatus = anyio.TASK_STATUS_IGNORED,
) -> None: ) -> None:
@@ -365,13 +262,10 @@ async def multi_user_processor_task(
worker_id: Worker identifier for logging worker_id: Worker identifier for logging
receive_stream: Stream to receive documents from receive_stream: Stream to receive documents from
shutdown_event: Event signaling shutdown shutdown_event: Event signaling shutdown
token_broker: Token broker for OAuth mode (None for BasicAuth mode)
nextcloud_host: Nextcloud base URL nextcloud_host: Nextcloud base URL
use_basic_auth: If True, use app passwords; if False, use OAuth tokens
task_status: Status object for signaling task readiness task_status: Status object for signaling task readiness
""" """
mode_label = "BasicAuth" if use_basic_auth else "OAuth" logger.info("[BasicAuth] Processor %s started", worker_id)
logger.info("[%s] Processor %s started", mode_label, worker_id)
task_status.started() task_status.started()
while not shutdown_event.is_set(): while not shutdown_event.is_set():
@@ -383,11 +277,8 @@ async def multi_user_processor_task(
doc_task = await receive_stream.receive() doc_task = await receive_stream.receive()
# Get credentials for THIS document's user # Get credentials for THIS document's user
nc_client = await get_user_client( nc_client = await get_user_client_basic_auth(
doc_task.user_id, doc_task.user_id, nextcloud_host
token_broker,
nextcloud_host,
use_basic_auth=use_basic_auth,
) )
# Process the document # Process the document
@@ -397,16 +288,13 @@ async def multi_user_processor_task(
continue continue
except anyio.EndOfStream: except anyio.EndOfStream:
logger.info( logger.info("[BasicAuth] Processor %s: Stream closed, exiting", worker_id)
"[%s] Processor %s: Stream closed, exiting", mode_label, worker_id
)
break break
except NotProvisionedError: except NotProvisionedError:
if doc_task: if doc_task:
logger.warning( logger.warning(
"[%s] User %s not provisioned, skipping %s_%s", "[BasicAuth] User %s not provisioned, skipping %s_%s",
mode_label,
doc_task.user_id, doc_task.user_id,
doc_task.doc_type, doc_task.doc_type,
doc_task.doc_id, doc_task.doc_id,
@@ -416,8 +304,7 @@ async def multi_user_processor_task(
except Exception as e: except Exception as e:
if doc_task: if doc_task:
logger.error( logger.error(
"[%s] Processor %s error processing %s_%s: %s", "[BasicAuth] Processor %s error processing %s_%s: %s",
mode_label,
worker_id, worker_id,
doc_task.doc_type, doc_task.doc_type,
doc_task.doc_id, doc_task.doc_id,
@@ -426,18 +313,14 @@ async def multi_user_processor_task(
) )
else: else:
logger.error( logger.error(
"[%s] Processor %s error: %s", "[BasicAuth] Processor %s error: %s", worker_id, e, exc_info=True
mode_label,
worker_id,
e,
exc_info=True,
) )
finally: finally:
if nc_client: if nc_client:
await nc_client.close() await nc_client.close()
logger.info("[%s] Processor %s stopped", mode_label, worker_id) logger.info("[BasicAuth] Processor %s stopped", worker_id)
# Backward compatibility alias # Backward compatibility alias
@@ -450,10 +333,8 @@ async def _run_user_scanner_with_scope(
send_stream: MemoryObjectSendStream[DocumentTask], send_stream: MemoryObjectSendStream[DocumentTask],
shutdown_event: anyio.Event, shutdown_event: anyio.Event,
wake_event: anyio.Event, wake_event: anyio.Event,
token_broker: "TokenBrokerService | None",
nextcloud_host: str, nextcloud_host: str,
user_states: dict[str, UserSyncState], user_states: dict[str, UserSyncState],
use_basic_auth: bool = False,
) -> None: ) -> None:
"""Wrapper to run scanner with cancellation scope. """Wrapper to run scanner with cancellation scope.
@@ -467,9 +348,7 @@ async def _run_user_scanner_with_scope(
send_stream=cloned_stream, send_stream=cloned_stream,
shutdown_event=shutdown_event, shutdown_event=shutdown_event,
wake_event=wake_event, wake_event=wake_event,
token_broker=token_broker,
nextcloud_host=nextcloud_host, nextcloud_host=nextcloud_host,
use_basic_auth=use_basic_auth,
) )
finally: finally:
# Clean up on exit # Clean up on exit
@@ -482,12 +361,10 @@ async def user_manager_task(
send_stream: MemoryObjectSendStream[DocumentTask], send_stream: MemoryObjectSendStream[DocumentTask],
shutdown_event: anyio.Event, shutdown_event: anyio.Event,
wake_event: anyio.Event, wake_event: anyio.Event,
token_broker: "TokenBrokerService | None",
refresh_token_storage: "RefreshTokenStorage", refresh_token_storage: "RefreshTokenStorage",
nextcloud_host: str, nextcloud_host: str,
user_states: dict[str, UserSyncState], user_states: dict[str, UserSyncState],
tg: TaskGroup, tg: TaskGroup,
use_basic_auth: bool = False,
*, *,
task_status: TaskStatus = anyio.TASK_STATUS_IGNORED, task_status: TaskStatus = anyio.TASK_STATUS_IGNORED,
) -> None: ) -> None:
@@ -501,42 +378,33 @@ async def user_manager_task(
send_stream: Stream to send documents to processors send_stream: Stream to send documents to processors
shutdown_event: Event signaling shutdown shutdown_event: Event signaling shutdown
wake_event: Event to wake scanners for immediate scan wake_event: Event to wake scanners for immediate scan
token_broker: Token broker for OAuth mode (None for BasicAuth mode)
refresh_token_storage: Storage for tracking provisioned users refresh_token_storage: Storage for tracking provisioned users
nextcloud_host: Nextcloud base URL nextcloud_host: Nextcloud base URL
user_states: Shared dict tracking active user scanners user_states: Shared dict tracking active user scanners
tg: Task group for spawning scanner tasks tg: Task group for spawning scanner tasks
use_basic_auth: If True, use app passwords; if False, use OAuth tokens
task_status: Status object for signaling task readiness task_status: Status object for signaling task readiness
""" """
settings = get_settings() settings = get_settings()
poll_interval = settings.vector_sync_user_poll_interval poll_interval = settings.vector_sync_user_poll_interval
mode_label = "BasicAuth" if use_basic_auth else "OAuth"
logger.info( logger.info("[BasicAuth] User manager started (poll interval: %ss)", poll_interval)
"[%s] User manager started (poll interval: %ss)", mode_label, poll_interval
)
task_status.started() task_status.started()
while not shutdown_event.is_set(): while not shutdown_event.is_set():
try: try:
# Get current provisioned users based on mode # Query the app_passwords table — background sync always
if use_basic_auth: # authenticates as the user via locally-stored Nextcloud app
# BasicAuth / Login Flow v2 mode: query app_passwords table # passwords (Login Flow v2 / multi-user BasicAuth).
provisioned_users = set( provisioned_users = set(
await refresh_token_storage.get_all_app_password_user_ids() await refresh_token_storage.get_all_app_password_user_ids()
) )
else:
# OAuth mode: query refresh_tokens table
provisioned_users = set(await refresh_token_storage.get_all_user_ids())
active_users = set(user_states.keys()) active_users = set(user_states.keys())
# Start scanners for new users # Start scanners for new users
new_users = provisioned_users - active_users new_users = provisioned_users - active_users
for user_id in new_users: for user_id in new_users:
logger.info( logger.info(
"[%s] Starting scanner for newly provisioned user: %s", "[BasicAuth] Starting scanner for newly provisioned user: %s",
mode_label,
user_id, user_id,
) )
cancel_scope = anyio.CancelScope() cancel_scope = anyio.CancelScope()
@@ -553,17 +421,15 @@ async def user_manager_task(
send_stream, send_stream,
shutdown_event, shutdown_event,
wake_event, wake_event,
token_broker,
nextcloud_host, nextcloud_host,
user_states, user_states,
use_basic_auth, # Positional after user_states
) )
# Cancel scanners for revoked users # Cancel scanners for revoked users
revoked_users = active_users - provisioned_users revoked_users = active_users - provisioned_users
for user_id in revoked_users: for user_id in revoked_users:
logger.info( logger.info(
"[%s] Stopping scanner for revoked user: %s", mode_label, user_id "[BasicAuth] Stopping scanner for revoked user: %s", user_id
) )
state = user_states.get(user_id) state = user_states.get(user_id)
if state: if state:
@@ -571,16 +437,12 @@ async def user_manager_task(
# Note: state will be removed by _run_user_scanner_with_scope on exit # Note: state will be removed by _run_user_scanner_with_scope on exit
if new_users: if new_users:
logger.info( logger.info("[BasicAuth] Started %s new scanner(s)", len(new_users))
"[%s] Started %s new scanner(s)", mode_label, len(new_users)
)
if revoked_users: if revoked_users:
logger.info( logger.info("[BasicAuth] Stopped %s scanner(s)", len(revoked_users))
"[%s] Stopped %s scanner(s)", mode_label, len(revoked_users)
)
except Exception as e: except Exception as e:
logger.error("[%s] User manager error: %s", mode_label, e, exc_info=True) logger.error("[BasicAuth] User manager error: %s", e, exc_info=True)
# Sleep until next poll # Sleep until next poll
try: try:
@@ -591,11 +453,10 @@ async def user_manager_task(
# Cancel all remaining scanners on shutdown # Cancel all remaining scanners on shutdown
logger.info( logger.info(
"[%s] User manager shutting down, cancelling %s scanner(s)", "[BasicAuth] User manager shutting down, cancelling %s scanner(s)",
mode_label,
len(user_states), len(user_states),
) )
for state in list(user_states.values()): for state in list(user_states.values()):
state.cancel_scope.cancel() state.cancel_scope.cancel()
logger.info("[%s] User manager stopped", mode_label) logger.info("[BasicAuth] User manager stopped")
+1 -1
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "nextcloud-mcp-server" name = "nextcloud-mcp-server"
version = "0.86.0" version = "0.86.2"
description = "Model Context Protocol (MCP) server for Nextcloud integration - enables AI assistants to interact with Nextcloud data" description = "Model Context Protocol (MCP) server for Nextcloud integration - enables AI assistants to interact with Nextcloud data"
authors = [ authors = [
{name = "Chris Coutinho", email = "chris@coutinho.io"} {name = "Chris Coutinho", email = "chris@coutinho.io"}
@@ -1,12 +1,12 @@
"""Integration tests for app password provisioning via management API. """Integration tests for app password provisioning via management API.
Tests the complete flow for multi-user BasicAuth mode: Tests the complete flow for multi-user BasicAuth and Login Flow v2 modes:
1. User stores app password via management API endpoint 1. User stores app password via management API endpoint (or Login Flow v2 browser flow)
2. MCP server stores it locally (encrypted) 2. MCP server stores it locally (encrypted)
3. Background sync uses locally stored password to access Nextcloud 3. Background sync uses locally stored password to access Nextcloud
These tests verify that BasicAuth and OAuth are completely separate concerns The earlier OAuth refresh-token background-sync path was removed in the
with no fallback between them. ADR-022 cleanup — these tests now cover the only supported path.
""" """
import tempfile import tempfile
@@ -18,9 +18,7 @@ from cryptography.fernet import Fernet
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
from nextcloud_mcp_server.vector.oauth_sync import ( from nextcloud_mcp_server.vector.oauth_sync import (
NotProvisionedError, NotProvisionedError,
get_user_client,
get_user_client_basic_auth, get_user_client_basic_auth,
get_user_client_oauth,
) )
@@ -85,117 +83,6 @@ async def test_basic_auth_mode_raises_error_without_app_password(temp_storage):
assert "test_user" in str(exc_info.value) assert "test_user" in str(exc_info.value)
@pytest.mark.integration
async def test_get_user_client_dispatches_to_basic_auth(temp_storage, mocker):
"""Test that get_user_client dispatches to BasicAuth mode correctly."""
# Store an app password
await temp_storage.store_app_password("alice", "aaaaa-bbbbb-ccccc-ddddd-eeeee")
# Mock RefreshTokenStorage.from_env at the source module
mocker.patch(
"nextcloud_mcp_server.auth.storage.RefreshTokenStorage.from_env",
return_value=temp_storage,
)
# Also mock initialize since from_env returns an uninitialized instance
mocker.patch.object(temp_storage, "initialize", return_value=None)
# Call get_user_client in BasicAuth mode
client = await get_user_client(
user_id="alice",
token_broker=None, # No token broker needed for BasicAuth mode
nextcloud_host="http://localhost:8080",
use_basic_auth=True,
)
# Verify client was created successfully
assert client is not None
assert client.username == "alice"
@pytest.mark.integration
async def test_oauth_mode_uses_refresh_token_only(mocker):
"""Test that OAuth mode uses ONLY refresh tokens, NOT app passwords.
In OAuth mode, app passwords are NOT used.
This is a complete separation of concerns.
"""
from nextcloud_mcp_server.auth.token_broker import TokenBrokerService
# Mock TokenBrokerService to return an access token
mock_token_broker = mocker.AsyncMock(spec=TokenBrokerService)
mock_token_broker.get_background_token.return_value = "test-access-token"
# Call get_user_client in OAuth mode
_client = await get_user_client(
user_id="test_user",
token_broker=mock_token_broker,
nextcloud_host="http://localhost:8080",
use_basic_auth=False, # OAuth mode
)
# Verify token broker was called
mock_token_broker.get_background_token.assert_called_once()
@pytest.mark.integration
async def test_oauth_mode_raises_error_without_token(mocker):
"""Test that OAuth mode raises NotProvisionedError if no refresh token.
There is NO fallback to app passwords - if no token, user must provision.
"""
from nextcloud_mcp_server.auth.token_broker import TokenBrokerService
# Mock TokenBrokerService to return None (no token)
mock_token_broker = mocker.AsyncMock(spec=TokenBrokerService)
mock_token_broker.get_background_token.return_value = None
# Call get_user_client in OAuth mode - should raise NotProvisionedError
with pytest.raises(NotProvisionedError) as exc_info:
await get_user_client(
user_id="test_user",
token_broker=mock_token_broker,
nextcloud_host="http://localhost:8080",
use_basic_auth=False,
)
# Verify error message mentions OAuth provisioning
assert "oauth" in str(exc_info.value).lower()
assert "test_user" in str(exc_info.value)
@pytest.mark.integration
async def test_get_user_client_oauth_function(mocker):
"""Test the dedicated get_user_client_oauth function."""
from nextcloud_mcp_server.auth.token_broker import TokenBrokerService
# Mock TokenBrokerService
mock_token_broker = mocker.AsyncMock(spec=TokenBrokerService)
mock_token_broker.get_background_token.return_value = "test-bearer-token"
# Call dedicated function
client = await get_user_client_oauth(
user_id="alice",
token_broker=mock_token_broker,
nextcloud_host="http://localhost:8080",
)
assert client is not None
assert client.username == "alice"
mock_token_broker.get_background_token.assert_called_once()
@pytest.mark.integration
async def test_oauth_mode_requires_token_broker():
"""Test that OAuth mode requires a token broker."""
with pytest.raises(ValueError, match="token_broker required"):
await get_user_client(
user_id="test_user",
token_broker=None, # Missing token broker
nextcloud_host="http://localhost:8080",
use_basic_auth=False, # OAuth mode
)
@pytest.mark.integration @pytest.mark.integration
async def test_multiple_users_basic_auth_mode(temp_storage, mocker): async def test_multiple_users_basic_auth_mode(temp_storage, mocker):
"""Test that multiple users can be provisioned independently.""" """Test that multiple users can be provisioned independently."""
Generated
+1 -1
View File
@@ -2123,7 +2123,7 @@ wheels = [
[[package]] [[package]]
name = "nextcloud-mcp-server" name = "nextcloud-mcp-server"
version = "0.86.0" version = "0.86.2"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "aiosqlite" }, { name = "aiosqlite" },