e26c5128b7
Service account tokens (client_credentials grant) violate OAuth "act on-behalf-of" principles and have been moved to ADR-002's "Will Not Implement" section. ## Problem Discovery Testing revealed that service account tokens create Nextcloud user accounts (e.g., `service-account-nextcloud-mcp-server`) due to user_oidc's bearer provisioning feature. This violates core OAuth principles: - ❌ Creates stateful server identity in Nextcloud - ❌ All actions attributed to service account, not real user - ❌ Breaks audit trail and user attribution - ❌ Service account becomes "admin by another name" ## Changes ### Documentation (ADR-002) - Moved service account (old Tier 1) to "Will Not Implement" section - Added "OAuth Act On-Behalf-Of Principle" section - Renumbered tiers: - Tier 1: Impersonation (NOT IMPLEMENTED) - Tier 2: Delegation via token exchange (IMPLEMENTED) - Updated status to reflect rejection of service accounts ### Code Warnings - Added comprehensive warning to KeycloakOAuthClient.get_service_account_token() - Clarified VALID use: only as subject_token for RFC 8693 token exchange - Clarified INVALID use: direct API access with service account token ### Supporting Documentation - CLAUDE.md: Removed outdated "Tier 1" references, added rejection note - oauth-impersonation-findings.md: Added prominent update banner - audience-validation-setup.md: Updated tier numbers, added rejection note - tests/manual/test_token_exchange.py: Added warning comment ## Valid Patterns (ADR-002) ✅ Foreground operations: User's access token from MCP request ✅ Background operations: Token exchange (impersonation/delegation) ✅ Offline access: Refresh tokens with user consent ❌ Service accounts: Creates independent server identity (REJECTED) ## Alternative If service account pattern is truly needed, use BasicAuth mode instead of OAuth mode. OAuth mode MUST maintain "act on-behalf-of" semantics. Related: c12df98 (revert of service account test) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
197 lines
7.4 KiB
Python
197 lines
7.4 KiB
Python
"""
|
|
Manual test for RFC 8693 Token Exchange with Keycloak.
|
|
|
|
This script demonstrates ADR-002 Tier 2 implementation:
|
|
1. Get service account token (client_credentials grant)
|
|
2. Exchange token for user-scoped token (RFC 8693)
|
|
3. Use exchanged token to access Nextcloud APIs
|
|
|
|
Usage:
|
|
# Start Keycloak and app containers
|
|
docker compose up -d keycloak app
|
|
|
|
# Run the test
|
|
uv run python tests/manual/test_token_exchange.py
|
|
"""
|
|
|
|
import asyncio
|
|
import logging
|
|
import os
|
|
import sys
|
|
|
|
# Add parent directory to path
|
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "../.."))
|
|
|
|
from nextcloud_mcp_server.auth.keycloak_oauth import KeycloakOAuthClient
|
|
from nextcloud_mcp_server.client import NextcloudClient
|
|
|
|
# Setup logging
|
|
logging.basicConfig(
|
|
level=logging.INFO, format="%(levelname)-8s | %(name)-30s | %(message)s"
|
|
)
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
async def main():
|
|
"""Test token exchange flow"""
|
|
|
|
# Configuration (matches docker-compose mcp-keycloak service)
|
|
keycloak_url = os.getenv("KEYCLOAK_URL", "http://localhost:8888")
|
|
realm = os.getenv("KEYCLOAK_REALM", "nextcloud-mcp")
|
|
client_id = os.getenv("KEYCLOAK_CLIENT_ID", "nextcloud-mcp-server")
|
|
client_secret = os.getenv(
|
|
"KEYCLOAK_CLIENT_SECRET", "mcp-secret-change-in-production"
|
|
)
|
|
nextcloud_host = os.getenv("NEXTCLOUD_HOST", "http://localhost:8080")
|
|
redirect_uri = "http://localhost:8002/oauth/callback"
|
|
|
|
logger.info("=" * 80)
|
|
logger.info("RFC 8693 Token Exchange Test")
|
|
logger.info("=" * 80)
|
|
logger.info(f"Keycloak URL: {keycloak_url}")
|
|
logger.info(f"Realm: {realm}")
|
|
logger.info(f"Client ID: {client_id}")
|
|
logger.info(f"Nextcloud: {nextcloud_host}")
|
|
logger.info("")
|
|
|
|
# Step 1: Create Keycloak OAuth client
|
|
logger.info("Step 1: Initializing Keycloak OAuth client...")
|
|
oauth_client = KeycloakOAuthClient(
|
|
keycloak_url=keycloak_url,
|
|
realm=realm,
|
|
client_id=client_id,
|
|
client_secret=client_secret,
|
|
redirect_uri=redirect_uri,
|
|
)
|
|
|
|
# Discover endpoints
|
|
await oauth_client.discover()
|
|
logger.info(f"✓ Discovered token endpoint: {oauth_client.token_endpoint}")
|
|
logger.info("")
|
|
|
|
# Step 2: Check token exchange support
|
|
logger.info("Step 2: Checking token exchange support...")
|
|
supported = await oauth_client.check_token_exchange_support()
|
|
|
|
if not supported:
|
|
logger.error("❌ Token exchange is NOT supported by this Keycloak instance")
|
|
logger.error(
|
|
" You may need to enable it with: --features=preview --features=token-exchange"
|
|
)
|
|
return 1
|
|
|
|
logger.info("")
|
|
|
|
# Step 3: Get service account token
|
|
# ⚠️ WARNING: Service account tokens MUST NOT be used directly with Nextcloud APIs!
|
|
# Using this token directly violates OAuth "act on-behalf-of" principles:
|
|
# - Creates Nextcloud user: service-account-{client_id}
|
|
# - Breaks audit trail (actions not attributable to real user)
|
|
# - Creates stateful server identity in Nextcloud
|
|
#
|
|
# VALID USE: ONLY as subject_token for RFC 8693 token exchange (Step 4 below)
|
|
# INVALID USE: Direct API access (see ADR-002 "Will Not Implement" section)
|
|
#
|
|
# If you need background operations without token exchange support, use BasicAuth mode.
|
|
logger.info("Step 3: Requesting service account token (client_credentials)...")
|
|
try:
|
|
service_token_response = await oauth_client.get_service_account_token(
|
|
scopes=["openid", "profile", "email"]
|
|
)
|
|
service_token = service_token_response["access_token"]
|
|
logger.info("✓ Service account token acquired")
|
|
logger.info(f" Token type: {service_token_response.get('token_type')}")
|
|
logger.info(f" Expires in: {service_token_response.get('expires_in')}s")
|
|
logger.info(f" Scope: {service_token_response.get('scope')}")
|
|
logger.info(f" Token (first 50 chars): {service_token[:50]}...")
|
|
except Exception as e:
|
|
logger.error(f"❌ Failed to get service account token: {e}")
|
|
logger.error(
|
|
" Make sure serviceAccountsEnabled=true for the client in Keycloak"
|
|
)
|
|
return 1
|
|
|
|
logger.info("")
|
|
|
|
# Step 4: Exchange token (without impersonation - Standard V2)
|
|
logger.info(
|
|
"Step 4: Exchanging service token with different audience (RFC 8693)..."
|
|
)
|
|
logger.info(" Note: Keycloak Standard V2 doesn't support user impersonation")
|
|
logger.info(" That requires Legacy V1 with --features=preview")
|
|
try:
|
|
user_token_response = await oauth_client.exchange_token_for_user(
|
|
subject_token=service_token,
|
|
target_user_id=None, # Don't request impersonation
|
|
audience=None, # No cross-client exchange in Standard V2
|
|
scopes=["openid", "profile"], # Try downscoping
|
|
)
|
|
user_token = user_token_response["access_token"]
|
|
logger.info("✓ Token exchange successful")
|
|
logger.info(
|
|
f" Issued token type: {user_token_response.get('issued_token_type')}"
|
|
)
|
|
logger.info(f" Token type: {user_token_response.get('token_type')}")
|
|
logger.info(f" Expires in: {user_token_response.get('expires_in')}s")
|
|
logger.info(f" User token (first 50 chars): {user_token[:50]}...")
|
|
except Exception as e:
|
|
logger.error(f"❌ Token exchange failed: {e}")
|
|
logger.error(" Possible causes:")
|
|
logger.error(" - token.exchange.grant.enabled not set to true")
|
|
logger.error(" - Missing exchange permissions in Keycloak")
|
|
logger.error(" - User 'admin' does not exist")
|
|
return 1
|
|
|
|
logger.info("")
|
|
|
|
# Step 5: Test user token with Nextcloud API
|
|
logger.info("Step 5: Testing exchanged token with Nextcloud capabilities API...")
|
|
try:
|
|
# Create Nextcloud client with exchanged token
|
|
nc_client = NextcloudClient.from_token(
|
|
base_url=nextcloud_host, token=user_token, username="admin"
|
|
)
|
|
|
|
# Test API call
|
|
capabilities = await nc_client.capabilities()
|
|
logger.info("✓ Nextcloud API call successful")
|
|
logger.info(f" Version: {capabilities.get('version', {}).get('string')}")
|
|
logger.info(
|
|
f" Edition: {capabilities.get('capabilities', {}).get('core', {}).get('webdav-root')}"
|
|
)
|
|
|
|
await nc_client.close()
|
|
except Exception as e:
|
|
logger.error(f"❌ Nextcloud API call failed: {e}")
|
|
logger.error(" The exchanged token may not be valid for Nextcloud")
|
|
logger.error(" Check that user_oidc app is configured correctly")
|
|
return 1
|
|
|
|
logger.info("")
|
|
logger.info("=" * 80)
|
|
logger.info("✅ Token Exchange Test PASSED")
|
|
logger.info("=" * 80)
|
|
logger.info("")
|
|
logger.info("Summary:")
|
|
logger.info(" 1. Service account token acquired")
|
|
logger.info(" 2. Token exchanged with different audience")
|
|
logger.info(" 3. Exchanged token works with Nextcloud APIs")
|
|
logger.info("")
|
|
logger.info("This demonstrates ADR-002 Tier 2: Token Exchange")
|
|
logger.info(
|
|
"The MCP server can perform token exchange for different audiences/scopes"
|
|
)
|
|
logger.info("without needing refresh tokens or admin credentials.")
|
|
logger.info("")
|
|
logger.info(
|
|
"Note: User impersonation requires Keycloak Legacy V1 with --features=preview"
|
|
)
|
|
logger.info("")
|
|
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
exit_code = asyncio.run(main())
|
|
sys.exit(exit_code)
|