diff --git a/keycloak/realm-export.json b/keycloak/realm-export.json index 84aa6f8..7464f38 100644 --- a/keycloak/realm-export.json +++ b/keycloak/realm-export.json @@ -78,6 +78,78 @@ "1073741824" ] } + }, + { + "username": "test_read_only", + "enabled": true, + "email": "readonly@example.com", + "emailVerified": true, + "firstName": "Read", + "lastName": "Only", + "credentials": [ + { + "type": "password", + "value": "test123", + "temporary": false + } + ], + "realmRoles": [ + "default-roles-nextcloud-mcp", + "offline_access" + ], + "attributes": { + "quota": [ + "1073741824" + ] + } + }, + { + "username": "test_write_only", + "enabled": true, + "email": "writeonly@example.com", + "emailVerified": true, + "firstName": "Write", + "lastName": "Only", + "credentials": [ + { + "type": "password", + "value": "test123", + "temporary": false + } + ], + "realmRoles": [ + "default-roles-nextcloud-mcp", + "offline_access" + ], + "attributes": { + "quota": [ + "1073741824" + ] + } + }, + { + "username": "test_no_scopes", + "enabled": true, + "email": "noscopes@example.com", + "emailVerified": true, + "firstName": "No", + "lastName": "Scopes", + "credentials": [ + { + "type": "password", + "value": "test123", + "temporary": false + } + ], + "realmRoles": [ + "default-roles-nextcloud-mcp", + "offline_access" + ], + "attributes": { + "quota": [ + "1073741824" + ] + } } ], "clients": [ diff --git a/pyproject.toml b/pyproject.toml index e8b6317..1e5fb2c 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 18b1d79..880fe98 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2709,6 +2709,77 @@ async def keycloak_oauth_token( ) +@pytest.fixture(scope="session") +async def keycloak_oauth_token_read_only( + anyio_backend, browser, keycloak_oauth_client_credentials, oauth_callback_server +) -> str: + """ + Fixture to obtain a Keycloak OAuth token with only read scopes. + + This token will only be able to perform read operations and should + have write tools filtered out from the tool list. + + Returns: + OAuth access token from Keycloak for test_read_only user with read-only scopes + """ + return await _get_keycloak_oauth_token( + browser, + keycloak_oauth_client_credentials, + oauth_callback_server, + scopes=DEFAULT_READ_SCOPES, + username="test_read_only", + password="test123", + ) + + +@pytest.fixture(scope="session") +async def keycloak_oauth_token_write_only( + anyio_backend, browser, keycloak_oauth_client_credentials, oauth_callback_server +) -> str: + """ + Fixture to obtain a Keycloak OAuth token with only write scopes. + + This token will only be able to perform write operations and should + have read tools filtered out from the tool list. + + Returns: + OAuth access token from Keycloak for test_write_only user with write-only scopes + """ + return await _get_keycloak_oauth_token( + browser, + keycloak_oauth_client_credentials, + oauth_callback_server, + scopes=DEFAULT_WRITE_SCOPES, + username="test_write_only", + password="test123", + ) + + +@pytest.fixture(scope="session") +async def keycloak_oauth_token_no_custom_scopes( + anyio_backend, browser, keycloak_oauth_client_credentials, oauth_callback_server +) -> str: + """ + Fixture to obtain a Keycloak OAuth token with NO custom scopes. + + Tests the security behavior when a user grants only default OIDC scopes + (openid, profile, email) but declines application-specific scopes. + + Expected behavior: Should see 0 tools (all tools require custom scopes). + + Returns: + OAuth access token from Keycloak for test_no_scopes user with no custom scopes + """ + return await _get_keycloak_oauth_token( + browser, + keycloak_oauth_client_credentials, + oauth_callback_server, + scopes="openid profile email", # No custom scopes + username="test_no_scopes", + password="test123", + ) + + @pytest.fixture(scope="session") async def nc_mcp_keycloak_client( anyio_backend, keycloak_oauth_token @@ -2739,3 +2810,79 @@ async def nc_mcp_keycloak_client( logger.info("✓ MCP client session established with Keycloak authentication") yield session logger.info("✓ MCP client session closed") + + +@pytest.fixture(scope="session") +async def nc_mcp_keycloak_client_read_only( + anyio_backend, keycloak_oauth_token_read_only +) -> AsyncGenerator[ClientSession, Any]: + """ + MCP client session authenticated with Keycloak read-only token. + + This client should only see read tools and should get filtered + write tools based on token scopes. + + Uses JWT tokens because they embed scope information in claims, + enabling proper scope-based tool filtering. + """ + mcp_url = "http://localhost:8002/mcp" + logger.info(f"Creating read-only MCP client session for Keycloak at {mcp_url}") + + async for session in create_mcp_client_session( + url=mcp_url, + token=keycloak_oauth_token_read_only, + client_name="Keycloak Read-Only MCP", + ): + yield session + + +@pytest.fixture(scope="session") +async def nc_mcp_keycloak_client_write_only( + anyio_backend, keycloak_oauth_token_write_only +) -> AsyncGenerator[ClientSession, Any]: + """ + MCP client session authenticated with Keycloak write-only token. + + This client should only see write tools and should get filtered + read tools based on token scopes. + + Uses JWT tokens because they embed scope information in claims, + enabling proper scope-based tool filtering. + """ + mcp_url = "http://localhost:8002/mcp" + logger.info(f"Creating write-only MCP client session for Keycloak at {mcp_url}") + + async for session in create_mcp_client_session( + url=mcp_url, + token=keycloak_oauth_token_write_only, + client_name="Keycloak Write-Only MCP", + ): + yield session + + +@pytest.fixture(scope="session") +async def nc_mcp_keycloak_client_no_custom_scopes( + anyio_backend, keycloak_oauth_token_no_custom_scopes +) -> AsyncGenerator[ClientSession, Any]: + """ + MCP client session authenticated with Keycloak token without custom scopes. + + This client has only OIDC default scopes (openid, profile, email) without + application-specific scopes (notes:read, notes:write, etc.). + + Expected behavior: Should see 0 tools (all tools require custom scopes). + + Uses JWT tokens because they embed scope information in claims, + enabling proper scope-based tool filtering. + """ + mcp_url = "http://localhost:8002/mcp" + logger.info( + f"Creating no-custom-scopes MCP client session for Keycloak at {mcp_url}" + ) + + async for session in create_mcp_client_session( + url=mcp_url, + token=keycloak_oauth_token_no_custom_scopes, + client_name="Keycloak No Custom Scopes MCP", + ): + yield session diff --git a/tests/server/oauth/test_keycloak_external_idp.py b/tests/server/oauth/test_keycloak_external_idp.py index b4e075d..99da439 100644 --- a/tests/server/oauth/test_keycloak_external_idp.py +++ b/tests/server/oauth/test_keycloak_external_idp.py @@ -406,3 +406,161 @@ async def test_external_idp_architecture(): logger.info(json.dumps(architecture, indent=2)) assert True + + +# ============================================================================ +# Scope-Based Authorization Tests (JWT Token Filtering) +# ============================================================================ + + +async def test_keycloak_read_only_token_filters_write_tools( + nc_mcp_keycloak_client_read_only, +): + """Test that a Keycloak token with only read scopes filters out write tools.""" + # Connect with token that has only read scopes + result = await nc_mcp_keycloak_client_read_only.list_tools() + assert result is not None + assert len(result.tools) > 0 + + tool_names = [tool.name for tool in result.tools] + logger.info(f"Keycloak read-only token sees {len(tool_names)} tools") + + # Verify read tools are present + expected_read_tools = [ + "nc_notes_get_note", # notes:read + "nc_notes_search_notes", # notes:read + "nc_calendar_list_calendars", # calendar:read + "nc_calendar_get_event", # calendar:read + ] + + for tool in expected_read_tools: + assert tool in tool_names, f"Expected read tool {tool} not found in tool list" + + # Verify write tools are NOT present (filtered out) + write_tools_should_be_filtered = [ + "nc_notes_create_note", # notes:write + "nc_notes_update_note", # notes:write + "nc_notes_delete_note", # notes:write + "nc_calendar_create_event", # calendar:write + "nc_calendar_update_event", # calendar:write + "nc_calendar_delete_event", # calendar:write + ] + + for tool in write_tools_should_be_filtered: + assert tool not in tool_names, ( + f"Write tool {tool} should be filtered out but was found in tool list" + ) + + logger.info( + f"✅ Keycloak read-only token properly filters tools: {len(tool_names)} read tools visible, " + f"write tools hidden" + ) + + +async def test_keycloak_write_only_token_filters_read_tools( + nc_mcp_keycloak_client_write_only, +): + """Test that a Keycloak token with only write scopes filters out read tools.""" + # Connect with token that has only write scopes + result = await nc_mcp_keycloak_client_write_only.list_tools() + assert result is not None + assert len(result.tools) > 0 + + tool_names = [tool.name for tool in result.tools] + logger.info(f"Keycloak write-only token sees {len(tool_names)} tools") + + # Verify write tools are present + expected_write_tools = [ + "nc_notes_create_note", # notes:write + "nc_notes_update_note", # notes:write + "nc_notes_delete_note", # notes:write + "nc_calendar_create_event", # calendar:write + "nc_calendar_update_event", # calendar:write + "nc_calendar_delete_event", # calendar:write + ] + + for tool in expected_write_tools: + assert tool in tool_names, f"Expected write tool {tool} not found in tool list" + + # Verify read-only tools are NOT present (write-only scope) + read_tools_should_be_filtered = [ + "nc_notes_get_note", # notes:read + "nc_notes_search_notes", # notes:read + "nc_calendar_list_calendars", # calendar:read + "nc_calendar_get_event", # calendar:read + ] + + for tool in read_tools_should_be_filtered: + assert tool not in tool_names, ( + f"Read tool {tool} should be filtered out but was found in tool list" + ) + + logger.info( + f"✅ Keycloak write-only token properly filters tools: {len(tool_names)} write tools visible, " + f"read tools hidden" + ) + + +async def test_keycloak_full_access_token_shows_all_tools(nc_mcp_keycloak_client): + """Test that a Keycloak token with both read and write scopes sees all tools.""" + # Connect with token that has both read and write scopes + result = await nc_mcp_keycloak_client.list_tools() + assert result is not None + assert len(result.tools) > 0 + + tool_names = [tool.name for tool in result.tools] + logger.info(f"Keycloak full access token sees {len(tool_names)} tools") + + # Verify both read and write tools are present + expected_read_tools = [ + "nc_notes_get_note", # notes:read + "nc_notes_search_notes", # notes:read + "nc_calendar_list_calendars", # calendar:read + ] + + expected_write_tools = [ + "nc_notes_create_note", # notes:write + "nc_calendar_create_event", # calendar:write + ] + + for tool in expected_read_tools: + assert tool in tool_names, f"Expected read tool {tool} not found" + + for tool in expected_write_tools: + assert tool in tool_names, f"Expected write tool {tool} not found" + + # Should have all 90+ tools (both read and write) + assert len(tool_names) >= 90 + + logger.info( + f"✅ Keycloak full access token sees all tools: {len(tool_names)} total (read + write)" + ) + + +async def test_keycloak_no_custom_scopes_returns_zero_tools( + nc_mcp_keycloak_client_no_custom_scopes, +): + """ + Test that a Keycloak JWT token with only OIDC default scopes returns 0 tools. + + This tests the security behavior when a user declines to grant custom scopes during consent. + Expected: JWT token has scopes=['openid', 'profile', 'email'] but no custom scopes. + All tools require at least one custom scope, so they should all be filtered out. + """ + # Connect with JWT token that has NO custom scopes (only openid, profile, email) + result = await nc_mcp_keycloak_client_no_custom_scopes.list_tools() + assert result is not None + + tool_names = [tool.name for tool in result.tools] + logger.info( + f"Keycloak JWT token with no custom scopes sees {len(tool_names)} tools (should be 0)" + ) + + # All tools require custom scopes, so should be filtered out + assert len(tool_names) == 0, ( + f"Expected 0 tools but got {len(tool_names)}: {tool_names[:10]}" + ) + + logger.info( + "✅ Keycloak JWT token without custom scopes correctly returns 0 tools (all filtered out)" + )