diff --git a/README.md b/README.md index 8e16e43..1fed3a0 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ __Table of Contents__ * [Usage](#usage) * [Authentication](#authentication) * [Web login](#web-login) + * [If web login suddenly stops working](#if-web-login-suddenly-stops-working) * [Development](#development) * [Setting Up a Development Environment](#setting-up-a-development-environment) * [Linting and Code Formatting](#linting-and-code-formatting) @@ -116,6 +117,29 @@ Web login uses the public web-login endpoints at `api.traderepublic.com`. Two va Both variants keep you logged in on your primary device, but you may need to re-authenticate every so often when running `pytr`. +### If web login suddenly stops working + +The web login identifies itself to Trade Republic as their own web frontend, using a build version, a platform name +and a browser `User-Agent` that are pinned in `pytr`. Trade Republic can invalidate any of them at any time, and when +they do, login fails for everyone until a new release goes out. Three environment variables let you fix it yourself +in the meantime: + +| Variable | Overrides | Use it when | +|---|---|---| +| `PYTR_TR_APP_VERSION` | The frontend build version sent as `X-TR-App-Version` | Login fails with `426 CLIENT_VERSION_OUTDATED` | +| `PYTR_TR_USER_AGENT` | The `User-Agent` sent on every request | Login is rejected or challenged in a way that looks like bot filtering | +| `PYTR_TR_PLATFORM` | The platform name sent as `X-Tr-Platform` | Login fails with a missing or invalid header error naming the platform | + +```sh + PYTR_TR_APP_VERSION=2.2700.4 pytr login --v2 + PYTR_TR_USER_AGENT='Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36' pytr login + PYTR_TR_PLATFORM=web pytr login --v2 +``` + +Read the current values off [app.traderepublic.com](https://app.traderepublic.com/) in your browser's dev tools, on the +network request to `/api/v2/auth/web/login`. Leaving a variable unset, or setting it to an empty string, keeps the +built-in default. If you need one of these, please also open an issue so the default can be updated for everyone. + ## Development ### Setting Up a Development Environment diff --git a/pytr/api.py b/pytr/api.py index 4822b0f..16ce660 100644 --- a/pytr/api.py +++ b/pytr/api.py @@ -86,11 +86,21 @@ # The web frontend's API client identifies itself with this platform on all v2 login calls. WEB_PLATFORM = "web-pro" +# Sent on every request. Trade Republic can start rejecting a stale one as bot traffic. +USER_AGENT = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36" + +# Trade Republic can invalidate any of the three values above at any time, and none of +# those failures needs a code change to fix, only a different string. Overriding them +# from the environment turns "wait for a pytr release" into "export a variable" for a +# user who is locked out today. See the README for when to reach for which. An unset or +# empty variable keeps the built-in default, so an empty assignment sends no empty header. +ENV_APP_VERSION = "PYTR_TR_APP_VERSION" +ENV_PLATFORM = "PYTR_TR_PLATFORM" +ENV_USER_AGENT = "PYTR_TR_USER_AGENT" + 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" - } + _default_headers = {"User-Agent": os.environ.get(ENV_USER_AGENT) or USER_AGENT} _host = "https://api.traderepublic.com" _waf_login_url = "https://app.traderepublic.com/login" @@ -324,8 +334,8 @@ def _login_headers(self): self._device_info = base64.b64encode(json.dumps(device).encode()).decode() return { "X-TR-Device-Info": self._device_info, - "X-TR-App-Version": APP_VERSION, - "X-Tr-Platform": WEB_PLATFORM, + "X-TR-App-Version": os.environ.get(ENV_APP_VERSION) or APP_VERSION, + "X-Tr-Platform": os.environ.get(ENV_PLATFORM) or WEB_PLATFORM, "Accept-Language": self._locale, } diff --git a/tests/test_api_urls.py b/tests/test_api_urls.py index 07c17e5..cd09723 100644 --- a/tests/test_api_urls.py +++ b/tests/test_api_urls.py @@ -1,6 +1,7 @@ """Pin the web login endpoints, their required headers and the login process state machine.""" import base64 +import importlib import json as jsonlib import re from typing import Any @@ -8,7 +9,16 @@ import pytest import requests -from pytr.api import TradeRepublicApi +import pytr.api +from pytr.api import ( + APP_VERSION, + ENV_APP_VERSION, + ENV_PLATFORM, + ENV_USER_AGENT, + USER_AGENT, + WEB_PLATFORM, + TradeRepublicApi, +) LOGIN = "https://api.traderepublic.com/api/v2/auth/web/login" PROCESS = "https://api.traderepublic.com/api/v2/auth/web/login/processes/pid-1" @@ -261,6 +271,84 @@ def test_app_version_and_platform_come_from_the_web_frontend(): assert headers["X-Tr-Platform"] == "web-pro" +# --- environment overrides ----------------------------------------------------------- + +CHROME_149 = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36" + + +FIREFOX_128 = "Mozilla/5.0 (X11; Linux x86_64; rv:128.0) Gecko/20100101 Firefox/128.0" + + +@pytest.fixture +def reimported_with(monkeypatch): + """pytr.api re-imported under a patched environment. + + X-TR-App-Version and X-Tr-Platform are read per call, so those overrides need + nothing special. The User-Agent is baked into _default_headers when the class + body runs, so seeing a different one means importing the module again. + """ + + def _load(**environment): + for name, value in environment.items(): + monkeypatch.setenv(name, value) + return importlib.reload(pytr.api) + + yield _load + monkeypatch.undo() + importlib.reload(pytr.api) + + +def _api_of(module): + return module.TradeRepublicApi(phone_no="+490000000000", pin="0000", waf_token=None, use_v2_login=True) + + +def _device_info_of(module): + return jsonlib.loads(base64.b64decode(_api_of(module)._login_headers()["X-TR-Device-Info"])) + + +def test_app_version_can_be_overridden_from_the_environment(monkeypatch): + """A frontend release makes the built-in stale; 426 must be fixable without a release.""" + monkeypatch.setenv(ENV_APP_VERSION, "2.9999.1") + + assert _api([])._login_headers()["X-TR-App-Version"] == "2.9999.1" + + +def test_platform_can_be_overridden_from_the_environment(monkeypatch): + monkeypatch.setenv(ENV_PLATFORM, "web") + + assert _api([])._login_headers()["X-Tr-Platform"] == "web" + + +def test_user_agent_can_be_overridden_from_the_environment(reimported_with): + module = reimported_with(**{ENV_USER_AGENT: CHROME_149}) + + assert module.TradeRepublicApi._default_headers["User-Agent"] == CHROME_149 + assert _api_of(module)._websession.headers["User-Agent"] == CHROME_149 + + +def test_an_empty_override_keeps_the_built_in_default(monkeypatch, reimported_with): + """An empty assignment must not send an empty header for any of the three.""" + monkeypatch.setenv(ENV_APP_VERSION, "") + monkeypatch.setenv(ENV_PLATFORM, "") + + headers = _api([])._login_headers() + assert headers["X-TR-App-Version"] == APP_VERSION + assert headers["X-Tr-Platform"] == WEB_PLATFORM + + module = reimported_with(**{ENV_USER_AGENT: ""}) + assert module.TradeRepublicApi._default_headers["User-Agent"] == USER_AGENT + + +def test_device_info_follows_the_overridden_user_agent(reimported_with): + """browserVersion is scraped from the User-Agent; the two must not drift apart.""" + assert _device_info_of(reimported_with(**{ENV_USER_AGENT: CHROME_149}))["browserVersion"] == "149.0.0.0" + + +def test_a_non_chrome_user_agent_leaves_the_browser_version_empty(reimported_with): + """The frontend omits what the browser does not provide, and TR accepts that.""" + assert _device_info_of(reimported_with(**{ENV_USER_AGENT: FIREFOX_128}))["browserVersion"] == "" + + # --- endpoints that must NOT move ----------------------------------------------------