Skip to content

Commit 5084f23

Browse files
authored
fix(auth): support dynamic OSS callbacks and LangBot Account copy (#2428)
* fix(auth): support dynamic OSS callbacks and LangBot Account copy * style(web): format LangBot Account copy --------- Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com>
1 parent b121c66 commit 5084f23

14 files changed

Lines changed: 299 additions & 233 deletions

File tree

src/langbot/pkg/api/http/controller/groups/user.py

Lines changed: 10 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,9 @@
1515
@group.group_class('user', '/api/v1/user')
1616
class UserRouterGroup(group.RouterGroup):
1717
@staticmethod
18-
def _origin(value: str) -> tuple[str, str, int | None] | None:
19-
parsed = urlsplit(value)
20-
if parsed.scheme not in {'http', 'https'} or not parsed.hostname:
21-
return None
22-
return parsed.scheme, parsed.hostname.casefold(), parsed.port
18+
def _is_loopback_host(hostname: str) -> bool:
19+
normalized = hostname.casefold().rstrip('.')
20+
return normalized in {'localhost', '127.0.0.1', '::1'}
2321

2422
def _validate_space_redirect_uri(self, redirect_uri: str, *, bind: bool) -> str:
2523
parsed = urlsplit(redirect_uri)
@@ -38,17 +36,12 @@ def _validate_space_redirect_uri(self, redirect_uri: str, *, bind: bool) -> str:
3836
if query != {'mode': ['bind']}:
3937
raise ValueError('Invalid Space binding redirect_uri')
4038
elif query:
41-
raise ValueError('Invalid Space login redirect_uri')
42-
43-
redirect_origin = self._origin(redirect_uri)
44-
api_config = self.ap.instance_config.data.get('api', {})
45-
trusted_origins = {
46-
self._origin(str(api_config.get(config_key, '') or '').strip())
47-
for config_key in ('webui_url', 'webhook_prefix')
48-
}
49-
trusted_origins.discard(None)
50-
if redirect_origin not in trusted_origins:
51-
raise ValueError('Untrusted redirect_uri origin')
39+
raise ValueError('Invalid LangBot Account login redirect_uri')
40+
41+
# OSS instances can live behind arbitrary domains and gateway ports.
42+
# Accept any HTTPS callback, plus HTTP only for local development.
43+
if parsed.scheme == 'http' and not self._is_loopback_host(parsed.hostname):
44+
raise ValueError('Insecure redirect_uri origin')
5245
return redirect_uri
5346

5447
async def initialize(self) -> None:
@@ -416,7 +409,7 @@ async def _() -> str:
416409
'Bind the LangBot Account with the same email as this local Account',
417410
)
418411
except ValueError:
419-
return self.http_status(400, -1, 'Space account binding failed')
412+
return self.http_status(400, -1, 'LangBot Account binding failed')
420413
except Exception:
421414
raise
422415

src/langbot/pkg/api/http/service/user.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,7 @@ async def issue_space_oauth_state(
114114
if purpose == 'login' and account_uuid is not None:
115115
raise ValueError('Login state cannot be bound to an Account')
116116
if purpose != 'login' and launch_workspace_uuid is not None:
117-
raise ValueError('Launch Workspace state is only valid for Space login')
117+
raise ValueError('Launch Workspace state is only valid for LangBot Account login')
118118
if ttl_seconds <= 0:
119119
raise ValueError('OAuth state lifetime must be positive')
120120

@@ -327,7 +327,7 @@ async def register_invited_account(
327327
normalized_email = normalize_email(user_email)
328328
if self._uses_control_plane_directory():
329329
raise ControlPlaneDirectoryRequiredError(
330-
'Cloud invitation registration must use a Space account to preserve control-plane identity'
330+
'Cloud invitation registration must use a LangBot Account to preserve control-plane identity'
331331
)
332332
invitation, _ = await self.ap.workspace_collaboration_service.inspect_invitation(invitation_token)
333333
if invitation.normalized_email != normalized_email:
@@ -394,7 +394,7 @@ async def authenticate(self, user_email: str, password: str) -> str | None:
394394

395395
# Check if this user has a local password set
396396
if not user_obj.password:
397-
raise ValueError('请使用 Space 账户登录')
397+
raise ValueError('请使用 LangBot 账号登录')
398398

399399
await self._verify_password(user_obj.password, password)
400400

@@ -825,7 +825,7 @@ async def bind_space_account(self, user_email: str, code: str) -> user.User:
825825
# Check if this Space account is already bound to another user
826826
existing_space_user = await self.get_user_by_space_account_uuid(space_account_uuid)
827827
if existing_space_user and existing_space_user.normalized_email != normalize_email(user_email):
828-
raise ValueError('This Space account is already bound to another user')
828+
raise ValueError('This LangBot Account is already bound to another user')
829829

830830
# Update local account to Space account
831831
normalized_email = normalize_email(user_email)

src/langbot/pkg/entity/errors/account.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,4 +17,4 @@ class SpaceAccountBindingRequiredError(AccountEmailMismatchError):
1717
code = 'space_account_binding_required'
1818

1919
def __str__(self) -> str:
20-
return 'This local Account must bind Space from Account settings before Space login'
20+
return 'This local account must bind a LangBot Account from Account settings before LangBot Account login'

tests/integration/api/test_user_space_oauth.py

Lines changed: 41 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -165,34 +165,50 @@ async def test_bind_state_is_account_bound_and_requires_authentication(space_oau
165165

166166

167167
@pytest.mark.asyncio
168-
async def test_redirect_origin_and_callback_path_are_restricted(space_oauth_api):
168+
async def test_redirect_allows_dynamic_https_origin_and_loopback_http(space_oauth_api):
169169
_, client = space_oauth_api
170170

171-
wrong_origin = await client.get(
172-
'/api/v1/user/space/authorize-url',
173-
query_string={'redirect_uri': 'https://evil.example/auth/space/callback'},
174-
headers={'Origin': 'http://localhost'},
175-
)
176-
wrong_path = await client.get(
177-
'/api/v1/user/space/authorize-url',
178-
query_string={'redirect_uri': 'http://localhost/arbitrary'},
179-
headers={'Origin': 'http://localhost'},
180-
)
181-
forged_origin = await client.get(
182-
'/api/v1/user/space/authorize-url',
183-
query_string={'redirect_uri': 'https://evil.example/auth/space/callback'},
184-
headers={'Origin': 'https://evil.example'},
185-
)
186-
forged_host = await client.get(
187-
'/api/v1/user/space/authorize-url',
188-
query_string={'redirect_uri': 'https://evil.example/auth/space/callback'},
189-
headers={'Host': 'evil.example'},
190-
)
171+
responses = [
172+
await client.get(
173+
'/api/v1/user/space/authorize-url',
174+
query_string={'redirect_uri': redirect_uri},
175+
headers={'Origin': 'https://irrelevant.example'},
176+
)
177+
for redirect_uri in (
178+
'https://langbot.example/auth/space/callback',
179+
'https://gateway.example:8443/auth/space/callback',
180+
'https://192.0.2.10/auth/space/callback',
181+
'http://localhost:5300/auth/space/callback',
182+
'http://127.0.0.1:5300/auth/space/callback',
183+
'http://[::1]:5300/auth/space/callback',
184+
)
185+
]
186+
187+
assert all(response.status_code == 200 for response in responses)
188+
payloads = [await response.get_json() for response in responses]
189+
assert all(payload['code'] == 0 for payload in payloads)
190+
191+
192+
@pytest.mark.asyncio
193+
async def test_redirect_rejects_insecure_remote_origin_and_invalid_callback_shape(space_oauth_api):
194+
_, client = space_oauth_api
195+
196+
responses = [
197+
await client.get(
198+
'/api/v1/user/space/authorize-url',
199+
query_string={'redirect_uri': redirect_uri},
200+
)
201+
for redirect_uri in (
202+
'http://langbot.example/auth/space/callback',
203+
'https://langbot.example/arbitrary',
204+
'https://langbot.example/auth/space/callback?next=https://evil.example',
205+
'https://user@langbot.example/auth/space/callback',
206+
'https://langbot.example/auth/space/callback#fragment',
207+
)
208+
]
191209

192-
assert (await wrong_origin.get_json())['code'] == 1
193-
assert (await wrong_path.get_json())['code'] == 1
194-
assert (await forged_origin.get_json())['code'] == 1
195-
assert (await forged_host.get_json())['code'] == 1
210+
payloads = [await response.get_json() for response in responses]
211+
assert all(payload['code'] == 1 for payload in payloads)
196212

197213

198214
@pytest.mark.asyncio

tests/unit_tests/api/service/test_user_service.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -377,7 +377,7 @@ async def test_authenticate_space_user_without_password_raises_error(self):
377377
service = UserService(ap)
378378

379379
# Execute & Verify
380-
with pytest.raises(ValueError, match='请使用 Space 账户登录'):
380+
with pytest.raises(ValueError, match='请使用 LangBot 账号登录'):
381381
await service.authenticate('space@example.com', 'password')
382382

383383

@@ -726,7 +726,7 @@ async def test_cloud_invitation_registration_requires_space_identity(self):
726726
)
727727
service = UserService(ap)
728728

729-
with pytest.raises(ControlPlaneDirectoryRequiredError, match='Space account'):
729+
with pytest.raises(ControlPlaneDirectoryRequiredError, match='LangBot Account'):
730730
await service.register_invited_account('invite-token', 'member@example.com', 'password')
731731

732732
async def test_create_or_update_new_space_user_first_init(self):

web/src/i18n/locales/en-US.ts

Lines changed: 24 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -85,18 +85,18 @@ const enUS = {
8585
'Recommended: Use official stable model APIs and cloud services',
8686
loginLocal: 'Login with local account',
8787
loginWithPassword: 'Login with password',
88-
spaceLoginTitle: 'Login with Space',
88+
spaceLoginTitle: 'Login with LangBot Account',
8989
spaceLoginDescription:
9090
'Scan the QR code or visit the link below to authorize',
9191
spaceLoginUserCode: 'Your code',
9292
spaceLoginExpires: 'Code expires in {{seconds}} seconds',
9393
spaceLoginWaiting: 'Waiting for authorization...',
9494
spaceLoginSuccess: 'Authorization successful',
95-
spaceLoginFailed: 'Space login failed',
95+
spaceLoginFailed: 'LangBot Account login failed',
9696
spaceLoginExpired: 'Authorization code expired, please try again',
9797
spaceLoginCancel: 'Cancel',
9898
spaceLoginVisitLink: 'Visit link',
99-
spaceLoginProcessing: 'Logging in with Space',
99+
spaceLoginProcessing: 'Logging in with LangBot Account',
100100
spaceLoginProcessingDescription:
101101
'Please wait while we complete your login...',
102102
spaceLoginSuccessDescription: 'Redirecting to LangBot...',
@@ -105,7 +105,7 @@ const enUS = {
105105
backToLogin: 'Back to Login',
106106
backToHome: 'Back to Home',
107107
spaceAccountCannotChangePassword:
108-
'Space accounts cannot change password here',
108+
'LangBot Accounts cannot change password here',
109109
theme: 'Theme',
110110
changePassword: 'Change Password',
111111
currentPassword: 'Current Password',
@@ -254,8 +254,9 @@ const enUS = {
254254
llmModels: 'LLM Models',
255255
localProvider: 'Local',
256256
localProviderDescription: 'Models configured and managed locally',
257-
spaceProviderDescription: 'Models synced from your Space account',
258-
spaceDisabledForLocalAccount: 'Login with Space to use cloud models',
257+
spaceProviderDescription: 'Models synced from your LangBot Account',
258+
spaceDisabledForLocalAccount:
259+
'Login with LangBot Account to use cloud models',
259260
syncModels: 'Sync',
260261
syncSuccess: 'Sync complete: {{created}} created, {{updated}} updated',
261262
syncError: 'Sync failed: ',
@@ -291,15 +292,15 @@ const enUS = {
291292
langbotModelsDescription: 'Cloud models powered by LangBot Space',
292293
credits: 'Credits',
293294
loginWithSpace: 'Login with LangBot Account',
294-
loginToUseModels: 'Login with Space to use cloud models',
295+
loginToUseModels: 'Login with LangBot Account to use cloud models',
295296
ownerMustBindSpace:
296-
'The Workspace owner must connect Space for LangBot Models.',
297+
'The Workspace owner must connect a LangBot Account for LangBot Models.',
297298
usesOwnerSpaceBilling:
298-
"Uses the Workspace owner's Space billing and credits.",
299+
"Uses the Workspace owner's LangBot Account billing and credits.",
299300
noModels: 'No models configured',
300301
langbotModels: 'LangBot Models',
301302
spaceTrialTooltip:
302-
'Free trial credits available! Login with Space to access cloud models with zero configuration.',
303+
'Free trial credits available! Login with LangBot Account to access cloud models with zero configuration.',
303304
unlockModels: 'Login to use',
304305
editProvider: 'Edit Provider',
305306
addProvider: 'Add Provider',
@@ -1218,13 +1219,13 @@ const enUS = {
12181219
adminAccountNote:
12191220
'The account you use here will be set as the administrator account',
12201221
register: 'Register',
1221-
initWithSpace: 'Initialize with Space',
1222+
initWithSpace: 'Initialize with LangBot Account',
12221223
spaceRecommended:
12231224
'Recommended: Use official stable model APIs and cloud services',
12241225
spaceInfoTip1:
12251226
'Space provides unified account authentication services without uploading any of your sensitive information.',
12261227
spaceInfoTip2:
1227-
'Logging in with a Space account gives you access to LangBot Models and other cloud services, including free model call credits to help you get started quickly.',
1228+
'Logging in with a LangBot Account gives you access to LangBot Models and other cloud services, including free model call credits to help you get started quickly.',
12281229
spaceInfoTip3:
12291230
'Your login method does not affect other features. You can configure and use models from other sources at any time.',
12301231
registerLocal: 'Register local account',
@@ -1281,32 +1282,32 @@ const enUS = {
12811282
passwordNotSet: 'Not Set',
12821283
passwordSetDescription:
12831284
'Password is set, you can login with email and password',
1284-
spaceStatus: 'Space Account',
1285+
spaceStatus: 'LangBot Account',
12851286
spaceBound: 'Bound',
12861287
spaceNotBound: 'Not Bound',
12871288
spaceBoundDescription:
1288-
'Space account bound, official model APIs and cloud services available',
1289-
bindSpace: 'Bind Space Account',
1289+
'LangBot Account bound, official model APIs and cloud services available',
1290+
bindSpace: 'Bind LangBot Account',
12901291
bindSpaceDescription: 'Bind to use official model APIs and cloud services',
12911292
bindSpaceButton: 'Bind',
12921293
bindSpaceConfirmTitle: 'Confirm Binding',
12931294
bindSpaceConfirmDescription:
1294-
'You are about to bind your local instance to a Space account',
1295+
'You are about to bind your local instance to a LangBot Account',
12951296
bindSpaceWarning:
1296-
'After binding, your login email will be changed from {{localEmail}} to the Space account email.',
1297-
bindSpaceSuccess: 'Space account bound successfully',
1298-
bindSpaceFailed: 'Failed to bind Space account',
1297+
'After binding, your login email will be changed from {{localEmail}} to the LangBot Account email.',
1298+
bindSpaceSuccess: 'LangBot Account bound successfully',
1299+
bindSpaceFailed: 'Failed to bind LangBot Account',
12991300
bindSpaceInvalidState:
13001301
'Invalid bind request. Please try again from account settings.',
13011302
setPasswordHint: 'Set a password to login with email and password',
13021303
spaceEmailMismatch:
1303-
'The Space login email does not match the local account email.',
1304+
'The LangBot Account login email does not match the local account email.',
13041305
space_account_not_registeredTitle: 'Account not registered',
13051306
space_account_not_registered:
1306-
'No local account is registered for this Space email. Ask the Workspace owner for an invitation.',
1307-
space_account_binding_requiredTitle: 'Space connection required',
1307+
'No local account is registered for this LangBot Account email. Ask the Workspace owner for an invitation.',
1308+
space_account_binding_requiredTitle: 'LangBot Account connection required',
13081309
space_account_binding_required:
1309-
'This local account must connect Space from Account settings before using Space login.',
1310+
'This local account must connect a LangBot Account from Account settings before using LangBot Account login.',
13101311
},
13111312
workspace: {
13121313
title: 'Workspace',

0 commit comments

Comments
 (0)