mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-09-02 23:57:14 +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 .....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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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\(\)/);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user