diff --git a/docker-compose.yml b/docker-compose.yml index 684d68c..b3b679d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -43,6 +43,11 @@ services: - MYSQL_USER=nextcloud - MYSQL_HOST=db - REDIS_HOST=redis + healthcheck: + test: ["CMD-SHELL", "curl -Ss http://localhost/status.php | grep '\"installed\":true' || exit 1"] + interval: 10s + timeout: 30s + retries: 30 recipes: image: docker.io/library/nginx:alpine@sha256:b3c656d55d7ad751196f21b7fd2e8d4da9cb430e32f646adcf92441b72f82b14 @@ -137,11 +142,12 @@ services: - OIDC_DISCOVERY_URL=http://keycloak:8080/realms/nextcloud-mcp/.well-known/openid-configuration - OIDC_CLIENT_ID=nextcloud-mcp-server - OIDC_CLIENT_SECRET=mcp-secret-change-in-production + - OIDC_JWKS_URI=http://keycloak:8080/realms/nextcloud-mcp/protocol/openid-connect/certs # Nextcloud API endpoint (for accessing APIs with validated token) - NEXTCLOUD_HOST=http://app:80 - NEXTCLOUD_MCP_SERVER_URL=http://localhost:8002 - - NEXTCLOUD_PUBLIC_ISSUER_URL=http://localhost:8888 + - NEXTCLOUD_PUBLIC_ISSUER_URL=http://localhost:8888/realms/nextcloud-mcp # Refresh token storage (ADR-002 Tier 1 & 2) - ENABLE_OFFLINE_ACCESS=true diff --git a/keycloak/realm-export.json b/keycloak/realm-export.json index 90a2f77..344ab54 100644 --- a/keycloak/realm-export.json +++ b/keycloak/realm-export.json @@ -23,6 +23,9 @@ "resetPasswordAllowed": false, "editUsernameAllowed": false, "bruteForceProtected": false, + "attributes": { + "frontendUrl": "http://localhost:8888" + }, "roles": { "realm": [ { @@ -196,7 +199,30 @@ } ], "defaultClientScopes": ["web-origins", "profile", "roles", "email"], - "optionalClientScopes": ["address", "phone", "offline_access", "microprofile-jwt"] + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "microprofile-jwt", + "notes:read", + "notes:write", + "calendar:read", + "calendar:write", + "contacts:read", + "contacts:write", + "cookbook:read", + "cookbook:write", + "deck:read", + "deck:write", + "tables:read", + "tables:write", + "files:read", + "files:write", + "sharing:read", + "sharing:write", + "todo:read", + "todo:write" + ] } ], "clientScopes": [ @@ -324,6 +350,186 @@ "config": {} } ] + }, + { + "name": "notes:read", + "description": "Nextcloud Notes read access", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "true", + "consent.screen.text": "Read your notes" + } + }, + { + "name": "notes:write", + "description": "Nextcloud Notes write access", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "true", + "consent.screen.text": "Create, update, and delete your notes" + } + }, + { + "name": "calendar:read", + "description": "Nextcloud Calendar read access", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "true", + "consent.screen.text": "Read your calendars and events" + } + }, + { + "name": "calendar:write", + "description": "Nextcloud Calendar write access", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "true", + "consent.screen.text": "Create, update, and delete calendars and events" + } + }, + { + "name": "contacts:read", + "description": "Nextcloud Contacts read access", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "true", + "consent.screen.text": "Read your contacts" + } + }, + { + "name": "contacts:write", + "description": "Nextcloud Contacts write access", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "true", + "consent.screen.text": "Create, update, and delete contacts" + } + }, + { + "name": "cookbook:read", + "description": "Nextcloud Cookbook read access", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "true", + "consent.screen.text": "Read your recipes" + } + }, + { + "name": "cookbook:write", + "description": "Nextcloud Cookbook write access", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "true", + "consent.screen.text": "Create, update, and delete recipes" + } + }, + { + "name": "deck:read", + "description": "Nextcloud Deck read access", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "true", + "consent.screen.text": "Read your boards and cards" + } + }, + { + "name": "deck:write", + "description": "Nextcloud Deck write access", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "true", + "consent.screen.text": "Create, update, and delete boards and cards" + } + }, + { + "name": "tables:read", + "description": "Nextcloud Tables read access", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "true", + "consent.screen.text": "Read your tables and rows" + } + }, + { + "name": "tables:write", + "description": "Nextcloud Tables write access", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "true", + "consent.screen.text": "Create, update, and delete tables and rows" + } + }, + { + "name": "files:read", + "description": "Nextcloud Files read access", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "true", + "consent.screen.text": "Read your files" + } + }, + { + "name": "files:write", + "description": "Nextcloud Files write access", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "true", + "consent.screen.text": "Upload, update, and delete files" + } + }, + { + "name": "sharing:read", + "description": "Nextcloud Sharing read access", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "true", + "consent.screen.text": "View shared resources" + } + }, + { + "name": "sharing:write", + "description": "Nextcloud Sharing write access", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "true", + "consent.screen.text": "Create and manage shares" + } + }, + { + "name": "todo:read", + "description": "Nextcloud Tasks/Todo read access", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "true", + "consent.screen.text": "Read your tasks" + } + }, + { + "name": "todo:write", + "description": "Nextcloud Tasks/Todo write access", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "true", + "consent.screen.text": "Create, update, and delete tasks" + } } ] } diff --git a/nextcloud_mcp_server/app.py b/nextcloud_mcp_server/app.py index 2cb481f..5476793 100644 --- a/nextcloud_mcp_server/app.py +++ b/nextcloud_mcp_server/app.py @@ -481,6 +481,13 @@ async def setup_oauth_config(): introspection_uri = discovery.get("introspection_endpoint") registration_endpoint = discovery.get("registration_endpoint") + # Allow overriding JWKS URI (useful when running in Docker with frontendUrl) + # Example: frontendUrl=http://localhost:8888 but MCP server needs http://keycloak:8080 + jwks_uri_override = os.getenv("OIDC_JWKS_URI") + if jwks_uri_override: + logger.info(f"OIDC_JWKS_URI override: {jwks_uri} → {jwks_uri_override}") + jwks_uri = jwks_uri_override + logger.info("OIDC endpoints discovered:") logger.info(f" Issuer: {issuer}") logger.info(f" Userinfo: {userinfo_uri}") diff --git a/pyproject.toml b/pyproject.toml index 9c17772..2b064cf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -42,7 +42,7 @@ Changelog = "https://github.com/cbcoutinho/nextcloud-mcp-server/blob/master/CHAN [tool.pytest.ini_options] anyio_mode = "auto" -addopts = "-p no:asyncio -x" # Disable pytest-asyncio plugin, use only anyio +addopts = "-p no:asyncio -x --headed" # Disable pytest-asyncio plugin, use only anyio log_cli = 1 log_cli_level = "ERROR" log_level = "ERROR" diff --git a/tests/conftest.py b/tests/conftest.py index 950d0df..18b1d79 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2456,3 +2456,286 @@ async def test_user_in_group(nc_client: NextcloudClient, test_user, test_group): logger.debug(f"Added user {user_config['userid']} to group {groupid}") yield (user_config, groupid) + + +# =========================================================================================== +# Keycloak External IdP OAuth Fixtures +# =========================================================================================== + + +@pytest.fixture(scope="session") +async def keycloak_oauth_client_credentials(anyio_backend, oauth_callback_server): + """ + Fixture to obtain Keycloak OAuth client credentials for external IdP testing. + + Uses pre-configured client from keycloak/realm-export.json (no DCR needed). + The client (nextcloud-mcp-server) is already configured with: + - serviceAccountsEnabled=true + - token.exchange.grant.enabled=true + - client.token.exchange.standard.enabled=true + + Returns: + Tuple of (client_id, client_secret, callback_url, token_endpoint, authorization_endpoint) + """ + # Get Keycloak configuration from environment + keycloak_discovery_url = os.getenv( + "OIDC_DISCOVERY_URL", + "http://localhost:8888/realms/nextcloud-mcp/.well-known/openid-configuration", + ) + client_id = os.getenv("OIDC_CLIENT_ID", "nextcloud-mcp-server") + client_secret = os.getenv("OIDC_CLIENT_SECRET", "mcp-secret-change-in-production") + + if not all([keycloak_discovery_url, client_id, client_secret]): + pytest.skip( + "Keycloak OAuth requires OIDC_DISCOVERY_URL, OIDC_CLIENT_ID, OIDC_CLIENT_SECRET" + ) + + # Get callback URL from the real callback server + auth_states, callback_url = oauth_callback_server + + logger.info("Setting up Keycloak external IdP OAuth client credentials...") + logger.info(f"Using Keycloak discovery URL: {keycloak_discovery_url}") + logger.info(f"Using static client credentials: {client_id}") + logger.info(f"Using real callback server at: {callback_url}") + + async with httpx.AsyncClient(timeout=30.0) as http_client: + # OIDC Discovery + discovery_response = await http_client.get(keycloak_discovery_url) + discovery_response.raise_for_status() + oidc_config = discovery_response.json() + + token_endpoint = oidc_config.get("token_endpoint") + authorization_endpoint = oidc_config.get("authorization_endpoint") + + if not token_endpoint or not authorization_endpoint: + raise ValueError( + "Keycloak OIDC discovery missing required endpoints (token_endpoint or authorization_endpoint)" + ) + + logger.info(f"✓ Discovered token endpoint: {token_endpoint}") + logger.info(f"✓ Discovered authorization endpoint: {authorization_endpoint}") + + yield ( + client_id, + client_secret, + callback_url, + token_endpoint, + authorization_endpoint, + ) + + # No cleanup needed - client is pre-configured in realm export + + +async def _get_keycloak_oauth_token( + browser, + keycloak_oauth_client_credentials, + oauth_callback_server, + scopes: str, + username: str = "admin", + password: str = "admin", +) -> str: + """ + Helper function to obtain OAuth token from Keycloak using Playwright. + + Args: + browser: Playwright browser instance + keycloak_oauth_client_credentials: Tuple of Keycloak OAuth client credentials + oauth_callback_server: OAuth callback server fixture + scopes: Space-separated list of scopes + username: Keycloak username (default: admin) + password: Keycloak password (default: admin) + + Returns: + OAuth access token string from Keycloak + """ + import base64 + import hashlib + import secrets + import time + from urllib.parse import quote + + # Get auth_states dict from callback server + auth_states, _ = oauth_callback_server + + # Unpack Keycloak client credentials + client_id, client_secret, callback_url, token_endpoint, authorization_endpoint = ( + keycloak_oauth_client_credentials + ) + + logger.info(f"Starting Playwright-based Keycloak OAuth flow with scopes: {scopes}") + logger.info(f"Using Keycloak client: {client_id}") + logger.info(f"Using real callback server at: {callback_url}") + logger.info(f"Authenticating as Keycloak user: {username}") + + # Generate unique state parameter for this OAuth flow + state = secrets.token_urlsafe(32) + logger.debug(f"Generated state: {state[:16]}...") + + # Generate PKCE parameters (required by Keycloak client configuration) + code_verifier = secrets.token_urlsafe(64) # 86 chars base64url + code_challenge = ( + base64.urlsafe_b64encode(hashlib.sha256(code_verifier.encode()).digest()) + .decode() + .rstrip("=") + ) + logger.debug(f"Generated PKCE code_challenge: {code_challenge[:20]}...") + + # URL-encode scopes + scopes_encoded = quote(scopes, safe="") + + # Construct authorization URL with state, scopes, and PKCE parameters + auth_url = ( + f"{authorization_endpoint}?" + f"response_type=code&" + f"client_id={client_id}&" + f"redirect_uri={quote(callback_url, safe='')}&" + f"state={state}&" + f"scope={scopes_encoded}&" + f"code_challenge={code_challenge}&" + f"code_challenge_method=S256" + ) + + logger.info(f"Authorization URL: {auth_url[:100]}...") + + # Create browser context and page + context = await browser.new_context() + page = await context.new_page() + + try: + # Navigate to Keycloak authorization endpoint + logger.info("Navigating to Keycloak authorization endpoint...") + await page.goto(auth_url, wait_until="networkidle", timeout=30000) + + # Handle Keycloak login page + # Keycloak uses input#username and input#password (different from Nextcloud) + logger.info(f"Filling Keycloak login credentials for {username}...") + await page.wait_for_selector("input#username", timeout=10000) + await page.fill("input#username", username) + await page.fill("input#password", password) + + logger.info("Submitting Keycloak login form...") + # Submit the form and wait for navigation + # Use JavaScript to submit the form directly (more reliable than clicking button) + async with page.expect_navigation(timeout=30000): + await page.evaluate("document.querySelector('form').submit()") + + logger.info(f"Keycloak login submitted for {username}, redirected to callback") + + # Check if we need to handle consent screen + # Keycloak consent screen has "Yes" button + consent_button = page.locator('input[name="accept"][value="Yes"]') + if await consent_button.count() > 0: + logger.info("Keycloak consent screen detected, clicking Yes...") + await consent_button.click() + await page.wait_for_load_state("networkidle", timeout=30000) + logger.info("Keycloak consent granted") + + # Wait for callback server to receive auth code with timeout + logger.info(f"Waiting for auth code with state: {state[:16]}...") + timeout = 30 # seconds + start_time = time.time() + auth_code = None + + while time.time() - start_time < timeout: + if state in auth_states: + auth_code = auth_states[state] + logger.info("Auth code received from callback server") + break + await anyio.sleep(0.1) + else: + raise TimeoutError( + f"Auth code not received within {timeout}s. State: {state[:16]}..." + ) + + finally: + await context.close() + + # Exchange authorization code for access token (with PKCE code_verifier) + logger.info("Exchanging authorization code for access token with PKCE...") + async with httpx.AsyncClient(timeout=30.0) as token_client: + token_response = await token_client.post( + token_endpoint, + data={ + "grant_type": "authorization_code", + "code": auth_code, + "redirect_uri": callback_url, + "client_id": client_id, + "client_secret": client_secret, + "code_verifier": code_verifier, # PKCE verifier + }, + ) + + token_response.raise_for_status() + token_data = token_response.json() + access_token = token_data.get("access_token") + + if not access_token: + raise ValueError(f"No access_token in response: {token_data}") + + logger.info( + f"Successfully obtained Keycloak OAuth access token with scopes: {scopes}" + ) + return access_token + + +@pytest.fixture(scope="session") +async def keycloak_oauth_token( + anyio_backend, browser, keycloak_oauth_client_credentials, oauth_callback_server +) -> str: + """ + Fixture to obtain an OAuth access token from Keycloak using Playwright automation. + + This fixture tests the external IdP flow where: + 1. User authenticates with Keycloak (external IdP) + 2. Keycloak issues an access token with Nextcloud custom scopes + 3. Token is used to access Nextcloud APIs via user_oidc app validation + + The Nextcloud custom scopes (notes:read, calendar:write, etc.) are now defined + in Keycloak's realm configuration and can be requested in the OAuth flow. + + Returns: + OAuth access token from Keycloak for the admin user with full scopes + """ + # Standard OIDC scopes + Nextcloud custom scopes (now defined in Keycloak realm) + default_scopes = "openid profile email offline_access notes:read notes:write calendar:read calendar:write contacts:read contacts:write cookbook:read cookbook:write deck:read deck:write tables:read tables:write files:read files:write sharing:read sharing:write todo:read todo:write" + + return await _get_keycloak_oauth_token( + browser, + keycloak_oauth_client_credentials, + oauth_callback_server, + scopes=default_scopes, + username="admin", + password="admin", + ) + + +@pytest.fixture(scope="session") +async def nc_mcp_keycloak_client( + anyio_backend, keycloak_oauth_token +) -> AsyncGenerator[ClientSession, Any]: + """ + Session-scoped fixture providing an MCP client session authenticated with Keycloak tokens. + + This MCP client connects to the mcp-keycloak service (port 8002) which is configured + to use Keycloak as an external identity provider. The token flow is: + + 1. Keycloak issues OAuth token (via keycloak_oauth_token fixture) + 2. MCP client uses token to authenticate with MCP server + 3. MCP server validates token via Nextcloud user_oidc app + 4. MCP server uses validated token to access Nextcloud APIs + + This tests ADR-002 external IdP integration. + + Yields: + MCP client session for testing tools/resources with Keycloak auth + """ + mcp_url = "http://localhost:8002/mcp" + logger.info(f"Creating MCP client session for Keycloak external IdP at {mcp_url}") + logger.info("Using Keycloak OAuth token for authentication") + + async for session in create_mcp_client_session( + url=mcp_url, token=keycloak_oauth_token, client_name="Keycloak External IdP MCP" + ): + logger.info("✓ MCP client session established with Keycloak authentication") + yield session + logger.info("✓ MCP client session closed") diff --git a/tests/server/oauth/test_keycloak_external_idp.py b/tests/server/oauth/test_keycloak_external_idp.py new file mode 100644 index 0000000..ec0018d --- /dev/null +++ b/tests/server/oauth/test_keycloak_external_idp.py @@ -0,0 +1,404 @@ +"""Keycloak External IdP Integration Tests. + +Tests verify ADR-002 external identity provider integration where: +1. Keycloak acts as external OAuth/OIDC provider +2. MCP server validates tokens via Nextcloud user_oidc app +3. Nextcloud auto-provisions users from Keycloak token claims +4. MCP tools execute successfully with Keycloak tokens + +Architecture: + MCP Client → Keycloak (OAuth) → MCP Server → Nextcloud user_oidc (validates) → APIs + +Tests: +1. Keycloak OAuth token acquisition via Playwright +2. MCP client connection to mcp-keycloak service (port 8002) +3. Token validation through Nextcloud user_oidc app +4. MCP tool execution with Keycloak tokens +5. User auto-provisioning from Keycloak claims +6. Scope-based tool filtering with Keycloak JWT tokens +""" + +import json +import logging + +import pytest + +logger = logging.getLogger(__name__) + +pytestmark = [pytest.mark.integration, pytest.mark.oauth] + + +# ============================================================================ +# OAuth Token Acquisition Tests +# ============================================================================ + + +async def test_keycloak_oauth_token_acquisition(keycloak_oauth_token): + """Test that Playwright can obtain OAuth token from Keycloak. + + Verifies: + - Playwright automation handles Keycloak login page (input#username, input#password) + - Keycloak consent screen is handled correctly + - Authorization code is exchanged for access token + - Token is returned successfully + + This is a foundational test - if this fails, all other Keycloak tests will fail. + """ + assert keycloak_oauth_token is not None + assert isinstance(keycloak_oauth_token, str) + assert len(keycloak_oauth_token) > 100 # Tokens should be substantial length + + logger.info( + f"✓ Keycloak OAuth token acquired (length: {len(keycloak_oauth_token)})" + ) + logger.info(f" Token prefix: {keycloak_oauth_token[:50]}...") + + +async def test_keycloak_oauth_client_credentials_discovery( + keycloak_oauth_client_credentials, +): + """Test Keycloak OIDC discovery and credential loading. + + Verifies: + - OIDC discovery endpoint is accessible + - Token and authorization endpoints are discovered + - Static client credentials are loaded from environment + - Callback server is initialized + """ + ( + client_id, + client_secret, + callback_url, + token_endpoint, + authorization_endpoint, + ) = keycloak_oauth_client_credentials + + assert client_id == "nextcloud-mcp-server" + assert client_secret == "mcp-secret-change-in-production" + assert callback_url.startswith("http://") + assert "keycloak" in token_endpoint + assert "keycloak" in authorization_endpoint + assert "/realms/nextcloud-mcp/" in token_endpoint + + logger.info("✓ Keycloak OIDC discovery successful") + logger.info(f" Client ID: {client_id}") + logger.info(f" Token endpoint: {token_endpoint}") + logger.info(f" Authorization endpoint: {authorization_endpoint}") + + +# ============================================================================ +# MCP Server Connectivity Tests +# ============================================================================ + + +async def test_mcp_client_connects_to_keycloak_server(nc_mcp_keycloak_client): + """Test MCP client can connect to mcp-keycloak service (port 8002). + + Verifies: + - MCP client session is established + - Server responds to list_tools request + - Tools are available for use + """ + result = await nc_mcp_keycloak_client.list_tools() + + assert result is not None + assert len(result.tools) > 0 + + logger.info( + f"✓ MCP client connected to Keycloak server with {len(result.tools)} tools" + ) + + +async def test_external_idp_server_initialization(nc_mcp_keycloak_client): + """Test that MCP server correctly initializes with external IdP configuration. + + Verifies: + - Server auto-detects external IdP mode (issuer != Nextcloud host) + - Server reports correct provider type + - All expected tools are registered + + The server should log messages like: + - "✓ Detected external IdP mode (issuer: http://keycloak:8080/realms/nextcloud-mcp != Nextcloud: http://app:80)" + """ + result = await nc_mcp_keycloak_client.list_tools() + + # Verify we have a full set of tools (not filtered to specific apps) + tool_names = [tool.name for tool in result.tools] + + # Should have tools from multiple apps + has_notes = any("notes" in name for name in tool_names) + has_calendar = any("calendar" in name for name in tool_names) + has_files = any("webdav" in name for name in tool_names) + + assert has_notes, "Missing Notes tools" + assert has_calendar, "Missing Calendar tools" + assert has_files, "Missing WebDAV/Files tools" + + logger.info("✓ MCP server initialized with external IdP mode") + logger.info(f" Tools from multiple apps detected: {len(result.tools)} total") + + +# ============================================================================ +# Token Validation Tests +# ============================================================================ + + +async def test_external_idp_token_validation(nc_mcp_keycloak_client): + """Test that Keycloak tokens are validated via Nextcloud user_oidc app. + + Token flow: + 1. Keycloak issues OAuth token + 2. MCP client sends token to MCP server + 3. MCP server passes token to Nextcloud user_oidc app + 4. user_oidc validates token with Keycloak (JWKS or introspection) + 5. Nextcloud returns user info to MCP server + 6. MCP server uses token to access Nextcloud APIs + + This test verifies the entire flow works. + """ + # Execute a read operation (requires token validation) + result = await nc_mcp_keycloak_client.call_tool( + "nc_notes_search_notes", arguments={"query": ""} + ) + + assert result.isError is False, f"Tool execution failed: {result.content}" + assert result.content is not None + response_data = json.loads(result.content[0].text) + + # Successful response means token was validated and user was authenticated + assert "results" in response_data + assert isinstance(response_data["results"], list) + + logger.info("✓ Keycloak token validated successfully via Nextcloud user_oidc app") + logger.info(f" Tool execution returned {len(response_data['results'])} results") + + +# ============================================================================ +# Tool Execution Tests +# ============================================================================ + + +async def test_tools_work_with_keycloak_token(nc_mcp_keycloak_client): + """Test that MCP tools execute successfully with Keycloak OAuth tokens. + + Verifies end-to-end functionality: + - Read operations work (nc_notes_search_notes) + - Write operations work (nc_notes_create_note) + - Different apps work (Notes, Calendar, Files) + """ + # Test 1: Read operation (Notes) + search_result = await nc_mcp_keycloak_client.call_tool( + "nc_notes_search_notes", arguments={"query": ""} + ) + assert search_result.isError is False + logger.info("✓ Read operation successful (nc_notes_search_notes)") + + # Test 2: Write operation (Notes) + create_result = await nc_mcp_keycloak_client.call_tool( + "nc_notes_create_note", + arguments={ + "title": "Keycloak Test Note", + "content": "Created via external IdP token", + "category": "Test", + }, + ) + assert create_result.isError is False + create_data = json.loads(create_result.content[0].text) + note_id = create_data["id"] + logger.info(f"✓ Write operation successful (created note {note_id})") + + # Test 3: Different app (Calendar) + calendar_result = await nc_mcp_keycloak_client.call_tool( + "nc_calendar_list_calendars", arguments={} + ) + assert calendar_result.isError is False + logger.info("✓ Calendar tool execution successful") + + # Test 4: File operations (WebDAV) + files_result = await nc_mcp_keycloak_client.call_tool( + "nc_webdav_list_directory", arguments={"path": "/"} + ) + assert files_result.isError is False + logger.info("✓ WebDAV tool execution successful") + + # Cleanup: Delete test note + await nc_mcp_keycloak_client.call_tool( + "nc_notes_delete_note", arguments={"note_id": note_id} + ) + logger.info(f"✓ Cleanup: Deleted test note {note_id}") + + +async def test_keycloak_token_persistence(nc_mcp_keycloak_client): + """Test that Keycloak token works across multiple operations. + + Verifies: + - Token is properly cached by MCP server + - Token can be reused for multiple API calls + - No re-authentication is required between calls + """ + # Execute multiple operations with same session + operations = [ + ("nc_notes_search_notes", {"query": ""}), + ("nc_calendar_list_calendars", {}), + ("nc_webdav_list_directory", {"path": "/"}), + ] + + for tool_name, arguments in operations: + result = await nc_mcp_keycloak_client.call_tool(tool_name, arguments=arguments) + assert result.isError is False, f"Failed to execute {tool_name}" + logger.info(f"✓ {tool_name} executed successfully") + + logger.info("✓ Keycloak token persistence verified (3 operations with same token)") + + +# ============================================================================ +# User Provisioning Tests +# ============================================================================ + + +async def test_user_auto_provisioning(nc_client, keycloak_oauth_token): + """Test that Nextcloud auto-provisions users from Keycloak token claims. + + When a user authenticates with Keycloak for the first time, Nextcloud + should automatically create a user account based on token claims. + + Verification: + 1. User exists in Nextcloud after OAuth authentication + 2. User ID is derived from Keycloak claims (hashed with unique_uid=1) + 3. User has proper metadata (email, display name from Keycloak) + + Note: The user 'admin' should already exist since we used it for OAuth flow. + """ + # The admin user should exist after authenticating via Keycloak + # With unique_uid=1, the user ID will be hashed + # We can verify by checking the user exists + + # Get list of users + users = await nc_client.users.list_users() + user_ids = [user["id"] for user in users] + + logger.info(f"Found {len(user_ids)} users in Nextcloud") + logger.info(f"Users: {user_ids}") + + # The Keycloak admin user should be provisioned + # Note: With unique_uid=1, the user ID is hashed, so we can't predict exact ID + # But there should be at least 2 users: nextcloud admin + keycloak admin + assert len(user_ids) >= 2, ( + "Expected at least 2 users (Nextcloud admin + Keycloak provisioned user)" + ) + + logger.info("✓ User auto-provisioning verified") + logger.info(f" Total users: {len(user_ids)}") + + +# ============================================================================ +# Scope-Based Authorization Tests +# ============================================================================ + + +async def test_scope_filtering_with_keycloak(nc_mcp_keycloak_client): + """Test that tool filtering works correctly with Keycloak JWT scopes. + + Keycloak tokens should include scopes in JWT payload (if JWT format). + The MCP server should filter tools based on these scopes. + + Expected scopes (from docker-compose.yml): + - openid profile email offline_access + - notes:read notes:write + - calendar:read calendar:write + - contacts:read contacts:write + - etc. + + Tools should be filtered accordingly. + """ + result = await nc_mcp_keycloak_client.list_tools() + tool_names = [tool.name for tool in result.tools] + + # With full scopes, all app tools should be available + expected_tools = [ + "nc_notes_get_note", # notes:read + "nc_notes_create_note", # notes:write + "nc_calendar_list_calendars", # calendar:read + "nc_calendar_create_event", # calendar:write + "nc_webdav_list_directory", # files:read + "nc_webdav_upload_file", # files:write + ] + + for tool_name in expected_tools: + assert tool_name in tool_names, f"Expected tool {tool_name} not found" + + logger.info("✓ Scope-based tool filtering working with Keycloak tokens") + logger.info(f" Available tools: {len(tool_names)}") + + +# ============================================================================ +# Error Handling Tests +# ============================================================================ + + +async def test_keycloak_error_handling(nc_mcp_keycloak_client): + """Test error handling with Keycloak tokens. + + Verifies: + - Invalid operations return proper errors + - Token validation errors are handled correctly + - API errors propagate correctly through the chain + """ + # Try to get a non-existent note + result = await nc_mcp_keycloak_client.call_tool( + "nc_notes_get_note", arguments={"note_id": 999999} + ) + + # Should get an error (note doesn't exist) + assert result.isError is True + logger.info( + "✓ Keycloak OAuth server correctly handles errors for invalid operations" + ) + + +# ============================================================================ +# Documentation Tests +# ============================================================================ + + +async def test_external_idp_architecture(): + """Document the external IdP architecture (ADR-002). + + This test captures the design and flow for reference. + """ + architecture = { + "flow": [ + "1. User authenticates with Keycloak (external IdP)", + "2. Keycloak issues OAuth access token with scopes", + "3. MCP client uses token to authenticate with MCP server", + "4. MCP server receives token and passes to Nextcloud", + "5. Nextcloud user_oidc app validates token with Keycloak", + "6. Nextcloud auto-provisions user from token claims (if first login)", + "7. Nextcloud returns validated user info to MCP server", + "8. MCP server executes tool using validated token", + ], + "components": { + "keycloak": "External OAuth/OIDC provider (port 8888)", + "mcp_server": "MCP server with external IdP config (port 8002)", + "nextcloud": "API server with user_oidc app (port 8080)", + "user_oidc": "Nextcloud app that validates external IdP tokens", + }, + "configuration": { + "keycloak_realm": "nextcloud-mcp", + "keycloak_client": "nextcloud-mcp-server", + "nextcloud_provider": "keycloak (via user_oidc app)", + "token_validation": "Keycloak JWKS or introspection endpoint", + }, + "advantages": [ + "No admin credentials needed in MCP server", + "Centralized identity management", + "Standards-based (RFC 6749, RFC 7662, RFC 9068)", + "Supports enterprise IdPs (Keycloak, Auth0, Okta, etc.)", + "User auto-provisioning from IdP claims", + ], + } + + logger.info("External IdP Architecture (ADR-002):") + logger.info(json.dumps(architecture, indent=2)) + + assert True