From 7e94d1acc11fcdcb52872c3d4cd288f7a340bb67 Mon Sep 17 00:00:00 2001 From: Twan Nooitmeer Date: Fri, 29 May 2026 19:42:17 +0200 Subject: [PATCH 1/2] Fix web login: migrate to /api/v2/auth/web/login + push approval Trade Republic deprecated the legacy /api/v1/auth/web/login endpoint (all requests now return HTTP 426 CLIENT_VERSION_OUTDATED regardless of User-Agent). The current web app uses /api/v2/auth/web/login with: * x-tr-platform: web * x-tr-app-version: 15.7.0 (web track, distinct from the Android version) * x-tr-device-info: base64(JSON) browser fingerprint * x-aws-waf-token: same value as the aws-waf-token cookie The v2 flow also no longer issues a numeric code: the user approves the login via a push notification in the TR mobile app. The web app polls GET /api/v2/auth/web/login/processes/{processId} until the push is acknowledged. Changes: * pytr/api.py: add TR_WEB_APP_VERSION / TR_WEB_USER_AGENT / TR_WEB_LOGIN_PATH constants (overridable via env), _get_device_id() persisting a stable UUID at ~/.pytr/device_id, _build_device_info_header(), _auth_headers() helper, and await_web_login_approval() polling loop. * initiate_weblogin() now POSTs to v2 with the new headers, polls for push approval, and returns 0 to signal the v2 flow. * complete_weblogin('') is a no-op for v2 (approval already happened). * pytr/account.py: when initiate_weblogin() returns 0, skip the code/SMS prompts and call complete_weblogin('') directly. Refs: #250 --- pytr/account.py | 39 ++++++++------- pytr/api.py | 126 +++++++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 142 insertions(+), 23 deletions(-) diff --git a/pytr/account.py b/pytr/account.py index 5f68854..a5c5d15 100644 --- a/pytr/account.py +++ b/pytr/account.py @@ -61,23 +61,28 @@ def login(phone_no=None, pin=None, store_credentials=False, waf_token="playwrigh except ValueError as e: log.fatal(str(e)) sys.exit(1) - request_time = time.time() - print("Enter the code you received to your mobile app as a notification.") - print(f"Enter nothing if you want to receive the (same) code as SMS. (Countdown: {countdown})") - code = input("Code: ") - if code == "": - countdown = countdown - (time.time() - request_time) - for remaining in range(int(countdown)): - print( - f"Need to wait {int(countdown - remaining)} seconds before requesting SMS...", - end="\r", - ) - time.sleep(1) - print() - tr.resend_weblogin() - code = input("SMS requested. Enter the confirmation code:") - tr.complete_weblogin(code) - log.info("Logged in.") + if countdown == 0: + # v2 push-approve flow: initiate_weblogin() already polled and got approval. + tr.complete_weblogin("") + log.info("Logged in.") + else: + request_time = time.time() + print("Enter the code you received to your mobile app as a notification.") + print(f"Enter nothing if you want to receive the (same) code as SMS. (Countdown: {countdown})") + code = input("Code: ") + if code == "": + countdown = countdown - (time.time() - request_time) + for remaining in range(int(countdown)): + print( + f"Need to wait {int(countdown - remaining)} seconds before requesting SMS...", + end="\r", + ) + time.sleep(1) + print() + tr.resend_weblogin() + code = input("SMS requested. Enter the confirmation code:") + tr.complete_weblogin(code) + log.info("Logged in.") log.debug(get_settings(tr)) return tr diff --git a/pytr/api.py b/pytr/api.py index 82af501..c54e636 100644 --- a/pytr/api.py +++ b/pytr/api.py @@ -21,7 +21,9 @@ # SOFTWARE. import asyncio +import base64 import json +import os import pathlib import re import ssl @@ -45,11 +47,24 @@ BASE_DIR = home / ".pytr" CREDENTIALS_FILE = BASE_DIR / "credentials" COOKIES_FILE = BASE_DIR / "cookies.txt" +DEVICE_ID_FILE = BASE_DIR / "device_id" + +# Web app values captured from app.traderepublic.com on 2026-05-29. +# These can be overridden via environment variables if Trade Republic bumps them. +TR_WEB_APP_VERSION = os.environ.get("PYTR_TR_APP_VERSION", "15.7.0") +TR_WEB_USER_AGENT = os.environ.get( + "PYTR_TR_USER_AGENT", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 " + "(KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36", +) +TR_WEB_LOGIN_PATH = "/api/v2/auth/web/login" class TradeRepublicApi: _default_headers = { - "User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36" + "User-Agent": TR_WEB_USER_AGENT, + "Origin": "https://app.traderepublic.com", + "Referer": "https://app.traderepublic.com/", } _host = "https://api.traderepublic.com" _waf_login_url = "https://app.traderepublic.com/login" @@ -183,6 +198,46 @@ def _fetch_waf_token_playwright(self, timeout_ms: int = 30000): self.log.warning("AWS WAF token not acquired. Value is None.") return token + def _get_device_id(self) -> str: + """Return a stable per-install device UUID, creating it on first use.""" + try: + return DEVICE_ID_FILE.read_text().strip() + except FileNotFoundError: + BASE_DIR.mkdir(parents=True, exist_ok=True) + device_id = uuid.uuid4().hex + uuid.uuid4().hex # 64 hex chars, matches web app + DEVICE_ID_FILE.write_text(device_id) + return device_id + + def _build_device_info_header(self) -> str: + """Build the base64(JSON) x-tr-device-info payload the web app sends.""" + payload = { + "stableDeviceId": self._get_device_id(), + "model": "Apple Macintosh", + "browser": "Chrome", + "browserVersion": "148.0.0.0", + "os": "Mac OS", + "osVersion": "10.15.7", + "timezone": "Europe/Amsterdam", + "timezoneOffset": -120, + "screen": "1800x1169x30", + "preferredLanguages": ["en", "en-US"], + "numberOfCores": 12, + "deviceMemory": 16, + } + raw = json.dumps(payload, separators=(",", ":")).encode("utf-8") + return base64.b64encode(raw).decode("ascii") + + def _auth_headers(self) -> Dict[str, str]: + """Headers required by /api/v2/auth/web/login.""" + headers = { + "x-tr-platform": "web", + "x-tr-app-version": TR_WEB_APP_VERSION, + "x-tr-device-info": self._build_device_info_header(), + } + if self._waf_token: + headers["x-aws-waf-token"] = self._waf_token + return headers + def _set_waf_cookie(self, token: str): """Set the aws-waf-token cookie on the web session.""" cookie = Cookie( @@ -222,8 +277,9 @@ def initiate_weblogin(self): self.log.warning("No WAF token available.") r = self._websession.post( - f"{self._host}/api/v1/auth/web/login", + f"{self._host}{TR_WEB_LOGIN_PATH}", json={"phoneNumber": self.phone_no, "pin": self.pin}, + headers=self._auth_headers(), ) self.log.debug(f"Web login returned: {r.status_code}") r.raise_for_status() @@ -237,12 +293,61 @@ def initiate_weblogin(self): raise ValueError(str(err)) else: raise ValueError("processId not in reponse") - return int(j["countdownInSeconds"]) + 1 + self.log.info("Web login keys: %s", sorted(j.keys())) + + # v2 web login uses push-to-approve in the TR mobile app: no SMS/code. + # Poll the process endpoint until the push is approved (or rejected/expired). + self.await_web_login_approval() + return 0 + + def await_web_login_approval(self, timeout_s: int = 180, interval_s: float = 2.0): + """Block until the TR mobile-app push for this login process is approved.""" + if not self._process_id: + raise ValueError("Initiate web login first.") + url = f"{self._host}/api/v2/auth/web/login/processes/{self._process_id}" + deadline = time.time() + timeout_s + print( + f"Approve the login in your Trade Republic mobile app " + f"(waiting up to {timeout_s}s)...", + flush=True, + ) + first_body_logged = False + while time.time() < deadline: + r = self._websession.get(url, headers=self._auth_headers()) + if r.status_code == 200: + try: + j = r.json() + except ValueError: + j = {} + if not first_body_logged: + self.log.info("Login poll keys: %s", sorted(j.keys()) if j else "") + first_body_logged = True + state = str(j.get("state") or j.get("status") or "").upper() + if state in ("APPROVED", "COMPLETED", "SUCCESS", "OK", "DONE"): + self.save_websession() + self.log.info("Push approved.") + return + if state in ("REJECTED", "DECLINED", "FAILED", "EXPIRED"): + raise ValueError(f"Login process {state.lower()}: {j}") + # State unknown but session cookie present? Treat as approved. + for c in self._websession.cookies: + if c.name in ("tr_session",) and c.value: + self.save_websession() + self.log.info("Session cookie detected, login complete.") + return + elif r.status_code in (401, 403, 404, 410): + raise ValueError( + f"Login process rejected or expired ({r.status_code}): {r.text[:200]}" + ) + else: + self.log.warning("Login poll %s: %s", r.status_code, r.text[:200]) + time.sleep(interval_s) + raise TimeoutError("Push approval not received within timeout.") def resend_weblogin(self): r = self._websession.post( - f"{self._host}/api/v1/auth/web/login/{self._process_id}/resend", - headers=self._default_headers, + f"{self._host}{TR_WEB_LOGIN_PATH}/{self._process_id}/resend", + headers=self._auth_headers(), ) r.raise_for_status() @@ -250,7 +355,16 @@ def complete_weblogin(self, verify_code): if not self._process_id and not self._websession: raise ValueError("Initiate web login first.") - r = self._websession.post(f"{self._host}/api/v1/auth/web/login/{self._process_id}/{verify_code}") + # v2 push flow: approval already happened during initiate_weblogin(). + # An empty verify_code signals nothing left to do. + if not verify_code: + self.save_websession() + return + + r = self._websession.post( + f"{self._host}{TR_WEB_LOGIN_PATH}/{self._process_id}/{verify_code}", + headers=self._auth_headers(), + ) r.raise_for_status() self.save_websession() From 0c4791bf7f152c4a1d4f9b049193e91e7950fa6d Mon Sep 17 00:00:00 2001 From: Twan Nooitmeer Date: Wed, 17 Jun 2026 18:11:07 +0200 Subject: [PATCH 2/2] 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 --- README.md | 15 ++++++++++--- pytr/account.py | 12 +++++++--- pytr/api.py | 59 +++++++++++++++++++++++++++---------------------- pytr/main.py | 17 ++++++++++++++ 4 files changed, 71 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index 8cf44c8..1a9e91f 100644 --- a/README.md +++ b/README.md @@ -99,9 +99,18 @@ There are two authentication methods: ### Web login (default) -Web login is the newer method that uses the same login method as [app.traderepublic.com](https://app.traderepublic.com/), -meaning you receive a four-digit code in the TradeRepublic app or via SMS. This will keep you logged in your primary -device, but means that you may need to repeat entering a new four-digit code ever so often when runnnig `pytr`. +Web login uses the public web-login endpoints at `api.traderepublic.com`. Two variants are available: + +- **v1 (default)**: `pytr login`. You receive a four-digit code in the TradeRepublic app (or via SMS as a + fallback) and enter it in the terminal. This is the original behavior; nothing changes for existing users. +- **v2 push approval (opt-in)**: `pytr login --v2`. Mirrors what + [app.traderepublic.com](https://app.traderepublic.com/) currently does: no numeric code, you approve the login from + a push notification in the Trade Republic mobile app. Useful if your account no longer issues a code via the app or + SMS. The `--v2` flag is available on every subcommand that performs a login, e.g. `pytr portfolio --v2`, + `pytr dl_docs --v2`. + +Both variants keep you logged in on your primary device, but you may need to re-authenticate every so often when +running `pytr`. ### App login diff --git a/pytr/account.py b/pytr/account.py index a5c5d15..e996a68 100644 --- a/pytr/account.py +++ b/pytr/account.py @@ -18,7 +18,7 @@ def get_settings(tr): return formatted_json -def login(phone_no=None, pin=None, store_credentials=False, waf_token="playwright"): +def login(phone_no=None, pin=None, store_credentials=False, waf_token="playwright", v2=False): """ Handle credentials parameters and store to credentials file if requested. 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 else: save_cookies = False - tr = TradeRepublicApi(phone_no=phone_no, pin=pin, save_cookies=save_cookies, waf_token=waf_token) + tr = TradeRepublicApi( + phone_no=phone_no, + pin=pin, + save_cookies=save_cookies, + waf_token=waf_token, + use_v2_login=v2, + ) # Use same login as app.traderepublic.com if not tr.resume_websession(): @@ -61,7 +67,7 @@ def login(phone_no=None, pin=None, store_credentials=False, waf_token="playwrigh except ValueError as e: log.fatal(str(e)) sys.exit(1) - if countdown == 0: + if v2: # v2 push-approve flow: initiate_weblogin() already polled and got approval. tr.complete_weblogin("") log.info("Logged in.") diff --git a/pytr/api.py b/pytr/api.py index c54e636..694109b 100644 --- a/pytr/api.py +++ b/pytr/api.py @@ -49,22 +49,22 @@ COOKIES_FILE = BASE_DIR / "cookies.txt" DEVICE_ID_FILE = BASE_DIR / "device_id" -# Web app values captured from app.traderepublic.com on 2026-05-29. -# These can be overridden via environment variables if Trade Republic bumps them. +# v2 web-login values captured from app.traderepublic.com on 2026-05-29. +# Only used when the caller opts into the v2 push-approval flow. +# Overridable via env vars in case Trade Republic bumps them. TR_WEB_APP_VERSION = os.environ.get("PYTR_TR_APP_VERSION", "15.7.0") -TR_WEB_USER_AGENT = os.environ.get( +TR_WEB_USER_AGENT_V2 = os.environ.get( "PYTR_TR_USER_AGENT", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 " "(KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36", ) -TR_WEB_LOGIN_PATH = "/api/v2/auth/web/login" +TR_V1_LOGIN_PATH = "/api/v1/auth/web/login" +TR_V2_LOGIN_PATH = "/api/v2/auth/web/login" class TradeRepublicApi: _default_headers = { - "User-Agent": TR_WEB_USER_AGENT, - "Origin": "https://app.traderepublic.com", - "Referer": "https://app.traderepublic.com/", + "User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36" } _host = "https://api.traderepublic.com" _waf_login_url = "https://app.traderepublic.com/login" @@ -90,11 +90,13 @@ def __init__( credentials_file=None, cookies_file=None, waf_token="playwright", + use_v2_login=False, ): self.log = get_logger(__name__) self._locale = locale self._save_cookies = save_cookies self._waf_token = waf_token + self._use_v2_login = use_v2_login self._credentials_file = pathlib.Path(credentials_file) if credentials_file else CREDENTIALS_FILE @@ -228,8 +230,11 @@ def _build_device_info_header(self) -> str: return base64.b64encode(raw).decode("ascii") def _auth_headers(self) -> Dict[str, str]: - """Headers required by /api/v2/auth/web/login.""" + """Headers required by /api/v2/auth/web/login (v2 push-approval flow).""" headers = { + "User-Agent": TR_WEB_USER_AGENT_V2, + "Origin": "https://app.traderepublic.com", + "Referer": "https://app.traderepublic.com/", "x-tr-platform": "web", "x-tr-app-version": TR_WEB_APP_VERSION, "x-tr-device-info": self._build_device_info_header(), @@ -276,10 +281,13 @@ def initiate_weblogin(self): else: self.log.warning("No WAF token available.") + login_path = TR_V2_LOGIN_PATH if self._use_v2_login else TR_V1_LOGIN_PATH + extra_headers = self._auth_headers() if self._use_v2_login else None + r = self._websession.post( - f"{self._host}{TR_WEB_LOGIN_PATH}", + f"{self._host}{login_path}", json={"phoneNumber": self.phone_no, "pin": self.pin}, - headers=self._auth_headers(), + headers=extra_headers, ) self.log.debug(f"Web login returned: {r.status_code}") r.raise_for_status() @@ -293,7 +301,9 @@ def initiate_weblogin(self): raise ValueError(str(err)) else: raise ValueError("processId not in reponse") - self.log.info("Web login keys: %s", sorted(j.keys())) + + if not self._use_v2_login: + return int(j["countdownInSeconds"]) + 1 # v2 web login uses push-to-approve in the TR mobile app: no SMS/code. # Poll the process endpoint until the push is approved (or rejected/expired). @@ -304,14 +314,12 @@ def await_web_login_approval(self, timeout_s: int = 180, interval_s: float = 2.0 """Block until the TR mobile-app push for this login process is approved.""" if not self._process_id: raise ValueError("Initiate web login first.") - url = f"{self._host}/api/v2/auth/web/login/processes/{self._process_id}" + url = f"{self._host}{TR_V2_LOGIN_PATH}/processes/{self._process_id}" deadline = time.time() + timeout_s print( - f"Approve the login in your Trade Republic mobile app " - f"(waiting up to {timeout_s}s)...", + f"Approve the login in your Trade Republic mobile app (waiting up to {timeout_s}s)...", flush=True, ) - first_body_logged = False while time.time() < deadline: r = self._websession.get(url, headers=self._auth_headers()) if r.status_code == 200: @@ -319,9 +327,6 @@ def await_web_login_approval(self, timeout_s: int = 180, interval_s: float = 2.0 j = r.json() except ValueError: j = {} - if not first_body_logged: - self.log.info("Login poll keys: %s", sorted(j.keys()) if j else "") - first_body_logged = True state = str(j.get("state") or j.get("status") or "").upper() if state in ("APPROVED", "COMPLETED", "SUCCESS", "OK", "DONE"): self.save_websession() @@ -336,18 +341,18 @@ def await_web_login_approval(self, timeout_s: int = 180, interval_s: float = 2.0 self.log.info("Session cookie detected, login complete.") return elif r.status_code in (401, 403, 404, 410): - raise ValueError( - f"Login process rejected or expired ({r.status_code}): {r.text[:200]}" - ) + raise ValueError(f"Login process rejected or expired ({r.status_code}): {r.text[:200]}") else: self.log.warning("Login poll %s: %s", r.status_code, r.text[:200]) time.sleep(interval_s) raise TimeoutError("Push approval not received within timeout.") def resend_weblogin(self): + path = TR_V2_LOGIN_PATH if self._use_v2_login else TR_V1_LOGIN_PATH + headers = self._auth_headers() if self._use_v2_login else self._default_headers r = self._websession.post( - f"{self._host}{TR_WEB_LOGIN_PATH}/{self._process_id}/resend", - headers=self._auth_headers(), + f"{self._host}{path}/{self._process_id}/resend", + headers=headers, ) r.raise_for_status() @@ -357,13 +362,15 @@ def complete_weblogin(self, verify_code): # v2 push flow: approval already happened during initiate_weblogin(). # An empty verify_code signals nothing left to do. - if not verify_code: + if self._use_v2_login and not verify_code: self.save_websession() return + path = TR_V2_LOGIN_PATH if self._use_v2_login else TR_V1_LOGIN_PATH + headers = self._auth_headers() if self._use_v2_login else None r = self._websession.post( - f"{self._host}{TR_WEB_LOGIN_PATH}/{self._process_id}/{verify_code}", - headers=self._auth_headers(), + f"{self._host}{path}/{self._process_id}/{verify_code}", + headers=headers, ) r.raise_for_status() self.save_websession() diff --git a/pytr/main.py b/pytr/main.py index d277ed4..fe04c4e 100644 --- a/pytr/main.py +++ b/pytr/main.py @@ -89,6 +89,15 @@ def formatter(prog): action="store_true", default=False, ) + parser_login_args.add_argument( + "--v2", + help=( + "Use the v2 web-login flow (push approval via the Trade Republic " + "mobile app). Default is the legacy v1 flow with an SMS/notification code." + ), + action="store_true", + default=False, + ) # parent subparser for lang option parser_lang = argparse.ArgumentParser(add_help=False) @@ -463,6 +472,7 @@ def main(): pin=args.pin, store_credentials=args.store_credentials, waf_token=args.waf_token, + v2=args.v2, ) elif args.command == "portfolio": Portfolio( @@ -471,6 +481,7 @@ def main(): pin=args.pin, store_credentials=args.store_credentials, waf_token=args.waf_token, + v2=args.v2, ), args.include_watchlist, lang=args.lang, @@ -486,6 +497,7 @@ def main(): pin=args.pin, store_credentials=args.store_credentials, waf_token=args.waf_token, + v2=args.v2, ), args.isin, ).get() @@ -496,6 +508,7 @@ def main(): pin=args.pin, store_credentials=args.store_credentials, waf_token=args.waf_token, + v2=args.v2, ), args.output, args.format, @@ -525,6 +538,7 @@ def main(): pin=args.pin, store_credentials=args.store_credentials, waf_token=args.waf_token, + v2=args.v2, ), args.outputdir, not_before, @@ -559,6 +573,7 @@ def main(): pin=args.pin, store_credentials=args.store_credentials, waf_token=args.waf_token, + v2=args.v2, ), args.input, args.outputfile, @@ -574,6 +589,7 @@ def main(): pin=args.pin, store_credentials=args.store_credentials, waf_token=args.waf_token, + v2=args.v2, ), args.input, args.inputfile, @@ -589,6 +605,7 @@ def main(): pin=args.pin, store_credentials=args.store_credentials, waf_token=args.waf_token, + v2=args.v2, ), args.outputfile, decimal_localization=args.decimal_localization,