fix: address PR #589 review feedback for Login Flow v2

- Fix data loss in nc_auth_update_scopes: remove premature
  delete_app_password call; old password stays valid until upsert
  replaces it on successful re-provisioning
- Replace assert with proper error return in nc_auth_check_status
- Add lazy singleton for RefreshTokenStorage in auth_tools,
  scope_authorization, and context to avoid per-call re-initialization
- Centralize _is_login_flow_mode() to get_settings().enable_login_flow
  and remove duplicate definitions and per-call os.getenv reads
- Add dev-only comment to TOKEN_ENCRYPTION_KEY in docker-compose.yml
- Gate OIDC build steps in CI behind matrix.needs-playwright
- Add diagnostic step reporting Playwright skip count in CI

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Chris Coutinho
2026-02-28 10:08:55 +01:00
co-authored by Claude Opus 4.6
parent 87ec3c4f5b
commit e28af5453b
6 changed files with 67 additions and 72 deletions
+14 -1
View File
@@ -45,6 +45,8 @@ jobs:
--ignore=tests/integration/test_qdrant_collection_creation.py --ignore=tests/integration/test_qdrant_collection_creation.py
--ignore=tests/rag_evaluation/ --ignore=tests/rag_evaluation/
# NOTE: Playwright browser tests are skipped in CI (no browser grant flow).
# These entries still run non-Playwright tests marked with the same markers.
- mode: oauth - mode: oauth
profile: oauth profile: oauth
markers: "oauth and not keycloak" markers: "oauth and not keycloak"
@@ -66,14 +68,16 @@ jobs:
with: with:
submodules: 'true' submodules: 'true'
# Build OIDC app (third_party is always mounted into the app container) # Build OIDC app (only needed for oauth/login-flow modes)
- name: Set up PHP 8.4 - name: Set up PHP 8.4
if: matrix.needs-playwright
uses: shivammathur/setup-php@44454db4f0199b8b9685a5d763dc37cbf79108e1 # 2.36.0 uses: shivammathur/setup-php@44454db4f0199b8b9685a5d763dc37cbf79108e1 # 2.36.0
with: with:
php-version: 8.4 php-version: 8.4
coverage: none coverage: none
- name: Install OIDC app composer dependencies - name: Install OIDC app composer dependencies
if: matrix.needs-playwright
run: | run: |
cd third_party/oidc cd third_party/oidc
composer install --no-dev composer install --no-dev
@@ -162,6 +166,15 @@ jobs:
--timeout=300 \ --timeout=300 \
${{ matrix.extra-args }} ${{ matrix.extra-args }}
- name: Report skipped Playwright tests
if: matrix.needs-playwright
run: |
echo "::notice::Playwright browser tests are skipped in CI. Run locally with: uv run pytest -m '${{ matrix.markers }}' --browser firefox"
uv run pytest --collect-only -q \
-m '${{ matrix.markers }}' \
${{ matrix.extra-args }} 2>/dev/null \
| tail -1 || true
- name: Show service logs on failure - name: Show service logs on failure
if: failure() if: failure()
run: docker compose --profile ${{ matrix.profile }} logs --tail=100 run: docker compose --profile ${{ matrix.profile }} logs --tail=100
+2
View File
@@ -145,6 +145,8 @@ services:
- ENABLE_BACKGROUND_OPERATIONS=true - ENABLE_BACKGROUND_OPERATIONS=true
# Token storage (required for middleware initialization) # Token storage (required for middleware initialization)
# DEVELOPMENT ONLY - generate a fresh key for production:
# python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
- TOKEN_ENCRYPTION_KEY=ESF1BvEQdGYsCluwMx9Cxvw3uh5pFowPH7Rg_nIliyo= - TOKEN_ENCRYPTION_KEY=ESF1BvEQdGYsCluwMx9Cxvw3uh5pFowPH7Rg_nIliyo=
- TOKEN_STORAGE_DB=/app/data/tokens.db - TOKEN_STORAGE_DB=/app/data/tokens.db
@@ -1,7 +1,6 @@
"""Scope-based authorization for MCP tools.""" """Scope-based authorization for MCP tools."""
import logging import logging
import os
from functools import wraps from functools import wraps
from typing import Any, Callable from typing import Any, Callable
@@ -10,6 +9,7 @@ from mcp.server.auth.provider import AccessToken
from mcp.server.fastmcp import Context from mcp.server.fastmcp import Context
from mcp.server.fastmcp.utilities.context_injection import find_context_parameter from mcp.server.fastmcp.utilities.context_injection import find_context_parameter
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
from nextcloud_mcp_server.config import get_settings from nextcloud_mcp_server.config import get_settings
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -123,9 +123,8 @@ def require_scopes(*required_scopes: str):
if access_token is None: if access_token is None:
# Check if single-user BasicAuth mode (env var app password) # Check if single-user BasicAuth mode (env var app password)
# If NEXTCLOUD_APP_PASSWORD or NEXTCLOUD_PASSWORD is set, bypass scope checks # If NEXTCLOUD_APP_PASSWORD or NEXTCLOUD_PASSWORD is set, bypass scope checks
if os.getenv("NEXTCLOUD_APP_PASSWORD") or os.getenv( settings = get_settings()
"NEXTCLOUD_PASSWORD" if settings.nextcloud_app_password or settings.nextcloud_password:
):
logger.debug( logger.debug(
f"No access token for {func_name} - allowing (env var app password)" f"No access token for {func_name} - allowing (env var app password)"
) )
@@ -142,7 +141,7 @@ def require_scopes(*required_scopes: str):
# In Login Flow v2 multi-user mode, OAuth tokens provide MCP session # In Login Flow v2 multi-user mode, OAuth tokens provide MCP session
# identity only. Nextcloud API access uses stored app passwords. # identity only. Nextcloud API access uses stored app passwords.
# Check if the user has a stored app password with appropriate scopes. # Check if the user has a stored app password with appropriate scopes.
if _is_login_flow_mode(): if get_settings().enable_login_flow:
from nextcloud_mcp_server.server.oauth_tools import ( # noqa: PLC0415 from nextcloud_mcp_server.server.oauth_tools import ( # noqa: PLC0415
extract_user_id_from_token, extract_user_id_from_token,
) )
@@ -479,19 +478,16 @@ def discover_all_scopes(mcp) -> list[str]:
# ── Login Flow v2 helpers ──────────────────────────────────────────────── # ── Login Flow v2 helpers ────────────────────────────────────────────────
def _is_login_flow_mode() -> bool: _scope_storage_instance = None
"""Check if server is configured for Login Flow v2 multi-user mode.
Login Flow v2 mode is active when:
- ENABLE_LOGIN_FLOW=true is set, OR
- Multi-user BasicAuth with offline access (uses stored app passwords)
Returns: async def _get_scope_storage():
True if Login Flow v2 enforcement should be active """Get initialized storage instance for scope checks (lazy singleton)."""
""" global _scope_storage_instance
if os.getenv("ENABLE_LOGIN_FLOW", "false").lower() == "true": if _scope_storage_instance is None:
return True _scope_storage_instance = RefreshTokenStorage.from_env()
return False await _scope_storage_instance.initialize()
return _scope_storage_instance
async def _get_stored_scopes(user_id: str) -> list[str] | str | None: async def _get_stored_scopes(user_id: str) -> list[str] | str | None:
@@ -502,11 +498,8 @@ async def _get_stored_scopes(user_id: str) -> list[str] | str | None:
- "all": NULL scopes in DB (legacy = all allowed) - "all": NULL scopes in DB (legacy = all allowed)
- None: No stored app password (provisioning required) - None: No stored app password (provisioning required)
""" """
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage # noqa: PLC0415
try: try:
storage = RefreshTokenStorage.from_env() storage = await _get_scope_storage()
await storage.initialize()
data = await storage.get_app_password_with_scopes(user_id) data = await storage.get_app_password_with_scopes(user_id)
if data is None: if data is None:
+13 -8
View File
@@ -1,7 +1,6 @@
"""Helper functions for accessing context in MCP tools.""" """Helper functions for accessing context in MCP tools."""
import logging import logging
import os
from httpx import BasicAuth from httpx import BasicAuth
from mcp.server.fastmcp import Context from mcp.server.fastmcp import Context
@@ -11,6 +10,7 @@ from nextcloud_mcp_server.auth.context_helper import (
get_session_client_from_context, get_session_client_from_context,
) )
from nextcloud_mcp_server.auth.scope_authorization import ProvisioningRequiredError from nextcloud_mcp_server.auth.scope_authorization import ProvisioningRequiredError
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
from nextcloud_mcp_server.client import NextcloudClient from nextcloud_mcp_server.client import NextcloudClient
from nextcloud_mcp_server.config import ( from nextcloud_mcp_server.config import (
DeploymentMode, DeploymentMode,
@@ -82,7 +82,7 @@ async def get_client(ctx: Context) -> NextcloudClient:
# Login Flow v2 multi-user mode: app password is REQUIRED for NC API access # Login Flow v2 multi-user mode: app password is REQUIRED for NC API access
# OAuth token is only used for MCP session identity, not NC API calls # OAuth token is only used for MCP session identity, not NC API calls
if hasattr(lifespan_ctx, "nextcloud_host") and _is_login_flow_mode(): if hasattr(lifespan_ctx, "nextcloud_host") and settings.enable_login_flow:
return await _get_client_from_login_flow(ctx, lifespan_ctx.nextcloud_host) return await _get_client_from_login_flow(ctx, lifespan_ctx.nextcloud_host)
# BasicAuth mode - use shared client (no token exchange) # BasicAuth mode - use shared client (no token exchange)
@@ -254,9 +254,16 @@ def _get_client_from_basic_auth(ctx: Context) -> NextcloudClient:
) )
def _is_login_flow_mode() -> bool: _login_flow_storage_instance = None
"""Check if Login Flow v2 multi-user mode is active."""
return os.getenv("ENABLE_LOGIN_FLOW", "false").lower() == "true"
async def _get_login_flow_storage():
"""Get initialized storage instance for login flow (lazy singleton)."""
global _login_flow_storage_instance
if _login_flow_storage_instance is None:
_login_flow_storage_instance = RefreshTokenStorage.from_env()
await _login_flow_storage_instance.initialize()
return _login_flow_storage_instance
async def _get_client_from_login_flow( async def _get_client_from_login_flow(
@@ -277,7 +284,6 @@ async def _get_client_from_login_flow(
Raises: Raises:
ProvisioningRequiredError: If no stored app password exists ProvisioningRequiredError: If no stored app password exists
""" """
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage # noqa: PLC0415
from nextcloud_mcp_server.server.oauth_tools import ( # noqa: PLC0415 from nextcloud_mcp_server.server.oauth_tools import ( # noqa: PLC0415
extract_user_id_from_token, extract_user_id_from_token,
) )
@@ -288,8 +294,7 @@ async def _get_client_from_login_flow(
"Cannot determine user identity from MCP token." "Cannot determine user identity from MCP token."
) )
storage = RefreshTokenStorage.from_env() storage = await _get_login_flow_storage()
await storage.initialize()
app_data = await storage.get_app_password_with_scopes(user_id) app_data = await storage.get_app_password_with_scopes(user_id)
if not app_data: if not app_data:
+20 -11
View File
@@ -28,11 +28,16 @@ from nextcloud_mcp_server.server.oauth_tools import extract_user_id_from_token
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
_storage_instance: RefreshTokenStorage | None = None
async def _get_storage() -> RefreshTokenStorage: async def _get_storage() -> RefreshTokenStorage:
"""Get initialized storage instance.""" """Get initialized storage instance (lazy singleton)."""
storage = RefreshTokenStorage.from_env() global _storage_instance
await storage.initialize() if _storage_instance is None:
return storage _storage_instance = RefreshTokenStorage.from_env()
await _storage_instance.initialize()
return _storage_instance
def register_auth_tools(mcp) -> None: def register_auth_tools(mcp) -> None:
@@ -241,7 +246,12 @@ def register_auth_tools(mcp) -> None:
if poll_result.status == "completed": if poll_result.status == "completed":
# Store the app password with scopes # Store the app password with scopes
assert poll_result.app_password is not None if poll_result.app_password is None:
return ProvisionStatusResponse(
status="error",
message="Login Flow completed but no app password was returned.",
success=False,
)
await storage.store_app_password_with_scopes( await storage.store_app_password_with_scopes(
user_id=user_id, user_id=user_id,
app_password=poll_result.app_password, app_password=poll_result.app_password,
@@ -287,8 +297,8 @@ def register_auth_tools(mcp) -> None:
title="Update Nextcloud Access Scopes", title="Update Nextcloud Access Scopes",
description=( description=(
"Update the scopes for your Nextcloud access. " "Update the scopes for your Nextcloud access. "
"This revokes the current app password and starts a new Login Flow " "This starts a new Login Flow with the combined scope set. "
"with the combined scope set." "The current app password remains valid until the new one is obtained."
), ),
annotations=ToolAnnotations( annotations=ToolAnnotations(
idempotentHint=False, idempotentHint=False,
@@ -357,11 +367,10 @@ def register_auth_tools(mcp) -> None:
success=False, success=False,
) )
# Delete existing app password from storage (user must revoke in NC Security settings)
if existing:
await storage.delete_app_password(user_id)
# Initiate new Login Flow v2 # Initiate new Login Flow v2
# Note: existing app password stays valid until the new flow completes.
# store_app_password_with_scopes() does an upsert, so the old password
# is replaced atomically when nc_auth_check_status stores the new one.
settings = get_settings() settings = get_settings()
nextcloud_host = settings.nextcloud_host nextcloud_host = settings.nextcloud_host
if not nextcloud_host: if not nextcloud_host:
+5 -32
View File
@@ -4,41 +4,17 @@ Tests the third enforcement mode in scope_authorization.py that checks
application-level scopes stored alongside app passwords. application-level scopes stored alongside app passwords.
""" """
import os
from unittest.mock import AsyncMock, patch from unittest.mock import AsyncMock, patch
import pytest import pytest
from nextcloud_mcp_server.auth.scope_authorization import ( from nextcloud_mcp_server.auth.scope_authorization import (
_get_stored_scopes, _get_stored_scopes,
_is_login_flow_mode,
) )
pytestmark = pytest.mark.unit pytestmark = pytest.mark.unit
def test_is_login_flow_mode_disabled():
"""Test that login flow mode is off by default."""
with patch.dict(os.environ, {}, clear=True):
assert _is_login_flow_mode() is False
def test_is_login_flow_mode_enabled():
"""Test that login flow mode is enabled when env var is set."""
with patch.dict(os.environ, {"ENABLE_LOGIN_FLOW": "true"}):
assert _is_login_flow_mode() is True
def test_is_login_flow_mode_case_insensitive():
"""Test case insensitivity of the env var."""
with patch.dict(os.environ, {"ENABLE_LOGIN_FLOW": "True"}):
assert _is_login_flow_mode() is True
with patch.dict(os.environ, {"ENABLE_LOGIN_FLOW": "TRUE"}):
assert _is_login_flow_mode() is True
with patch.dict(os.environ, {"ENABLE_LOGIN_FLOW": "false"}):
assert _is_login_flow_mode() is False
async def test_get_stored_scopes_with_scopes(): async def test_get_stored_scopes_with_scopes():
"""Test getting specific scopes from storage.""" """Test getting specific scopes from storage."""
mock_storage = AsyncMock() mock_storage = AsyncMock()
@@ -49,10 +25,9 @@ async def test_get_stored_scopes_with_scopes():
"created_at": 1000, "created_at": 1000,
"updated_at": 1000, "updated_at": 1000,
} }
mock_storage.initialize = AsyncMock()
with patch( with patch(
"nextcloud_mcp_server.auth.storage.RefreshTokenStorage.from_env", "nextcloud_mcp_server.auth.scope_authorization._get_scope_storage",
return_value=mock_storage, return_value=mock_storage,
): ):
result = await _get_stored_scopes("alice") result = await _get_stored_scopes("alice")
@@ -70,10 +45,9 @@ async def test_get_stored_scopes_null_scopes():
"created_at": 1000, "created_at": 1000,
"updated_at": 1000, "updated_at": 1000,
} }
mock_storage.initialize = AsyncMock()
with patch( with patch(
"nextcloud_mcp_server.auth.storage.RefreshTokenStorage.from_env", "nextcloud_mcp_server.auth.scope_authorization._get_scope_storage",
return_value=mock_storage, return_value=mock_storage,
): ):
result = await _get_stored_scopes("bob") result = await _get_stored_scopes("bob")
@@ -85,10 +59,9 @@ async def test_get_stored_scopes_no_password():
"""Test that missing app password returns None.""" """Test that missing app password returns None."""
mock_storage = AsyncMock() mock_storage = AsyncMock()
mock_storage.get_app_password_with_scopes.return_value = None mock_storage.get_app_password_with_scopes.return_value = None
mock_storage.initialize = AsyncMock()
with patch( with patch(
"nextcloud_mcp_server.auth.storage.RefreshTokenStorage.from_env", "nextcloud_mcp_server.auth.scope_authorization._get_scope_storage",
return_value=mock_storage, return_value=mock_storage,
): ):
result = await _get_stored_scopes("nobody") result = await _get_stored_scopes("nobody")
@@ -99,10 +72,10 @@ async def test_get_stored_scopes_no_password():
async def test_get_stored_scopes_storage_error(): async def test_get_stored_scopes_storage_error():
"""Test that storage errors return None (fail-closed).""" """Test that storage errors return None (fail-closed)."""
mock_storage = AsyncMock() mock_storage = AsyncMock()
mock_storage.initialize.side_effect = RuntimeError("DB error") mock_storage.get_app_password_with_scopes.side_effect = RuntimeError("DB error")
with patch( with patch(
"nextcloud_mcp_server.auth.storage.RefreshTokenStorage.from_env", "nextcloud_mcp_server.auth.scope_authorization._get_scope_storage",
return_value=mock_storage, return_value=mock_storage,
): ):
result = await _get_stored_scopes("alice") result = await _get_stored_scopes("alice")