feat: Add Keycloak external IdP integration with custom scopes
Add comprehensive support for using Keycloak as an external identity provider with Nextcloud custom scopes. This enables testing of ADR-002 external IdP integration patterns. **Keycloak Realm Configuration:** - Add frontendUrl attribute to issue tokens with public issuer URL - Define 18 Nextcloud custom client scopes (notes:read/write, calendar:read/write, contacts:read/write, cookbook:read/write, deck:read/write, tables:read/write, files:read/write, sharing:read/write, todo:read/write) - Add all custom scopes to nextcloud-mcp-server client optional scopes - Scopes include consent screen text for user-friendly OAuth flow **MCP Server Configuration:** - Add OIDC_JWKS_URI environment variable support - Implement JWKS URI override logic for Docker networking - Update NEXTCLOUD_PUBLIC_ISSUER_URL to include full realm path - Enable MCP server to fetch JWKS from internal Docker network **Test Infrastructure:** - Add keycloak_oauth_client_credentials fixture (session-scoped) - Add keycloak_oauth_token fixture with Playwright automation - Implement PKCE (S256) support for Keycloak OAuth flow - Add nc_mcp_keycloak_client fixture for MCP testing - Create comprehensive test suite in test_keycloak_external_idp.py **Tests Created:** - test_keycloak_oauth_token_acquisition: Token acquisition via Playwright - test_keycloak_oauth_client_credentials_discovery: OIDC discovery - test_mcp_client_connects_to_keycloak_server: MCP connectivity - test_external_idp_server_initialization: Server auto-detection - test_external_idp_token_validation: Token validation flow - test_tools_work_with_keycloak_token: End-to-end tool execution - test_keycloak_token_persistence: Multi-operation token reuse - test_user_auto_provisioning: Nextcloud user provisioning - test_scope_filtering_with_keycloak: Scope-based tool filtering - test_keycloak_error_handling: Error handling - test_external_idp_architecture: Architecture documentation **Current Status:** - ✅ Keycloak realm configuration complete - ✅ Custom scopes defined and available - ✅ OAuth token acquisition working (1 test passing) - ⚠️ Token validation needs additional work (external IdP userinfo) **Files Modified:** - keycloak/realm-export.json: Realm configuration with scopes - tests/conftest.py: Keycloak OAuth fixtures (+285 lines) - tests/server/oauth/test_keycloak_external_idp.py: New test suite - docker-compose.yml: OIDC_JWKS_URI and issuer configuration - nextcloud_mcp_server/app.py: JWKS URI override logic 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
+7
-1
@@ -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
|
||||
|
||||
+207
-1
@@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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}")
|
||||
|
||||
+1
-1
@@ -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"
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user