Skip to content

Commit ee17a8b

Browse files
Romazesclaude
andauthored
feat: add require-user-name support to AuthConfiguration and Auth0 endpoints (#636)
* feat: add require-user-name support to AuthConfiguration and Auth0 endpoints Adds optional userId parameter to Auth0 read and authorize flows so brokerages like Charles Schwab can pass a login ID to pre-fill the OAuth authorization page. AuthConfiguration now reads require-user-name from config JSON, prompts the user for their login ID (checking CLI args and lean config first), and threads the value through get_authorization → Auth0Client.read (payload) and Auth0Client.authorize (URL). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: persist user_name to lean_config and add get_user_name unit tests After prompting for login ID, the value is saved to lean_config under the derived key (e.g. charles-schwab-user-name) so subsequent runs skip the prompt. Adds 4 unit tests covering all get_user_name code paths. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: validate and clear stale account number from lean_config after OAuth When the Auth0 OAuth flow returns a new account list, the previously saved account number in lean.json may no longer be valid. Previously the code would silently use the stale value, causing live trading to be configured with the wrong account. Now, after fetching API account IDs: - If the saved account is no longer in the API response, it is cleared so the user is prompted to select a valid one. - If the API returns multiple accounts (even if the saved value is still valid), it is cleared so the user actively confirms their selection from the current list. - If the API returns exactly one account and it matches the saved value, no prompt is needed. Also adds three unit tests covering stale, ambiguous, and valid cases. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: validate and clear stale account number from lean_config after OAuth Use plain brokerage_id as cache key when user_name is None to preserve original behaviour; use (brokerage_id, user_name) tuple when user_name is provided so different users get isolated cache entries. Adds two cache tests: no-user-name single-call check and per-user-name isolation check. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 7c0590b commit ee17a8b

6 files changed

Lines changed: 322 additions & 10 deletions

File tree

lean/components/api/auth0_client.py

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -29,40 +29,50 @@ def __init__(self, api_client: 'APIClient') -> None:
2929
self._api = api_client
3030
self._cache = {}
3131

32-
def read(self, brokerage_id: str) -> QCAuth0Authorization:
32+
def read(self, brokerage_id: str, user_name: str = None) -> QCAuth0Authorization:
3333
"""Reads the authorization data for a brokerage.
3434
3535
:param brokerage_id: the id of the brokerage to read the authorization data for
36+
:param user_name: the optional login ID of the user
3637
:return: the authorization data for the specified brokerage
3738
"""
3839
try:
3940
# First check cache
40-
if brokerage_id in self._cache.keys():
41-
return self._cache[brokerage_id]
41+
if user_name:
42+
cache_key = (brokerage_id, user_name)
43+
else:
44+
cache_key = brokerage_id
45+
if cache_key in self._cache:
46+
return self._cache[cache_key]
4247
payload = {
4348
"brokerage": brokerage_id
4449
}
50+
if user_name:
51+
payload["userId"] = user_name
4552

4653
data = self._api.post("live/auth0/read", payload)
4754
# Store in cache
4855
result = QCAuth0Authorization(**data)
49-
self._cache[brokerage_id] = result
56+
self._cache[cache_key] = result
5057
return result
5158
except RequestFailedError as e:
5259
return QCAuth0Authorization(authorization=None)
5360

5461
@staticmethod
55-
def authorize(brokerage_id: str, logger: Logger, project_id: int, no_browser: bool = False) -> None:
62+
def authorize(brokerage_id: str, logger: Logger, project_id: int, no_browser: bool = False, user_name: str = None) -> None:
5663
"""Starts the authorization process for a brokerage.
5764
5865
:param brokerage_id: the id of the brokerage to start the authorization process for
5966
:param logger: the logger instance to use
6067
:param project_id: The local or cloud project_id
68+
:param user_name: the optional login ID of the user to pre-fill in the authorization page
6169
:param no_browser: whether to disable opening the browser
6270
"""
6371
from webbrowser import open
6472

6573
full_url = f"{API_BASE_URL}live/auth0/authorize?brokerage={brokerage_id}&projectId={project_id}"
74+
if user_name:
75+
full_url += f"&userId={user_name}"
6676

6777
logger.info(f"Please open the following URL in your browser to authorize the LEAN CLI.")
6878
logger.info(full_url)

lean/components/util/auth0_helper.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
from lean.components.util.logger import Logger
1717

1818

19-
def get_authorization(auth0_client: Auth0Client, brokerage_id: str, logger: Logger, project_id: int, no_browser: bool = False) -> QCAuth0Authorization:
19+
def get_authorization(auth0_client: Auth0Client, brokerage_id: str, logger: Logger, project_id: int, no_browser: bool = False, user_name: str = None) -> QCAuth0Authorization:
2020
"""Gets the authorization data for a brokerage, authorizing if necessary.
2121
2222
:param auth0_client: An instance of Auth0Client, containing methods to interact with live/auth0/* API endpoints.
@@ -28,18 +28,18 @@ def get_authorization(auth0_client: Auth0Client, brokerage_id: str, logger: Logg
2828
"""
2929
from time import time, sleep
3030

31-
data = auth0_client.read(brokerage_id)
31+
data = auth0_client.read(brokerage_id, user_name=user_name)
3232
if data.authorization is not None:
3333
return data
3434

3535
start_time = time()
36-
auth0_client.authorize(brokerage_id, logger, project_id, no_browser)
36+
auth0_client.authorize(brokerage_id, logger, project_id, no_browser, user_name=user_name)
3737

3838
# keep checking for new data every 5 seconds for 7 minutes
3939
while time() - start_time < 420:
4040
logger.debug("Will sleep 5 seconds and retry fetching authorization...")
4141
sleep(5)
42-
data = auth0_client.read(brokerage_id)
42+
data = auth0_client.read(brokerage_id, user_name=user_name)
4343
if data.authorization is None:
4444
continue
4545
return data

lean/models/configuration.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -401,6 +401,7 @@ class AuthConfiguration(InternalInputUserInput):
401401
def __init__(self, config_json_object):
402402
super().__init__(config_json_object)
403403
self.require_project_id = config_json_object.get("require-project-id", False)
404+
self.require_user_name = config_json_object.get("require-user-name", False)
404405

405406
def factory(config_json_object) -> 'AuthConfiguration':
406407
"""Creates an instance of the child classes.

lean/models/json_module.py

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,30 @@ def convert_variable_to_lean_key(self, variable_key: str) -> str:
175175
"""
176176
return variable_key.replace('_', '-')
177177

178+
def get_user_name(self, lean_config: Dict[str, Any], configuration, user_provided_options: Dict[str, Any], require_user_name: bool) -> str:
179+
"""Retrieve the user name, prompting the user if required and not already set.
180+
181+
:param lean_config: The Lean config dict to read defaults from.
182+
:param configuration: The AuthConfiguration instance.
183+
:param user_provided_options: Options passed as command-line arguments.
184+
:param require_user_name: Flag to determine if prompting is necessary.
185+
:return: The user name, or None if not required.
186+
"""
187+
if not require_user_name:
188+
return None
189+
from click import prompt
190+
user_name_key = configuration._id.replace("-oauth-token", "") + "-user-name"
191+
user_name_variable = self.convert_lean_key_to_variable(user_name_key)
192+
if user_name_variable in user_provided_options and user_provided_options[user_name_variable]:
193+
return user_provided_options[user_name_variable]
194+
if lean_config and lean_config.get(user_name_key):
195+
return lean_config[user_name_key]
196+
user_name = prompt("Please enter your Login ID to proceed with Auth0 authentication",
197+
show_default=False)
198+
if lean_config is not None:
199+
lean_config[user_name_key] = user_name
200+
return user_name
201+
178202
def get_project_id(self, default_project_id: int, require_project_id: bool) -> int:
179203
"""Retrieve the project ID, prompting the user if required and default is invalid.
180204
@@ -238,8 +262,12 @@ def config_build(self,
238262
lean_config["project-id"] = self.get_project_id(lean_config["project-id"],
239263
configuration.require_project_id)
240264
logger.debug(f'project_id: {lean_config["project-id"]}')
265+
user_name = self.get_user_name(lean_config, configuration, user_provided_options,
266+
configuration.require_user_name)
267+
logger.debug(f'user_name: {user_name}')
241268
auth_authorizations = get_authorization(container.api_client.auth0, self._display_name.lower(),
242-
logger, lean_config["project-id"], no_browser=no_browser)
269+
logger, lean_config["project-id"], no_browser=no_browser,
270+
user_name=user_name)
243271
logger.debug(f'auth: {auth_authorizations}')
244272
configuration._value = auth_authorizations.get_authorization_config_without_account()
245273
for inner_config in self._lean_configs:
@@ -255,6 +283,12 @@ def config_build(self,
255283
for account_id in api_account_ids)):
256284
raise ValueError(f"The provided account id '{user_provide_account_id}' is not valid, "
257285
f"available: {api_account_ids}")
286+
existing_account = lean_config.get(inner_config._id)
287+
if existing_account and (existing_account not in api_account_ids
288+
or len(api_account_ids) > 1):
289+
# Clear stale or ambiguous account so the user is prompted
290+
# to select from the current API choices
291+
lean_config.pop(inner_config._id)
258292
break
259293
continue
260294

tests/components/api/test_auth0_client.py

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
from unittest import mock
1616
from lean.constants import API_BASE_URL
1717
from lean.components.api.api_client import APIClient
18+
from lean.components.api.auth0_client import Auth0Client
1819
from lean.components.util.http_client import HTTPClient
1920

2021

@@ -49,6 +50,127 @@ def test_auth0client_trade_station() -> None:
4950
assert len(result.get_account_ids()) > 0
5051

5152

53+
def test_auth0client_authorize_with_user_name() -> None:
54+
with mock.patch("webbrowser.open") as mock_open:
55+
Auth0Client.authorize("charles-schwab", mock.Mock(), 123, user_name="test_login")
56+
mock_open.assert_called_once()
57+
called_url = mock_open.call_args[0][0]
58+
assert "&userId=test_login" in called_url
59+
60+
61+
def test_auth0client_authorize_without_user_name() -> None:
62+
with mock.patch("webbrowser.open") as mock_open:
63+
Auth0Client.authorize("charles-schwab", mock.Mock(), 123)
64+
mock_open.assert_called_once()
65+
called_url = mock_open.call_args[0][0]
66+
assert "userId" not in called_url
67+
68+
69+
@responses.activate
70+
def test_auth0client_read_with_user_name() -> None:
71+
api_clint = APIClient(mock.Mock(), HTTPClient(mock.Mock()), user_id="123", api_token="abc")
72+
73+
responses.add(
74+
responses.POST,
75+
f"{API_BASE_URL}live/auth0/read",
76+
json={
77+
"authorization": {
78+
"charles-schwab-access-token": "abc123",
79+
"accounts": [{"id": "ACC001", "name": "ACC001 | Individual | USD"}]
80+
},
81+
"success": "true"},
82+
status=200
83+
)
84+
85+
result = api_clint.auth0.read("charles-schwab", user_name="test_login")
86+
87+
assert result
88+
assert result.authorization
89+
sent_body = responses.calls[0].request.body.decode()
90+
assert "userId" in sent_body
91+
assert "test_login" in sent_body
92+
93+
94+
@responses.activate
95+
def test_auth0client_read_without_user_name() -> None:
96+
api_clint = APIClient(mock.Mock(), HTTPClient(mock.Mock()), user_id="123", api_token="abc")
97+
98+
responses.add(
99+
responses.POST,
100+
f"{API_BASE_URL}live/auth0/read",
101+
json={
102+
"authorization": {
103+
"charles-schwab-access-token": "abc123",
104+
"accounts": [{"id": "ACC001", "name": "ACC001 | Individual | USD"}]
105+
},
106+
"success": "true"},
107+
status=200
108+
)
109+
110+
result = api_clint.auth0.read("charles-schwab")
111+
112+
assert result
113+
assert result.authorization
114+
sent_body = responses.calls[0].request.body.decode()
115+
assert "userId" not in sent_body
116+
117+
118+
@responses.activate
119+
def test_auth0client_read_caches_without_user_name() -> None:
120+
api_clint = APIClient(mock.Mock(), HTTPClient(mock.Mock()), user_id="123", api_token="abc")
121+
122+
responses.add(
123+
responses.POST,
124+
f"{API_BASE_URL}live/auth0/read",
125+
json={
126+
"authorization": {
127+
"charles-schwab-access-token": "abc123",
128+
"accounts": [{"id": "ACC001", "name": "ACC001 | Individual | USD"}]
129+
},
130+
"success": "true"},
131+
status=200
132+
)
133+
134+
api_clint.auth0.read("charles-schwab")
135+
api_clint.auth0.read("charles-schwab")
136+
137+
assert len(responses.calls) == 1
138+
139+
140+
@responses.activate
141+
def test_auth0client_read_caches_per_user_name() -> None:
142+
api_clint = APIClient(mock.Mock(), HTTPClient(mock.Mock()), user_id="123", api_token="abc")
143+
144+
responses.add(
145+
responses.POST,
146+
f"{API_BASE_URL}live/auth0/read",
147+
json={
148+
"authorization": {
149+
"charles-schwab-access-token": "abc123",
150+
"accounts": [{"id": "ACC001", "name": "ACC001 | Individual | USD"}]
151+
},
152+
"success": "true"},
153+
status=200
154+
)
155+
responses.add(
156+
responses.POST,
157+
f"{API_BASE_URL}live/auth0/read",
158+
json={
159+
"authorization": {
160+
"charles-schwab-access-token": "xyz789",
161+
"accounts": [{"id": "ACC002", "name": "ACC002 | Individual | USD"}]
162+
},
163+
"success": "true"},
164+
status=200
165+
)
166+
167+
api_clint.auth0.read("charles-schwab", user_name="user_a")
168+
api_clint.auth0.read("charles-schwab", user_name="user_a") # cache hit
169+
api_clint.auth0.read("charles-schwab", user_name="user_b") # different user — new call
170+
171+
assert len(responses.calls) == 2
172+
173+
52174
@responses.activate
53175
def test_auth0client_alpaca() -> None:
54176
api_clint = APIClient(mock.Mock(), HTTPClient(mock.Mock()), user_id="123", api_token="abc")

0 commit comments

Comments
 (0)