From ede203add3a59ba68d7bc511fbaf2fac5543302a Mon Sep 17 00:00:00 2001 From: Joe Laverty Date: Fri, 6 Mar 2026 11:49:20 -0500 Subject: [PATCH] feat(enterprise): Bitbucket Data Center Integration (#13228) Co-authored-by: openhands --- .../allhands-realm-github-provider.json.tmpl | 54 ++++++ enterprise/enterprise_local/convert_to_env.py | 3 + .../bitbucket_data_center/__init__.py | 0 .../bitbucket_dc_service.py | 65 +++++++ enterprise/saas_server.py | 7 + enterprise/server/auth/constants.py | 10 + enterprise/server/auth/saas_user_auth.py | 4 + enterprise/server/auth/token_manager.py | 33 ++++ enterprise/server/config.py | 4 + .../server/routes/bitbucket_dc_proxy.py | 63 ++++++ .../bitbucket_data_center/__init__.py | 0 .../test_bitbucket_dc_service.py | 132 +++++++++++++ .../server/routes/test_bitbucket_dc_proxy.py | 182 ++++++++++++++++++ enterprise/tests/unit/test_saas_user_auth.py | 102 ++++++++++ .../tests/unit/test_token_manager_extended.py | 105 ++++++++++ 15 files changed, 764 insertions(+) create mode 100644 enterprise/integrations/bitbucket_data_center/__init__.py create mode 100644 enterprise/integrations/bitbucket_data_center/bitbucket_dc_service.py create mode 100644 enterprise/server/routes/bitbucket_dc_proxy.py create mode 100644 enterprise/tests/unit/integrations/bitbucket_data_center/__init__.py create mode 100644 enterprise/tests/unit/integrations/bitbucket_data_center/test_bitbucket_dc_service.py create mode 100644 enterprise/tests/unit/server/routes/test_bitbucket_dc_proxy.py diff --git a/enterprise/allhands-realm-github-provider.json.tmpl b/enterprise/allhands-realm-github-provider.json.tmpl index 35ff5f0afc..c3e24e61d4 100644 --- a/enterprise/allhands-realm-github-provider.json.tmpl +++ b/enterprise/allhands-realm-github-provider.json.tmpl @@ -1772,6 +1772,40 @@ "sendIdTokenOnLogout": "true", "passMaxAge": "false" } + }, + { + "alias": "bitbucket_data_center", + "displayName": "Bitbucket Data Center", + "internalId": "b77b4ead-20e8-451c-ad27-99f92d561616", + "providerId": "oauth2", + "enabled": true, + "updateProfileFirstLoginMode": "on", + "trustEmail": true, + "storeToken": true, + "addReadTokenRoleOnCreate": false, + "authenticateByDefault": false, + "linkOnly": false, + "hideOnLogin": false, + "config": { + "givenNameClaim": "given_name", + "userInfoUrl": "https://${WEB_HOST}/bitbucket-dc-proxy/oauth2/userinfo", + "clientId": "$BITBUCKET_DATA_CENTER_CLIENT_ID", + "tokenUrl": "https://${BITBUCKET_DATA_CENTER_HOST}/rest/oauth2/latest/token", + "acceptsPromptNoneForwardFromClient": "false", + "fullNameClaim": "name", + "userIDClaim": "sub", + "emailClaim": "email", + "userNameClaim": "preferred_username", + "caseSensitiveOriginalUsername": "false", + "familyNameClaim": "family_name", + "pkceEnabled": "false", + "authorizationUrl": "https://${BITBUCKET_DATA_CENTER_HOST}/rest/oauth2/latest/authorize", + "clientAuthMethod": "client_secret_post", + "syncMode": "IMPORT", + "clientSecret": "$BITBUCKET_DATA_CENTER_CLIENT_SECRET", + "allowedClockSkew": "0", + "defaultScope": "REPO_WRITE" + } } ], "identityProviderMappers": [ @@ -1829,6 +1863,26 @@ "syncMode": "FORCE", "attribute": "identity_provider" } + }, + { + "name": "id-mapper", + "identityProviderAlias": "bitbucket_data_center", + "identityProviderMapper": "oidc-user-attribute-idp-mapper", + "config": { + "syncMode": "FORCE", + "claim": "sub", + "user.attribute": "bitbucket_data_center_id" + } + }, + { + "name": "identity-provider", + "identityProviderAlias": "bitbucket_data_center", + "identityProviderMapper": "hardcoded-attribute-idp-mapper", + "config": { + "attribute.value": "bitbucket_data_center", + "syncMode": "FORCE", + "attribute": "identity_provider" + } } ], "components": { diff --git a/enterprise/enterprise_local/convert_to_env.py b/enterprise/enterprise_local/convert_to_env.py index cbd04b6449..cfef08bf19 100644 --- a/enterprise/enterprise_local/convert_to_env.py +++ b/enterprise/enterprise_local/convert_to_env.py @@ -109,6 +109,9 @@ lines.append( lines.append( 'OPENHANDS_BITBUCKET_SERVICE_CLS=integrations.bitbucket.bitbucket_service.SaaSBitBucketService' ) +lines.append( + 'OPENHANDS_BITBUCKET_DATA_CENTER_SERVICE_CLS=integrations.bitbucket_data_center.bitbucket_dc_service.SaaSBitbucketDCService' +) lines.append( 'OPENHANDS_CONVERSATION_VALIDATOR_CLS=storage.saas_conversation_validator.SaasConversationValidator' ) diff --git a/enterprise/integrations/bitbucket_data_center/__init__.py b/enterprise/integrations/bitbucket_data_center/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/enterprise/integrations/bitbucket_data_center/bitbucket_dc_service.py b/enterprise/integrations/bitbucket_data_center/bitbucket_dc_service.py new file mode 100644 index 0000000000..eeb0334b95 --- /dev/null +++ b/enterprise/integrations/bitbucket_data_center/bitbucket_dc_service.py @@ -0,0 +1,65 @@ +from pydantic import SecretStr +from server.auth.token_manager import TokenManager + +from openhands.core.logger import openhands_logger as logger +from openhands.integrations.bitbucket_data_center.bitbucket_dc_service import ( + BitbucketDCService, +) +from openhands.integrations.service_types import ProviderType + + +class SaaSBitbucketDCService(BitbucketDCService): + def __init__( + self, + user_id: str | None = None, + external_auth_token: SecretStr | None = None, + external_auth_id: str | None = None, + token: SecretStr | None = None, + external_token_manager: bool = False, + base_domain: str | None = None, + ): + logger.debug( + f'SaaSBitbucketDCService created with user_id {user_id}, external_auth_id {external_auth_id}, external_auth_token {'set' if external_auth_token else 'None'}, token {'set' if token else 'None'}, external_token_manager {external_token_manager}' + ) + super().__init__( + user_id=user_id, + external_auth_token=external_auth_token, + external_auth_id=external_auth_id, + token=token, + external_token_manager=external_token_manager, + base_domain=base_domain, + ) + + self.token_manager = TokenManager(external=external_token_manager) + self.refresh = True + + async def get_latest_token(self) -> SecretStr | None: + bitbucket_dc_token = None + if self.external_auth_token: + bitbucket_dc_token = SecretStr( + await self.token_manager.get_idp_token( + self.external_auth_token.get_secret_value(), + idp=ProviderType.BITBUCKET_DATA_CENTER, + ) + ) + logger.debug('Got Bitbucket DC token via external_auth_token') + elif self.external_auth_id: + offline_token = await self.token_manager.load_offline_token( + self.external_auth_id + ) + bitbucket_dc_token = SecretStr( + await self.token_manager.get_idp_token_from_offline_token( + offline_token, ProviderType.BITBUCKET_DATA_CENTER + ) + ) + logger.debug('Got Bitbucket DC token via external_auth_id') + elif self.user_id: + bitbucket_dc_token = SecretStr( + await self.token_manager.get_idp_token_from_idp_user_id( + self.user_id, ProviderType.BITBUCKET_DATA_CENTER + ) + ) + logger.debug('Got Bitbucket DC token via user_id') + else: + logger.warning('external_auth_token and user_id not set!') + return bitbucket_dc_token diff --git a/enterprise/saas_server.py b/enterprise/saas_server.py index 106ca93200..8bb576a55b 100644 --- a/enterprise/saas_server.py +++ b/enterprise/saas_server.py @@ -14,6 +14,7 @@ from fastapi.middleware.cors import CORSMiddleware # noqa: E402 from fastapi.responses import JSONResponse # noqa: E402 from server.auth.auth_error import ExpiredError, NoCredentialsError # noqa: E402 from server.auth.constants import ( # noqa: E402 + BITBUCKET_DATA_CENTER_HOST, ENABLE_JIRA, ENABLE_JIRA_DC, ENABLE_LINEAR, @@ -130,6 +131,12 @@ if ENABLE_JIRA_DC: base_app.include_router(jira_dc_integration_router) if ENABLE_LINEAR: base_app.include_router(linear_integration_router) +if BITBUCKET_DATA_CENTER_HOST: + from server.routes.bitbucket_dc_proxy import ( + router as bitbucket_dc_proxy_router, # noqa: E402 + ) + + base_app.include_router(bitbucket_dc_proxy_router) base_app.include_router(email_router) # Add routes for email management base_app.include_router(feedback_router) # Add routes for conversation feedback base_app.include_router( diff --git a/enterprise/server/auth/constants.py b/enterprise/server/auth/constants.py index df6e9aef54..eb8b1aaf60 100644 --- a/enterprise/server/auth/constants.py +++ b/enterprise/server/auth/constants.py @@ -40,6 +40,16 @@ ROLE_CHECK_ENABLED = os.getenv('ROLE_CHECK_ENABLED', 'false').lower() in ( ) DUPLICATE_EMAIL_CHECK = os.getenv('DUPLICATE_EMAIL_CHECK', 'true') in ('1', 'true') +BITBUCKET_DATA_CENTER_CLIENT_ID = os.getenv( + 'BITBUCKET_DATA_CENTER_CLIENT_ID', '' +).strip() +BITBUCKET_DATA_CENTER_CLIENT_SECRET = os.getenv( + 'BITBUCKET_DATA_CENTER_CLIENT_SECRET', '' +).strip() +BITBUCKET_DATA_CENTER_HOST = os.getenv('BITBUCKET_DATA_CENTER_HOST', '').strip() +BITBUCKET_DATA_CENTER_TOKEN_URL = ( + f'https://{BITBUCKET_DATA_CENTER_HOST}/rest/oauth2/latest/token' +) # reCAPTCHA Enterprise RECAPTCHA_PROJECT_ID = os.getenv('RECAPTCHA_PROJECT_ID', '').strip() diff --git a/enterprise/server/auth/saas_user_auth.py b/enterprise/server/auth/saas_user_auth.py index 501f0c31a6..c2b3e1fbe9 100644 --- a/enterprise/server/auth/saas_user_auth.py +++ b/enterprise/server/auth/saas_user_auth.py @@ -13,6 +13,7 @@ from server.auth.auth_error import ( ExpiredError, NoCredentialsError, ) +from server.auth.constants import BITBUCKET_DATA_CENTER_HOST from server.auth.token_manager import TokenManager from server.config import get_config from server.logger import logger @@ -177,6 +178,9 @@ class SaasUserAuth(UserAuth): if user_secrets and idp_type in user_secrets.provider_tokens: host = user_secrets.provider_tokens[idp_type].host + if idp_type == ProviderType.BITBUCKET_DATA_CENTER and not host: + host = BITBUCKET_DATA_CENTER_HOST or None + provider_token = await token_manager.get_idp_token( access_token.get_secret_value(), idp=idp_type, diff --git a/enterprise/server/auth/token_manager.py b/enterprise/server/auth/token_manager.py index b057968a0d..e409d62c1a 100644 --- a/enterprise/server/auth/token_manager.py +++ b/enterprise/server/auth/token_manager.py @@ -21,6 +21,10 @@ from server.auth.auth_error import ExpiredError from server.auth.constants import ( BITBUCKET_APP_CLIENT_ID, BITBUCKET_APP_CLIENT_SECRET, + BITBUCKET_DATA_CENTER_CLIENT_ID, + BITBUCKET_DATA_CENTER_CLIENT_SECRET, + BITBUCKET_DATA_CENTER_HOST, + BITBUCKET_DATA_CENTER_TOKEN_URL, DUPLICATE_EMAIL_CHECK, GITHUB_APP_CLIENT_ID, GITHUB_APP_CLIENT_SECRET, @@ -379,6 +383,8 @@ class TokenManager: return await self._refresh_gitlab_token(refresh_token) elif idp == ProviderType.BITBUCKET: return await self._refresh_bitbucket_token(refresh_token) + elif idp == ProviderType.BITBUCKET_DATA_CENTER: + return await self._refresh_bitbucket_data_center_token(refresh_token) else: raise ValueError(f'Unsupported IDP: {idp}') @@ -460,6 +466,33 @@ class TokenManager: data = response.json() return await self._parse_refresh_response(data) + async def _refresh_bitbucket_data_center_token( + self, refresh_token: str + ) -> dict[str, str | int]: + if not BITBUCKET_DATA_CENTER_HOST: + raise ValueError( + 'BITBUCKET_DATA_CENTER_HOST is not configured. ' + 'Set the BITBUCKET_DATA_CENTER_HOST environment variable.' + ) + url = BITBUCKET_DATA_CENTER_TOKEN_URL + logger.info(f'Refreshing Bitbucket Data Center token with URL: {url}') + + payload = { + 'client_id': BITBUCKET_DATA_CENTER_CLIENT_ID, + 'client_secret': BITBUCKET_DATA_CENTER_CLIENT_SECRET, + 'refresh_token': refresh_token, + 'grant_type': 'refresh_token', + } + async with httpx.AsyncClient( + verify=httpx_verify_option(), timeout=IDP_HTTP_TIMEOUT + ) as client: + response = await client.post(url, data=payload) + response.raise_for_status() + logger.info('Successfully refreshed Bitbucket Data Center token') + + data = response.json() + return await self._parse_refresh_response(data) + async def _parse_refresh_response(self, data: dict) -> dict[str, str | int]: access_token = data.get('access_token') refresh_token = data.get('refresh_token') diff --git a/enterprise/server/config.py b/enterprise/server/config.py index bc20b94706..dbccc94a55 100644 --- a/enterprise/server/config.py +++ b/enterprise/server/config.py @@ -9,6 +9,7 @@ import requests # type: ignore from fastapi import HTTPException from server.auth.constants import ( BITBUCKET_APP_CLIENT_ID, + BITBUCKET_DATA_CENTER_CLIENT_ID, ENABLE_ENTERPRISE_SSO, ENABLE_JIRA, ENABLE_JIRA_DC, @@ -164,6 +165,9 @@ class SaaSServerConfig(ServerConfig): if ENABLE_ENTERPRISE_SSO: providers_configured.append(ProviderType.ENTERPRISE_SSO) + if BITBUCKET_DATA_CENTER_CLIENT_ID: + providers_configured.append(ProviderType.BITBUCKET_DATA_CENTER) + config: dict[str, typing.Any] = { 'APP_MODE': self.app_mode, 'APP_SLUG': self.app_slug, diff --git a/enterprise/server/routes/bitbucket_dc_proxy.py b/enterprise/server/routes/bitbucket_dc_proxy.py new file mode 100644 index 0000000000..aae25d823f --- /dev/null +++ b/enterprise/server/routes/bitbucket_dc_proxy.py @@ -0,0 +1,63 @@ +import httpx +from fastapi import APIRouter, Request +from fastapi.responses import JSONResponse +from server.auth.constants import BITBUCKET_DATA_CENTER_HOST + +from openhands.utils.http_session import httpx_verify_option + +router = APIRouter(prefix='/bitbucket-dc-proxy') + +BITBUCKET_DC_TIMEOUT = 10 # seconds + + +# Bitbucket Data Center is not an OIDC provider, so keycloak +# can't retrieve user info from it directly. +# This endpoint proxies requests to bitbucket data center to get user info +# given a Bitbucket Data Center access token. Keycloak +# is configured to use this endpoint as the User Info Endpoint +# for the Bitbucket Data Center OIDC provider. +@router.get('/oauth2/userinfo') +async def userinfo(request: Request): + if not BITBUCKET_DATA_CENTER_HOST: + raise ValueError('BITBUCKET_DATA_CENTER_HOST must be configured') + bitbucket_base_url = f'https://{BITBUCKET_DATA_CENTER_HOST}' + + auth_header = request.headers.get('Authorization', '') + if not auth_header.startswith('Bearer '): + return JSONResponse({'error': 'missing_token'}, status_code=401) + + headers = {'Authorization': auth_header} + async with httpx.AsyncClient(verify=httpx_verify_option()) as client: + # Step 1: get username + whoami_resp = await client.get( + f'{bitbucket_base_url}/plugins/servlet/applinks/whoami', + headers=headers, + timeout=BITBUCKET_DC_TIMEOUT, + ) + if whoami_resp.status_code != 200: + return JSONResponse({'error': 'not_authenticated'}, status_code=401) + username = whoami_resp.text.strip() + if not username: + return JSONResponse({'error': 'not_authenticated'}, status_code=401) + + # Step 2: get user details + user_resp = await client.get( + f'{bitbucket_base_url}/rest/api/latest/users/{username}', + headers=headers, + timeout=BITBUCKET_DC_TIMEOUT, + ) + if user_resp.status_code != 200: + return JSONResponse( + {'error': f'bitbucket_error: {user_resp.status_code}'}, + status_code=user_resp.status_code, + ) + user_data = user_resp.json() + + return JSONResponse( + { + 'sub': str(user_data.get('id', username)), + 'preferred_username': user_data.get('name', username), + 'name': user_data.get('displayName', username), + 'email': user_data.get('emailAddress', ''), + } + ) diff --git a/enterprise/tests/unit/integrations/bitbucket_data_center/__init__.py b/enterprise/tests/unit/integrations/bitbucket_data_center/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/enterprise/tests/unit/integrations/bitbucket_data_center/test_bitbucket_dc_service.py b/enterprise/tests/unit/integrations/bitbucket_data_center/test_bitbucket_dc_service.py new file mode 100644 index 0000000000..935c3ef40c --- /dev/null +++ b/enterprise/tests/unit/integrations/bitbucket_data_center/test_bitbucket_dc_service.py @@ -0,0 +1,132 @@ +"""Unit tests for SaaSBitbucketDCService.""" + +from unittest.mock import AsyncMock, patch + +import pytest +from integrations.bitbucket_data_center.bitbucket_dc_service import ( + SaaSBitbucketDCService, +) +from pydantic import SecretStr +from server.auth.token_manager import TokenManager + + +@pytest.fixture +def service(): + return SaaSBitbucketDCService() + + +@pytest.fixture +def service_with_external_auth_token(): + return SaaSBitbucketDCService(external_auth_token=SecretStr('test_keycloak_token')) + + +@pytest.fixture +def service_with_external_auth_id(): + return SaaSBitbucketDCService(external_auth_id='test_user_id') + + +@pytest.fixture +def service_with_user_id(): + return SaaSBitbucketDCService(user_id='test_user_id') + + +class TestSaaSBitbucketDCServiceInit: + def test_refresh_flag_is_true(self): + # self.refresh = True is required so the base class BitbucketDCService + # retries the request with a refreshed token on 401 responses. + # See openhands/integrations/bitbucket_data_center/service/base.py, + # which checks `if self.refresh` before attempting the retry. + service = SaaSBitbucketDCService() + assert service.refresh is True + + def test_token_manager_is_created(self): + service = SaaSBitbucketDCService() + assert isinstance(service.token_manager, TokenManager) + + def test_external_token_manager_flag_passed(self): + service = SaaSBitbucketDCService(external_token_manager=True) + assert service.token_manager.external is True + + +class TestGetLatestToken: + @pytest.mark.asyncio + async def test_get_latest_token_with_external_auth_token( + self, service_with_external_auth_token + ): + expected_token = 'test_bitbucket_dc_token' + with patch.object( + service_with_external_auth_token.token_manager, + 'get_idp_token', + new_callable=AsyncMock, + return_value=expected_token, + ): + token = await service_with_external_auth_token.get_latest_token() + + assert token is not None + assert token.get_secret_value() == expected_token + + @pytest.mark.asyncio + async def test_get_latest_token_with_external_auth_id( + self, service_with_external_auth_id + ): + offline_token = 'test_offline_token' + expected_token = 'test_bitbucket_dc_token' + with patch.object( + service_with_external_auth_id.token_manager, + 'load_offline_token', + new_callable=AsyncMock, + return_value=offline_token, + ), patch.object( + service_with_external_auth_id.token_manager, + 'get_idp_token_from_offline_token', + new_callable=AsyncMock, + return_value=expected_token, + ): + token = await service_with_external_auth_id.get_latest_token() + + assert token is not None + assert token.get_secret_value() == expected_token + + @pytest.mark.asyncio + async def test_get_latest_token_with_user_id(self, service_with_user_id): + expected_token = 'test_bitbucket_dc_token' + with patch.object( + service_with_user_id.token_manager, + 'get_idp_token_from_idp_user_id', + new_callable=AsyncMock, + return_value=expected_token, + ): + token = await service_with_user_id.get_latest_token() + + assert token is not None + assert token.get_secret_value() == expected_token + + @pytest.mark.asyncio + async def test_get_latest_token_no_auth_returns_none(self, service): + token = await service.get_latest_token() + assert token is None + + @pytest.mark.asyncio + async def test_get_latest_token_external_auth_token_priority(self): + """external_auth_token takes priority over external_auth_id.""" + expected_token = 'test_bitbucket_dc_token' + service = SaaSBitbucketDCService( + external_auth_token=SecretStr('test_keycloak_token'), + external_auth_id='test_user_id', + ) + with patch.object( + service.token_manager, + 'get_idp_token', + new_callable=AsyncMock, + return_value=expected_token, + ) as mock_get_idp_token, patch.object( + service.token_manager, + 'load_offline_token', + new_callable=AsyncMock, + ) as mock_load_offline: + token = await service.get_latest_token() + + assert token is not None + assert token.get_secret_value() == expected_token + mock_get_idp_token.assert_called_once() + mock_load_offline.assert_not_called() diff --git a/enterprise/tests/unit/server/routes/test_bitbucket_dc_proxy.py b/enterprise/tests/unit/server/routes/test_bitbucket_dc_proxy.py new file mode 100644 index 0000000000..9fd0b33b64 --- /dev/null +++ b/enterprise/tests/unit/server/routes/test_bitbucket_dc_proxy.py @@ -0,0 +1,182 @@ +from unittest.mock import AsyncMock, MagicMock, call, patch + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient +from server.routes.bitbucket_dc_proxy import router + + +@pytest.fixture +def client(): + app = FastAPI() + app.include_router(router) + with patch( + 'server.routes.bitbucket_dc_proxy.BITBUCKET_DATA_CENTER_HOST', 'bitbucket.test' + ): + yield TestClient(app) + + +def test_missing_authorization_header(client): + response = client.get('/bitbucket-dc-proxy/oauth2/userinfo') + assert response.status_code == 401 + assert response.json() == {'error': 'missing_token'} + + +def test_non_bearer_scheme(client): + response = client.get( + '/bitbucket-dc-proxy/oauth2/userinfo', + headers={'Authorization': 'Basic xyz'}, + ) + assert response.status_code == 401 + assert response.json() == {'error': 'missing_token'} + + +def test_whoami_non_200(client): + whoami_resp = MagicMock() + whoami_resp.status_code = 403 + + with patch('server.routes.bitbucket_dc_proxy.httpx.AsyncClient') as mock_client_cls: + mock_client = AsyncMock() + mock_client.get = AsyncMock(side_effect=[whoami_resp]) + mock_client_cls.return_value.__aenter__ = AsyncMock(return_value=mock_client) + mock_client_cls.return_value.__aexit__ = AsyncMock(return_value=None) + + response = client.get( + '/bitbucket-dc-proxy/oauth2/userinfo', + headers={'Authorization': 'Bearer some_token'}, + ) + + assert response.status_code == 401 + assert response.json() == {'error': 'not_authenticated'} + + +def test_whoami_empty_body(client): + whoami_resp = MagicMock() + whoami_resp.status_code = 200 + whoami_resp.text = ' ' + + with patch('server.routes.bitbucket_dc_proxy.httpx.AsyncClient') as mock_client_cls: + mock_client = AsyncMock() + mock_client.get = AsyncMock(side_effect=[whoami_resp]) + mock_client_cls.return_value.__aenter__ = AsyncMock(return_value=mock_client) + mock_client_cls.return_value.__aexit__ = AsyncMock(return_value=None) + + response = client.get( + '/bitbucket-dc-proxy/oauth2/userinfo', + headers={'Authorization': 'Bearer some_token'}, + ) + + assert response.status_code == 401 + assert response.json() == {'error': 'not_authenticated'} + + +def test_user_details_non_200(client): + whoami_resp = MagicMock() + whoami_resp.status_code = 200 + whoami_resp.text = 'testuser' + + user_resp = MagicMock() + user_resp.status_code = 404 + + with patch('server.routes.bitbucket_dc_proxy.httpx.AsyncClient') as mock_client_cls: + mock_client = AsyncMock() + mock_client.get = AsyncMock(side_effect=[whoami_resp, user_resp]) + mock_client_cls.return_value.__aenter__ = AsyncMock(return_value=mock_client) + mock_client_cls.return_value.__aexit__ = AsyncMock(return_value=None) + + response = client.get( + '/bitbucket-dc-proxy/oauth2/userinfo', + headers={'Authorization': 'Bearer some_token'}, + ) + + assert response.status_code == 404 + assert response.json() == {'error': 'bitbucket_error: 404'} + + +def test_happy_path_full_user_data(client): + whoami_resp = MagicMock() + whoami_resp.status_code = 200 + whoami_resp.text = 'jsmith' + + user_resp = MagicMock() + user_resp.status_code = 200 + user_resp.json.return_value = { + 'id': 42, + 'name': 'jsmith', + 'displayName': 'John Smith', + 'emailAddress': 'john@example.com', + } + + with patch('server.routes.bitbucket_dc_proxy.httpx.AsyncClient') as mock_client_cls: + mock_client = AsyncMock() + mock_client.get = AsyncMock(side_effect=[whoami_resp, user_resp]) + mock_client_cls.return_value.__aenter__ = AsyncMock(return_value=mock_client) + mock_client_cls.return_value.__aexit__ = AsyncMock(return_value=None) + + response = client.get( + '/bitbucket-dc-proxy/oauth2/userinfo', + headers={'Authorization': 'Bearer some_token'}, + ) + + assert response.status_code == 200 + data = response.json() + assert data['sub'] == '42' + assert data['preferred_username'] == 'jsmith' + assert data['name'] == 'John Smith' + assert data['email'] == 'john@example.com' + mock_client.get.assert_has_calls( + [ + call( + 'https://bitbucket.test/plugins/servlet/applinks/whoami', + headers={'Authorization': 'Bearer some_token'}, + timeout=10, + ), + call( + 'https://bitbucket.test/rest/api/latest/users/jsmith', + headers={'Authorization': 'Bearer some_token'}, + timeout=10, + ), + ] + ) + + +def test_happy_path_missing_id_falls_back_to_username(client): + whoami_resp = MagicMock() + whoami_resp.status_code = 200 + whoami_resp.text = 'jsmith' + + user_resp = MagicMock() + user_resp.status_code = 200 + user_resp.json.return_value = { + 'name': 'jsmith', + 'displayName': 'John Smith', + 'emailAddress': 'john@example.com', + } + + with patch('server.routes.bitbucket_dc_proxy.httpx.AsyncClient') as mock_client_cls: + mock_client = AsyncMock() + mock_client.get = AsyncMock(side_effect=[whoami_resp, user_resp]) + mock_client_cls.return_value.__aenter__ = AsyncMock(return_value=mock_client) + mock_client_cls.return_value.__aexit__ = AsyncMock(return_value=None) + + response = client.get( + '/bitbucket-dc-proxy/oauth2/userinfo', + headers={'Authorization': 'Bearer some_token'}, + ) + + assert response.status_code == 200 + assert response.json()['sub'] == 'jsmith' + mock_client.get.assert_has_calls( + [ + call( + 'https://bitbucket.test/plugins/servlet/applinks/whoami', + headers={'Authorization': 'Bearer some_token'}, + timeout=10, + ), + call( + 'https://bitbucket.test/rest/api/latest/users/jsmith', + headers={'Authorization': 'Bearer some_token'}, + timeout=10, + ), + ] + ) diff --git a/enterprise/tests/unit/test_saas_user_auth.py b/enterprise/tests/unit/test_saas_user_auth.py index 1b3355ab1a..92552de3ad 100644 --- a/enterprise/tests/unit/test_saas_user_auth.py +++ b/enterprise/tests/unit/test_saas_user_auth.py @@ -21,6 +21,7 @@ from server.auth.saas_user_auth import ( from storage.user_authorization import UserAuthorizationType from openhands.integrations.provider import ProviderToken, ProviderType +from openhands.storage.data_models.secrets import Secrets @pytest.fixture @@ -238,6 +239,107 @@ async def test_get_provider_tokens(mock_token_manager): pass +class TestGetProviderTokensBitbucketDCHost: + """Tests for Bitbucket DC host fallback from BITBUCKET_DATA_CENTER_HOST.""" + + def _make_auth_token(self): + mock_token = MagicMock() + mock_token.identity_provider = 'bitbucket_data_center' + mock_token.id = 'token-id-1' + return mock_token + + def _make_user_auth(self, mock_session_maker): + mock_session = AsyncMock() + mock_session.__aenter__ = AsyncMock(return_value=mock_session) + mock_session.__aexit__ = AsyncMock(return_value=None) + mock_result = MagicMock() + mock_result.scalars.return_value.all.return_value = [self._make_auth_token()] + mock_session.execute = AsyncMock(return_value=mock_result) + mock_session_maker.return_value = mock_session + + access_payload = {'sub': 'test_user_id', 'exp': int(time.time()) + 3600} + access_token = jwt.encode(access_payload, 'secret', algorithm='HS256') + + user_auth = SaasUserAuth( + user_id='test_user_id', + refresh_token=SecretStr('refresh_token'), + access_token=SecretStr(access_token), + ) + return user_auth, mock_session + + @pytest.mark.asyncio + async def test_host_derived_from_token_url(self): + """host is populated from BITBUCKET_DATA_CENTER_HOST when user secrets lack it.""" + with ( + patch('server.auth.saas_user_auth.token_manager') as mock_tm, + patch('server.auth.saas_user_auth.a_session_maker') as mock_session_maker, + patch( + 'server.auth.saas_user_auth.BITBUCKET_DATA_CENTER_HOST', + 'bitbucket.company.com', + ), + ): + mock_tm.get_idp_token = AsyncMock(return_value='bdc_access_token') + user_auth, mock_session = self._make_user_auth(mock_session_maker) + user_auth.get_secrets = AsyncMock(return_value=None) + + result = await user_auth.get_provider_tokens() + + assert ProviderType.BITBUCKET_DATA_CENTER in result + assert ( + result[ProviderType.BITBUCKET_DATA_CENTER].host == 'bitbucket.company.com' + ) + mock_session.execute.assert_called_once() + + @pytest.mark.asyncio + async def test_host_from_user_secrets_takes_priority(self): + """User-configured host in secrets takes priority over the HOST fallback.""" + with ( + patch('server.auth.saas_user_auth.token_manager') as mock_tm, + patch('server.auth.saas_user_auth.a_session_maker') as mock_session_maker, + patch( + 'server.auth.saas_user_auth.BITBUCKET_DATA_CENTER_HOST', + 'bitbucket.company.com', + ), + ): + mock_tm.get_idp_token = AsyncMock(return_value='bdc_access_token') + user_auth, mock_session = self._make_user_auth(mock_session_maker) + user_secrets = Secrets( + provider_tokens={ + ProviderType.BITBUCKET_DATA_CENTER: ProviderToken( + token=SecretStr('existing_token'), + host='custom.bitbucket.host', + ) + } + ) + user_auth.get_secrets = AsyncMock(return_value=user_secrets) + + result = await user_auth.get_provider_tokens() + + assert ProviderType.BITBUCKET_DATA_CENTER in result + assert ( + result[ProviderType.BITBUCKET_DATA_CENTER].host == 'custom.bitbucket.host' + ) + mock_session.execute.assert_called_once() + + @pytest.mark.asyncio + async def test_host_remains_none_when_host_empty(self): + """host stays None when BITBUCKET_DATA_CENTER_HOST is empty.""" + with ( + patch('server.auth.saas_user_auth.token_manager') as mock_tm, + patch('server.auth.saas_user_auth.a_session_maker') as mock_session_maker, + patch('server.auth.saas_user_auth.BITBUCKET_DATA_CENTER_HOST', ''), + ): + mock_tm.get_idp_token = AsyncMock(return_value='bdc_access_token') + user_auth, mock_session = self._make_user_auth(mock_session_maker) + user_auth.get_secrets = AsyncMock(return_value=None) + + result = await user_auth.get_provider_tokens() + + assert ProviderType.BITBUCKET_DATA_CENTER in result + assert result[ProviderType.BITBUCKET_DATA_CENTER].host is None + mock_session.execute.assert_called_once() + + @pytest.mark.asyncio async def test_get_provider_tokens_cached(mock_token_manager): """Test that get_provider_tokens returns cached tokens if available.""" diff --git a/enterprise/tests/unit/test_token_manager_extended.py b/enterprise/tests/unit/test_token_manager_extended.py index 012fdaa08e..90b7df0a6b 100644 --- a/enterprise/tests/unit/test_token_manager_extended.py +++ b/enterprise/tests/unit/test_token_manager_extended.py @@ -362,6 +362,111 @@ async def test_disable_keycloak_user_exception_handling(token_manager): mock_admin.a_get_user.assert_called_once_with(user_id) +class TestRefreshBitbucketDataCenterToken: + """Tests for the _refresh_bitbucket_data_center_token code path.""" + + @pytest.mark.asyncio + async def test_happy_path(self, token_manager): + """Credentials are sent in the POST body (not Basic auth); response is parsed.""" + mock_response = MagicMock() + mock_response.raise_for_status = MagicMock() + mock_response.json.return_value = { + 'access_token': 'new_bbs_access', + 'refresh_token': 'new_bbs_refresh', + 'expires_in': 3600, + 'refresh_token_expires_in': 86400, + } + + with ( + patch( + 'server.auth.token_manager.BITBUCKET_DATA_CENTER_HOST', + 'bitbucket.example.com', + ), + patch( + 'server.auth.token_manager.BITBUCKET_DATA_CENTER_TOKEN_URL', + 'https://bitbucket.example.com/oauth2/token', + ), + patch( + 'server.auth.token_manager.BITBUCKET_DATA_CENTER_CLIENT_ID', + 'test_client_id', + ), + patch( + 'server.auth.token_manager.BITBUCKET_DATA_CENTER_CLIENT_SECRET', + 'test_client_secret', + ), + patch('httpx.AsyncClient') as mock_client_cls, + ): + mock_client = AsyncMock() + mock_client.post = AsyncMock(return_value=mock_response) + mock_client_cls.return_value.__aenter__ = AsyncMock( + return_value=mock_client + ) + mock_client_cls.return_value.__aexit__ = AsyncMock(return_value=None) + + result = await token_manager._refresh_bitbucket_data_center_token( + 'old_refresh_token' + ) + + # Credentials are sent in the POST body, not in a Basic-auth header + mock_client.post.assert_called_once_with( + 'https://bitbucket.example.com/oauth2/token', + data={ + 'client_id': 'test_client_id', + 'client_secret': 'test_client_secret', + 'refresh_token': 'old_refresh_token', + 'grant_type': 'refresh_token', + }, + ) + + # Response is parsed correctly + assert result['access_token'] == 'new_bbs_access' + assert result['refresh_token'] == 'new_bbs_refresh' + + @pytest.mark.asyncio + async def test_empty_url_raises_value_error(self, token_manager): + """When BITBUCKET_DATA_CENTER_HOST is not set, ValueError is raised immediately.""" + with patch('server.auth.token_manager.BITBUCKET_DATA_CENTER_HOST', ''): + with pytest.raises(ValueError, match='BITBUCKET_DATA_CENTER_HOST'): + await token_manager._refresh_bitbucket_data_center_token( + 'some_refresh_token' + ) + + @pytest.mark.asyncio + async def test_http_error_propagates(self, token_manager): + """When raise_for_status() raises, the exception propagates to the caller.""" + import httpx + + mock_response = MagicMock() + mock_response.raise_for_status.side_effect = httpx.HTTPStatusError( + '401 Unauthorized', + request=MagicMock(), + response=MagicMock(status_code=401), + ) + + with ( + patch( + 'server.auth.token_manager.BITBUCKET_DATA_CENTER_HOST', + 'bitbucket.example.com', + ), + patch( + 'server.auth.token_manager.BITBUCKET_DATA_CENTER_TOKEN_URL', + 'https://bitbucket.example.com/oauth2/token', + ), + patch('httpx.AsyncClient') as mock_client_cls, + ): + mock_client = AsyncMock() + mock_client.post = AsyncMock(return_value=mock_response) + mock_client_cls.return_value.__aenter__ = AsyncMock( + return_value=mock_client + ) + mock_client_cls.return_value.__aexit__ = AsyncMock(return_value=None) + + with pytest.raises(httpx.HTTPStatusError): + await token_manager._refresh_bitbucket_data_center_token( + 'old_refresh_token' + ) + + class TestOrgTokenMethods: """Test cases for store_org_token and load_org_token methods."""