Skip to content

Move web login to the api/v2 endpoints - #373

Open
tharshan09 wants to merge 1 commit into
pytr-org:masterfrom
tharshan09:upstream-pr/tr-login-v2
Open

Move web login to the api/v2 endpoints#373
tharshan09 wants to merge 1 commit into
pytr-org:masterfrom
tharshan09:upstream-pr/tr-login-v2

Conversation

@tharshan09

Copy link
Copy Markdown

Trade Republic removed the v1 web login routes. pytr login no longer works on
0.4.9 or on current master; the three login POSTs answer 405 straight from the
load balancer (server: awselb/2.0, empty body):

POST api/v1/auth/web/login                      -> 405 (awselb/2.0)
POST api/v1/auth/web/login/{processId}/resend   -> 405 (awselb/2.0)
POST api/v1/auth/web/login/{processId}/{code}   -> 405 (awselb/2.0)
POST api/v2/auth/web/login                      -> 400 with an errorCode body

This is not the AWS WAF: the 405 also occurs with a valid aws-waf-token cookie.
Nor is the whole api/v1 namespace gone. api/v1/auth/web/session and
api/v1/user/costtransparency still answer 401 when unauthenticated, and
api/v2/auth/account does too. Only the three login POSTs were removed.

That is also why this has not shown up as a flood of issues yet: anyone with a
valid ~/.pytr/cookies.txt keeps working, because cookie resume never touches the
login routes. It breaks the moment a session expires and a fresh login is needed.

I could not find an existing issue for this, so this PR is not linked to one. See
the note on #353 at the bottom.

What Trade Republic changed

It is not a URL bump. The SMS one-time code is gone; the login is confirmed in the
mobile app instead, and which second factor applies is a property of the login
process, not of the login response:

POST api/v2/auth/web/login
     -> {processId, countdownInSeconds?}
GET  api/v2/auth/web/login/processes/{processId}
     -> {status: PENDING|CONFIRMED|COMPLETED, requiredAction?, expiresAt?}
POST api/v2/auth/web/login/processes/{processId}/authenticator-verification
     body {code}

requiredAction: AUTHENTICATOR_VERIFICATION means the account confirms with a code
from an authenticator app; otherwise the client polls the process until the user
confirms in the app. There is no v2 resend route, because there is no code to
resend.

The v2 endpoints also require request headers that v1 did without. With only a
User-Agent, and both with and without an aws-waf-token cookie:

POST api/v2/auth/web/login  {"phoneNumber","pin"}
     -> 400 {"errorCode":"MISSING_REQUIRED_HEADER"}

The web frontend builds the header set in one place and sends the same set on all
three login calls:

header content
X-TR-Device-Info base64 of a JSON object describing the device
X-TR-App-Version the build version of the frontend
X-Tr-Platform the platform its API client is configured with
Accept-Language the locale it wants to be answered in

Note the spelling of X-Tr-Platform; it is a default of the frontend's HTTP client
rather than part of the same helper as the other two, which is easy to miss.

How this was established

Two independent ways, both without touching anything private.

From Trade Republic's own public frontend. The routes, the branch on
requiredAction, the terminal states, the errorCode handling and the header set
were all read out of the JavaScript bundle app.traderepublic.com serves to any
visitor. Nothing here was guessed from behaviour.

Which headers are actually required, measured. There is one v2 login route that
takes no credentials at all: an empty POST to api/v2/auth/web/login/qr-challenges,
which the frontend sends the very same headers to. Only the headers differ between
these runs:

no headers            -> 400 MISSING_REQUIRED_HEADER
no X-TR-Device-Info   -> 400 MISSING_REQUIRED_HEADER
no X-TR-App-Version   -> 400 MISSING_REQUIRED_HEADER
no X-Tr-Platform      -> 400 MISSING_REQUIRED_HEADER
the three of them     -> 200 {"challengeId":...,"challengeExpiresAt":...}

So each of the three is required, together they are sufficient, and the device
description built here is accepted as it stands. Accept-Language is not required;
it is sent because the frontend sends it.

A login POST with an invalid phone number answers NUMBER_INVALID with or without
the headers, so the number is checked first and such a request proves nothing about
them. That is why the measurement above uses the credential-free route instead.

End to end against a real account. The login POST answers 200, the process
polls PENDING, and after the confirmation in the mobile app it answers
CONFIRMED. The session cookies pytr persists are set on that poll
(tr_claims, tr_device, tr_external_id, tr_refresh, tr_session; the login
POST itself only sets JSESSIONID), so save_websession() and
resume_websession() keep working unchanged, and settings() answers 200
afterwards. That account confirms in the app, so the AUTHENTICATOR_VERIFICATION
branch is implemented from the frontend but has not been exercised against a live
authenticator account. If a maintainer has one, that is the part worth a second
pair of eyes.

What is constructed rather than observed

I would rather name this than have it found in review.

  • X-TR-Device-Info content. The format is certain: base64 of
    JSON.stringify(...) with the frontend's field names. The values are not a
    browser's. stableDeviceId is a canvas fingerprint hashed with SHA-512 in the
    browser; there is no canvas here, so the hash is taken over what identifies the
    machine instead - same length, same alphabet, computed once and cached on the
    client, no state on disk and no request to anyone. screen is the one field with no counterpart
    outside a browser and is a fixed "1920x1080x24". Fields only a browser can
    answer (model, deviceMemory) are omitted rather than invented; the frontend
    omits them itself when the browser does not provide them, so the server accepts
    their absence, and the measurement above confirms the whole object is accepted.
  • X-TR-App-Version = "2.2631.13" is a literal from the frontend bundle, so it
    ages with every Trade Republic deployment. What is measured is that the header must
    be present; whether the value is validated is not. If TR ever starts rejecting
    stale versions, the symptom will be a login failure with
    MOBILE_APP_VERSION_NOT_SUPPORTED, which is an error code their own client already
    knows. The current value can be read back out of the bundle
    app.traderepublic.com serves - it is also the release id the frontend reports to
    its error tracker - and bumping the constant is then the whole fix. It is a single
    named constant at the top of api.py for that reason.

Behaviour changes for users

  • The login prompt changes. Instead of asking for an SMS code it asks the user to
    confirm in the Trade Republic app, and only asks for a code when the process
    reports AUTHENTICATOR_VERIFICATION.
  • complete_weblogin() takes code=None and, in the in-app case, blocks while
    polling until the login is confirmed. The deadline comes from the server's
    expiresAt; 120s is only a fallback. It logs progress rather than sitting silent.
  • resend_weblogin() logs a warning and does nothing. There is no v2 route for it.
    I kept it callable instead of raising, so third-party callers are not broken by
    this; it can become a hard error or disappear whenever you prefer.
  • Rejection and expiry arrive as HTTP errors carrying an errorCode, not as status
    values. PROCESS_GONE, ALREADY_PROCESSED, NOT_FOUND, TOO_MANY_REQUESTS and
    the two authenticator codes are translated into readable messages.
    raise_for_status() is deliberately not used on the two process routes, because
    its message embeds the URL and with it the process id.
  • A null processId (a login needing no second factor) is rejected explicitly
    instead of producing a request against /processes/None.

api/v1/auth/web/session, api/v2/auth/account and the remaining api/v1 routes
are untouched, as is the websocket, whose URL carries no version. No new
dependency: base64, hashlib, os, platform and datetime are all stdlib.

Not addressed here: the process response also carries
trustDeviceRegistrationRequired and trustDeviceRegistrationOptions, which this
change ignores. They look like the path to longer-lived, device-bound sessions and
are worth a separate look.

Tests

tests/test_api_urls.py (21 tests) pins the endpoints, the required headers on each
of the three login calls, and the process state machine, including that session and
account must not move off their current versions. It stubs the session rather than
reaching the network, so it needs no credentials and makes no requests. One test
asserts that a failing login never puts the process id into the exception message.

The branch is based on current master and applied without conflicts. My fork has
GitHub Actions disabled, so in case no checks show up here, this is the result of
the workflow's steps run locally on this branch:

uv run pytest                              34 passed  (13 existing + 21 new)
uvx ruff@0.9.6 check                       All checks passed!
uvx ruff@0.9.6 format --check --diff       27 files already formatted
uv run mypy .                              Success: no issues found in 27 source files
README.md mksync check                     no diff

Also run on Python 3.10 and 3.13: 34 passed on both.

On #353

#353 asks whether the new Trade Republic security features are supported. The
authenticator-app second factor is handled here, but I am not marking that issue as
fixed from this PR: it is broader than the login transport, and as noted above I
could not exercise the authenticator branch against a real account.

Trade Republic removed the v1 web login routes. They now return 405 straight
from the load balancer (`server: awselb/2.0`, empty body), so pytr can no
longer log in. Measured against api.traderepublic.com:

  POST api/v1/auth/web/login                      -> 405 (awselb/2.0)
  POST api/v1/auth/web/login/{processId}/resend   -> 405 (awselb/2.0)
  POST api/v1/auth/web/login/{processId}/{code}   -> 405 (awselb/2.0)
  POST api/v2/auth/web/login                      -> 400 with an errorCode body

This is not the AWS WAF: the 405 also occurs with a valid aws-waf-token
cookie. Nor is the whole v1 namespace gone - api/v1/auth/web/session and
api/v1/user/costtransparency still answer 401 when unauthenticated. Only the
three login POSTs were removed.

The v2 flow is not a pure URL bump. The login is confirmed in the mobile app
instead of with an SMS code, and which second factor applies is a property of
the login process, not of the login response:

  POST api/v2/auth/web/login
       -> {processId, countdownInSeconds?}
  GET  api/v2/auth/web/login/processes/{processId}
       -> {status: PENDING|CONFIRMED|COMPLETED, requiredAction?, expiresAt?}
  POST api/v2/auth/web/login/processes/{processId}/authenticator-verification
       body {code}

Accordingly:

- initiate_weblogin posts to api/v2/auth/web/login and then reads the process
  once to learn requiredAction, mirroring the frontend. countdownInSeconds is
  optional in v2 and falls back to 120s, as the frontend does. A null
  processId (a login that needs no second factor) is rejected explicitly
  instead of producing a request against /processes/None.
- weblogin_needs_authenticator exposes that decision so the caller can prompt
  for an authenticator code only when one is actually needed.
- complete_weblogin posts to authenticator-verification when the process asks
  for it, and otherwise polls until CONFIRMED or COMPLETED. The deadline comes
  from the server field expiresAt; 120s is only the fallback.
- Rejection and expiry are HTTP errors carrying an errorCode, not status
  values: PROCESS_GONE, ALREADY_PROCESSED, NOT_FOUND, TOO_MANY_REQUESTS are
  translated into readable messages. raise_for_status() is deliberately not
  used on these calls, because its message embeds the URL and with it the
  process id.
- resend_weblogin logs a warning and does nothing. v2 has no resend route,
  because there is no code to resend. It stays callable rather than raising,
  so existing callers keep working.
- The login prompt asks for the app confirmation, and the wait reports
  progress instead of blocking silently.

The v2 endpoints also require headers that v1 did without. With only a
User-Agent they answer, both with and without an aws-waf-token cookie:

  POST api/v2/auth/web/login   {"phoneNumber","pin"}
       -> 400 {"errorCode":"MISSING_REQUIRED_HEADER"}

The web frontend builds them in one place and sends that same set on all
three login calls (start, process poll, authenticator verification):

  X-TR-Device-Info   base64 of a JSON object describing the device
  X-TR-App-Version   the build version of the frontend
  X-Tr-Platform      the platform its API client is configured with
  Accept-Language    the locale it wants to be answered in

_login_headers() reproduces that set. The device description keeps the
frontend's field names and fills them from the machine pytr runs on. Its
stableDeviceId is a canvas fingerprint hashed with SHA-512 in the browser;
there is no canvas here, so the hash covers what identifies the machine
instead - same length, same alphabet, computed once, no state on disk and no
request to anyone. Fields that only a browser can answer are omitted rather
than invented; the frontend omits them itself when the browser does not
provide them (`model` on desktop, `deviceMemory` outside Chromium), so their
absence is accepted.

Which of them are required was measured on the one v2 login route that needs no
credentials at all - an empty POST to api/v2/auth/web/login/qr-challenges,
which the frontend sends the very same headers to. Nothing but the headers
differs between these:

  no headers            -> 400 MISSING_REQUIRED_HEADER
  no X-TR-Device-Info   -> 400 MISSING_REQUIRED_HEADER
  no X-TR-App-Version   -> 400 MISSING_REQUIRED_HEADER
  no X-Tr-Platform      -> 400 MISSING_REQUIRED_HEADER
  the three of them     -> 200 {"challengeId":...,"challengeExpiresAt":...}

So each of the three is required, together they are enough, and the device
description built here is accepted as it stands. Accept-Language is not
required; it is sent because the frontend sends it.

A login POST with an invalid number answers NUMBER_INVALID whether the headers
are there or not, so the number is checked before them. Such a request says
nothing about the headers, which is why the measurement above does not use one.

X-TR-OTP-Less is not sent: the frontend only sets it when it is re-logging in
after an idle timeout, which pytr has no notion of.

The flow was run end to end against the live API with a real account: the login
POST answers 200, the process polls PENDING, and after the confirmation in the
mobile app it answers CONFIRMED. The session cookies pytr persists are set on
that poll (tr_claims, tr_device, tr_external_id, tr_refresh, tr_session; the
login POST itself only sets JSESSIONID), so save_websession() and
resume_websession() keep working unchanged and settings() answers 200
afterwards.

api/v1/auth/web/session, api/v2/auth/account and the remaining api/v1 routes
are left untouched, as is the websocket, whose URL carries no version.

tests/test_api_urls.py pins the endpoints, the required headers on each of the
three login calls, and the process state machine, including that session and
account must not move.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant