Skip to content

fix: improve 2FA handling for China mainland accounts#222

Open
Twsa wants to merge 1 commit intotimlaing:mainfrom
Twsa:fix/china-mainland-2fa
Open

fix: improve 2FA handling for China mainland accounts#222
Twsa wants to merge 1 commit intotimlaing:mainfrom
Twsa:fix/china-mainland-2fa

Conversation

@Twsa
Copy link
Copy Markdown

@Twsa Twsa commented Apr 27, 2026

Summary

Fixes 2FA authentication for China mainland Apple ID accounts by addressing three issues:

  1. requires_2fa/requires_2sa not triggered: When Apple server doesn't return hsaVersion, these properties incorrectly return False even when _requires_mfa is True
  2. China mainland endpoint not auto-detected: Chinese Apple IDs need .cn endpoints but the library defaults to international endpoints
  3. trust_session fails after 2FA verification: The GET /2sv/trust returns 204 (success) but subsequent accountLogin still requires 2FA

Changes

base.py

  • requires_2sa and requires_2fa: Added check for _requires_mfa flag alongside hsaVersion
  • _authenticate_with_token: Auto-detect China mainland based on account_country: CHN and switch endpoints; handle 302 redirect with domainToUse hint
  • trust_session: Properly handle 204 response from /2sv/trust and mark session as trusted when trust is established; extract session tokens from response headers

Test

Successfully tested with China mainland Apple ID:

  • Account can authenticate with 2FA
  • Session is properly marked as trusted
  • All iCloud services (photos, drive, etc.) are accessible

Related Issues

  • Fixes login issues for China region accounts

- Fix requires_2fa/requires_2sa to check _requires_mfa flag when
  hsaVersion is not returned by Apple's server
- Auto-detect China mainland endpoints when account_country is CHN
- Handle 302 redirect with domainToUse hint to switch to correct
  regional endpoint
- Improve trust_session to properly handle 204 response from
  /2sv/trust and mark session as trusted when trust is established
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 27, 2026

📝 Walkthrough

Summary by CodeRabbit

  • Bug Fixes
    • Improved authentication flow for China mainland iCloud accounts with automatic endpoint detection
    • Enhanced multi-factor authentication requirement detection and session trust verification

Walkthrough

Modifications to authentication logic in pyicloud/base.py add China mainland endpoint auto-switching during token-based authentication, redirect-driven endpoint correction via domainToUse parsing, updated MFA requirement checks incorporating _requires_mfa flag, and refactored trust session flow with explicit GET response handling requiring HTTP 204 status.

Changes

Cohort / File(s) Summary
Authentication Flow Enhancements
pyicloud/base.py
Added China mainland endpoint auto-switching in _authenticate_with_token via session.data["account_country"] inspection. Implemented redirect-driven endpoint correction handling HTTP 302 responses with domainToUse parsing. Updated requires_2sa/requires_2fa to consider _requires_mfa flag. Refactored trust_session to validate HTTP 204 response, extract tokens from headers, log status/headers, and handle trust establishment independently of subsequent authentication failures.

Sequence Diagram(s)

sequenceDiagram
    participant Browser
    participant pyicloud as pyicloud.base
    participant Auth as Authentication Service
    participant Redirect as Redirect Handler

    Browser->>pyicloud: authenticate_with_token()
    pyicloud->>pyicloud: Check account_country in session.data
    
    alt China Mainland Detected
        pyicloud->>pyicloud: Rebuild endpoints with .cn domains
        pyicloud->>pyicloud: Update request headers
    end
    
    pyicloud->>Auth: accountLogin request
    
    alt HTTP 302 Redirect
        Auth-->>pyicloud: 302 response with domainToUse
        pyicloud->>Redirect: Parse JSON for domainToUse
        alt .cn domain suggested
            Redirect->>pyicloud: Switch to China endpoints
            pyicloud->>Auth: Retry accountLogin
        end
    else HTTP 200 Success
        Auth-->>pyicloud: Authentication successful
    end
    
    pyicloud->>pyicloud: Check requires_2fa (_requires_mfa flag)
    
    alt 2FA Required
        pyicloud-->>Browser: Raise 2FA Exception
    else Proceed
        pyicloud-->>Browser: Authentication complete
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Poem

🐰 Hops of logic dance through endpoints new,
China's paths now route correctly too,
Redirects caught, trust flows refined,
MFA flags put back in mind,
Authentication blooms with care—
A rabbit's code, beyond compare! 🌟

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title directly and specifically summarizes the main change: improving 2FA handling for China mainland accounts, which aligns perfectly with the primary objectives of the changeset.
Description check ✅ Passed The description is clearly related to the changeset, providing detailed context about the three issues fixed, the specific changes made to base.py, and testing results with China mainland accounts.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 Pylint (4.0.5)
pyicloud/base.py

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
pyicloud/base.py (1)

1224-1230: ⚠️ Potential issue | 🟠 Major

Don't clear _requires_mfa before trust is actually established.

self._requires_mfa = False is set unconditionally on entry. If the GET on line 1231 raises, returns a non-204 status, or the inner _authenticate_with_token ultimately fails, the method returns False but _requires_mfa has already been zeroed — meaning subsequent calls to requires_2fa/requires_2sa will report no MFA needed even though MFA clearly was needed. Move this assignment to the success path (right next to where hsaTrustedBrowser is set) so the flag is only cleared when trust really succeeds.

🛠️ Suggested fix
     def trust_session(self) -> bool:
         """Request session trust to avoid user log in going forward."""
-        self._requires_mfa = False
-
         headers: dict[str, Any] = self._get_auth_headers()

…and in the 204 success branch (lines 1263-1273), add self._requires_mfa = False alongside self.data['hsaTrustedBrowser'] = True.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pyicloud/base.py` around lines 1224 - 1230, The trust_session method
currently clears self._requires_mfa immediately on entry which can incorrectly
suppress MFA flags if the trust request fails; move the assignment so that
self._requires_mfa = False is only set when trust is actually established (i.e.,
in the success/204 branch where you already set self.data['hsaTrustedBrowser'] =
True), and remove the unconditional clear at the top of trust_session so that
failures in the GET or in _authenticate_with_token do not incorrectly clear the
MFA requirement.
🧹 Nitpick comments (1)
pyicloud/base.py (1)

769-789: Redundant inner check — _is_mfa_required() already covers _requires_mfa.

_is_mfa_required() (line 763-767) already returns True whenever self._requires_mfa is True. Once the outer call is True because of _requires_mfa, the new inner or self._requires_mfa is also True, so the disjunction collapses to: "outer is true AND (hsaVersion matches OR _requires_mfa)". Functionally correct, but the logic can be expressed more simply and is easier to reason about for the next reader.

♻️ Optional simplification
     `@property`
     def requires_2sa(self) -> bool:
         """Returns True if two-step authentication is required."""
         return (
-            self._is_mfa_required()
-            and (
-                self.data.get("dsInfo", {}).get("hsaVersion", 0) >= 1
-                or self._requires_mfa
-            )
+            self._requires_mfa
+            or (
+                self._is_mfa_required()
+                and self.data.get("dsInfo", {}).get("hsaVersion", 0) >= 1
+            )
         )

     `@property`
     def requires_2fa(self) -> bool:
         """Returns True if two-factor authentication is required."""
         return (
-            self._is_mfa_required()
-            and (
-                self.data.get("dsInfo", {}).get("hsaVersion", 0) == 2
-                or self._requires_mfa
-            )
+            self._requires_mfa
+            or (
+                self._is_mfa_required()
+                and self.data.get("dsInfo", {}).get("hsaVersion", 0) == 2
+            )
         )
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pyicloud/base.py` around lines 769 - 789, The requires_2sa and requires_2fa
properties contain a redundant inner "or self._requires_mfa" because
_is_mfa_required() already returns True when self._requires_mfa is True; update
both properties (requires_2sa, requires_2fa) to remove the inner disjunction and
simply combine _is_mfa_required() with the dsInfo hsaVersion checks
(self.data.get("dsInfo", {}).get("hsaVersion", 0) >= 1 for requires_2sa and == 2
for requires_2fa) so the logic is clearer while preserving behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@pyicloud/base.py`:
- Around line 1266-1273: The current trust_session handler catches
PyiCloud2FARequiredException and unconditionally sets
self.data['hsaTrustedBrowser']=True and self.data['hsaChallengeRequired']=False
after _authenticate_with_token, which can mask real 2FA requirements; modify
trust_session so it only forces hsaTrustedBrowser when the caught
PyiCloud2FARequiredException (or its underlying response) clearly indicates the
trust request succeeded for that purpose (inspect the exception/response
body/status to confirm trust was the missing piece) and otherwise preserve
hsaChallengeRequired state; if you must override, change LOGGER.debug to
LOGGER.warning and include the exception/response details in the log so callers
of requires_2fa/requires_2sa (and CLI _handle_2fa/_handle_2sa) can behave
correctly.
- Around line 1235-1236: The debug log currently prints full response headers in
pyicloud/base.py (the LOGGER.debug call that logs dict(response.headers)), which
exposes sensitive session/trust tokens; change this so only non-sensitive
information is logged — e.g., keep the existing LOGGER.debug("trust_session
response status: %s", response.status_code) and remove or replace the
LOGGER.debug that prints dict(response.headers) with either no header logging or
a filtered/redacted version that strips keys like "X-Apple-Session-Token",
"X-Apple-TwoSV-Trust-Token", "X-Apple-ID-Session-Id", and "scnt" before logging;
locate the logging lines around the trust_session handling and update the
LOGGER.debug that references response.headers accordingly.
- Around line 1242-1254: Remove the manual header extraction block in
pyicloud/base.py that reads response.headers.get('X-Apple-Session-Token'),
'scnt', 'X-Apple-ID-Session-Id', and 'X-Apple-TwoSV-Trust-Token' and
conditionally writes them into self.session.data; instead rely on
PyiCloudSession._update_session_data() (which uses the HEADER_DATA mapping) to
perform this update automatically to avoid overwriting fresh values with
stale/missing ones—delete the entire conditional assignment block handling
new_session_token, new_scnt, new_session_id, and trust_token.
- Around line 660-677: The 302-handling block can silently fall through and
corrupt login state and also retries without updating headers; update the logic
in the block handling resp.status_code == 302 so that if resp.json() does not
contain a usable domainToUse or parsing fails you explicitly raise or return
instead of continuing, and when you successfully switch endpoints (set
self._is_china_mainland and call self._setup_endpoints()) refresh
session.headers["Origin"] and session.headers["Referer"] before reissuing the
POST (the same headers update done in the auto-detect branch), then reassign
resp from the retry and ensure you handle another non-2xx/3xx by calling
resp.raise_for_status() only after confirming it's not a redirect; replace the
broad silent except (ValueError, KeyError): pass with logging at debug (catch
ValueError and AttributeError) so malformed or non-dict bodies are recorded
rather than ignored.
- Around line 639-647: The China auto-switch path sets _is_china_mainland and
updates endpoints via _setup_endpoints and session.headers["Origin"/"Referer"]
but the alternate 302-redirect retry path does not refresh headers or ensure
cookies match the new .cn domain, which can break re-entry into
_authenticate_with_token (called from trust_session); fix by, when switching to
China (either via account_country auto-detect or the 302 redirect retry flow),
explicitly clear or migrate cookies tied to the previous international domain on
self.session.cookies (or recreate the requests.Session) and ensure
session.headers["Origin"] and session.headers["Referer"] are updated before
retrying the POST; add a unit/integration test that simulates a first auth
attempt against the international domain then triggers the 302-driven China
switch and re-enters _authenticate_with_token to verify the session sends
correct cookies/headers to the .cn endpoints.

---

Outside diff comments:
In `@pyicloud/base.py`:
- Around line 1224-1230: The trust_session method currently clears
self._requires_mfa immediately on entry which can incorrectly suppress MFA flags
if the trust request fails; move the assignment so that self._requires_mfa =
False is only set when trust is actually established (i.e., in the success/204
branch where you already set self.data['hsaTrustedBrowser'] = True), and remove
the unconditional clear at the top of trust_session so that failures in the GET
or in _authenticate_with_token do not incorrectly clear the MFA requirement.

---

Nitpick comments:
In `@pyicloud/base.py`:
- Around line 769-789: The requires_2sa and requires_2fa properties contain a
redundant inner "or self._requires_mfa" because _is_mfa_required() already
returns True when self._requires_mfa is True; update both properties
(requires_2sa, requires_2fa) to remove the inner disjunction and simply combine
_is_mfa_required() with the dsInfo hsaVersion checks (self.data.get("dsInfo",
{}).get("hsaVersion", 0) >= 1 for requires_2sa and == 2 for requires_2fa) so the
logic is clearer while preserving behavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 5d9c175c-99af-47cb-ad37-87cd77551cc1

📥 Commits

Reviewing files that changed from the base of the PR and between 7abec01 and 8239d0b.

📒 Files selected for processing (1)
  • pyicloud/base.py

Comment thread pyicloud/base.py
Comment on lines +639 to +647
# Auto-detect China mainland based on account country in session data
account_country = self.session.data.get("account_country", "")
if account_country == "CHN" and not self._is_china_mainland:
LOGGER.debug("Auto-switching to China mainland endpoints based on account_country")
self._is_china_mainland = True
self._setup_endpoints()
# Update session headers with new endpoint
self.session.headers["Origin"] = self._home_endpoint
self.session.headers["Referer"] = f"{self._home_endpoint}/"
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Find all callers of _authenticate_with_token to confirm where it can be re-entered.
rg -nP -C2 '\b_authenticate_with_token\s*\('

Repository: timlaing/pyicloud

Length of output: 1215


🏁 Script executed:

#!/bin/bash
# Extract trust_session method to understand the flow and when line 1261 is reached
rg -nA 80 'def trust_session' pyicloud/base.py | head -150

Repository: timlaing/pyicloud

Length of output: 4464


🏁 Script executed:

#!/bin/bash
# Check PyiCloudSession for any cookie or header management
rg -n 'class PyiCloudSession' pyicloud/

Repository: timlaing/pyicloud

Length of output: 125


🏁 Script executed:

#!/bin/bash
# Get the full _authenticate_with_token method
rg -nA 50 'def _authenticate_with_token\(self\)' pyicloud/base.py | head -100

Repository: timlaing/pyicloud

Length of output: 2684


🏁 Script executed:

#!/bin/bash
# Get _get_auth_headers method
rg -nA 20 'def _get_auth_headers' pyicloud/base.py

Repository: timlaing/pyicloud

Length of output: 985


🏁 Script executed:

#!/bin/bash
# Check if setup_endpoints modifies the session or cookies
rg -nA 15 'def _setup_endpoints' pyicloud/base.py

Repository: timlaing/pyicloud

Length of output: 1084


🏁 Script executed:

#!/bin/bash
# Look for tests related to trust_session or multi-auth flows
fd -e py test | xargs grep -l 'trust_session\|_authenticate_with_token' 2>/dev/null | head -5

Repository: timlaing/pyicloud

Length of output: 164


🏁 Script executed:

#!/bin/bash
# Check if there are any cookie-related dependencies in the code
rg 'cookies|cookie_jar' pyicloud/ -i

Repository: timlaing/pyicloud

Length of output: 2308


🏁 Script executed:

#!/bin/bash
# Verify what the /accountLogin endpoint actually requires
rg -B5 -A10 'accountLogin' pyicloud/base.py | head -40

Repository: timlaing/pyicloud

Length of output: 1719


🏁 Script executed:

#!/bin/bash
# Check if there's any reference to what /accountLogin expects or returns
rg -B3 -A3 'X-APPLE-WEBAUTH-TOKEN' pyicloud/base.py

Repository: timlaing/pyicloud

Length of output: 903


🏁 Script executed:

#!/bin/bash
# Look at the test files to see if they cover the 302 redirect or trust_session flow
rg -A20 'def test.*trust' tests/test_base.py | head -50

Repository: timlaing/pyicloud

Length of output: 1940


🏁 Script executed:

#!/bin/bash
# Check how requests.Session handles cookies across domain changes
rg -n 'allow_redirects' pyicloud/session.py

Repository: timlaing/pyicloud

Length of output: 227


🏁 Script executed:

#!/bin/bash
# Look at the full requires_2sa and requires_2fa properties to verify redundancy claim
rg -B2 -A8 'def requires_2sa|def requires_2fa' pyicloud/base.py

Repository: timlaing/pyicloud

Length of output: 705


🏁 Script executed:

#!/bin/bash
# Check the _requires_mfa usage to understand the redundancy claim
rg -n '_requires_mfa' pyicloud/base.py

Repository: timlaing/pyicloud

Length of output: 353


🏁 Script executed:

#!/bin/bash
# Check the _is_mfa_required method implementation
rg -B2 -A10 'def _is_mfa_required' pyicloud/base.py

Repository: timlaing/pyicloud

Length of output: 493


🏁 Script executed:

#!/bin/bash
# Check where _requires_mfa is set to True to understand its semantics
rg -B3 -A3 '_requires_mfa = True' pyicloud/base.py

Repository: timlaing/pyicloud

Length of output: 337


🏁 Script executed:

#!/bin/bash
# Verify the order of operations in trust_session - when is _requires_mfa cleared
rg -B10 'self._requires_mfa = False' pyicloud/base.py | grep -A10 'trust_session'

Repository: timlaing/pyicloud

Length of output: 205


🏁 Script executed:

#!/bin/bash
# Check if PyiCloudSession has any automatic header extraction logic
rg -n 'X-Apple-Session-Token\|X-Apple-TwoSV-Trust-Token\|X-Apple-ID-Session-Id' pyicloud/session.py

Repository: timlaing/pyicloud

Length of output: 43


🏁 Script executed:

#!/bin/bash
# Look at the full flow around line 1226 to see if _requires_mfa should be cleared before or after trust attempt
rg -B5 -A50 'def trust_session' pyicloud/base.py | head -70

Repository: timlaing/pyicloud

Length of output: 2867


🏁 Script executed:

#!/bin/bash
# Check the response handling to see if 204 is truly guaranteed before line 1261 is reached
rg -n 'status_code.*204\|204.*status' pyicloud/base.py

Repository: timlaing/pyicloud

Length of output: 43


🏁 Script executed:

#!/bin/bash
# Check if there are tests for trust_session failure cases
rg -A30 'test_trust_session' tests/test_base.py

Repository: timlaing/pyicloud

Length of output: 2043


🏁 Script executed:

#!/bin/bash
# Verify if line 1226 _requires_mfa clear happens before the trust attempt
sed -n '1224,1280p' pyicloud/base.py

Repository: timlaing/pyicloud

Length of output: 3088


China auto-switch mid-flow is safe for initial login, but verify second-entry cookie state.

The auto-detect logic at line 641 uses a guard (and not self._is_china_mainland) so it triggers only once per session. However, the 302 redirect path (lines 662–676) provides an alternative switch route, and neither path clears cookies that were already set against the international domain.

requests.Session scopes cookies by domain: any cookies sent to setup.icloud.com or www.icloud.com before the switch will not be sent to the .cn endpoint in subsequent requests. For a fresh login before any service instantiation, this is benign. However, when _authenticate_with_token is re-entered at line 1261 (within trust_session), the session may already carry cookies from the initial international auth attempt. Posting to .cn thereafter without those cookies could fail if Apple's endpoint expects them for session continuity, even though the initial POST to /accountLogin uses token-based auth.

Additionally, the 302 redirect path (line 672) retries the POST without refreshing the Origin/Referer headers, whereas the auto-detect branch does (lines 646–647). This inconsistency could trigger Apple's validation checks.

Recommend adding a test for the "second auth on the same session after China switch via 302 redirect" path to confirm the session state remains valid.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pyicloud/base.py` around lines 639 - 647, The China auto-switch path sets
_is_china_mainland and updates endpoints via _setup_endpoints and
session.headers["Origin"/"Referer"] but the alternate 302-redirect retry path
does not refresh headers or ensure cookies match the new .cn domain, which can
break re-entry into _authenticate_with_token (called from trust_session); fix
by, when switching to China (either via account_country auto-detect or the 302
redirect retry flow), explicitly clear or migrate cookies tied to the previous
international domain on self.session.cookies (or recreate the requests.Session)
and ensure session.headers["Origin"] and session.headers["Referer"] are updated
before retrying the POST; add a unit/integration test that simulates a first
auth attempt against the international domain then triggers the 302-driven China
switch and re-enters _authenticate_with_token to verify the session sends
correct cookies/headers to the .cn endpoints.

Comment thread pyicloud/base.py
Comment on lines +660 to +677

# Check if response indicates wrong domain (China account using international endpoint)
if resp.status_code == 302:
try:
body = resp.json()
if body.get("domainToUse"):
LOGGER.debug("Detected domain redirect to %s, switching endpoints", body["domainToUse"])
# Switch to China endpoint if not already
if not self._is_china_mainland and ".cn" in body["domainToUse"]:
self._is_china_mainland = True
self._setup_endpoints()
# Retry with correct endpoint
resp = self.session.post(
f"{self._setup_endpoint}/accountLogin", json=login_data
)
except (ValueError, KeyError):
pass # Not JSON or unexpected format

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Confirm whether PyiCloudSession.post disables redirects so a 302 is observable.
rg -nP -C5 '\b(def\s+(post|request)|allow_redirects)\b' pyicloud/session.py

Repository: timlaing/pyicloud

Length of output: 1626


🏁 Script executed:

#!/bin/bash
# Get full post method definition to check redirect settings
rg -nA30 'def post\(' pyicloud/session.py

Repository: timlaing/pyicloud

Length of output: 43


🏁 Script executed:

#!/bin/bash
# Get the full context around lines 660-677 in base.py
sed -n '650,680p' pyicloud/base.py | cat -n

Repository: timlaing/pyicloud

Length of output: 1773


🏁 Script executed:

#!/bin/bash
# Check if session is configured to disable redirects globally
rg -n 'allow_redirects.*=.*False' pyicloud/

Repository: timlaing/pyicloud

Length of output: 43


🏁 Script executed:

#!/bin/bash
# Search for PyiCloudSession class to understand how it handles requests
rg -n 'class PyiCloudSession' pyicloud/

Repository: timlaing/pyicloud

Length of output: 125


🏁 Script executed:

#!/bin/bash
# Look at the actual post call in context - line 658
sed -n '655,665p' pyicloud/base.py | cat -n

Repository: timlaing/pyicloud

Length of output: 536


🏁 Script executed:

#!/bin/bash
# Check if allow_redirects is set in session.post call itself anywhere in base.py
rg -n 'allow_redirects' pyicloud/base.py

Repository: timlaing/pyicloud

Length of output: 43


🏁 Script executed:

#!/bin/bash
# Look at PyiCloudSession class initialization and configuration
sed -n '60,150p' pyicloud/session.py | cat -n

Repository: timlaing/pyicloud

Length of output: 3923


🏁 Script executed:

#!/bin/bash
# Search for hooks, redirects, or other session-level configuration in session.py
rg -n 'hooks|redirect' pyicloud/session.py

Repository: timlaing/pyicloud

Length of output: 333


🏁 Script executed:

#!/bin/bash
# Check if there's a mocked response or test configuration affecting this
rg -n 'Response|status_code.*302' pyicloud/base.py | head -20

Repository: timlaing/pyicloud

Length of output: 1140


🏁 Script executed:

#!/bin/bash
# Check what hooks are configured in session.py
rg -n -B2 -A2 'hooks' pyicloud/session.py

Repository: timlaing/pyicloud

Length of output: 684


🏁 Script executed:

#!/bin/bash
# Look at the request method implementation more carefully - around line 186-230
sed -n '186,230p' pyicloud/session.py | cat -n

Repository: timlaing/pyicloud

Length of output: 1400


🏁 Script executed:

#!/bin/bash
# Check if there's a response hook that might prevent redirect following
rg -n 'response.*hook\|def.*hook\|BeforeRedirect' pyicloud/

Repository: timlaing/pyicloud

Length of output: 43


🏁 Script executed:

#!/bin/bash
# Look for the _request method implementation that's called by request()
rg -n 'def _request' pyicloud/session.py

Repository: timlaing/pyicloud

Length of output: 109


🏁 Script executed:

#!/bin/bash
# Check the entire session.py to see if allow_redirects is modified anywhere
wc -l pyicloud/session.py

Repository: timlaing/pyicloud

Length of output: 85


🏁 Script executed:

#!/bin/bash
# View the full session.py to understand the complete flow
cat pyicloud/session.py | head -300

Repository: timlaing/pyicloud

Length of output: 8592


🏁 Script executed:

#!/bin/bash
# Continue reading session.py from line 300 onwards
sed -n '300,453p' pyicloud/session.py | cat -n

Repository: timlaing/pyicloud

Length of output: 6714


🏁 Script executed:

#!/bin/bash
# Check the actual call to post() in base.py to see if allow_redirects is passed
sed -n '655,665p' pyicloud/base.py

Repository: timlaing/pyicloud

Length of output: 459


🏁 Script executed:

#!/bin/bash
# Search for where post() might be called with allow_redirects=False
rg -n 'post\(' pyicloud/base.py | head -10

Repository: timlaing/pyicloud

Length of output: 582


🏁 Script executed:

#!/bin/bash
# Search for tests that might exercise this 302 handling code
find . -name '*test*.py' -type f | head -5

Repository: timlaing/pyicloud

Length of output: 213


🏁 Script executed:

#!/bin/bash
# Look for tests related to China endpoint or 302
rg -l '302|domainToUse|china' --type py | head -10

Repository: timlaing/pyicloud

Length of output: 298


🏁 Script executed:

#!/bin/bash
# Check if there are any mocks or fixtures that disable redirects
rg -n 'allow_redirects.*False|redirect' pyicloud/base.py

Repository: timlaing/pyicloud

Length of output: 258


🏁 Script executed:

#!/bin/bash
# Look at conftest.py to understand test setup
head -100 tests/conftest.py

Repository: timlaing/pyicloud

Length of output: 3185


🏁 Script executed:

#!/bin/bash
# Search for any requests mocking or allow_redirects in tests
rg -n 'allow_redirects|302|domainToUse' tests/

Repository: timlaing/pyicloud

Length of output: 820


🏁 Script executed:

#!/bin/bash
# Check the test_hsa2_bridge.py for clues about how 302 responses are handled
rg -B5 -A10 '302|domainToUse' tests/test_hsa2_bridge.py

Repository: timlaing/pyicloud

Length of output: 3486


🏁 Script executed:

#!/bin/bash
# Look at test_base.py around line 983 to see what those allow_redirects=True tests are
sed -n '975,1000p' tests/test_base.py

Repository: timlaing/pyicloud

Length of output: 839


🏁 Script executed:

#!/bin/bash
# Search for tests related to accountLogin or domain switching
rg -B10 -A10 'accountLogin|domainToUse' tests/

Repository: timlaing/pyicloud

Length of output: 7228


🏁 Script executed:

#!/bin/bash
# Check if there are any mocks for requests.Session.post that might disable redirects
rg -n 'post.*allow_redirects|MagicMock.*post' tests/test_base.py | head -20

Repository: timlaing/pyicloud

Length of output: 43


🏁 Script executed:

#!/bin/bash
# Look for PyiCloudSessionMock to understand how tests handle this
rg -n 'class PyiCloudSessionMock' tests/

Repository: timlaing/pyicloud

Length of output: 131


🏁 Script executed:

#!/bin/bash
# Check tests/__init__.py to see how mock session handles POST
sed -n '1,100p' tests/__init__.py | cat -n

Repository: timlaing/pyicloud

Length of output: 3628


🏁 Script executed:

#!/bin/bash
# Look at the actual _handle_account_login implementation
rg -B5 -A20 '_handle_account_login' tests/__init__.py

Repository: timlaing/pyicloud

Length of output: 2160


🏁 Script executed:

#!/bin/bash
# Search for explicit post method override in PyiCloudSession
sed -n '60,453p' pyicloud/session.py | grep -n 'def post'

Repository: timlaing/pyicloud

Length of output: 43


🏁 Script executed:

#!/bin/bash
# Check if there's any code that explicitly calls allow_redirects=False
rg -n 'post.*allow_redirects.*False' pyicloud/

Repository: timlaing/pyicloud

Length of output: 43


🏁 Script executed:

#!/bin/bash
# Double-check: look at where accountLogin is called and see the exact arguments
sed -n '655,680p' pyicloud/base.py | cat -n

Repository: timlaing/pyicloud

Length of output: 1429


🏁 Script executed:

#!/bin/bash
# Look at the actual requests behavior - check if there's any hook or other mechanism
# Search more broadly for any session configuration
rg -n 'def __init__' pyicloud/session.py -A30

Repository: timlaing/pyicloud

Length of output: 1102


🏁 Script executed:

#!/bin/bash
# Check if requests.Session has any redirect-disabling hooks or if there's patching in tests
rg -n 'allow_redirects\|PreparedRequest\|request_hook' pyicloud/session.py

Repository: timlaing/pyicloud

Length of output: 43


🏁 Script executed:

#!/bin/bash
# Critical: Check git history or comments that might explain the 302 handling
rg -B10 'status_code == 302' pyicloud/base.py

Repository: timlaing/pyicloud

Length of output: 537


🏁 Script executed:

#!/bin/bash
# Search for tests that specifically test the 302 handling
rg -B5 -A15 'status_code.*302\|domainToUse' tests/test_base.py

Repository: timlaing/pyicloud

Length of output: 43


🏁 Script executed:

#!/bin/bash
# Look for any test mocks that return 302
rg -n '302' tests/

Repository: timlaing/pyicloud

Length of output: 647


🏁 Script executed:

#!/bin/bash
# Check if requests.Session is patched in conftest to disable redirects
rg -n 'allow_redirects|redirects\|PreparedRequest' tests/conftest.py

Repository: timlaing/pyicloud

Length of output: 43


🏁 Script executed:

#!/bin/bash
# Check git log to see when this 302 handling was added
git log -p --all -S 'status_code == 302' -- pyicloud/base.py | head -100

Repository: timlaing/pyicloud

Length of output: 43


🏁 Script executed:

#!/bin/bash
# Search for any documentation or comments about why 302 is handled
rg -B20 'status_code == 302' pyicloud/base.py | head -50

Repository: timlaing/pyicloud

Length of output: 1041


🏁 Script executed:

#!/bin/bash
# Check if maybe this is testing code or if there's any reason this would work
# Look at what requests actually does - search for any custom request hooks
rg -n 'self.hooks\|hooks.*=' pyicloud/session.py

Repository: timlaing/pyicloud

Length of output: 43


🏁 Script executed:

#!/bin/bash
# Final check: look at the exact context around the auto-detect branch (lines 646-647)
# to confirm the header update pattern mentioned in the review
sed -n '640,680p' pyicloud/base.py

Repository: timlaing/pyicloud

Length of output: 2077


Fix silent fall-through on 302 redirect and missing header update on retry.

Three substantive issues in this code block:

  1. Silent fall-through to garbage data: If the 302 response has no domainToUse field, _is_china_mainland is already True, body is not JSON-parseable, or the retry itself returns another 302, control falls through to line 679 where resp.raise_for_status() does not raise for 3xx status codes. The code then stores the redirect-hint payload (e.g. {"domainToUse": "..."}) as account login data, corrupting subsequent calls to _handle_accept_terms and _update_state.

  2. Retry omits header updates: Unlike the auto-detect branch above (lines 646–647), the retry POST does not refresh session.headers["Origin"] or session.headers["Referer"] after switching to the new endpoint. Apple's IDMS/setup endpoints typically validate these headers; the retry can be rejected even though the URL is now correct.

  3. Imprecise exception handling: except (ValueError, KeyError): pass silently discards errors. KeyError is unreachable (only .get() and guarded indexing used), and AttributeError (if response body is not a dict) is not caught. At minimum, log at debug level.

Suggested fix
            resp: Response = self.session.post(
                f"{self._setup_endpoint}/accountLogin", json=login_data
            )

            # Check if response indicates wrong domain (China account using international endpoint)
            if resp.status_code == 302:
+                domain_to_use: Optional[str] = None
                 try:
                     body = resp.json()
-                    if body.get("domainToUse"):
-                        LOGGER.debug("Detected domain redirect to %s", body["domainToUse"])
-                        # Switch to China endpoint if not already
-                        if not self._is_china_mainland and ".cn" in body["domainToUse"]:
-                            self._is_china_mainland = True
-                            self._setup_endpoints()
-                            # Retry with correct endpoint
-                            resp = self.session.post(
-                                f"{self._setup_endpoint}/accountLogin", json=login_data
-                            )
-                except (ValueError, KeyError):
-                    pass  # Not JSON or unexpected format
+                    if isinstance(body, dict):
+                        domain_to_use = body.get("domainToUse")
+                except ValueError:
+                    LOGGER.debug("accountLogin 302 body was not JSON", exc_info=True)
+
+                if domain_to_use and ".cn" in domain_to_use and not self._is_china_mainland:
+                    LOGGER.debug("Switching to China endpoints based on domainToUse=%s", domain_to_use)
+                    self._is_china_mainland = True
+                    self._setup_endpoints()
+                    self.session.headers["Origin"] = self._home_endpoint
+                    self.session.headers["Referer"] = f"{self._home_endpoint}/"
+                    resp = self.session.post(
+                        f"{self._setup_endpoint}/accountLogin", json=login_data
+                    )
+
+                if resp.status_code == 302:
+                    raise PyiCloudFailedLoginException(
+                        f"accountLogin returned an unhandled 302 redirect (domainToUse={domain_to_use!r})"
+                    )

             resp.raise_for_status()
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pyicloud/base.py` around lines 660 - 677, The 302-handling block can silently
fall through and corrupt login state and also retries without updating headers;
update the logic in the block handling resp.status_code == 302 so that if
resp.json() does not contain a usable domainToUse or parsing fails you
explicitly raise or return instead of continuing, and when you successfully
switch endpoints (set self._is_china_mainland and call self._setup_endpoints())
refresh session.headers["Origin"] and session.headers["Referer"] before
reissuing the POST (the same headers update done in the auto-detect branch),
then reassign resp from the retry and ensure you handle another non-2xx/3xx by
calling resp.raise_for_status() only after confirming it's not a redirect;
replace the broad silent except (ValueError, KeyError): pass with logging at
debug (catch ValueError and AttributeError) so malformed or non-dict bodies are
recorded rather than ignored.

Comment thread pyicloud/base.py
Comment on lines +1235 to +1236
LOGGER.debug("trust_session response status: %s", response.status_code)
LOGGER.debug("trust_session response headers: %s", dict(response.headers))
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Don't log full response headers — they contain session/trust tokens.

response.headers for /2sv/trust includes X-Apple-Session-Token, X-Apple-TwoSV-Trust-Token, X-Apple-ID-Session-Id, and scnt. These are bearer credentials for the account; dumping them at DEBUG means anyone with access to the log file (or a user pasting their debug log into a bug report) is effectively handing over the account. The status code is fine; the headers are not.

🛡️ Suggested fix
-            LOGGER.debug("trust_session response status: %s", response.status_code)
-            LOGGER.debug("trust_session response headers: %s", dict(response.headers))
+            LOGGER.debug("trust_session response status: %s", response.status_code)
+            # Avoid logging response.headers verbatim: they carry session/trust tokens.
+            LOGGER.debug(
+                "trust_session response header keys: %s",
+                sorted(response.headers.keys()),
+            )
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
LOGGER.debug("trust_session response status: %s", response.status_code)
LOGGER.debug("trust_session response headers: %s", dict(response.headers))
LOGGER.debug("trust_session response status: %s", response.status_code)
# Avoid logging response.headers verbatim: they carry session/trust tokens.
LOGGER.debug(
"trust_session response header keys: %s",
sorted(response.headers.keys()),
)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pyicloud/base.py` around lines 1235 - 1236, The debug log currently prints
full response headers in pyicloud/base.py (the LOGGER.debug call that logs
dict(response.headers)), which exposes sensitive session/trust tokens; change
this so only non-sensitive information is logged — e.g., keep the existing
LOGGER.debug("trust_session response status: %s", response.status_code) and
remove or replace the LOGGER.debug that prints dict(response.headers) with
either no header logging or a filtered/redacted version that strips keys like
"X-Apple-Session-Token", "X-Apple-TwoSV-Trust-Token", "X-Apple-ID-Session-Id",
and "scnt" before logging; locate the logging lines around the trust_session
handling and update the LOGGER.debug that references response.headers
accordingly.

Comment thread pyicloud/base.py
Comment on lines +1242 to +1254
new_session_token = response.headers.get('X-Apple-Session-Token')
new_scnt = response.headers.get('scnt')
new_session_id = response.headers.get('X-Apple-ID-Session-Id')
trust_token = response.headers.get('X-Apple-TwoSV-Trust-Token')

if new_session_token:
self.session.data['session_token'] = new_session_token
if new_scnt:
self.session.data['scnt'] = new_scnt
if new_session_id:
self.session.data['session_id'] = new_session_id
if trust_token:
self.session.data['trust_token'] = trust_token
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Look at how PyiCloudSession captures auth headers from responses.
rg -nP -C3 'X-Apple-Session-Token|X-Apple-TwoSV-Trust-Token|X-Apple-ID-Session-Id|\bscnt\b' pyicloud/session.py

Repository: timlaing/pyicloud

Length of output: 43


🏁 Script executed:

# First, find the PyiCloudSession class definition
rg -n 'class PyiCloudSession' --type py

Repository: timlaing/pyicloud

Length of output: 195


🏁 Script executed:

# Look for response hooks or session data updates in session-related files
fd -e py 'session' --type f

Repository: timlaing/pyicloud

Length of output: 81


🏁 Script executed:

# Check the structure of pyicloud module
ls -la pyicloud/

Repository: timlaing/pyicloud

Length of output: 1178


🏁 Script executed:

# Read the PyiCloudSession class to understand response hooks
cat -n pyicloud/session.py

Repository: timlaing/pyicloud

Length of output: 17321


🏁 Script executed:

# Read the relevant section of base.py around lines 1242-1254
sed -n '1230,1270p' pyicloud/base.py

Repository: timlaing/pyicloud

Length of output: 2346


🏁 Script executed:

# Check what HEADER_DATA contains in const.py
rg -n 'HEADER_DATA' pyicloud/const.py -A 10

Repository: timlaing/pyicloud

Length of output: 558


Remove the redundant manual header extraction; PyiCloudSession already handles these headers.

The PyiCloudSession._update_session_data() method (lines 170–175 in pyicloud/session.py) automatically extracts all four headers (X-Apple-Session-Token, scnt, X-Apple-ID-Session-Id, X-Apple-TwoSV-Trust-Token) from response headers via the HEADER_DATA mapping in const.py and updates session.data immediately after every request. This code in base.py runs after that automatic update, overwriting freshly captured values with conditional logic that skips missing headers, creating a window where stale or incomplete session data persists. Remove these lines entirely.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pyicloud/base.py` around lines 1242 - 1254, Remove the manual header
extraction block in pyicloud/base.py that reads
response.headers.get('X-Apple-Session-Token'), 'scnt', 'X-Apple-ID-Session-Id',
and 'X-Apple-TwoSV-Trust-Token' and conditionally writes them into
self.session.data; instead rely on PyiCloudSession._update_session_data() (which
uses the HEADER_DATA mapping) to perform this update automatically to avoid
overwriting fresh values with stale/missing ones—delete the entire conditional
assignment block handling new_session_token, new_scnt, new_session_id, and
trust_token.

Comment thread pyicloud/base.py
Comment on lines +1266 to +1273
except PyiCloud2FARequiredException:
# Even though _authenticate_with_token raised 2FA exception,
# the trust request itself succeeded (204). So we mark as trusted.
LOGGER.debug("trust_session: _authenticate_with_token raised 2FA but trust is established")
self.data['hsaTrustedBrowser'] = True
self.data['hsaChallengeRequired'] = False
LOGGER.debug("Session trust marked as successful despite 2FA exception.")
return True
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Forcing hsaChallengeRequired = False can mask a genuine 2FA-required state.

Catching PyiCloud2FARequiredException and unconditionally setting both hsaTrustedBrowser = True and hsaChallengeRequired = False makes requires_2fa/requires_2sa return False even in cases where the server really is asking for another factor (e.g. trust succeeded but a different policy now demands re-verification, or the inner _authenticate_with_token raised 2FA for a reason unrelated to trust). The CLI in pyicloud/cli/context.py will then skip _handle_2fa/_handle_2sa and proceed as if logged in, producing a confusing failure later.

Consider gating this on something stronger than "we got a 204" — e.g. only force-trust when the inner exception's response indicates trust was the missing piece, or at minimum log at WARNING (not DEBUG) so users can see the override happened.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pyicloud/base.py` around lines 1266 - 1273, The current trust_session handler
catches PyiCloud2FARequiredException and unconditionally sets
self.data['hsaTrustedBrowser']=True and self.data['hsaChallengeRequired']=False
after _authenticate_with_token, which can mask real 2FA requirements; modify
trust_session so it only forces hsaTrustedBrowser when the caught
PyiCloud2FARequiredException (or its underlying response) clearly indicates the
trust request succeeded for that purpose (inspect the exception/response
body/status to confirm trust was the missing piece) and otherwise preserve
hsaChallengeRequired state; if you must override, change LOGGER.debug to
LOGGER.warning and include the exception/response details in the log so callers
of requires_2fa/requires_2sa (and CLI _handle_2fa/_handle_2sa) can behave
correctly.

Copy link
Copy Markdown
Owner

@timlaing timlaing left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Twsa - Thank you for your contribution

Please break this pull request down in to two fixes:

  1. For automatic domain resolution
  2. 2FA handling.

The automatic domain handling looks good, but work in going to be required for the 2FA code.

Comment thread pyicloud/base.py
and self.data.get("dsInfo", {}).get("hsaVersion", 0) >= 1
and (
self.data.get("dsInfo", {}).get("hsaVersion", 0) >= 1
or self._requires_mfa
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This check is performed in the _is_mfa_required method and as such not needed here

Comment thread pyicloud/base.py
and self.data.get("dsInfo", {}).get("hsaVersion", 0) == 2
and (
self.data.get("dsInfo", {}).get("hsaVersion", 0) == 2
or self._requires_mfa
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This check is performed in the _is_mfa_required method and as such not needed here

Comment thread pyicloud/base.py
Comment on lines +1241 to +1254
# Extract and update session tokens from response headers
new_session_token = response.headers.get('X-Apple-Session-Token')
new_scnt = response.headers.get('scnt')
new_session_id = response.headers.get('X-Apple-ID-Session-Id')
trust_token = response.headers.get('X-Apple-TwoSV-Trust-Token')

if new_session_token:
self.session.data['session_token'] = new_session_token
if new_scnt:
self.session.data['scnt'] = new_scnt
if new_session_id:
self.session.data['session_id'] = new_session_id
if trust_token:
self.session.data['trust_token'] = trust_token
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is already handled by the session class

Comment thread pyicloud/base.py
Comment on lines +1266 to +1272
except PyiCloud2FARequiredException:
# Even though _authenticate_with_token raised 2FA exception,
# the trust request itself succeeded (204). So we mark as trusted.
LOGGER.debug("trust_session: _authenticate_with_token raised 2FA but trust is established")
self.data['hsaTrustedBrowser'] = True
self.data['hsaChallengeRequired'] = False
LOGGER.debug("Session trust marked as successful despite 2FA exception.")
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is going to hide valid errors, such as authentication failure on subsequent logins where the browser is trusted.

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.

2 participants