From a292af11b397de8d3a54bbb41e07ae43d8a96301 Mon Sep 17 00:00:00 2001 From: Carlos Damken Date: Wed, 17 Jun 2026 20:36:16 +0200 Subject: [PATCH] fix(auth): use curl_cffi (Chrome TLS impersonation) for Auth0 login MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scalable's Auth0 fronts the login with Friendly Captcha bot detection that triggers on the TLS/JA3 fingerprint of plain `requests` — it injects a `captcha` field the headless client can't solve, re-renders the login form, and the old heuristic misread that as InvalidCredentials (exit 12). A real browser is never challenged; neither is curl_cffi, which replays Chrome's exact TLS fingerprint from pure Python — no browser, no Chromium. - _build_session() returns curl_cffi Session(impersonate="chrome"), with a plain-requests fallback when curl_cffi is unavailable. - login_flow() normalizes the cookie jar via new _to_requests_jar() (curl_cffi's jar isn't a RequestsCookieJar; save_jar_to_file iterates expecting http.cookiejar.Cookie objects). - add curl_cffi>=0.7 dependency; bump 0.0.2 -> 0.0.3. Verified end-to-end on the server: fresh login -> push 2FA -> cookies persisted -> data fetched, exit 0. Co-Authored-By: Claude Opus 4.8 --- pyproject.toml | 6 ++++- src/sc_api/auth.py | 65 ++++++++++++++++++++++++++++++++++------------ 2 files changed, 54 insertions(+), 17 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 2462017..5c776ad 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "sc-api" -version = "0.0.2" +version = "0.0.3" description = "Minimal Python client for Scalable Capital (DE) — Broker + Wealth, cookie-based auth via browser, no headed-browser workaround" readme = "README.md" requires-python = ">=3.10" @@ -21,6 +21,10 @@ dependencies = [ "requests>=2.31", "websockets>=14", "pycookiecheat>=0.7", + # curl_cffi replays Chrome's TLS/JA3 fingerprint for the Auth0 login so + # Scalable's bot detection serves a captcha-free form (plain requests gets + # fingerprinted and walled). auth.py falls back to requests if it's absent. + "curl_cffi>=0.7", ] [project.optional-dependencies] diff --git a/src/sc_api/auth.py b/src/sc_api/auth.py index 052e754..bf6a0ff 100644 --- a/src/sc_api/auth.py +++ b/src/sc_api/auth.py @@ -77,6 +77,11 @@ def on_push_pending(mfa_session_id): "Chrome/148.0.0.0 Safari/537.36" ) +# curl_cffi browser profile to impersonate for the Auth0 login. Must be a +# profile curl_cffi ships a TLS fingerprint for; "chrome" tracks a recent +# stable Chrome. Bump if Auth0 starts fingerprinting newer Chrome builds. +CFFI_IMPERSONATE = "chrome" + # Push approval timing PUSH_POLL_INTERVAL_SEC = 2.0 PUSH_POLL_TIMEOUT_SEC = 120.0 @@ -152,22 +157,50 @@ def on_push(mfa_id): # --------------------------------------------------------------------------- # Browser-shaped session helper # --------------------------------------------------------------------------- -def _build_session() -> requests.Session: - """Return a requests.Session that looks like a real Chrome. - - Scalable's Auth0 and edge gateways check Origin/Referer/UA on some - paths. Matching them avoids spurious 4xx. +def _build_session(): + """Return a session that talks to Auth0 with a real Chrome fingerprint. + + Scalable's Auth0 fronts the login with bot detection (Friendly Captcha). + It triggers on the TLS/JA3 fingerprint of plain `requests`/urllib3 — it + injects a `captcha` field the headless client can't solve, then re-renders + the login form (which we'd misread as bad credentials, exit 12). A real + browser is never challenged. `curl_cffi` replays Chrome's exact TLS + fingerprint from pure Python — no browser, no Chromium — so Auth0 serves + the normal, captcha-free login form. Verified server-side 2026-06-17. + + Falls back to plain `requests` (browser-shaped headers only) if curl_cffi + isn't installed — useful for environments not behind the bot wall. """ - s = requests.Session() - s.headers.update({ - "User-Agent": DEFAULT_USER_AGENT, - "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8", - "Accept-Language": "en-US,en;q=0.9", - "Sec-Ch-Ua": '"Google Chrome";v="148", "Chromium";v="148", "Not=A?Brand";v="24"', - "Sec-Ch-Ua-Mobile": "?0", - "Sec-Ch-Ua-Platform": '"macOS"', - }) - return s + try: + from curl_cffi import requests as _cffi + # impersonate replays Chrome's TLS/JA3 AND a matching header set; + # do NOT override UA/Sec-Ch-Ua here or the fingerprint goes + # inconsistent (header says one Chrome, JA3 says another). + return _cffi.Session(impersonate=CFFI_IMPERSONATE) + except ImportError: + s = requests.Session() + s.headers.update({ + "User-Agent": DEFAULT_USER_AGENT, + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8", + "Accept-Language": "en-US,en;q=0.9", + "Sec-Ch-Ua": '"Google Chrome";v="148", "Chromium";v="148", "Not=A?Brand";v="24"', + "Sec-Ch-Ua-Mobile": "?0", + "Sec-Ch-Ua-Platform": '"macOS"', + }) + return s + + +def _to_requests_jar(session) -> "requests.cookies.RequestsCookieJar": + """Normalize a session's cookie jar (curl_cffi or requests) to a + RequestsCookieJar of http.cookiejar.Cookie objects, so downstream + `cookies.save_jar_to_file` (which iterates yielding Cookie objects) + works regardless of the HTTP backend. + """ + jar = requests.cookies.RequestsCookieJar() + src = getattr(session.cookies, "jar", session.cookies) + for c in src: + jar.set_cookie(c) + return jar def _post_auth_graphql( @@ -662,7 +695,7 @@ def login_flow( _warm_up_cockpit(session) return LoginResult( - cookies=session.cookies, + cookies=_to_requests_jar(session), user_id=user_id, mfa_was_required=mfa_triggered, )