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:
Hyu
2026-09-02 21:41:35 +08:00
committed by GitHub
parent 7b7d3f04e8
commit 018dd7a363
6 changed files with 143 additions and 9 deletions
@@ -9,6 +9,7 @@ from .. import group
from .....entity.errors import account as account_errors from .....entity.errors import account as account_errors
from ...context import RequestContext from ...context import RequestContext
from .....cloud.launch import SpaceLaunchError from .....cloud.launch import SpaceLaunchError
from .....workspace.errors import WorkspaceNotFoundError
from ...service.user import ControlPlaneDirectoryRequiredError, PublicRegistrationClosedError from ...service.user import ControlPlaneDirectoryRequiredError, PublicRegistrationClosedError
@@ -143,6 +144,13 @@ class UserRouterGroup(group.RouterGroup):
try: try:
redirect_uri = self._validate_space_redirect_uri(redirect_uri, bind=False) redirect_uri = self._validate_space_redirect_uri(redirect_uri, bind=False)
launch_workspace_uuid = quart.request.args.get('launch_workspace_uuid') 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 launch_workspace_uuid:
if not getattr(getattr(self.ap, 'deployment', None), 'multi_workspace_enabled', False): if not getattr(getattr(self.ap, 'deployment', None), 'multi_workspace_enabled', False):
return self.fail(1, 'Space launch requires Cloud mode') 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']) 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: if account is None:
raise SpaceLaunchError('Launch Account is not projected into Core') raise SpaceLaunchError('Launch Account is not projected into Core')
self.ap.user_service._require_active_account(account) if access is None: # pragma: no cover - bounded loop resolves or raises.
access = await self.ap.workspace_collaboration_service.resolve_account_workspace( raise SpaceLaunchError('Launch Workspace is not projected into Core')
account.uuid,
launch['workspace_uuid'],
)
token = await self.ap.user_service.generate_jwt_token(account) token = await self.ap.user_service.generate_jwt_token(account)
return self.success( return self.success(
data={ data={
@@ -124,6 +124,11 @@ class SpaceService:
params['state'] = state params['state'] = state
return f'{authorize_url}?{urlencode(params)}' 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( async def exchange_oauth_code(
self, self,
code: str, code: str,
+73 -1
View File
@@ -11,6 +11,7 @@ import pytest
import quart import quart
from langbot.pkg.api.http.controller.groups.user import UserRouterGroup from langbot.pkg.api.http.controller.groups.user import UserRouterGroup
from langbot.pkg.workspace.errors import WorkspaceNotFoundError
pytestmark = pytest.mark.integration pytestmark = pytest.mark.integration
@@ -27,7 +28,8 @@ async def space_oauth_api():
execution=SimpleNamespace(instance_uuid='instance-a', placement_generation=1), execution=SimpleNamespace(instance_uuid='instance-a', placement_generation=1),
) )
application = Mock() 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.persistence_mgr = None
application.user_service.get_authenticated_account = AsyncMock(return_value=account) application.user_service.get_authenticated_account = AsyncMock(return_value=account)
application.user_service.issue_space_oauth_state = AsyncMock( 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( application.space_service.get_oauth_authorize_url = Mock(
side_effect=lambda redirect_uri, state: f'https://space.example/authorize?state={state}' 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( application.space_service.exchange_oauth_code = AsyncMock(
return_value={ return_value={
'access_token': 'space-access-token', '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 @pytest.mark.asyncio
async def test_public_login_rejects_caller_supplied_state(space_oauth_api): async def test_public_login_rejects_caller_supplied_state(space_oauth_api):
application, client = 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.user_service.consume_space_oauth_state.assert_not_awaited()
application.space_service.exchange_oauth_code.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
+8 -2
View File
@@ -1385,12 +1385,18 @@ export class BackendClient extends BaseHttpClient {
} }
// ============ Space OAuth API (Redirect Flow) ============ // ============ Space OAuth API (Redirect Flow) ============
public getSpaceAuthorizeUrl(redirectUri: string): Promise<{ public getSpaceAuthorizeUrl(
redirectUri: string,
options?: { cloudEntry?: boolean },
): Promise<{
authorize_url: string; authorize_url: string;
}> { }> {
return this.get( return this.get(
'/api/v1/user/space/authorize-url', '/api/v1/user/space/authorize-url',
{ redirect_uri: redirectUri }, {
redirect_uri: redirectUri,
...(options?.cloudEntry ? { cloud_entry: '1' } : {}),
},
{ skipWorkspace: true }, { skipWorkspace: true },
); );
} }
+7 -1
View File
@@ -202,7 +202,13 @@ export default function Login() {
try { try {
const currentOrigin = window.location.origin; const currentOrigin = window.location.origin;
const redirectUri = `${currentOrigin}/auth/space/callback`; 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; window.location.href = response.authorize_url;
} catch { } catch {
toast.error(t('common.spaceLoginFailed')); 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\(\)/);
});