mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-10-29 20:05:25 +08:00
fix(auth): allow sole OSS account to adopt Space email
Co-authored-by: multica-agent <[email protected]>
This commit is contained in:
co-authored by
multica-agent
parent
3a77306c3c
commit
4687fc96e7
@@ -929,6 +929,7 @@ class UserService:
|
||||
|
||||
async def bind_space_account(self, user_email: str, code: str, *, redirect_uri: str = '') -> user.User:
|
||||
"""Bind Space account to existing local account"""
|
||||
self._require_local_directory()
|
||||
local_account = await self.get_user_by_email(user_email)
|
||||
if local_account is None:
|
||||
raise ValueError('User not found')
|
||||
@@ -973,19 +974,20 @@ class UserService:
|
||||
|
||||
if not space_account_uuid or not space_email:
|
||||
raise ValueError('Invalid Space user info')
|
||||
if normalize_email(space_email) != normalize_email(user_email):
|
||||
if normalize_email(space_email) != normalize_email(user_email) and not await self._can_replace_local_email(
|
||||
local_account
|
||||
):
|
||||
raise account_errors.AccountEmailMismatchError()
|
||||
|
||||
# Check if this Space account is already bound to another user
|
||||
existing_space_user = await self.get_user_by_space_account_uuid(space_account_uuid)
|
||||
if existing_space_user and existing_space_user.normalized_email != normalize_email(user_email):
|
||||
if existing_space_user and existing_space_user.uuid != local_account.uuid:
|
||||
raise ValueError('This LangBot Account is already bound to another user')
|
||||
|
||||
# Update local account to Space account
|
||||
normalized_email = normalize_email(user_email)
|
||||
await self._identity_execute(
|
||||
sqlalchemy.update(user.User)
|
||||
.where(user.User.normalized_email == normalized_email)
|
||||
.where(user.User.uuid == local_account.uuid)
|
||||
.values(
|
||||
user=normalize_email(space_email), # Update email to Space email
|
||||
normalized_email=normalize_email(space_email),
|
||||
@@ -996,13 +998,32 @@ class UserService:
|
||||
space_api_key=api_key,
|
||||
space_access_token_expires_at=expires_at,
|
||||
),
|
||||
f'email:{normalized_email}',
|
||||
f'uuid:{local_account.uuid}',
|
||||
)
|
||||
|
||||
# Update Space model provider API keys
|
||||
await self._update_space_provider_for_account(local_account, api_key)
|
||||
|
||||
return await self.get_user_by_email(space_email)
|
||||
return await self.get_user_by_uuid(local_account.uuid)
|
||||
|
||||
async def _can_replace_local_email(self, account: user.User) -> bool:
|
||||
"""Allow only the sole, unbound OSS local Account to adopt its Space email."""
|
||||
workspace_service = getattr(self.ap, 'workspace_service', None)
|
||||
if (
|
||||
workspace_service is None
|
||||
or workspace_service.policy.multi_workspace_enabled
|
||||
or account.source != user.AccountSource.LOCAL.value
|
||||
or account.account_type != 'local'
|
||||
or account.space_account_uuid is not None
|
||||
):
|
||||
return False
|
||||
# Invited members must retain the email check. The explicit bind flow
|
||||
# already proves ownership of both accounts in a single-account instance.
|
||||
other_account = await self._identity_scalar(
|
||||
sqlalchemy.select(user.User).where(user.User.uuid != account.uuid).limit(1),
|
||||
f'instance:{self._jwt_identity()[1]}',
|
||||
)
|
||||
return other_account is None
|
||||
|
||||
def _prune_passkey_challenges(self, now: float) -> None:
|
||||
while self._passkey_challenge_expiry_heap:
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
"""Database-backed regressions for explicit OSS Space account binding."""
|
||||
|
||||
import datetime
|
||||
from contextlib import asynccontextmanager
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||
|
||||
from langbot.pkg.api.http.service.user import ControlPlaneDirectoryRequiredError, UserService
|
||||
from langbot.pkg.entity.errors.account import AccountEmailMismatchError
|
||||
from langbot.pkg.entity.persistence.user import User
|
||||
|
||||
pytestmark = pytest.mark.asyncio
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def binding_context(tmp_path):
|
||||
engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "binding.db"}')
|
||||
async with engine.begin() as connection:
|
||||
await connection.run_sync(User.__table__.create)
|
||||
sessions = async_sessionmaker(engine, expire_on_commit=False)
|
||||
|
||||
@asynccontextmanager
|
||||
async def identity_uow(digest):
|
||||
async with sessions() as session, session.begin():
|
||||
yield SimpleNamespace(session=session)
|
||||
|
||||
async with sessions() as session, session.begin():
|
||||
session.add(
|
||||
User(uuid='local-owner', user='[email protected]', normalized_email='[email protected]', password='hash')
|
||||
)
|
||||
|
||||
application = SimpleNamespace(
|
||||
persistence_mgr=SimpleNamespace(identity_discovery_uow=identity_uow),
|
||||
workspace_service=SimpleNamespace(
|
||||
instance_uuid='instance',
|
||||
policy=SimpleNamespace(multi_workspace_enabled=False),
|
||||
get_execution_binding=AsyncMock(
|
||||
return_value=SimpleNamespace(
|
||||
workspace_uuid='workspace', workspace_created_at=datetime.datetime(2026, 1, 1)
|
||||
)
|
||||
),
|
||||
),
|
||||
space_service=SimpleNamespace(
|
||||
exchange_oauth_code=AsyncMock(return_value={'access_token': 'access', 'refresh_token': 'refresh'}),
|
||||
get_user_info_raw=AsyncMock(
|
||||
return_value={'account': {'uuid': 'space-owner', 'email': ' [email protected] '}, 'api_key': 'key'}
|
||||
),
|
||||
),
|
||||
instance_config=SimpleNamespace(
|
||||
data={'system': {'jwt': {'secret': 'test-secret-long-enough-for-jwt-32', 'expire': 3600}}}
|
||||
),
|
||||
)
|
||||
service = UserService(application)
|
||||
service._update_space_provider_for_account = AsyncMock()
|
||||
yield service, sessions
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
async def test_sole_local_account_adopts_space_email_and_preserves_identity(binding_context):
|
||||
service, _ = binding_context
|
||||
old_token = await service.generate_jwt_token(await service.get_user_by_uuid('local-owner'))
|
||||
account = await service.bind_space_account('[email protected]', 'code')
|
||||
assert account.uuid == 'local-owner'
|
||||
assert account.user == account.normalized_email == '[email protected]'
|
||||
assert account.password == 'hash'
|
||||
assert account.account_type == 'space'
|
||||
assert account.space_account_uuid == 'space-owner'
|
||||
assert account.space_access_token == 'access'
|
||||
assert account.space_refresh_token == 'refresh'
|
||||
assert account.space_api_key == 'key'
|
||||
assert await service.get_user_by_email('[email protected]') is None
|
||||
assert (await service.get_authenticated_account(old_token)).uuid == account.uuid
|
||||
new_token = await service.generate_jwt_token(account)
|
||||
assert (await service.get_authenticated_account(new_token)).user == '[email protected]'
|
||||
service._update_space_provider_for_account.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.parametrize('status', ['active', 'disabled', 'deleted'])
|
||||
async def test_multiple_accounts_keep_email_check(binding_context, status):
|
||||
service, sessions = binding_context
|
||||
async with sessions() as session, session.begin():
|
||||
session.add(
|
||||
User(
|
||||
uuid='other', user='[email protected]', normalized_email='[email protected]', password='', status=status
|
||||
)
|
||||
)
|
||||
with pytest.raises(AccountEmailMismatchError):
|
||||
await service.bind_space_account('[email protected]', 'code')
|
||||
account = await service.get_user_by_uuid('local-owner')
|
||||
assert account.user == '[email protected]'
|
||||
assert account.space_account_uuid is None
|
||||
|
||||
|
||||
async def test_same_email_binding_still_works_with_multiple_accounts(binding_context):
|
||||
service, sessions = binding_context
|
||||
async with sessions() as session, session.begin():
|
||||
session.add(User(uuid='other', user='[email protected]', normalized_email='[email protected]', password=''))
|
||||
service.ap.space_service.get_user_info_raw.return_value['account']['email'] = '[email protected]'
|
||||
account = await service.bind_space_account('[email protected]', 'code')
|
||||
assert account.space_account_uuid == 'space-owner'
|
||||
|
||||
|
||||
async def test_cloud_binding_is_rejected_before_oauth(binding_context):
|
||||
service, _ = binding_context
|
||||
service.ap.workspace_service.policy.multi_workspace_enabled = True
|
||||
with pytest.raises(ControlPlaneDirectoryRequiredError):
|
||||
await service.bind_space_account('[email protected]', 'code')
|
||||
service.ap.space_service.exchange_oauth_code.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
'field,value', [('source', 'cloud_projection'), ('account_type', 'space'), ('space_account_uuid', 'already-bound')]
|
||||
)
|
||||
async def test_only_unbound_local_identity_can_replace_email(binding_context, field, value):
|
||||
service, sessions = binding_context
|
||||
async with sessions() as session, session.begin():
|
||||
account = await session.get(User, 1)
|
||||
setattr(account, field, value)
|
||||
with pytest.raises(AccountEmailMismatchError):
|
||||
await service.bind_space_account('[email protected]', 'code')
|
||||
|
||||
|
||||
async def test_space_subject_bound_to_another_account_is_rejected(binding_context):
|
||||
service, sessions = binding_context
|
||||
async with sessions() as session, session.begin():
|
||||
session.add(
|
||||
User(
|
||||
uuid='other',
|
||||
user='[email protected]',
|
||||
normalized_email='[email protected]',
|
||||
password='',
|
||||
space_account_uuid='space-owner',
|
||||
)
|
||||
)
|
||||
service.ap.space_service.get_user_info_raw.return_value['account']['email'] = '[email protected]'
|
||||
with pytest.raises(ValueError, match='already bound'):
|
||||
await service.bind_space_account('[email protected]', 'code')
|
||||
assert (await service.get_user_by_uuid('local-owner')).space_account_uuid is None
|
||||
Reference in New Issue
Block a user