mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-09-02 15:47:16 +00:00
fix(cloud): launch newly registered accounts through Space (#2499)
* fix(cloud): launch new accounts through Space * style: format Cloud entry URL * fix(cloud): wait for launch workspace projection --------- Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com>
This commit is contained in:
@@ -9,6 +9,7 @@ from .. import group
|
||||
from .....entity.errors import account as account_errors
|
||||
from ...context import RequestContext
|
||||
from .....cloud.launch import SpaceLaunchError
|
||||
from .....workspace.errors import WorkspaceNotFoundError
|
||||
from ...service.user import ControlPlaneDirectoryRequiredError, PublicRegistrationClosedError
|
||||
|
||||
|
||||
@@ -143,6 +144,13 @@ class UserRouterGroup(group.RouterGroup):
|
||||
try:
|
||||
redirect_uri = self._validate_space_redirect_uri(redirect_uri, bind=False)
|
||||
launch_workspace_uuid = quart.request.args.get('launch_workspace_uuid')
|
||||
cloud_entry = quart.request.args.get('cloud_entry') == '1'
|
||||
if (
|
||||
cloud_entry
|
||||
and not launch_workspace_uuid
|
||||
and getattr(getattr(self.ap, 'deployment', None), 'mode', 'oss') == 'cloud'
|
||||
):
|
||||
return self.success(data={'authorize_url': self.ap.space_service.get_cloud_entry_url()})
|
||||
if launch_workspace_uuid:
|
||||
if not getattr(getattr(self.ap, 'deployment', None), 'multi_workspace_enabled', False):
|
||||
return self.fail(1, 'Space launch requires Cloud mode')
|
||||
@@ -429,13 +437,33 @@ class UserRouterGroup(group.RouterGroup):
|
||||
)
|
||||
|
||||
account = await self.ap.user_service.get_user_by_uuid(launch['account_uuid'])
|
||||
projection_service = self.ap.directory_projection_service
|
||||
access = None
|
||||
# A first Cloud launch creates the personal Workspace immediately
|
||||
# before redirecting here. Pull a bounded number of signed event
|
||||
# pages until both the Account and its target Workspace membership
|
||||
# are visible instead of rejecting during the background-sync window.
|
||||
for attempt in range(4):
|
||||
if account is not None:
|
||||
self.ap.user_service._require_active_account(account)
|
||||
try:
|
||||
access = await self.ap.workspace_collaboration_service.resolve_account_workspace(
|
||||
account.uuid,
|
||||
launch['workspace_uuid'],
|
||||
)
|
||||
break
|
||||
except WorkspaceNotFoundError:
|
||||
if projection_service is None or attempt == 3:
|
||||
raise
|
||||
elif projection_service is None or attempt == 3:
|
||||
break
|
||||
|
||||
await projection_service.sync_once()
|
||||
account = await self.ap.user_service.get_user_by_uuid(launch['account_uuid'])
|
||||
if account is None:
|
||||
raise SpaceLaunchError('Launch Account is not projected into Core')
|
||||
self.ap.user_service._require_active_account(account)
|
||||
access = await self.ap.workspace_collaboration_service.resolve_account_workspace(
|
||||
account.uuid,
|
||||
launch['workspace_uuid'],
|
||||
)
|
||||
if access is None: # pragma: no cover - bounded loop resolves or raises.
|
||||
raise SpaceLaunchError('Launch Workspace is not projected into Core')
|
||||
token = await self.ap.user_service.generate_jwt_token(account)
|
||||
return self.success(
|
||||
data={
|
||||
|
||||
@@ -124,6 +124,11 @@ class SpaceService:
|
||||
params['state'] = state
|
||||
return f'{authorize_url}?{urlencode(params)}'
|
||||
|
||||
def get_cloud_entry_url(self) -> str:
|
||||
"""Return the Space-owned Cloud selector for a Cloud Account login."""
|
||||
|
||||
return f'{self._get_space_config()["url"].rstrip("/")}/cloud?environment=beta'
|
||||
|
||||
async def exchange_oauth_code(
|
||||
self,
|
||||
code: str,
|
||||
|
||||
@@ -11,6 +11,7 @@ import pytest
|
||||
import quart
|
||||
|
||||
from langbot.pkg.api.http.controller.groups.user import UserRouterGroup
|
||||
from langbot.pkg.workspace.errors import WorkspaceNotFoundError
|
||||
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
@@ -27,7 +28,8 @@ async def space_oauth_api():
|
||||
execution=SimpleNamespace(instance_uuid='instance-a', placement_generation=1),
|
||||
)
|
||||
application = Mock()
|
||||
application.deployment = SimpleNamespace(multi_workspace_enabled=False)
|
||||
application.deployment = SimpleNamespace(multi_workspace_enabled=False, mode='oss')
|
||||
application.directory_projection_service = None
|
||||
application.persistence_mgr = None
|
||||
application.user_service.get_authenticated_account = AsyncMock(return_value=account)
|
||||
application.user_service.issue_space_oauth_state = AsyncMock(
|
||||
@@ -69,6 +71,7 @@ async def space_oauth_api():
|
||||
application.space_service.get_oauth_authorize_url = Mock(
|
||||
side_effect=lambda redirect_uri, state: f'https://space.example/authorize?state={state}'
|
||||
)
|
||||
application.space_service.get_cloud_entry_url = Mock(return_value='https://space.example/cloud?environment=beta')
|
||||
application.space_service.exchange_oauth_code = AsyncMock(
|
||||
return_value={
|
||||
'access_token': 'space-access-token',
|
||||
@@ -125,6 +128,26 @@ async def test_cloud_launch_state_is_server_issued_and_workspace_bound(space_oau
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cloud_login_entry_redirects_to_space_workspace_launcher(space_oauth_api):
|
||||
application, client = space_oauth_api
|
||||
application.deployment.mode = 'cloud'
|
||||
|
||||
response = await client.get(
|
||||
'/api/v1/user/space/authorize-url',
|
||||
query_string={
|
||||
'redirect_uri': 'http://localhost/auth/space/callback',
|
||||
'cloud_entry': '1',
|
||||
},
|
||||
headers={'Origin': 'http://localhost'},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert (await response.get_json())['data']['authorize_url'] == ('https://space.example/cloud?environment=beta')
|
||||
application.space_service.get_cloud_entry_url.assert_called_once_with()
|
||||
application.user_service.issue_space_oauth_state.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_public_login_rejects_caller_supplied_state(space_oauth_api):
|
||||
application, client = space_oauth_api
|
||||
@@ -414,3 +437,52 @@ async def test_direct_launch_assertion_does_not_consume_normal_oauth_state(space
|
||||
)
|
||||
application.user_service.consume_space_oauth_state.assert_not_awaited()
|
||||
application.space_service.exchange_oauth_code.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_direct_launch_refreshes_new_workspace_projection_before_rejecting_account(space_oauth_api):
|
||||
application, client = space_oauth_api
|
||||
projected_account = SimpleNamespace(
|
||||
uuid='account-a',
|
||||
user='owner@example.com',
|
||||
account_type='space',
|
||||
status='active',
|
||||
)
|
||||
application.user_service.get_user_by_uuid = AsyncMock(side_effect=[None, None, projected_account])
|
||||
application.directory_projection_service = SimpleNamespace(sync_once=AsyncMock())
|
||||
|
||||
response = await client.post(
|
||||
'/api/v1/user/space/callback',
|
||||
json={
|
||||
'workspace_uuid': WORKSPACE_UUID,
|
||||
'launch_assertion': 'signed-launch-token',
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert (await response.get_json())['data']['workspace_uuid'] == WORKSPACE_UUID
|
||||
assert application.directory_projection_service.sync_once.await_count == 2
|
||||
assert application.user_service.get_user_by_uuid.await_count == 3
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_direct_launch_refreshes_projection_when_account_exists_before_workspace(space_oauth_api):
|
||||
application, client = space_oauth_api
|
||||
projected_access = application.workspace_collaboration_service.resolve_account_workspace.return_value
|
||||
application.workspace_collaboration_service.resolve_account_workspace = AsyncMock(
|
||||
side_effect=[WorkspaceNotFoundError('Workspace not found'), projected_access]
|
||||
)
|
||||
application.directory_projection_service = SimpleNamespace(sync_once=AsyncMock())
|
||||
|
||||
response = await client.post(
|
||||
'/api/v1/user/space/callback',
|
||||
json={
|
||||
'workspace_uuid': WORKSPACE_UUID,
|
||||
'launch_assertion': 'signed-launch-token',
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert (await response.get_json())['data']['workspace_uuid'] == WORKSPACE_UUID
|
||||
application.directory_projection_service.sync_once.assert_awaited_once_with()
|
||||
assert application.workspace_collaboration_service.resolve_account_workspace.await_count == 2
|
||||
|
||||
@@ -1385,12 +1385,18 @@ export class BackendClient extends BaseHttpClient {
|
||||
}
|
||||
|
||||
// ============ Space OAuth API (Redirect Flow) ============
|
||||
public getSpaceAuthorizeUrl(redirectUri: string): Promise<{
|
||||
public getSpaceAuthorizeUrl(
|
||||
redirectUri: string,
|
||||
options?: { cloudEntry?: boolean },
|
||||
): Promise<{
|
||||
authorize_url: string;
|
||||
}> {
|
||||
return this.get(
|
||||
'/api/v1/user/space/authorize-url',
|
||||
{ redirect_uri: redirectUri },
|
||||
{
|
||||
redirect_uri: redirectUri,
|
||||
...(options?.cloudEntry ? { cloud_entry: '1' } : {}),
|
||||
},
|
||||
{ skipWorkspace: true },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -202,7 +202,13 @@ export default function Login() {
|
||||
try {
|
||||
const currentOrigin = window.location.origin;
|
||||
const redirectUri = `${currentOrigin}/auth/space/callback`;
|
||||
const response = await httpClient.getSpaceAuthorizeUrl(redirectUri);
|
||||
const response = await httpClient.getSpaceAuthorizeUrl(redirectUri, {
|
||||
// Cloud Accounts must be launched from Space so a first visit can
|
||||
// lazily create and project the personal Workspace. Invitation login
|
||||
// remains on the OAuth callback path because it targets the invited
|
||||
// Workspace instead.
|
||||
cloudEntry: !getPendingInvitationToken(),
|
||||
});
|
||||
window.location.href = response.authorize_url;
|
||||
} catch {
|
||||
toast.error(t('common.spaceLoginFailed'));
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import test from 'node:test';
|
||||
|
||||
const source = fs.readFileSync(
|
||||
new URL('../../src/app/login/page.tsx', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
test('normal Cloud login enters through the Space Workspace launcher', () => {
|
||||
assert.match(source, /cloudEntry:\s*!getPendingInvitationToken\(\)/);
|
||||
assert.match(source, /getSpaceAuthorizeUrl\(redirectUri,\s*\{/);
|
||||
});
|
||||
|
||||
test('invitation login remains on the OAuth callback path', () => {
|
||||
assert.match(source, /cloudEntry:\s*!getPendingInvitationToken\(\)/);
|
||||
});
|
||||
Reference in New Issue
Block a user