Move web login to the api/v2 endpoints - #373
Open
tharshan09 wants to merge 1 commit into
Open
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Trade Republic removed the v1 web login routes.
pytr loginno longer works on0.4.9 or on current
master; the three login POSTs answer405straight from theload balancer (
server: awselb/2.0, empty body):This is not the AWS WAF: the
405also occurs with a validaws-waf-tokencookie.Nor is the whole
api/v1namespace gone.api/v1/auth/web/sessionandapi/v1/user/costtransparencystill answer401when unauthenticated, andapi/v2/auth/accountdoes 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.txtkeeps working, because cookie resume never touches thelogin 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:
requiredAction: AUTHENTICATOR_VERIFICATIONmeans the account confirms with a codefrom 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 anaws-waf-tokencookie:The web frontend builds the header set in one place and sends the same set on all
three login calls:
X-TR-Device-InfoX-TR-App-VersionX-Tr-PlatformAccept-LanguageNote the spelling of
X-Tr-Platform; it is a default of the frontend's HTTP clientrather 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, theerrorCodehandling and the header setwere all read out of the JavaScript bundle
app.traderepublic.comserves to anyvisitor. 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:
So each of the three is required, together they are sufficient, and the device
description built here is accepted as it stands.
Accept-Languageis not required;it is sent because the frontend sends it.
A login POST with an invalid phone number answers
NUMBER_INVALIDwith or withoutthe 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 processpolls
PENDING, and after the confirmation in the mobile app it answersCONFIRMED. The session cookies pytr persists are set on that poll(
tr_claims,tr_device,tr_external_id,tr_refresh,tr_session; the loginPOST itself only sets
JSESSIONID), sosave_websession()andresume_websession()keep working unchanged, andsettings()answers200afterwards. That account confirms in the app, so the
AUTHENTICATOR_VERIFICATIONbranch 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-Infocontent. The format is certain: base64 ofJSON.stringify(...)with the frontend's field names. The values are not abrowser's.
stableDeviceIdis a canvas fingerprint hashed with SHA-512 in thebrowser; 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.
screenis the one field with no counterpartoutside a browser and is a fixed
"1920x1080x24". Fields only a browser cananswer (
model,deviceMemory) are omitted rather than invented; the frontendomits 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 itages 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 alreadyknows. The current value can be read back out of the bundle
app.traderepublic.comserves - it is also the release id the frontend reports toits error tracker - and bumping the constant is then the whole fix. It is a single
named constant at the top of
api.pyfor that reason.Behaviour changes for users
confirm in the Trade Republic app, and only asks for a code when the process
reports
AUTHENTICATOR_VERIFICATION.complete_weblogin()takescode=Noneand, in the in-app case, blocks whilepolling 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.
errorCode, not as statusvalues.
PROCESS_GONE,ALREADY_PROCESSED,NOT_FOUND,TOO_MANY_REQUESTSandthe two authenticator codes are translated into readable messages.
raise_for_status()is deliberately not used on the two process routes, becauseits message embeds the URL and with it the process id.
nullprocessId(a login needing no second factor) is rejected explicitlyinstead of producing a request against
/processes/None.api/v1/auth/web/session,api/v2/auth/accountand the remainingapi/v1routesare untouched, as is the websocket, whose URL carries no version. No new
dependency:
base64,hashlib,os,platformanddatetimeare all stdlib.Not addressed here: the process response also carries
trustDeviceRegistrationRequiredandtrustDeviceRegistrationOptions, which thischange 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 eachof 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
masterand applied without conflicts. My fork hasGitHub Actions disabled, so in case no checks show up here, this is the result of
the workflow's steps run locally on this branch:
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.