Skip to content

Commit afa3d21

Browse files
committed
Make v2 web-login opt-in via --v2 flag
Per maintainer feedback on #355: the legacy v1 web-login flow still works for many users, so the v2 path should be opt-in rather than the default. - pytr/main.py: add --v2 flag to the shared login args; plumb args.v2 through every login() call site. - pytr/account.py: accept v2 kwarg, pass through as use_v2_login=. The v1 prompt/SMS path is restored byte-for-byte; the v2 push-approval branch only runs when v2=True. - pytr/api.py: gate the v2 endpoint, headers (Origin/Referer, x-tr-*, x-aws-waf-token, macOS Chrome UA), device-id fingerprint, and await_web_login_approval() poll loop behind use_v2_login. Default TradeRepublicApi() behavior is identical to upstream master: - POST /api/v1/auth/web/login with session default headers - resend_weblogin() POSTs to v1 with _default_headers - complete_weblogin(code) POSTs to v1 with no extra headers - initiate_weblogin() returns countdownInSeconds+1 Constants renamed for clarity: TR_V1_LOGIN_PATH, TR_V2_LOGIN_PATH, TR_WEB_USER_AGENT_V2 (env-overridable as before). Usage: pytr login # v1, unchanged pytr login --v2 # v2 push-approval flow
1 parent 7e94d1a commit afa3d21

3 files changed

Lines changed: 59 additions & 29 deletions

File tree

pytr/account.py

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ def get_settings(tr):
1818
return formatted_json
1919

2020

21-
def login(phone_no=None, pin=None, store_credentials=False, waf_token="playwright"):
21+
def login(phone_no=None, pin=None, store_credentials=False, waf_token="playwright", v2=False):
2222
"""
2323
Handle credentials parameters and store to credentials file if requested.
2424
If no parameters are set but are needed then ask for input
@@ -52,7 +52,13 @@ def login(phone_no=None, pin=None, store_credentials=False, waf_token="playwrigh
5252
else:
5353
save_cookies = False
5454

55-
tr = TradeRepublicApi(phone_no=phone_no, pin=pin, save_cookies=save_cookies, waf_token=waf_token)
55+
tr = TradeRepublicApi(
56+
phone_no=phone_no,
57+
pin=pin,
58+
save_cookies=save_cookies,
59+
waf_token=waf_token,
60+
use_v2_login=v2,
61+
)
5662

5763
# Use same login as app.traderepublic.com
5864
if not tr.resume_websession():
@@ -61,7 +67,7 @@ def login(phone_no=None, pin=None, store_credentials=False, waf_token="playwrigh
6167
except ValueError as e:
6268
log.fatal(str(e))
6369
sys.exit(1)
64-
if countdown == 0:
70+
if v2:
6571
# v2 push-approve flow: initiate_weblogin() already polled and got approval.
6672
tr.complete_weblogin("")
6773
log.info("Logged in.")

pytr/api.py

Lines changed: 33 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -49,22 +49,22 @@
4949
COOKIES_FILE = BASE_DIR / "cookies.txt"
5050
DEVICE_ID_FILE = BASE_DIR / "device_id"
5151

52-
# Web app values captured from app.traderepublic.com on 2026-05-29.
53-
# These can be overridden via environment variables if Trade Republic bumps them.
52+
# v2 web-login values captured from app.traderepublic.com on 2026-05-29.
53+
# Only used when the caller opts into the v2 push-approval flow.
54+
# Overridable via env vars in case Trade Republic bumps them.
5455
TR_WEB_APP_VERSION = os.environ.get("PYTR_TR_APP_VERSION", "15.7.0")
55-
TR_WEB_USER_AGENT = os.environ.get(
56+
TR_WEB_USER_AGENT_V2 = os.environ.get(
5657
"PYTR_TR_USER_AGENT",
5758
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 "
5859
"(KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36",
5960
)
60-
TR_WEB_LOGIN_PATH = "/api/v2/auth/web/login"
61+
TR_V1_LOGIN_PATH = "/api/v1/auth/web/login"
62+
TR_V2_LOGIN_PATH = "/api/v2/auth/web/login"
6163

6264

6365
class TradeRepublicApi:
6466
_default_headers = {
65-
"User-Agent": TR_WEB_USER_AGENT,
66-
"Origin": "https://app.traderepublic.com",
67-
"Referer": "https://app.traderepublic.com/",
67+
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36"
6868
}
6969
_host = "https://api.traderepublic.com"
7070
_waf_login_url = "https://app.traderepublic.com/login"
@@ -90,11 +90,13 @@ def __init__(
9090
credentials_file=None,
9191
cookies_file=None,
9292
waf_token="playwright",
93+
use_v2_login=False,
9394
):
9495
self.log = get_logger(__name__)
9596
self._locale = locale
9697
self._save_cookies = save_cookies
9798
self._waf_token = waf_token
99+
self._use_v2_login = use_v2_login
98100

99101
self._credentials_file = pathlib.Path(credentials_file) if credentials_file else CREDENTIALS_FILE
100102

@@ -228,8 +230,11 @@ def _build_device_info_header(self) -> str:
228230
return base64.b64encode(raw).decode("ascii")
229231

230232
def _auth_headers(self) -> Dict[str, str]:
231-
"""Headers required by /api/v2/auth/web/login."""
233+
"""Headers required by /api/v2/auth/web/login (v2 push-approval flow)."""
232234
headers = {
235+
"User-Agent": TR_WEB_USER_AGENT_V2,
236+
"Origin": "https://app.traderepublic.com",
237+
"Referer": "https://app.traderepublic.com/",
233238
"x-tr-platform": "web",
234239
"x-tr-app-version": TR_WEB_APP_VERSION,
235240
"x-tr-device-info": self._build_device_info_header(),
@@ -276,10 +281,13 @@ def initiate_weblogin(self):
276281
else:
277282
self.log.warning("No WAF token available.")
278283

284+
login_path = TR_V2_LOGIN_PATH if self._use_v2_login else TR_V1_LOGIN_PATH
285+
extra_headers = self._auth_headers() if self._use_v2_login else None
286+
279287
r = self._websession.post(
280-
f"{self._host}{TR_WEB_LOGIN_PATH}",
288+
f"{self._host}{login_path}",
281289
json={"phoneNumber": self.phone_no, "pin": self.pin},
282-
headers=self._auth_headers(),
290+
headers=extra_headers,
283291
)
284292
self.log.debug(f"Web login returned: {r.status_code}")
285293
r.raise_for_status()
@@ -293,7 +301,9 @@ def initiate_weblogin(self):
293301
raise ValueError(str(err))
294302
else:
295303
raise ValueError("processId not in reponse")
296-
self.log.info("Web login keys: %s", sorted(j.keys()))
304+
305+
if not self._use_v2_login:
306+
return int(j["countdownInSeconds"]) + 1
297307

298308
# v2 web login uses push-to-approve in the TR mobile app: no SMS/code.
299309
# Poll the process endpoint until the push is approved (or rejected/expired).
@@ -304,24 +314,19 @@ def await_web_login_approval(self, timeout_s: int = 180, interval_s: float = 2.0
304314
"""Block until the TR mobile-app push for this login process is approved."""
305315
if not self._process_id:
306316
raise ValueError("Initiate web login first.")
307-
url = f"{self._host}/api/v2/auth/web/login/processes/{self._process_id}"
317+
url = f"{self._host}{TR_V2_LOGIN_PATH}/processes/{self._process_id}"
308318
deadline = time.time() + timeout_s
309319
print(
310-
f"Approve the login in your Trade Republic mobile app "
311-
f"(waiting up to {timeout_s}s)...",
320+
f"Approve the login in your Trade Republic mobile app (waiting up to {timeout_s}s)...",
312321
flush=True,
313322
)
314-
first_body_logged = False
315323
while time.time() < deadline:
316324
r = self._websession.get(url, headers=self._auth_headers())
317325
if r.status_code == 200:
318326
try:
319327
j = r.json()
320328
except ValueError:
321329
j = {}
322-
if not first_body_logged:
323-
self.log.info("Login poll keys: %s", sorted(j.keys()) if j else "<empty>")
324-
first_body_logged = True
325330
state = str(j.get("state") or j.get("status") or "").upper()
326331
if state in ("APPROVED", "COMPLETED", "SUCCESS", "OK", "DONE"):
327332
self.save_websession()
@@ -336,18 +341,18 @@ def await_web_login_approval(self, timeout_s: int = 180, interval_s: float = 2.0
336341
self.log.info("Session cookie detected, login complete.")
337342
return
338343
elif r.status_code in (401, 403, 404, 410):
339-
raise ValueError(
340-
f"Login process rejected or expired ({r.status_code}): {r.text[:200]}"
341-
)
344+
raise ValueError(f"Login process rejected or expired ({r.status_code}): {r.text[:200]}")
342345
else:
343346
self.log.warning("Login poll %s: %s", r.status_code, r.text[:200])
344347
time.sleep(interval_s)
345348
raise TimeoutError("Push approval not received within timeout.")
346349

347350
def resend_weblogin(self):
351+
path = TR_V2_LOGIN_PATH if self._use_v2_login else TR_V1_LOGIN_PATH
352+
headers = self._auth_headers() if self._use_v2_login else self._default_headers
348353
r = self._websession.post(
349-
f"{self._host}{TR_WEB_LOGIN_PATH}/{self._process_id}/resend",
350-
headers=self._auth_headers(),
354+
f"{self._host}{path}/{self._process_id}/resend",
355+
headers=headers,
351356
)
352357
r.raise_for_status()
353358

@@ -357,13 +362,15 @@ def complete_weblogin(self, verify_code):
357362

358363
# v2 push flow: approval already happened during initiate_weblogin().
359364
# An empty verify_code signals nothing left to do.
360-
if not verify_code:
365+
if self._use_v2_login and not verify_code:
361366
self.save_websession()
362367
return
363368

369+
path = TR_V2_LOGIN_PATH if self._use_v2_login else TR_V1_LOGIN_PATH
370+
headers = self._auth_headers() if self._use_v2_login else None
364371
r = self._websession.post(
365-
f"{self._host}{TR_WEB_LOGIN_PATH}/{self._process_id}/{verify_code}",
366-
headers=self._auth_headers(),
372+
f"{self._host}{path}/{self._process_id}/{verify_code}",
373+
headers=headers,
367374
)
368375
r.raise_for_status()
369376
self.save_websession()

pytr/main.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,15 @@ def formatter(prog):
8989
action="store_true",
9090
default=False,
9191
)
92+
parser_login_args.add_argument(
93+
"--v2",
94+
help=(
95+
"Use the v2 web-login flow (push approval via the Trade Republic "
96+
"mobile app). Default is the legacy v1 flow with an SMS/notification code."
97+
),
98+
action="store_true",
99+
default=False,
100+
)
92101

93102
# parent subparser for lang option
94103
parser_lang = argparse.ArgumentParser(add_help=False)
@@ -463,6 +472,7 @@ def main():
463472
pin=args.pin,
464473
store_credentials=args.store_credentials,
465474
waf_token=args.waf_token,
475+
v2=args.v2,
466476
)
467477
elif args.command == "portfolio":
468478
Portfolio(
@@ -471,6 +481,7 @@ def main():
471481
pin=args.pin,
472482
store_credentials=args.store_credentials,
473483
waf_token=args.waf_token,
484+
v2=args.v2,
474485
),
475486
args.include_watchlist,
476487
lang=args.lang,
@@ -486,6 +497,7 @@ def main():
486497
pin=args.pin,
487498
store_credentials=args.store_credentials,
488499
waf_token=args.waf_token,
500+
v2=args.v2,
489501
),
490502
args.isin,
491503
).get()
@@ -496,6 +508,7 @@ def main():
496508
pin=args.pin,
497509
store_credentials=args.store_credentials,
498510
waf_token=args.waf_token,
511+
v2=args.v2,
499512
),
500513
args.output,
501514
args.format,
@@ -525,6 +538,7 @@ def main():
525538
pin=args.pin,
526539
store_credentials=args.store_credentials,
527540
waf_token=args.waf_token,
541+
v2=args.v2,
528542
),
529543
args.outputdir,
530544
not_before,
@@ -559,6 +573,7 @@ def main():
559573
pin=args.pin,
560574
store_credentials=args.store_credentials,
561575
waf_token=args.waf_token,
576+
v2=args.v2,
562577
),
563578
args.input,
564579
args.outputfile,
@@ -574,6 +589,7 @@ def main():
574589
pin=args.pin,
575590
store_credentials=args.store_credentials,
576591
waf_token=args.waf_token,
592+
v2=args.v2,
577593
),
578594
args.input,
579595
args.inputfile,
@@ -589,6 +605,7 @@ def main():
589605
pin=args.pin,
590606
store_credentials=args.store_credentials,
591607
waf_token=args.waf_token,
608+
v2=args.v2,
592609
),
593610
args.outputfile,
594611
decimal_localization=args.decimal_localization,

0 commit comments

Comments
 (0)