fix: address PR #589 review feedback (round 2)
Consolidate three independent RefreshTokenStorage lazy singletons into a single lock-protected get_shared_storage() function, eliminating race conditions on concurrent first-access. Remove blanket try/except in _get_stored_scopes so storage errors propagate as proper MCP errors instead of silently triggering "please provision" messages. Handle declined/cancelled elicitation results in Login Flow tools by cleaning up sessions and returning clear status. Add update_app_password_scopes() to avoid unnecessary decrypt/re-encrypt when only scopes change. Add unprovisioned-user early exit and no-op detection to nc_auth_update_scopes. Remove four dead config fields and misleading NEXTCLOUD_PASSWORD deprecation warning. Add periodic login flow session cleanup task. Generate separate Fernet keys per service. Add board cleanup in deck integration test. Gate CI unit tests on linting and skip Astrolabe build for single-user profile. Fix test markers from oauth to multi_user_basic for astrolabe integration tests. Update login_flow.py docstrings to document outbound HTTP calls. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
33cf0fee9b
commit
db1e0606ad
@@ -62,9 +62,10 @@ class LoginFlowV2Client:
|
||||
async def initiate(
|
||||
self, user_agent: str = "nextcloud-mcp-server"
|
||||
) -> LoginFlowInitResponse:
|
||||
"""Initiate Login Flow v2.
|
||||
"""Initiate Login Flow v2 by sending an HTTP POST to the Nextcloud instance.
|
||||
|
||||
Posts to /index.php/login/v2 to start a new login flow.
|
||||
Makes an outbound HTTP request to POST /index.php/login/v2 on the
|
||||
configured Nextcloud server to start a new login flow.
|
||||
|
||||
Args:
|
||||
user_agent: User-Agent string for the app password name
|
||||
@@ -99,9 +100,10 @@ class LoginFlowV2Client:
|
||||
return result
|
||||
|
||||
async def poll(self, poll_endpoint: str, poll_token: str) -> LoginFlowPollResult:
|
||||
"""Poll for Login Flow v2 completion.
|
||||
"""Poll for Login Flow v2 completion by sending an HTTP POST to the Nextcloud instance.
|
||||
|
||||
Posts to the poll endpoint with the token. Nextcloud returns:
|
||||
Makes an outbound HTTP request to the poll endpoint provided by the
|
||||
initiate response. Nextcloud returns:
|
||||
- 200 with credentials when the user completes login
|
||||
- 404 when still pending
|
||||
- Other errors for expired/invalid flows
|
||||
|
||||
@@ -9,7 +9,7 @@ from mcp.server.auth.provider import AccessToken
|
||||
from mcp.server.fastmcp import Context
|
||||
from mcp.server.fastmcp.utilities.context_injection import find_context_parameter
|
||||
|
||||
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
|
||||
from nextcloud_mcp_server.auth.storage import get_shared_storage
|
||||
from nextcloud_mcp_server.config import get_settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -478,18 +478,6 @@ def discover_all_scopes(mcp) -> list[str]:
|
||||
# ── Login Flow v2 helpers ────────────────────────────────────────────────
|
||||
|
||||
|
||||
_scope_storage_instance = None
|
||||
|
||||
|
||||
async def _get_scope_storage():
|
||||
"""Get initialized storage instance for scope checks (lazy singleton)."""
|
||||
global _scope_storage_instance
|
||||
if _scope_storage_instance is None:
|
||||
_scope_storage_instance = RefreshTokenStorage.from_env()
|
||||
await _scope_storage_instance.initialize()
|
||||
return _scope_storage_instance
|
||||
|
||||
|
||||
async def _get_stored_scopes(user_id: str) -> list[str] | str | None:
|
||||
"""Look up stored app password scopes for a user.
|
||||
|
||||
@@ -497,16 +485,16 @@ async def _get_stored_scopes(user_id: str) -> list[str] | str | None:
|
||||
- list[str]: Specific scopes granted
|
||||
- "all": NULL scopes in DB (legacy = all allowed)
|
||||
- None: No stored app password (provisioning required)
|
||||
"""
|
||||
try:
|
||||
storage = await _get_scope_storage()
|
||||
|
||||
data = await storage.get_app_password_with_scopes(user_id)
|
||||
if data is None:
|
||||
return None
|
||||
if data["scopes"] is None:
|
||||
return "all"
|
||||
return data["scopes"]
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to check stored scopes for {user_id}: {e}")
|
||||
Raises:
|
||||
Storage/infrastructure exceptions propagate to the caller
|
||||
(require_scopes decorator) for proper MCP error responses.
|
||||
"""
|
||||
storage = await get_shared_storage()
|
||||
|
||||
data = await storage.get_app_password_with_scopes(user_id)
|
||||
if data is None:
|
||||
return None
|
||||
if data["scopes"] is None:
|
||||
return "all"
|
||||
return data["scopes"]
|
||||
|
||||
@@ -1614,6 +1614,40 @@ class RefreshTokenStorage:
|
||||
)
|
||||
return None
|
||||
|
||||
async def update_app_password_scopes(self, user_id: str, scopes: list[str]) -> bool:
|
||||
"""Update only the scopes for an existing app password (no decrypt/re-encrypt).
|
||||
|
||||
Args:
|
||||
user_id: MCP user ID
|
||||
scopes: New scope list
|
||||
|
||||
Returns:
|
||||
True if a row was updated, False if user not found
|
||||
"""
|
||||
if not self._initialized:
|
||||
await self.initialize()
|
||||
|
||||
scopes_json = json.dumps(scopes)
|
||||
now = int(time.time())
|
||||
start_time = time.time()
|
||||
try:
|
||||
async with aiosqlite.connect(self.db_path) as db:
|
||||
cursor = await db.execute(
|
||||
"UPDATE app_passwords SET scopes = ?, updated_at = ? WHERE user_id = ?",
|
||||
(scopes_json, now, user_id),
|
||||
)
|
||||
await db.commit()
|
||||
updated = cursor.rowcount > 0
|
||||
|
||||
duration = time.time() - start_time
|
||||
record_db_operation("sqlite", "update", duration, "success")
|
||||
return updated
|
||||
|
||||
except Exception:
|
||||
duration = time.time() - start_time
|
||||
record_db_operation("sqlite", "update", duration, "error")
|
||||
raise
|
||||
|
||||
# ── Login Flow v2: Session Tracking ──────────────────────────────────
|
||||
|
||||
async def store_login_flow_session(
|
||||
@@ -1811,6 +1845,25 @@ class RefreshTokenStorage:
|
||||
raise
|
||||
|
||||
|
||||
_shared_instance: RefreshTokenStorage | None = None
|
||||
_shared_lock = anyio.Lock()
|
||||
|
||||
|
||||
async def get_shared_storage() -> RefreshTokenStorage:
|
||||
"""Get the process-wide RefreshTokenStorage singleton (lock-protected).
|
||||
|
||||
All modules that need storage should use this function instead of
|
||||
creating their own lazy singletons. The lock ensures thread-safe
|
||||
initialization on concurrent first-access.
|
||||
"""
|
||||
global _shared_instance
|
||||
async with _shared_lock:
|
||||
if _shared_instance is None:
|
||||
_shared_instance = RefreshTokenStorage.from_env()
|
||||
await _shared_instance.initialize()
|
||||
return _shared_instance
|
||||
|
||||
|
||||
async def generate_encryption_key() -> str:
|
||||
"""
|
||||
Generate a new Fernet encryption key.
|
||||
|
||||
Reference in New Issue
Block a user