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:
Chris Coutinho
2025-11-02 12:44:50 +01:00
parent 2a1274d8a8
commit 403f8be429
6 changed files with 909 additions and 3 deletions
+7 -1
View File
@@ -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
View File
@@ -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"
}
}
]
}
+7
View File
@@ -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
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"
+283
View File
@@ -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