Skip to content

Commit 7e94d1a

Browse files
committed
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
1 parent 2626222 commit 7e94d1a

2 files changed

Lines changed: 142 additions & 23 deletions

File tree

pytr/account.py

Lines changed: 22 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -61,23 +61,28 @@ def login(phone_no=None, pin=None, store_credentials=False, waf_token="playwrigh
6161
except ValueError as e:
6262
log.fatal(str(e))
6363
sys.exit(1)
64-
request_time = time.time()
65-
print("Enter the code you received to your mobile app as a notification.")
66-
print(f"Enter nothing if you want to receive the (same) code as SMS. (Countdown: {countdown})")
67-
code = input("Code: ")
68-
if code == "":
69-
countdown = countdown - (time.time() - request_time)
70-
for remaining in range(int(countdown)):
71-
print(
72-
f"Need to wait {int(countdown - remaining)} seconds before requesting SMS...",
73-
end="\r",
74-
)
75-
time.sleep(1)
76-
print()
77-
tr.resend_weblogin()
78-
code = input("SMS requested. Enter the confirmation code:")
79-
tr.complete_weblogin(code)
80-
log.info("Logged in.")
64+
if countdown == 0:
65+
# v2 push-approve flow: initiate_weblogin() already polled and got approval.
66+
tr.complete_weblogin("")
67+
log.info("Logged in.")
68+
else:
69+
request_time = time.time()
70+
print("Enter the code you received to your mobile app as a notification.")
71+
print(f"Enter nothing if you want to receive the (same) code as SMS. (Countdown: {countdown})")
72+
code = input("Code: ")
73+
if code == "":
74+
countdown = countdown - (time.time() - request_time)
75+
for remaining in range(int(countdown)):
76+
print(
77+
f"Need to wait {int(countdown - remaining)} seconds before requesting SMS...",
78+
end="\r",
79+
)
80+
time.sleep(1)
81+
print()
82+
tr.resend_weblogin()
83+
code = input("SMS requested. Enter the confirmation code:")
84+
tr.complete_weblogin(code)
85+
log.info("Logged in.")
8186

8287
log.debug(get_settings(tr))
8388
return tr

pytr/api.py

Lines changed: 120 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,9 @@
2121
# SOFTWARE.
2222

2323
import asyncio
24+
import base64
2425
import json
26+
import os
2527
import pathlib
2628
import re
2729
import ssl
@@ -45,11 +47,24 @@
4547
BASE_DIR = home / ".pytr"
4648
CREDENTIALS_FILE = BASE_DIR / "credentials"
4749
COOKIES_FILE = BASE_DIR / "cookies.txt"
50+
DEVICE_ID_FILE = BASE_DIR / "device_id"
51+
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.
54+
TR_WEB_APP_VERSION = os.environ.get("PYTR_TR_APP_VERSION", "15.7.0")
55+
TR_WEB_USER_AGENT = os.environ.get(
56+
"PYTR_TR_USER_AGENT",
57+
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 "
58+
"(KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36",
59+
)
60+
TR_WEB_LOGIN_PATH = "/api/v2/auth/web/login"
4861

4962

5063
class TradeRepublicApi:
5164
_default_headers = {
52-
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36"
65+
"User-Agent": TR_WEB_USER_AGENT,
66+
"Origin": "https://app.traderepublic.com",
67+
"Referer": "https://app.traderepublic.com/",
5368
}
5469
_host = "https://api.traderepublic.com"
5570
_waf_login_url = "https://app.traderepublic.com/login"
@@ -183,6 +198,46 @@ def _fetch_waf_token_playwright(self, timeout_ms: int = 30000):
183198
self.log.warning("AWS WAF token not acquired. Value is None.")
184199
return token
185200

201+
def _get_device_id(self) -> str:
202+
"""Return a stable per-install device UUID, creating it on first use."""
203+
try:
204+
return DEVICE_ID_FILE.read_text().strip()
205+
except FileNotFoundError:
206+
BASE_DIR.mkdir(parents=True, exist_ok=True)
207+
device_id = uuid.uuid4().hex + uuid.uuid4().hex # 64 hex chars, matches web app
208+
DEVICE_ID_FILE.write_text(device_id)
209+
return device_id
210+
211+
def _build_device_info_header(self) -> str:
212+
"""Build the base64(JSON) x-tr-device-info payload the web app sends."""
213+
payload = {
214+
"stableDeviceId": self._get_device_id(),
215+
"model": "Apple Macintosh",
216+
"browser": "Chrome",
217+
"browserVersion": "148.0.0.0",
218+
"os": "Mac OS",
219+
"osVersion": "10.15.7",
220+
"timezone": "Europe/Amsterdam",
221+
"timezoneOffset": -120,
222+
"screen": "1800x1169x30",
223+
"preferredLanguages": ["en", "en-US"],
224+
"numberOfCores": 12,
225+
"deviceMemory": 16,
226+
}
227+
raw = json.dumps(payload, separators=(",", ":")).encode("utf-8")
228+
return base64.b64encode(raw).decode("ascii")
229+
230+
def _auth_headers(self) -> Dict[str, str]:
231+
"""Headers required by /api/v2/auth/web/login."""
232+
headers = {
233+
"x-tr-platform": "web",
234+
"x-tr-app-version": TR_WEB_APP_VERSION,
235+
"x-tr-device-info": self._build_device_info_header(),
236+
}
237+
if self._waf_token:
238+
headers["x-aws-waf-token"] = self._waf_token
239+
return headers
240+
186241
def _set_waf_cookie(self, token: str):
187242
"""Set the aws-waf-token cookie on the web session."""
188243
cookie = Cookie(
@@ -222,8 +277,9 @@ def initiate_weblogin(self):
222277
self.log.warning("No WAF token available.")
223278

224279
r = self._websession.post(
225-
f"{self._host}/api/v1/auth/web/login",
280+
f"{self._host}{TR_WEB_LOGIN_PATH}",
226281
json={"phoneNumber": self.phone_no, "pin": self.pin},
282+
headers=self._auth_headers(),
227283
)
228284
self.log.debug(f"Web login returned: {r.status_code}")
229285
r.raise_for_status()
@@ -237,20 +293,78 @@ def initiate_weblogin(self):
237293
raise ValueError(str(err))
238294
else:
239295
raise ValueError("processId not in reponse")
240-
return int(j["countdownInSeconds"]) + 1
296+
self.log.info("Web login keys: %s", sorted(j.keys()))
297+
298+
# v2 web login uses push-to-approve in the TR mobile app: no SMS/code.
299+
# Poll the process endpoint until the push is approved (or rejected/expired).
300+
self.await_web_login_approval()
301+
return 0
302+
303+
def await_web_login_approval(self, timeout_s: int = 180, interval_s: float = 2.0):
304+
"""Block until the TR mobile-app push for this login process is approved."""
305+
if not self._process_id:
306+
raise ValueError("Initiate web login first.")
307+
url = f"{self._host}/api/v2/auth/web/login/processes/{self._process_id}"
308+
deadline = time.time() + timeout_s
309+
print(
310+
f"Approve the login in your Trade Republic mobile app "
311+
f"(waiting up to {timeout_s}s)...",
312+
flush=True,
313+
)
314+
first_body_logged = False
315+
while time.time() < deadline:
316+
r = self._websession.get(url, headers=self._auth_headers())
317+
if r.status_code == 200:
318+
try:
319+
j = r.json()
320+
except ValueError:
321+
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
325+
state = str(j.get("state") or j.get("status") or "").upper()
326+
if state in ("APPROVED", "COMPLETED", "SUCCESS", "OK", "DONE"):
327+
self.save_websession()
328+
self.log.info("Push approved.")
329+
return
330+
if state in ("REJECTED", "DECLINED", "FAILED", "EXPIRED"):
331+
raise ValueError(f"Login process {state.lower()}: {j}")
332+
# State unknown but session cookie present? Treat as approved.
333+
for c in self._websession.cookies:
334+
if c.name in ("tr_session",) and c.value:
335+
self.save_websession()
336+
self.log.info("Session cookie detected, login complete.")
337+
return
338+
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+
)
342+
else:
343+
self.log.warning("Login poll %s: %s", r.status_code, r.text[:200])
344+
time.sleep(interval_s)
345+
raise TimeoutError("Push approval not received within timeout.")
241346

242347
def resend_weblogin(self):
243348
r = self._websession.post(
244-
f"{self._host}/api/v1/auth/web/login/{self._process_id}/resend",
245-
headers=self._default_headers,
349+
f"{self._host}{TR_WEB_LOGIN_PATH}/{self._process_id}/resend",
350+
headers=self._auth_headers(),
246351
)
247352
r.raise_for_status()
248353

249354
def complete_weblogin(self, verify_code):
250355
if not self._process_id and not self._websession:
251356
raise ValueError("Initiate web login first.")
252357

253-
r = self._websession.post(f"{self._host}/api/v1/auth/web/login/{self._process_id}/{verify_code}")
358+
# v2 push flow: approval already happened during initiate_weblogin().
359+
# An empty verify_code signals nothing left to do.
360+
if not verify_code:
361+
self.save_websession()
362+
return
363+
364+
r = self._websession.post(
365+
f"{self._host}{TR_WEB_LOGIN_PATH}/{self._process_id}/{verify_code}",
366+
headers=self._auth_headers(),
367+
)
254368
r.raise_for_status()
255369
self.save_websession()
256370

0 commit comments

Comments
 (0)