diff --git a/nextcloud_mcp_server/api/management.py b/nextcloud_mcp_server/api/management.py index 0f10d6b9..b621cec2 100644 --- a/nextcloud_mcp_server/api/management.py +++ b/nextcloud_mcp_server/api/management.py @@ -212,7 +212,7 @@ async def get_server_status(request: Request) -> JSONResponse: # Map deployment mode to auth_mode for API response # This helps clients (like Astrolabe) determine which auth flow to use - if mode == AuthMode.OAUTH_SINGLE_AUDIENCE: + if mode == AuthMode.LOGIN_FLOW: auth_mode = "oauth" elif mode == AuthMode.MULTI_USER_BASIC: auth_mode = "multi_user_basic" diff --git a/nextcloud_mcp_server/app.py b/nextcloud_mcp_server/app.py index 2fa6eff9..5bdedf86 100644 --- a/nextcloud_mcp_server/app.py +++ b/nextcloud_mcp_server/app.py @@ -1015,8 +1015,11 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None = logger.info(f"✅ Configuration validated successfully for {mode.value} mode") logger.debug(f"Mode details:\n{get_mode_summary(mode)}") - # Derive helper variables for backward compatibility with existing code - oauth_enabled = mode == AuthMode.OAUTH_SINGLE_AUDIENCE + # Derive helper variables for backward compatibility with existing code. + # `oauth_enabled` is True for the LOGIN_FLOW (formerly OAUTH_SINGLE_AUDIENCE) + # multi-user OAuth mode — in this mode the MCP server is an OIDC relying + # party and Login Flow v2 acquires per-user Nextcloud app passwords. + oauth_enabled = mode == AuthMode.LOGIN_FLOW # Log hybrid authentication status for multi-user BasicAuth with offline access if mode == AuthMode.MULTI_USER_BASIC and settings.enable_offline_access: logger.info( @@ -1166,8 +1169,8 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None = raise # Create MCP server based on detected mode - if mode == AuthMode.OAUTH_SINGLE_AUDIENCE: - logger.info("Configuring MCP server for OAuth mode") + if mode == AuthMode.LOGIN_FLOW: + logger.info("Configuring MCP server for %s mode", mode.value) # Asynchronously get the OAuth configuration ( @@ -1932,7 +1935,7 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None = # Check authentication configuration # Report the deployment mode, not just whether OAuth is enabled # This helps clients (like Astrolabe) determine which auth flow to use - if mode == AuthMode.OAUTH_SINGLE_AUDIENCE: + if mode == AuthMode.LOGIN_FLOW: checks["auth_mode"] = "oauth" checks["auth_configured"] = "ok" elif mode == AuthMode.MULTI_USER_BASIC: diff --git a/nextcloud_mcp_server/config.py b/nextcloud_mcp_server/config.py index 3a205110..dd806385 100644 --- a/nextcloud_mcp_server/config.py +++ b/nextcloud_mcp_server/config.py @@ -408,9 +408,10 @@ def get_document_processor_config() -> dict[str, Any]: class Settings: """Application settings from environment variables.""" - # Deployment mode (ADR-021: explicit mode selection) + # Deployment mode (ADR-021: explicit mode selection; updated by ADR-022) # Optional: If not set, mode is auto-detected from other settings - # Valid values: single_user_basic, multi_user_basic, oauth_single_audience + # Valid values: single_user_basic, multi_user_basic, login_flow + # (ADR-022: `oauth_single_audience` was renamed to `login_flow`.) deployment_mode: str | None = None # OAuth/OIDC settings diff --git a/nextcloud_mcp_server/config_validators.py b/nextcloud_mcp_server/config_validators.py index d2e4ff8a..1d497817 100644 --- a/nextcloud_mcp_server/config_validators.py +++ b/nextcloud_mcp_server/config_validators.py @@ -25,7 +25,7 @@ class AuthMode(Enum): SINGLE_USER_BASIC = "single_user_basic" MULTI_USER_BASIC = "multi_user_basic" - OAUTH_SINGLE_AUDIENCE = "oauth_single" + LOGIN_FLOW = "login_flow" @dataclass @@ -113,8 +113,8 @@ MODE_REQUIREMENTS: dict[AuthMode, ModeRequirements] = { "Users provide credentials in request headers. " "Optional background sync using app passwords stored via Astrolabe.", ), - AuthMode.OAUTH_SINGLE_AUDIENCE: ModeRequirements( - required=["nextcloud_host"], + AuthMode.LOGIN_FLOW: ModeRequirements( + required=["nextcloud_host", "enable_login_flow"], optional=[ # OAuth credentials (uses DCR if not provided) "oidc_client_id", @@ -149,9 +149,13 @@ MODE_REQUIREMENTS: dict[AuthMode, ModeRequirements] = { # enables background operations in multi-user modes. No explicit # enable_offline_access setting required. }, - description="OAuth multi-user deployment with single-audience tokens. " - "Tokens work for both MCP server and Nextcloud APIs (pass-through). " - "Uses Dynamic Client Registration if credentials not provided.", + description="OAuth multi-user deployment using Login Flow v2 to acquire " + "per-user Nextcloud app passwords via a browser flow. The MCP server " + "is an OIDC relying party of a configurable IdP (Nextcloud's built-in " + "OIDC by default; Keycloak, AWS Cognito, etc. via OIDC_DISCOVERY_URL). " + "Uses Dynamic Client Registration if credentials not provided. " + "Replaces the deprecated direct OAuth bearer-token pass-through which " + "required unmerged user_oidc patches (see ADR-022).", ), } @@ -159,11 +163,11 @@ MODE_REQUIREMENTS: dict[AuthMode, ModeRequirements] = { def detect_auth_mode(settings: Settings) -> AuthMode: """Detect authentication mode from configuration. - Mode detection priority (ADR-021): + Mode detection priority (ADR-021, updated for ADR-022): 0. Explicit MCP_DEPLOYMENT_MODE (if set) - NEW in ADR-021 1. Multi-user BasicAuth 2. Single-user BasicAuth - 3. OAuth single-audience (default OAuth mode) + 3. Login Flow v2 (default — was OAuth single-audience pre-ADR-022) Args: settings: Application settings @@ -185,7 +189,7 @@ def detect_auth_mode(settings: Settings) -> AuthMode: mode_map = { "single_user_basic": AuthMode.SINGLE_USER_BASIC, "multi_user_basic": AuthMode.MULTI_USER_BASIC, - "oauth_single_audience": AuthMode.OAUTH_SINGLE_AUDIENCE, + "login_flow": AuthMode.LOGIN_FLOW, } if mode_str not in mode_map: @@ -208,9 +212,11 @@ def detect_auth_mode(settings: Settings) -> AuthMode: if settings.nextcloud_username and settings.nextcloud_password: return AuthMode.SINGLE_USER_BASIC - # Default: OAuth single-audience mode - # This is the safest multi-user mode (no credential storage) - return AuthMode.OAUTH_SINGLE_AUDIENCE + # Default: Login Flow v2 multi-user mode (browser-based app-password flow). + # The un-augmented OAuth bearer pass-through it replaced required unmerged + # Nextcloud user_oidc patches (see ADR-022); selecting LOGIN_FLOW without + # ENABLE_LOGIN_FLOW=true fails validation below. + return AuthMode.LOGIN_FLOW def validate_configuration(settings: Settings) -> tuple[AuthMode, list[str]]: @@ -301,7 +307,18 @@ def validate_configuration(settings: Settings) -> tuple[AuthMode, list[str]]: f"{settings.nextcloud_host}" ) - if mode == AuthMode.OAUTH_SINGLE_AUDIENCE: + if mode == AuthMode.LOGIN_FLOW: + # ADR-022: LOGIN_FLOW requires the Login Flow v2 layer. The un-augmented + # OAuth bearer pass-through (formerly OAUTH_SINGLE_AUDIENCE without + # ENABLE_LOGIN_FLOW) needed unmerged Nextcloud user_oidc patches and + # is no longer supported. + if not settings.enable_login_flow: + errors.append( + f"[{mode.value}] ENABLE_LOGIN_FLOW=true is required for " + "login_flow mode. The un-augmented OAuth path is no longer " + "supported — see ADR-022." + ) + # If OAuth credentials not provided, DCR must be available # (This is a runtime check, not a config check, so we just warn) if not settings.oidc_client_id or not settings.oidc_client_secret: diff --git a/tests/unit/test_config_validators.py b/tests/unit/test_config_validators.py index 3aad9eaa..5f633754 100644 --- a/tests/unit/test_config_validators.py +++ b/tests/unit/test_config_validators.py @@ -43,14 +43,14 @@ class TestModeDetection: mode = detect_auth_mode(settings) assert mode == AuthMode.SINGLE_USER_BASIC - def test_oauth_single_audience_default(self): - """Test OAuth single-audience is default mode.""" + def test_login_flow_default(self): + """Test Login Flow v2 is the default multi-user mode.""" settings = Settings( nextcloud_host="http://localhost", ) mode = detect_auth_mode(settings) - assert mode == AuthMode.OAUTH_SINGLE_AUDIENCE + assert mode == AuthMode.LOGIN_FLOW class TestSingleUserBasicValidation: @@ -108,7 +108,7 @@ class TestSingleUserBasicValidation: # Mode detection requires BOTH username AND password for single-user BasicAuth # If only one is present, it defaults to OAuth single-audience - assert mode == AuthMode.OAUTH_SINGLE_AUDIENCE + assert mode == AuthMode.LOGIN_FLOW # In OAuth mode, having a password set is forbidden assert any("nextcloud_password" in err.lower() for err in errors) @@ -123,7 +123,7 @@ class TestSingleUserBasicValidation: # Mode detection requires BOTH username AND password for single-user BasicAuth # If only one is present, it defaults to OAuth single-audience - assert mode == AuthMode.OAUTH_SINGLE_AUDIENCE + assert mode == AuthMode.LOGIN_FLOW # In OAuth mode, having a username set is forbidden assert any("nextcloud_username" in err.lower() for err in errors) @@ -285,37 +285,40 @@ class TestMultiUserBasicValidation: assert settings.enable_offline_access is True -class TestOAuthSingleAudienceValidation: - """Test validation for OAuth single-audience mode.""" +class TestLoginFlowValidation: + """Test validation for Login Flow v2 mode (formerly OAUTH_SINGLE_AUDIENCE).""" def test_valid_minimal_config(self): - """Test valid minimal OAuth single-audience config.""" + """Test valid minimal Login Flow v2 config.""" settings = Settings( nextcloud_host="http://localhost", + enable_login_flow=True, ) mode, errors = validate_configuration(settings) - assert mode == AuthMode.OAUTH_SINGLE_AUDIENCE + assert mode == AuthMode.LOGIN_FLOW assert len(errors) == 0 def test_valid_with_static_credentials(self): """Test valid config with static OAuth credentials.""" settings = Settings( nextcloud_host="http://localhost", + enable_login_flow=True, oidc_client_id="test-client", oidc_client_secret="test-secret", ) mode, errors = validate_configuration(settings) - assert mode == AuthMode.OAUTH_SINGLE_AUDIENCE + assert mode == AuthMode.LOGIN_FLOW assert len(errors) == 0 def test_valid_with_offline_access(self): """Test valid config with offline access.""" settings = Settings( nextcloud_host="http://localhost", + enable_login_flow=True, oidc_client_id="test-client", oidc_client_secret="test-secret", enable_offline_access=True, @@ -325,7 +328,7 @@ class TestOAuthSingleAudienceValidation: mode, errors = validate_configuration(settings) - assert mode == AuthMode.OAUTH_SINGLE_AUDIENCE + assert mode == AuthMode.LOGIN_FLOW assert len(errors) == 0 def test_forbidden_username_password(self): @@ -345,29 +348,45 @@ class TestOAuthSingleAudienceValidation: """Test error when offline access enabled but encryption key missing.""" settings = Settings( nextcloud_host="http://localhost", + enable_login_flow=True, enable_offline_access=True, token_storage_db="/tmp/tokens.db", ) mode, errors = validate_configuration(settings) - assert mode == AuthMode.OAUTH_SINGLE_AUDIENCE + assert mode == AuthMode.LOGIN_FLOW assert any("token_encryption_key" in err.lower() for err in errors) - def test_vector_sync_auto_enables_background_ops_in_oauth_mode(self): - """Test vector sync automatically enables background operations in OAuth mode (ADR-021).""" + def test_login_flow_requires_enable_login_flow_flag(self): + """ADR-022: LOGIN_FLOW mode must error when ENABLE_LOGIN_FLOW is not true.""" + settings = Settings( + nextcloud_host="http://localhost", + # enable_login_flow deliberately omitted (defaults to False) + ) + + mode, errors = validate_configuration(settings) + + assert mode == AuthMode.LOGIN_FLOW + assert any("ENABLE_LOGIN_FLOW" in err for err in errors), ( + f"Expected ENABLE_LOGIN_FLOW gate error, got: {errors}" + ) + + def test_vector_sync_auto_enables_background_ops_in_login_flow_mode(self): + """Test vector sync automatically enables background operations in Login Flow v2 mode (ADR-021).""" # Before ADR-021: This would have failed validation (required explicit ENABLE_OFFLINE_ACCESS) # After ADR-021: vector_sync_enabled auto-enables background operations in multi-user modes with patch.dict( os.environ, { "NEXTCLOUD_HOST": "http://localhost:8080", + "ENABLE_LOGIN_FLOW": "true", "VECTOR_SYNC_ENABLED": "true", "QDRANT_LOCATION": ":memory:", "OLLAMA_BASE_URL": "http://ollama:11434", "TOKEN_ENCRYPTION_KEY": "test-key", "TOKEN_STORAGE_DB": "/tmp/test.db", - # Note: No username/password = OAuth mode + # Note: No username/password = Login Flow v2 multi-user OAuth mode }, clear=True, ): @@ -377,7 +396,7 @@ class TestOAuthSingleAudienceValidation: settings = get_settings() mode, errors = validate_configuration(settings) - assert mode == AuthMode.OAUTH_SINGLE_AUDIENCE + assert mode == AuthMode.LOGIN_FLOW # Should have no errors - background operations auto-enabled assert len(errors) == 0 # Verify background operations were auto-enabled @@ -515,8 +534,8 @@ class TestConfigurationConsolidation: settings = get_settings() assert settings.enable_offline_access is True - def test_semantic_search_auto_enables_background_ops_in_oauth_mode(self): - """Test ENABLE_SEMANTIC_SEARCH automatically enables background operations in OAuth mode.""" + def test_semantic_search_auto_enables_background_ops_in_login_flow_mode(self): + """Test ENABLE_SEMANTIC_SEARCH automatically enables background operations in Login Flow v2 mode.""" with patch.dict( os.environ, { @@ -707,13 +726,13 @@ class TestExplicitModeSelection: assert mode == AuthMode.MULTI_USER_BASIC - def test_explicit_oauth_single_audience_mode(self): - """Test explicit oauth_single_audience mode selection.""" + def test_explicit_login_flow_mode(self): + """Test explicit login_flow mode selection.""" with patch.dict( os.environ, { "NEXTCLOUD_HOST": "http://localhost:8080", - "MCP_DEPLOYMENT_MODE": "oauth_single_audience", + "MCP_DEPLOYMENT_MODE": "login_flow", }, clear=True, ): @@ -723,7 +742,7 @@ class TestExplicitModeSelection: settings = get_settings() mode = detect_auth_mode(settings) - assert mode == AuthMode.OAUTH_SINGLE_AUDIENCE + assert mode == AuthMode.LOGIN_FLOW def test_invalid_deployment_mode_raises_error(self): """Test invalid MCP_DEPLOYMENT_MODE raises ValueError.""" @@ -757,7 +776,7 @@ class TestExplicitModeSelection: "NEXTCLOUD_HOST": "http://localhost:8080", "NEXTCLOUD_USERNAME": "admin", # Would auto-detect as single_user_basic "NEXTCLOUD_PASSWORD": "password", - "MCP_DEPLOYMENT_MODE": "oauth_single_audience", # Explicit override + "MCP_DEPLOYMENT_MODE": "login_flow", # Explicit override }, clear=True, ): @@ -768,7 +787,7 @@ class TestExplicitModeSelection: mode = detect_auth_mode(settings) # Should use explicit mode, not auto-detected mode - assert mode == AuthMode.OAUTH_SINGLE_AUDIENCE + assert mode == AuthMode.LOGIN_FLOW def test_case_insensitive_mode_names(self): """Test MCP_DEPLOYMENT_MODE is case-insensitive.""" @@ -776,7 +795,7 @@ class TestExplicitModeSelection: os.environ, { "NEXTCLOUD_HOST": "http://localhost:8080", - "MCP_DEPLOYMENT_MODE": "OAUTH_SINGLE_AUDIENCE", # Uppercase + "MCP_DEPLOYMENT_MODE": "LOGIN_FLOW", # Uppercase }, clear=True, ): @@ -786,7 +805,7 @@ class TestExplicitModeSelection: settings = get_settings() mode = detect_auth_mode(settings) - assert mode == AuthMode.OAUTH_SINGLE_AUDIENCE + assert mode == AuthMode.LOGIN_FLOW def test_whitespace_in_mode_name_stripped(self): """Test whitespace in MCP_DEPLOYMENT_MODE is stripped.""" @@ -794,7 +813,7 @@ class TestExplicitModeSelection: os.environ, { "NEXTCLOUD_HOST": "http://localhost:8080", - "MCP_DEPLOYMENT_MODE": " oauth_single_audience ", # Whitespace + "MCP_DEPLOYMENT_MODE": " login_flow ", # Whitespace }, clear=True, ): @@ -804,4 +823,4 @@ class TestExplicitModeSelection: settings = get_settings() mode = detect_auth_mode(settings) - assert mode == AuthMode.OAUTH_SINGLE_AUDIENCE + assert mode == AuthMode.LOGIN_FLOW diff --git a/tests/unit/test_management_status_endpoint.py b/tests/unit/test_management_status_endpoint.py index f6ea5f40..313a12a7 100644 --- a/tests/unit/test_management_status_endpoint.py +++ b/tests/unit/test_management_status_endpoint.py @@ -173,7 +173,7 @@ class TestStatusEndpointOidcConfig: ), patch( "nextcloud_mcp_server.api.management.detect_auth_mode", - return_value=AuthMode.OAUTH_SINGLE_AUDIENCE, + return_value=AuthMode.LOGIN_FLOW, ), ): app = create_test_app()