from server.auth.auth_error import AuthError, ExpiredError from server.auth.saas_user_auth import saas_user_auth_from_signed_token from server.auth.token_manager import TokenManager from socketio.exceptions import ConnectionRefusedError from storage.api_key_store import ApiKeyStore from openhands.core.config import load_openhands_config from openhands.core.logger import openhands_logger as logger from openhands.server.shared import ConversationStoreImpl from openhands.storage.conversation.conversation_validator import ConversationValidator class SaasConversationValidator(ConversationValidator): """Storage for conversation metadata. May or may not support multiple users depending on the environment.""" async def _validate_api_key(self, api_key: str) -> str | None: """ Validate an API key and return the user_id and github_user_id if valid. Args: api_key: The API key to validate Returns: A tuple of (user_id, github_user_id) if the API key is valid, None otherwise """ try: token_manager = TokenManager() # Validate the API key and get the user_id api_key_store = ApiKeyStore.get_instance() user_id = api_key_store.validate_api_key(api_key) if not user_id: logger.warning('Invalid API key') return None # Get the offline token for the user offline_token = await token_manager.load_offline_token(user_id) if not offline_token: logger.warning(f'No offline token found for user {user_id}') return None return user_id except Exception as e: logger.warning(f'Error validating API key: {str(e)}') return None async def _validate_conversation_access( self, conversation_id: str, user_id: str ) -> bool: """ Validate that the user has access to the conversation. Args: conversation_id: The ID of the conversation user_id: The ID of the user github_user_id: The GitHub user ID, if available Returns: True if the user has access to the conversation, False otherwise Raises: ConnectionRefusedError: If the user does not have access to the conversation """ config = load_openhands_config() conversation_store = await ConversationStoreImpl.get_instance(config, user_id) if not await conversation_store.validate_metadata(conversation_id, user_id): logger.error( f'User {user_id} is not allowed to join conversation {conversation_id}' ) raise ConnectionRefusedError( f'User {user_id} is not allowed to join conversation {conversation_id}' ) return True async def validate( self, conversation_id: str, cookies_str: str, authorization_header: str | None = None, ) -> str | None: """ Validate the conversation access using either an API key from the Authorization header or a keycloak_auth cookie. Args: conversation_id: The ID of the conversation cookies_str: The cookies string from the request authorization_header: The Authorization header from the request, if available Returns: A tuple of (user_id, github_user_id) Raises: ConnectionRefusedError: If the user does not have access to the conversation AuthError: If the authentication fails RuntimeError: If there is an error with the configuration or user info """ # Try to authenticate using Authorization header first if authorization_header and authorization_header.startswith('Bearer '): api_key = authorization_header.replace('Bearer ', '') user_id = await self._validate_api_key(api_key) if user_id: logger.info( f'User {user_id} is connecting to conversation {conversation_id} via API key' ) await self._validate_conversation_access(conversation_id, user_id) return user_id # Fall back to cookie authentication token_manager = TokenManager() config = load_openhands_config() cookies = ( dict(cookie.split('=', 1) for cookie in cookies_str.split('; ')) if cookies_str else {} ) signed_token = cookies.get('keycloak_auth', '') if not signed_token: logger.warning('No keycloak_auth cookie or valid Authorization header') raise ConnectionRefusedError( 'No keycloak_auth cookie or valid Authorization header' ) if not config.jwt_secret: raise RuntimeError('JWT secret not found') try: user_auth = await saas_user_auth_from_signed_token(signed_token) access_token = await user_auth.get_access_token() except ExpiredError: raise ConnectionRefusedError('SESSION$TIMEOUT_MESSAGE') if access_token is None: raise AuthError('no_access_token') user_info_dict = await token_manager.get_user_info( access_token.get_secret_value() ) if not user_info_dict or 'sub' not in user_info_dict: logger.info( f'Invalid user_info {user_info_dict} for access token {access_token}' ) raise RuntimeError('Invalid user_info') user_id = user_info_dict['sub'] logger.info(f'User {user_id} is connecting to conversation {conversation_id}') await self._validate_conversation_access(conversation_id, user_id) # type: ignore return user_id