"""User info routes for the MCP server admin UI. Provides browser-based endpoints to view information about the currently authenticated user. Uses session-based authentication with OAuth flow. For BasicAuth mode: Shows configured user info (no login needed). For OAuth mode: Requires browser-based OAuth login to establish session. """ import logging import os from typing import Any import httpx from starlette.authentication import requires from starlette.requests import Request from starlette.responses import HTMLResponse, JSONResponse logger = logging.getLogger(__name__) async def _get_processing_status(request: Request) -> dict[str, Any] | None: """Get vector sync processing status. Returns processing status information including indexed count, pending count, and sync status. Only available when VECTOR_SYNC_ENABLED=true. Args: request: Starlette request object Returns: Dictionary with processing status, or None if vector sync is disabled or components are unavailable: { "indexed_count": int, # Number of documents in Qdrant "pending_count": int, # Number of documents in queue "status": str, # "syncing" or "idle" } """ # Check if vector sync is enabled vector_sync_enabled = os.getenv("VECTOR_SYNC_ENABLED", "false").lower() == "true" if not vector_sync_enabled: return None try: # Get document receive stream from app state document_receive_stream = getattr( request.app.state, "document_receive_stream", None ) if document_receive_stream is None: logger.debug("document_receive_stream not available in app state") return None # Get pending count from stream statistics stats = document_receive_stream.statistics() pending_count = stats.current_buffer_used # Get Qdrant client and query indexed count indexed_count = 0 try: from nextcloud_mcp_server.config import get_settings from nextcloud_mcp_server.vector.qdrant_client import get_qdrant_client settings = get_settings() qdrant_client = await get_qdrant_client() # Count documents in collection count_result = await qdrant_client.count( collection_name=settings.get_collection_name() ) indexed_count = count_result.count except Exception as e: logger.warning(f"Failed to query Qdrant for indexed count: {e}") # Continue with indexed_count = 0 # Determine status status = "syncing" if pending_count > 0 else "idle" return { "indexed_count": indexed_count, "pending_count": pending_count, "status": status, } except Exception as e: logger.error(f"Error getting processing status: {e}") return None async def _get_userinfo_endpoint(oauth_ctx: dict[str, Any]) -> str | None: """Get the correct userinfo endpoint based on OAuth mode. Args: oauth_ctx: OAuth context from app.state Returns: Userinfo endpoint URL, or None if unavailable """ oauth_client = oauth_ctx.get("oauth_client") # External IdP mode (Keycloak): use oauth_client's userinfo endpoint if oauth_client: # Ensure discovery has been performed if not oauth_client.userinfo_endpoint: try: await oauth_client.discover() except Exception as e: logger.error(f"Failed to discover IdP endpoints: {e}") return None logger.debug( f"Using external IdP userinfo endpoint: {oauth_client.userinfo_endpoint}" ) return oauth_client.userinfo_endpoint # Integrated mode (Nextcloud): query discovery document oauth_config = oauth_ctx.get("config") if not oauth_config: return None discovery_url = oauth_config.get("discovery_url") if not discovery_url: return None try: async with httpx.AsyncClient(timeout=10.0) as client: response = await client.get(discovery_url) response.raise_for_status() discovery = response.json() userinfo_endpoint = discovery.get("userinfo_endpoint") if userinfo_endpoint: logger.debug( f"Using Nextcloud userinfo endpoint from discovery: {userinfo_endpoint}" ) return userinfo_endpoint logger.warning("No userinfo_endpoint in discovery document") return None except Exception as e: logger.error(f"Failed to query discovery document for userinfo endpoint: {e}") return None async def _query_idp_userinfo( access_token_str: str, userinfo_uri: str ) -> dict[str, Any] | None: """Query the IdP's userinfo endpoint. Args: access_token_str: The access token string userinfo_uri: The userinfo endpoint URI Returns: User info dictionary from IdP, or None if query fails """ try: async with httpx.AsyncClient(timeout=10.0) as client: response = await client.get( userinfo_uri, headers={"Authorization": f"Bearer {access_token_str}"}, ) response.raise_for_status() return response.json() except Exception as e: logger.warning(f"Failed to query IdP userinfo endpoint: {e}") return None async def _get_user_info(request: Request) -> dict[str, Any]: """Get user information for the currently authenticated user. IMPORTANT: This function reads from cached profile data stored at login time. It does NOT perform token refresh or query the IdP on every request. The profile was cached once during oauth_login_callback and is displayed from storage thereafter. This is for BROWSER UI DISPLAY ONLY. Do not use this for authorization decisions or background job authentication. Args: request: Starlette request object (must be authenticated) Returns: Dictionary containing user information from cache """ username = request.user.display_name oauth_ctx = getattr(request.app.state, "oauth_context", None) # BasicAuth mode if not oauth_ctx: return { "username": username, "auth_mode": "basic", "nextcloud_host": os.getenv("NEXTCLOUD_HOST", "unknown"), } # OAuth mode - read cached profile from browser session storage = oauth_ctx.get("storage") session_id = request.cookies.get("mcp_session") if not storage or not session_id: return { "error": "Session not found", "username": username, "auth_mode": "oauth", } try: # Check if background access was granted (refresh token exists) # This works for both Flow 2 (elicitation) and browser login token_data = await storage.get_refresh_token(session_id) background_access_granted = token_data is not None # Build background access details background_access_details = None if token_data: background_access_details = { "flow_type": token_data.get("flow_type", "unknown"), "provisioned_at": token_data.get("provisioned_at", "unknown"), "provisioning_client_id": token_data.get( "provisioning_client_id", "N/A" ), "scopes": token_data.get("scopes", "N/A"), "token_audience": token_data.get("token_audience", "unknown"), } # Retrieve cached user profile (no token operations!) profile_data = await storage.get_user_profile(session_id) # Build user context user_context = { "username": username, # From request.user.display_name (session_id) "auth_mode": "oauth", "session_id": session_id[:16] + "...", # Truncated for security "background_access_granted": background_access_granted, "background_access_details": background_access_details, } # Include cached profile if available if profile_data: user_context["idp_profile"] = profile_data logger.debug(f"Loaded cached profile for {session_id[:16]}...") else: logger.warning(f"No cached profile found for {session_id[:16]}...") user_context["idp_profile_error"] = ( "Profile not cached. Try logging out and back in." ) return user_context except Exception as e: import traceback logger.error(f"Error retrieving user info: {e}") logger.error(f"Traceback: {traceback.format_exc()}") return { "error": f"Failed to retrieve user info: {e}", "username": username, "auth_mode": "oauth", } @requires("authenticated", redirect="oauth_login") async def user_info_json(request: Request) -> JSONResponse: """User info endpoint - returns JSON with current user information. Requires authentication via session cookie (redirects to oauth_login route if not authenticated). Args: request: Starlette request object Returns: JSON response with user information """ user_info = await _get_user_info(request) return JSONResponse(user_info) @requires("authenticated", redirect="oauth_login") async def user_info_html(request: Request) -> HTMLResponse: """User info page - returns HTML with current user information. Requires authentication via session cookie (redirects to oauth_login route if not authenticated). Args: request: Starlette request object Returns: HTML response with formatted user information """ user_context = await _get_user_info(request) # Get vector sync processing status processing_status = await _get_processing_status(request) # Check for error if "error" in user_context and user_context["error"] != "": # Get login URL dynamically oauth_ctx = getattr(request.app.state, "oauth_context", None) login_url = str(request.url_for("oauth_login")) if oauth_ctx else "/oauth/login" error_html = f"""
| Nextcloud Host | {nextcloud_host} |
{scopes}| Session ID | {session_id} |
| Indexed Documents | {indexed_count_str} |
| Pending Documents | {pending_count_str} |
| Status | {status_badge} |
| {key} | {value_str} |
| Username | {username} |
| Authentication Mode | {auth_mode} |
OAuth mode not enabled
""", status_code=400, ) storage = oauth_ctx.get("storage") session_id = request.cookies.get("mcp_session") if not storage or not session_id: return HTMLResponse( """Session not found
""", status_code=400, ) try: # Delete the refresh token logger.info(f"Revoking background access for session {session_id[:16]}...") await storage.delete_refresh_token(session_id) logger.info(f"✓ Background access revoked for session {session_id[:16]}...") # Redirect back to user page user_page_url = str(request.url_for("user_info_html")) return HTMLResponse( f"""Your refresh token has been deleted successfully.
Browser session remains active.
Redirecting back to user page...
Failed to revoke background access: {e}
""", status_code=500, )