Skip to main content
Glama
test_token_exchange.py7.54 kB
""" 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)

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/No-Smoke/nextcloud-mcp-comprehensive'

If you have feedback or need assistance with the MCP directory API, please join our Discord server