diff --git a/docs/authentication.md b/docs/authentication.md index 271b795..ef48fe8 100644 --- a/docs/authentication.md +++ b/docs/authentication.md @@ -223,6 +223,55 @@ NEXTCLOUD_OIDC_CLIENT_SECRET= | Token Storage | None | Refresh tokens only | All tokens | | Deployment Complexity | Low | Medium | High | +### Astrolabe User Setup (Hybrid Mode) + +When Astrolabe connects to an MCP server running in hybrid mode, users must complete a **two-step credential setup**: + +#### Step 1: OAuth Authorization (Search Access) + +**Purpose**: Allows Astrolabe to call MCP server APIs on the user's behalf. + +**Flow**: +1. User opens Astrolabe Personal Settings in Nextcloud +2. Clicks "Authorize" button +3. Redirected to Astrolabe's OAuth controller (`/apps/astrolabe/oauth/initiate`) +4. OAuth controller discovers IdP from MCP server's `/api/v1/status` endpoint +5. User authenticates with Identity Provider (Nextcloud OIDC or external IdP) +6. Tokens stored in Nextcloud user config (`McpTokenStorage`) +7. Astrolabe can now perform semantic searches via MCP API + +**Technical Details**: +- Token audience: MCP server +- Token storage: Nextcloud app config (`oc_preferences`) +- Used for: `/api/v1/search`, `/api/v1/status` (authenticated endpoints) + +#### Step 2: App Password (Background Indexing) + +**Purpose**: Allows MCP server to access Nextcloud content for background sync. + +**Flow**: +1. User generates app password in Nextcloud Security settings +2. Enters app password in Astrolabe Personal Settings +3. App password validated against Nextcloud and stored (encrypted) +4. MCP server can now index user's content in the background + +**Technical Details**: +- Credential type: Nextcloud app password +- Token storage: MCP server's refresh token database +- Used for: Background indexing, content sync to vector database + +#### Why Two Credentials? + +| Direction | Auth Method | Purpose | +|-----------|-------------|---------| +| Astrolabe → MCP Server | OAuth Bearer Token | User searches, settings management | +| MCP Server → Nextcloud | BasicAuth (App Password) | Background content indexing | + +The separation ensures: +- **Security**: Each credential has limited scope +- **Audit Trail**: OAuth tokens identify users; app passwords enable background ops +- **User Control**: Users explicitly grant each type of access + ### See Also - [OAuth Architecture](oauth-architecture.md) - Progressive Consent (Flow 2) details - [Configuration](configuration.md#enable_offline_access) - Hybrid mode configuration diff --git a/tests/unit/test_management_status_endpoint.py b/tests/unit/test_management_status_endpoint.py new file mode 100644 index 0000000..11fff7e --- /dev/null +++ b/tests/unit/test_management_status_endpoint.py @@ -0,0 +1,337 @@ +""" +Unit tests for Management API status endpoint. + +Tests the /api/v1/status endpoint focusing on: +- OIDC config availability in different auth modes +- Hybrid mode (multi_user_basic + enable_offline_access) returning OIDC config +- OAuth mode returning OIDC config +- Non-OAuth modes NOT returning OIDC config +""" + +from unittest.mock import MagicMock, patch + +import pytest +from starlette.applications import Starlette +from starlette.routing import Route +from starlette.testclient import TestClient + +from nextcloud_mcp_server.api.management import get_server_status +from nextcloud_mcp_server.config_validators import AuthMode + +pytestmark = pytest.mark.unit + + +def create_test_app(): + """Create a test Starlette app with the status endpoint.""" + return Starlette( + routes=[ + Route("/api/v1/status", get_server_status, methods=["GET"]), + ] + ) + + +def create_mock_settings( + enable_multi_user_basic: bool = False, + enable_offline_access: bool = False, + oidc_discovery_url: str | None = None, + oidc_issuer: str | None = None, + vector_sync_enabled: bool = False, + nextcloud_url: str = "http://localhost", + enable_token_exchange: bool = False, + mcp_client_id: str | None = None, + mcp_client_secret: str | None = None, +): + """Create mock settings with specified auth configuration.""" + settings = MagicMock() + settings.enable_multi_user_basic_auth = enable_multi_user_basic + settings.enable_offline_access = enable_offline_access + settings.oidc_discovery_url = oidc_discovery_url + settings.oidc_issuer = oidc_issuer + settings.vector_sync_enabled = vector_sync_enabled + settings.nextcloud_url = nextcloud_url + settings.enable_token_exchange = enable_token_exchange + settings.mcp_client_id = mcp_client_id + settings.mcp_client_secret = mcp_client_secret + return settings + + +class TestStatusEndpointOidcConfig: + """Tests for OIDC configuration in status endpoint.""" + + def test_hybrid_mode_returns_oidc_config(self): + """Test that hybrid mode (multi_user_basic + offline_access) returns OIDC config.""" + mock_settings = create_mock_settings( + enable_multi_user_basic=True, + enable_offline_access=True, + oidc_discovery_url="http://keycloak/.well-known/openid-configuration", + oidc_issuer="http://keycloak/realms/test", + ) + + # get_settings and detect_auth_mode are imported inside the function + with ( + patch( + "nextcloud_mcp_server.config.get_settings", return_value=mock_settings + ), + patch( + "nextcloud_mcp_server.config_validators.detect_auth_mode", + return_value=AuthMode.MULTI_USER_BASIC, + ), + ): + app = create_test_app() + client = TestClient(app) + response = client.get("/api/v1/status") + + assert response.status_code == 200 + data = response.json() + + # Verify auth mode + assert data["auth_mode"] == "multi_user_basic" + assert data["supports_app_passwords"] is True + + # Verify OIDC config is present (key feature for hybrid mode) + assert "oidc" in data + assert ( + data["oidc"]["discovery_url"] + == "http://keycloak/.well-known/openid-configuration" + ) + assert data["oidc"]["issuer"] == "http://keycloak/realms/test" + + def test_hybrid_mode_without_oidc_settings_no_oidc_key(self): + """Test that hybrid mode without OIDC settings doesn't include empty oidc key.""" + mock_settings = create_mock_settings( + enable_multi_user_basic=True, + enable_offline_access=True, + oidc_discovery_url=None, + oidc_issuer=None, + ) + + with ( + patch( + "nextcloud_mcp_server.config.get_settings", return_value=mock_settings + ), + patch( + "nextcloud_mcp_server.config_validators.detect_auth_mode", + return_value=AuthMode.MULTI_USER_BASIC, + ), + ): + app = create_test_app() + client = TestClient(app) + response = client.get("/api/v1/status") + + assert response.status_code == 200 + data = response.json() + + # OIDC key should NOT be present if no OIDC settings configured + assert "oidc" not in data + + def test_multi_user_basic_without_offline_access_no_oidc(self): + """Test that multi_user_basic WITHOUT offline_access doesn't return OIDC config.""" + mock_settings = create_mock_settings( + enable_multi_user_basic=True, + enable_offline_access=False, # Key difference: no offline access + oidc_discovery_url="http://keycloak/.well-known/openid-configuration", + oidc_issuer="http://keycloak/realms/test", + ) + + with ( + patch( + "nextcloud_mcp_server.config.get_settings", return_value=mock_settings + ), + patch( + "nextcloud_mcp_server.config_validators.detect_auth_mode", + return_value=AuthMode.MULTI_USER_BASIC, + ), + ): + app = create_test_app() + client = TestClient(app) + response = client.get("/api/v1/status") + + assert response.status_code == 200 + data = response.json() + + # Verify auth mode + assert data["auth_mode"] == "multi_user_basic" + assert data["supports_app_passwords"] is False + + # OIDC config should NOT be present (not hybrid mode) + assert "oidc" not in data + + def test_oauth_mode_returns_oidc_config(self): + """Test that OAuth mode returns OIDC config.""" + mock_settings = create_mock_settings( + enable_multi_user_basic=False, + enable_offline_access=True, + oidc_discovery_url="http://nextcloud/.well-known/openid-configuration", + oidc_issuer="http://nextcloud", + ) + + with ( + patch( + "nextcloud_mcp_server.config.get_settings", return_value=mock_settings + ), + patch( + "nextcloud_mcp_server.config_validators.detect_auth_mode", + return_value=AuthMode.OAUTH_SINGLE_AUDIENCE, + ), + ): + app = create_test_app() + client = TestClient(app) + response = client.get("/api/v1/status") + + assert response.status_code == 200 + data = response.json() + + # Verify auth mode + assert data["auth_mode"] == "oauth" + + # Verify OIDC config is present + assert "oidc" in data + assert ( + data["oidc"]["discovery_url"] + == "http://nextcloud/.well-known/openid-configuration" + ) + + def test_single_user_basic_no_oidc(self): + """Test that single-user BasicAuth mode doesn't return OIDC config.""" + mock_settings = create_mock_settings( + enable_multi_user_basic=False, + enable_offline_access=False, + oidc_discovery_url="http://keycloak/.well-known/openid-configuration", + oidc_issuer="http://keycloak/realms/test", + ) + + with ( + patch( + "nextcloud_mcp_server.config.get_settings", return_value=mock_settings + ), + patch( + "nextcloud_mcp_server.config_validators.detect_auth_mode", + return_value=AuthMode.SINGLE_USER_BASIC, + ), + ): + app = create_test_app() + client = TestClient(app) + response = client.get("/api/v1/status") + + assert response.status_code == 200 + data = response.json() + + # Verify auth mode + assert data["auth_mode"] == "basic" + + # OIDC config should NOT be present + assert "oidc" not in data + # supports_app_passwords should NOT be present (only for multi_user_basic) + assert "supports_app_passwords" not in data + + def test_oidc_partial_config_only_discovery_url(self): + """Test OIDC config with only discovery URL set.""" + mock_settings = create_mock_settings( + enable_multi_user_basic=True, + enable_offline_access=True, + oidc_discovery_url="http://keycloak/.well-known/openid-configuration", + oidc_issuer=None, # Only discovery URL + ) + + with ( + patch( + "nextcloud_mcp_server.config.get_settings", return_value=mock_settings + ), + patch( + "nextcloud_mcp_server.config_validators.detect_auth_mode", + return_value=AuthMode.MULTI_USER_BASIC, + ), + ): + app = create_test_app() + client = TestClient(app) + response = client.get("/api/v1/status") + + assert response.status_code == 200 + data = response.json() + + assert "oidc" in data + assert ( + data["oidc"]["discovery_url"] + == "http://keycloak/.well-known/openid-configuration" + ) + assert "issuer" not in data["oidc"] + + def test_oidc_partial_config_only_issuer(self): + """Test OIDC config with only issuer set.""" + mock_settings = create_mock_settings( + enable_multi_user_basic=True, + enable_offline_access=True, + oidc_discovery_url=None, # Only issuer + oidc_issuer="http://keycloak/realms/test", + ) + + with ( + patch( + "nextcloud_mcp_server.config.get_settings", return_value=mock_settings + ), + patch( + "nextcloud_mcp_server.config_validators.detect_auth_mode", + return_value=AuthMode.MULTI_USER_BASIC, + ), + ): + app = create_test_app() + client = TestClient(app) + response = client.get("/api/v1/status") + + assert response.status_code == 200 + data = response.json() + + assert "oidc" in data + assert "discovery_url" not in data["oidc"] + assert data["oidc"]["issuer"] == "http://keycloak/realms/test" + + +class TestStatusEndpointBasicResponse: + """Tests for basic status endpoint response fields.""" + + def test_status_includes_version(self): + """Test that status endpoint includes version.""" + mock_settings = create_mock_settings() + + with ( + patch( + "nextcloud_mcp_server.config.get_settings", return_value=mock_settings + ), + patch( + "nextcloud_mcp_server.config_validators.detect_auth_mode", + return_value=AuthMode.SINGLE_USER_BASIC, + ), + ): + app = create_test_app() + client = TestClient(app) + response = client.get("/api/v1/status") + + assert response.status_code == 200 + data = response.json() + + assert "version" in data + assert "uptime_seconds" in data + assert "management_api_version" in data + assert data["management_api_version"] == "1.0" + + def test_status_includes_vector_sync_enabled(self): + """Test that status endpoint includes vector_sync_enabled.""" + mock_settings = create_mock_settings(vector_sync_enabled=True) + + with ( + patch( + "nextcloud_mcp_server.config.get_settings", return_value=mock_settings + ), + patch( + "nextcloud_mcp_server.config_validators.detect_auth_mode", + return_value=AuthMode.SINGLE_USER_BASIC, + ), + ): + app = create_test_app() + client = TestClient(app) + response = client.get("/api/v1/status") + + assert response.status_code == 200 + data = response.json() + + assert data["vector_sync_enabled"] is True