test: Add scope-based authorization tests for Keycloak external IdP

This enhances the Keycloak integration test suite with comprehensive
scope-based authorization validation, matching the OIDC test structure.

Changes:
- Add 3 test users to Keycloak realm (read-only, write-only, no-custom-scopes)
- Create OAuth token fixtures with different scope combinations
- Create MCP client fixtures for each scope configuration
- Add 4 new tests validating scope-based tool filtering:
  * Read-only tokens filter out write tools
  * Write-only tokens filter out read tools
  * Full access tokens show all 90+ tools
  * No custom scopes result in zero tools

Test Results:
- All 15 Keycloak integration tests pass (11 existing + 4 new)
- Validates proper JWT scope enforcement in external IdP architecture
- Confirms security isolation when users decline custom scopes

This completes ADR-002 scope authorization testing for the Keycloak
external identity provider integration.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Chris Coutinho
2025-11-02 18:47:52 +01:00
parent b68c704c4d
commit 4c7d1cfc8d
4 changed files with 378 additions and 1 deletions
+72
View File
@@ -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": [
+1 -1
View File
@@ -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"
+147
View File
@@ -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
@@ -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)"
)