mirror of
https://github.com/OpenHands/OpenHands.git
synced 2026-03-22 05:37:20 +08:00
feat(enterprise): Bitbucket Data Center Integration (#13228)
Co-authored-by: openhands <openhands@all-hands.dev>
This commit is contained in:
@@ -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": {
|
||||
|
||||
@@ -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'
|
||||
)
|
||||
|
||||
@@ -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
|
||||
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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,
|
||||
|
||||
63
enterprise/server/routes/bitbucket_dc_proxy.py
Normal file
63
enterprise/server/routes/bitbucket_dc_proxy.py
Normal file
@@ -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', ''),
|
||||
}
|
||||
)
|
||||
@@ -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()
|
||||
182
enterprise/tests/unit/server/routes/test_bitbucket_dc_proxy.py
Normal file
182
enterprise/tests/unit/server/routes/test_bitbucket_dc_proxy.py
Normal file
@@ -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,
|
||||
),
|
||||
]
|
||||
)
|
||||
@@ -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."""
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
Reference in New Issue
Block a user