diff --git a/openhands/app_server/app_conversation/live_status_app_conversation_service.py b/openhands/app_server/app_conversation/live_status_app_conversation_service.py index 5c81e4841c..9d0e6baacb 100644 --- a/openhands/app_server/app_conversation/live_status_app_conversation_service.py +++ b/openhands/app_server/app_conversation/live_status_app_conversation_service.py @@ -73,12 +73,12 @@ from openhands.sdk.conversation.secret_source import LookupSecret, StaticSecret from openhands.sdk.llm import LLM from openhands.sdk.security.confirmation_policy import AlwaysConfirm from openhands.sdk.workspace.remote.async_remote_workspace import AsyncRemoteWorkspace +from openhands.server.types import AppMode from openhands.tools.preset.default import get_default_agent from openhands.tools.preset.planning import get_planning_agent _conversation_info_type_adapter = TypeAdapter(list[ConversationInfo | None]) _logger = logging.getLogger(__name__) -GIT_TOKEN = 'GIT_TOKEN' @dataclass @@ -97,6 +97,8 @@ class LiveStatusAppConversationService(AppConversationServiceBase): httpx_client: httpx.AsyncClient web_url: str | None access_token_hard_timeout: timedelta | None + app_mode: str | None = None + keycloak_auth_cookie: str | None = None async def search_app_conversations( self, @@ -529,6 +531,7 @@ class LiveStatusAppConversationService(AppConversationServiceBase): # Set up a secret for the git token secrets = await self.user_context.get_secrets() if git_provider: + secret_name = f'{git_provider.name}_TOKEN' if self.web_url: # If there is a web url, then we create an access token to access it. # For security reasons, we are explicit here - only this user, and @@ -540,9 +543,15 @@ class LiveStatusAppConversationService(AppConversationServiceBase): }, expires_in=self.access_token_hard_timeout, ) - secrets[GIT_TOKEN] = LookupSecret( + headers = {'X-Access-Token': access_token} + + # Include keycloak_auth cookie in headers if app_mode is SaaS + if self.app_mode == 'saas' and self.keycloak_auth_cookie: + headers['Cookie'] = f'keycloak_auth={self.keycloak_auth_cookie}' + + secrets[secret_name] = LookupSecret( url=self.web_url + '/api/v1/webhooks/secrets', - headers={'X-Access-Token': access_token}, + headers=headers, ) else: # If there is no URL specified where the sandbox can access the app server @@ -550,7 +559,7 @@ class LiveStatusAppConversationService(AppConversationServiceBase): # on the type, this may eventually expire. static_token = await self.user_context.get_latest_token(git_provider) if static_token: - secrets[GIT_TOKEN] = StaticSecret(value=static_token) + secrets[secret_name] = StaticSecret(value=static_token) workspace = LocalWorkspace(working_dir=working_dir) @@ -841,6 +850,21 @@ class LiveStatusAppConversationServiceInjector(AppConversationServiceInjector): if isinstance(sandbox_service, DockerSandboxService): web_url = f'http://host.docker.internal:{sandbox_service.host_port}' + # Get app_mode and keycloak_auth cookie for SaaS mode + app_mode = None + keycloak_auth_cookie = None + try: + from openhands.server.shared import server_config + + app_mode = ( + server_config.app_mode.value if server_config.app_mode else None + ) + if request and server_config.app_mode == AppMode.SAAS: + keycloak_auth_cookie = request.cookies.get('keycloak_auth') + except (ImportError, AttributeError): + # If server_config is not available (e.g., in tests), continue without it + pass + yield LiveStatusAppConversationService( init_git_in_empty_workspace=self.init_git_in_empty_workspace, user_context=user_context, @@ -855,4 +879,6 @@ class LiveStatusAppConversationServiceInjector(AppConversationServiceInjector): httpx_client=httpx_client, web_url=web_url, access_token_hard_timeout=access_token_hard_timeout, + app_mode=app_mode, + keycloak_auth_cookie=keycloak_auth_cookie, ) diff --git a/openhands/app_server/event_callback/webhook_router.py b/openhands/app_server/event_callback/webhook_router.py index 498ebd2fd2..1170d94fe4 100644 --- a/openhands/app_server/event_callback/webhook_router.py +++ b/openhands/app_server/event_callback/webhook_router.py @@ -6,9 +6,10 @@ import logging import pkgutil from uuid import UUID -from fastapi import APIRouter, Depends, HTTPException, status +from fastapi import APIRouter, Depends, HTTPException, Response, status from fastapi.security import APIKeyHeader from jwt import InvalidTokenError +from pydantic import SecretStr from openhands import tools # type: ignore[attr-defined] from openhands.agent_server.models import ConversationInfo, Success @@ -33,6 +34,7 @@ from openhands.app_server.sandbox.sandbox_models import SandboxInfo from openhands.app_server.sandbox.sandbox_service import SandboxService from openhands.app_server.services.injector import InjectorState from openhands.app_server.services.jwt_service import JwtService +from openhands.app_server.user.auth_user_context import AuthUserContext from openhands.app_server.user.specifiy_user_context import ( USER_CONTEXT_ATTR, SpecifyUserContext, @@ -41,6 +43,10 @@ from openhands.app_server.user.specifiy_user_context import ( from openhands.app_server.user.user_context import UserContext from openhands.integrations.provider import ProviderType from openhands.sdk import Event +from openhands.server.user_auth.default_user_auth import DefaultUserAuth +from openhands.server.user_auth.user_auth import ( + get_for_user as get_user_auth_for_user, +) router = APIRouter(prefix='/webhooks', tags=['Webhooks']) sandbox_service_dependency = depends_sandbox_service() @@ -154,23 +160,34 @@ async def on_event( async def get_secret( access_token: str = Depends(APIKeyHeader(name='X-Access-Token', auto_error=False)), jwt_service: JwtService = jwt_dependency, -) -> str: +) -> Response: """Given an access token, retrieve a user secret. The access token is limited by user and provider type, and may include a timeout, limiting the damage in the event that a token is ever leaked""" try: payload = jwt_service.verify_jws_token(access_token) user_id = payload['user_id'] - provider_type = ProviderType[payload['provider_type']] - user_injector = config.user - assert user_injector is not None - user_context = await user_injector.get_for_user(user_id) - secret = None - if user_context: - secret = await user_context.get_latest_token(provider_type) + provider_type = ProviderType(payload['provider_type']) + + # Get UserAuth for the user_id + if user_id: + user_auth = await get_user_auth_for_user(user_id) + else: + # OSS mode - use default user auth + user_auth = DefaultUserAuth() + + # Create UserContext directly + user_context = AuthUserContext(user_auth=user_auth) + + secret = await user_context.get_latest_token(provider_type) if secret is None: raise HTTPException(404, 'No such provider') - return secret + if isinstance(secret, SecretStr): + secret_value = secret.get_secret_value() + else: + secret_value = secret + + return Response(content=secret_value, media_type='text/plain') except InvalidTokenError: raise HTTPException(status.HTTP_401_UNAUTHORIZED)