Skip to content

Commit 4c7d1cf

Browse files
cbcoutinhoclaude
andcommitted
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 <[email protected]>
1 parent b68c704 commit 4c7d1cf

File tree

4 files changed

+378
-1
lines changed

4 files changed

+378
-1
lines changed

keycloak/realm-export.json

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,78 @@
7878
"1073741824"
7979
]
8080
}
81+
},
82+
{
83+
"username": "test_read_only",
84+
"enabled": true,
85+
"email": "[email protected]",
86+
"emailVerified": true,
87+
"firstName": "Read",
88+
"lastName": "Only",
89+
"credentials": [
90+
{
91+
"type": "password",
92+
"value": "test123",
93+
"temporary": false
94+
}
95+
],
96+
"realmRoles": [
97+
"default-roles-nextcloud-mcp",
98+
"offline_access"
99+
],
100+
"attributes": {
101+
"quota": [
102+
"1073741824"
103+
]
104+
}
105+
},
106+
{
107+
"username": "test_write_only",
108+
"enabled": true,
109+
"email": "[email protected]",
110+
"emailVerified": true,
111+
"firstName": "Write",
112+
"lastName": "Only",
113+
"credentials": [
114+
{
115+
"type": "password",
116+
"value": "test123",
117+
"temporary": false
118+
}
119+
],
120+
"realmRoles": [
121+
"default-roles-nextcloud-mcp",
122+
"offline_access"
123+
],
124+
"attributes": {
125+
"quota": [
126+
"1073741824"
127+
]
128+
}
129+
},
130+
{
131+
"username": "test_no_scopes",
132+
"enabled": true,
133+
"email": "[email protected]",
134+
"emailVerified": true,
135+
"firstName": "No",
136+
"lastName": "Scopes",
137+
"credentials": [
138+
{
139+
"type": "password",
140+
"value": "test123",
141+
"temporary": false
142+
}
143+
],
144+
"realmRoles": [
145+
"default-roles-nextcloud-mcp",
146+
"offline_access"
147+
],
148+
"attributes": {
149+
"quota": [
150+
"1073741824"
151+
]
152+
}
81153
}
82154
],
83155
"clients": [

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ Changelog = "https://github.com/cbcoutinho/nextcloud-mcp-server/blob/master/CHAN
4242

4343
[tool.pytest.ini_options]
4444
anyio_mode = "auto"
45-
addopts = "-p no:asyncio -x" # Disable pytest-asyncio plugin, use only anyio
45+
addopts = "-p no:asyncio -x --headed" # Disable pytest-asyncio plugin, use only anyio
4646
log_cli = 1
4747
log_cli_level = "ERROR"
4848
log_level = "ERROR"

tests/conftest.py

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2709,6 +2709,77 @@ async def keycloak_oauth_token(
27092709
)
27102710

27112711

2712+
@pytest.fixture(scope="session")
2713+
async def keycloak_oauth_token_read_only(
2714+
anyio_backend, browser, keycloak_oauth_client_credentials, oauth_callback_server
2715+
) -> str:
2716+
"""
2717+
Fixture to obtain a Keycloak OAuth token with only read scopes.
2718+
2719+
This token will only be able to perform read operations and should
2720+
have write tools filtered out from the tool list.
2721+
2722+
Returns:
2723+
OAuth access token from Keycloak for test_read_only user with read-only scopes
2724+
"""
2725+
return await _get_keycloak_oauth_token(
2726+
browser,
2727+
keycloak_oauth_client_credentials,
2728+
oauth_callback_server,
2729+
scopes=DEFAULT_READ_SCOPES,
2730+
username="test_read_only",
2731+
password="test123",
2732+
)
2733+
2734+
2735+
@pytest.fixture(scope="session")
2736+
async def keycloak_oauth_token_write_only(
2737+
anyio_backend, browser, keycloak_oauth_client_credentials, oauth_callback_server
2738+
) -> str:
2739+
"""
2740+
Fixture to obtain a Keycloak OAuth token with only write scopes.
2741+
2742+
This token will only be able to perform write operations and should
2743+
have read tools filtered out from the tool list.
2744+
2745+
Returns:
2746+
OAuth access token from Keycloak for test_write_only user with write-only scopes
2747+
"""
2748+
return await _get_keycloak_oauth_token(
2749+
browser,
2750+
keycloak_oauth_client_credentials,
2751+
oauth_callback_server,
2752+
scopes=DEFAULT_WRITE_SCOPES,
2753+
username="test_write_only",
2754+
password="test123",
2755+
)
2756+
2757+
2758+
@pytest.fixture(scope="session")
2759+
async def keycloak_oauth_token_no_custom_scopes(
2760+
anyio_backend, browser, keycloak_oauth_client_credentials, oauth_callback_server
2761+
) -> str:
2762+
"""
2763+
Fixture to obtain a Keycloak OAuth token with NO custom scopes.
2764+
2765+
Tests the security behavior when a user grants only default OIDC scopes
2766+
(openid, profile, email) but declines application-specific scopes.
2767+
2768+
Expected behavior: Should see 0 tools (all tools require custom scopes).
2769+
2770+
Returns:
2771+
OAuth access token from Keycloak for test_no_scopes user with no custom scopes
2772+
"""
2773+
return await _get_keycloak_oauth_token(
2774+
browser,
2775+
keycloak_oauth_client_credentials,
2776+
oauth_callback_server,
2777+
scopes="openid profile email", # No custom scopes
2778+
username="test_no_scopes",
2779+
password="test123",
2780+
)
2781+
2782+
27122783
@pytest.fixture(scope="session")
27132784
async def nc_mcp_keycloak_client(
27142785
anyio_backend, keycloak_oauth_token
@@ -2739,3 +2810,79 @@ async def nc_mcp_keycloak_client(
27392810
logger.info("✓ MCP client session established with Keycloak authentication")
27402811
yield session
27412812
logger.info("✓ MCP client session closed")
2813+
2814+
2815+
@pytest.fixture(scope="session")
2816+
async def nc_mcp_keycloak_client_read_only(
2817+
anyio_backend, keycloak_oauth_token_read_only
2818+
) -> AsyncGenerator[ClientSession, Any]:
2819+
"""
2820+
MCP client session authenticated with Keycloak read-only token.
2821+
2822+
This client should only see read tools and should get filtered
2823+
write tools based on token scopes.
2824+
2825+
Uses JWT tokens because they embed scope information in claims,
2826+
enabling proper scope-based tool filtering.
2827+
"""
2828+
mcp_url = "http://localhost:8002/mcp"
2829+
logger.info(f"Creating read-only MCP client session for Keycloak at {mcp_url}")
2830+
2831+
async for session in create_mcp_client_session(
2832+
url=mcp_url,
2833+
token=keycloak_oauth_token_read_only,
2834+
client_name="Keycloak Read-Only MCP",
2835+
):
2836+
yield session
2837+
2838+
2839+
@pytest.fixture(scope="session")
2840+
async def nc_mcp_keycloak_client_write_only(
2841+
anyio_backend, keycloak_oauth_token_write_only
2842+
) -> AsyncGenerator[ClientSession, Any]:
2843+
"""
2844+
MCP client session authenticated with Keycloak write-only token.
2845+
2846+
This client should only see write tools and should get filtered
2847+
read tools based on token scopes.
2848+
2849+
Uses JWT tokens because they embed scope information in claims,
2850+
enabling proper scope-based tool filtering.
2851+
"""
2852+
mcp_url = "http://localhost:8002/mcp"
2853+
logger.info(f"Creating write-only MCP client session for Keycloak at {mcp_url}")
2854+
2855+
async for session in create_mcp_client_session(
2856+
url=mcp_url,
2857+
token=keycloak_oauth_token_write_only,
2858+
client_name="Keycloak Write-Only MCP",
2859+
):
2860+
yield session
2861+
2862+
2863+
@pytest.fixture(scope="session")
2864+
async def nc_mcp_keycloak_client_no_custom_scopes(
2865+
anyio_backend, keycloak_oauth_token_no_custom_scopes
2866+
) -> AsyncGenerator[ClientSession, Any]:
2867+
"""
2868+
MCP client session authenticated with Keycloak token without custom scopes.
2869+
2870+
This client has only OIDC default scopes (openid, profile, email) without
2871+
application-specific scopes (notes:read, notes:write, etc.).
2872+
2873+
Expected behavior: Should see 0 tools (all tools require custom scopes).
2874+
2875+
Uses JWT tokens because they embed scope information in claims,
2876+
enabling proper scope-based tool filtering.
2877+
"""
2878+
mcp_url = "http://localhost:8002/mcp"
2879+
logger.info(
2880+
f"Creating no-custom-scopes MCP client session for Keycloak at {mcp_url}"
2881+
)
2882+
2883+
async for session in create_mcp_client_session(
2884+
url=mcp_url,
2885+
token=keycloak_oauth_token_no_custom_scopes,
2886+
client_name="Keycloak No Custom Scopes MCP",
2887+
):
2888+
yield session

0 commit comments

Comments
 (0)