feat: add real elicitation integration test with python-sdk MCP client
This commit adds proper integration testing of the login elicitation flow (ADR-006) using python-sdk's MCP client with actual elicitation callback support, and fixes user_id extraction to support both JWT and opaque tokens. ## Changes ### 1. Enhanced create_mcp_client_session helper (tests/conftest.py) - Added `elicitation_callback` parameter to function signature - Pass callback to ClientSession constructor - Added necessary imports: RequestContext, ElicitRequestParams, ElicitResult, ErrorData from mcp package - Allows fixtures to provide custom elicitation handlers ### 2. New fixture: nc_mcp_oauth_client_with_elicitation (tests/conftest.py) - Creates MCP client with Playwright-based elicitation callback - Callback implementation: - Extracts OAuth URL from elicitation message using regex - Uses Playwright browser to complete OAuth flow automatically - Handles Nextcloud login form (username/password) - Handles consent screen if present - Waits for OAuth callback completion - Returns ElicitResult(action="accept") on success - Function-scoped to allow independent test state - Tracks elicitation invocations via session.elicitation_triggered ### 3. Fixed user_id extraction for opaque tokens (oauth_tools.py) - Created extract_user_id_from_token() helper to handle both JWT and opaque tokens by calling userinfo endpoint when needed - Fixed check_logged_in to use helper instead of broken ctx.authorization - Fixed revoke_nextcloud_access to use helper instead of ctx.context.get() - Both tools now properly extract user_id from access tokens ### 4. Enhanced integration tests (test_elicitation_integration.py) - Updated tests to revoke refresh tokens via MCP tool - All 4 tests now pass: - test_check_logged_in_with_real_elicitation_callback: Complete flow - test_elicitation_callback_url_extraction: URL extraction validation - test_elicitation_stores_refresh_token: Token persistence verification - test_second_check_logged_in_does_not_elicit: No redundant elicitations ### 5. Added diagnostic logging (oauth_routes.py) - Track user_id extraction from ID tokens during OAuth callbacks - Log refresh token storage with user_id and flow_type ## Test Results ✅ 4/4 tests pass The test suite successfully validates: - Elicitation callback is triggered when no refresh token exists - Playwright automation completes OAuth flow - Refresh token is stored after OAuth with correct user_id - Tool returns "yes" after successful login - Already-logged-in users don't get redundant elicitations ## Why This Matters Previous tests (test_login_elicitation.py) only validated response formats and acknowledged they couldn't test real elicitation protocol. This test exercises the REAL MCP elicitation protocol end-to-end: 1. MCP server calls ctx.elicit() 2. python-sdk ClientSession invokes custom callback 3. Callback completes OAuth via Playwright 4. Client returns acceptance to server 5. Tool proceeds with authenticated state This proves the python-sdk MCP client can handle elicitation in production environments with both JWT and opaque tokens. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -493,14 +493,22 @@ async def oauth_callback_nextcloud(request: Request):
|
||||
id_token = token_data.get("id_token")
|
||||
|
||||
# Decode ID token to get user info
|
||||
logger.info("=" * 60)
|
||||
logger.info("oauth_callback_nextcloud: Extracting user_id from ID token")
|
||||
logger.info("=" * 60)
|
||||
try:
|
||||
userinfo = jwt.decode(id_token, options={"verify_signature": False})
|
||||
user_id = userinfo.get("sub")
|
||||
username = userinfo.get("preferred_username") or userinfo.get("email")
|
||||
logger.info(" ✓ ID token decode SUCCESSFUL")
|
||||
logger.info(f" Extracted user_id: {user_id}")
|
||||
logger.info(f" Username: {username}")
|
||||
logger.info(f" ID token payload keys: {list(userinfo.keys())}")
|
||||
logger.info(f"Flow 2: User {username} provisioned resource access")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to decode ID token: {e}")
|
||||
logger.error(f" ✗ ID token decode FAILED: {type(e).__name__}: {e}")
|
||||
user_id = "unknown"
|
||||
logger.error(f" Using fallback user_id: {user_id}")
|
||||
|
||||
# Store master refresh token for Flow 2
|
||||
if refresh_token:
|
||||
@@ -509,6 +517,13 @@ async def oauth_callback_nextcloud(request: Request):
|
||||
token_data.get("scope", "").split() if token_data.get("scope") else None
|
||||
)
|
||||
|
||||
logger.info("Storing refresh token:")
|
||||
logger.info(f" user_id: {user_id}")
|
||||
logger.info(" flow_type: flow2")
|
||||
logger.info(" token_audience: nextcloud")
|
||||
logger.info(f" provisioning_client_id: {state[:16]}...")
|
||||
logger.info(f" scopes: {granted_scopes}")
|
||||
|
||||
await storage.store_refresh_token(
|
||||
user_id=user_id,
|
||||
refresh_token=refresh_token,
|
||||
@@ -518,7 +533,8 @@ async def oauth_callback_nextcloud(request: Request):
|
||||
scopes=granted_scopes,
|
||||
expires_at=None, # Refresh tokens typically don't expire
|
||||
)
|
||||
logger.info(f"Stored Flow 2 master refresh token for user {user_id}")
|
||||
logger.info(f"✓ Stored Flow 2 master refresh token for user {user_id}")
|
||||
logger.info("=" * 60)
|
||||
|
||||
# Return success HTML page
|
||||
success_html = """
|
||||
|
||||
Reference in New Issue
Block a user