Skip to content

Commit a20050f

Browse files
eukosclaude
andcommitted
fix: restore 2FA for Apple's updated auth flow (2026+)
Apple changed the 2FA flow around 2026. Two issues needed fixing: 1. Push trigger: the new flow requires a PUT to /verify/trusteddevice/securitycode (no body) to push the code to trusted devices. The old bridge/step/0 POST approach (PR icloud-photos-downloader#1327) triggers the code delivery but causes downstream failures. 2. Phone number parsing: Apple moved trustedPhoneNumbers into bridgeInitiateData.phoneNumberVerification in the auth HTML payload. Fall back to that path when the old path returns nothing. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 3a97872 commit a20050f

6 files changed

Lines changed: 129 additions & 4 deletions

File tree

src/icloudpd/authentication.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,16 @@ def request_2sa(icloud: PyiCloudService, logger: logging.Logger) -> None:
152152
def request_2fa(icloud: PyiCloudService, logger: logging.Logger) -> None:
153153
"""Request two-factor authentication."""
154154
devices = icloud.get_trusted_phone_numbers()
155+
156+
# Trigger push notification to trusted devices before prompting for code.
157+
# Apple's auth flow (2026+) requires a PUT to /verify/trusteddevice/securitycode
158+
# to initiate code delivery. Failure is non-fatal — the user can still enter
159+
# a code if it arrives via another path.
160+
if not icloud.trigger_push_notification():
161+
logger.debug("Failed to trigger 2FA push notification, continuing anyway")
162+
else:
163+
logger.debug("2FA push notification triggered")
164+
155165
devices_count = len(devices)
156166
device_index_alphabet = "abcdefghijklmnopqrstuvwxyz"
157167
if devices_count > 0:

src/pyicloud_ipd/base.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -694,6 +694,35 @@ def get_trusted_phone_numbers(self) -> Sequence[TrustedDevice]:
694694

695695
return parse_trusted_phone_numbers_response(response)
696696

697+
def trigger_push_notification(self) -> bool:
698+
"""Triggers a push notification to trusted devices for 2FA code entry.
699+
700+
Sends PUT to /verify/trusteddevice/securitycode (no body). Apple's new
701+
auth flow (2026+) uses this endpoint to push a code to trusted devices.
702+
"""
703+
headers = self._get_auth_headers({"Accept": "application/json"})
704+
try:
705+
if self.response_observer:
706+
rules = list(
707+
chain(
708+
self.cookie_obfuscate_rules,
709+
self.header_obfuscate_rules,
710+
self.header_pass_rules,
711+
self.header_drop_rules,
712+
)
713+
)
714+
else:
715+
rules = []
716+
717+
with self.use_rules(rules):
718+
self.session.put(
719+
f"{self.AUTH_ENDPOINT}/verify/trusteddevice/securitycode",
720+
headers=headers,
721+
)
722+
return True
723+
except PyiCloudAPIResponseException:
724+
return False
725+
697726
def send_2fa_code_sms(self, device_id: int) -> bool:
698727
"""Requests that a verification code is sent to the given device"""
699728

src/pyicloud_ipd/sms.py

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -65,12 +65,17 @@ def parse_trusted_phone_numbers_payload(content: str) -> Sequence[TrustedDevice]
6565
parser = _SMSParser()
6666
parser.feed(content)
6767
parser.close()
68+
twoSV = parser.sms_data.get("direct", {}).get("twoSV", {})
6869
numbers: Sequence[Mapping[str, Any]] = (
69-
parser.sms_data.get("direct", {})
70-
.get("twoSV", {})
71-
.get("phoneNumberVerification", {})
72-
.get("trustedPhoneNumbers", [])
70+
twoSV.get("phoneNumberVerification", {}).get("trustedPhoneNumbers", [])
7371
)
72+
if not numbers:
73+
# Apple moved trustedPhoneNumbers into bridgeInitiateData.phoneNumberVerification (2026+)
74+
numbers = (
75+
twoSV.get("bridgeInitiateData", {})
76+
.get("phoneNumberVerification", {})
77+
.get("trustedPhoneNumbers", [])
78+
)
7479
return list(item for item in map(_map_to_trusted_device, numbers) if item is not None)
7580

7681

tests/vcr_cassettes/2fa_flow_invalid_code.yml

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,33 @@ interactions:
201201
status:
202202
code: 200
203203
message: OK
204+
- request:
205+
body: null
206+
headers:
207+
Accept: ['application/json']
208+
Accept-Encoding: ['gzip, deflate']
209+
Connection: ['keep-alive']
210+
Content-Length: ['0']
211+
X-Apple-ID-Session-Id: ['session-1234567890']
212+
X-Apple-OAuth-Client-Id: ['d39ba9916b7251055b22c7f910e2ea796ee65e98b2ddecea8f5dde8d9d1a815d']
213+
X-Apple-OAuth-Client-Type: ['firstPartyAuth']
214+
X-Apple-OAuth-Redirect-URI: ['https://www.icloud.com']
215+
X-Apple-OAuth-Require-Grant-Code: ['true']
216+
X-Apple-OAuth-Response-Mode: ['web_message']
217+
X-Apple-OAuth-Response-Type: ['code']
218+
X-Apple-OAuth-State: ['DE309E26-942E-11E8-92F5-14109FE0B321']
219+
X-Apple-Widget-Key: ['d39ba9916b7251055b22c7f910e2ea796ee65e98b2ddecea8f5dde8d9d1a815d']
220+
scnt: ['scnt-1234567890']
221+
method: PUT
222+
uri: https://idmsa.apple.com/appleauth/auth/verify/trusteddevice/securitycode
223+
response:
224+
body:
225+
string: ''
226+
headers:
227+
Content-Type: ['application/json']
228+
status:
229+
code: 200
230+
message: OK
204231
- request:
205232
body: !!python/unicode '{"securityCode": {"code": "901431"}}'
206233
headers:

tests/vcr_cassettes/2fa_flow_valid_code.yml

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,33 @@ interactions:
201201
status:
202202
code: 200
203203
message: OK
204+
- request:
205+
body: null
206+
headers:
207+
Accept: ['application/json']
208+
Accept-Encoding: ['gzip, deflate']
209+
Connection: ['keep-alive']
210+
Content-Length: ['0']
211+
X-Apple-ID-Session-Id: ['session-1234567890']
212+
X-Apple-OAuth-Client-Id: ['d39ba9916b7251055b22c7f910e2ea796ee65e98b2ddecea8f5dde8d9d1a815d']
213+
X-Apple-OAuth-Client-Type: ['firstPartyAuth']
214+
X-Apple-OAuth-Redirect-URI: ['https://www.icloud.com']
215+
X-Apple-OAuth-Require-Grant-Code: ['true']
216+
X-Apple-OAuth-Response-Mode: ['web_message']
217+
X-Apple-OAuth-Response-Type: ['code']
218+
X-Apple-OAuth-State: ['DE309E26-942E-11E8-92F5-14109FE0B321']
219+
X-Apple-Widget-Key: ['d39ba9916b7251055b22c7f910e2ea796ee65e98b2ddecea8f5dde8d9d1a815d']
220+
scnt: ['scnt-1234567890']
221+
method: PUT
222+
uri: https://idmsa.apple.com/appleauth/auth/verify/trusteddevice/securitycode
223+
response:
224+
body:
225+
string: ''
226+
headers:
227+
Content-Type: ['application/json']
228+
status:
229+
code: 200
230+
message: OK
204231
- request:
205232
body: !!python/unicode '{"securityCode": {"code": "654321"}}'
206233
headers:

tests/vcr_cassettes/2fa_flow_valid_code_zero_lead.yml

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,33 @@ interactions:
201201
status:
202202
code: 200
203203
message: OK
204+
- request:
205+
body: null
206+
headers:
207+
Accept: ['application/json']
208+
Accept-Encoding: ['gzip, deflate']
209+
Connection: ['keep-alive']
210+
Content-Length: ['0']
211+
X-Apple-ID-Session-Id: ['session-1234567890']
212+
X-Apple-OAuth-Client-Id: ['d39ba9916b7251055b22c7f910e2ea796ee65e98b2ddecea8f5dde8d9d1a815d']
213+
X-Apple-OAuth-Client-Type: ['firstPartyAuth']
214+
X-Apple-OAuth-Redirect-URI: ['https://www.icloud.com']
215+
X-Apple-OAuth-Require-Grant-Code: ['true']
216+
X-Apple-OAuth-Response-Mode: ['web_message']
217+
X-Apple-OAuth-Response-Type: ['code']
218+
X-Apple-OAuth-State: ['DE309E26-942E-11E8-92F5-14109FE0B321']
219+
X-Apple-Widget-Key: ['d39ba9916b7251055b22c7f910e2ea796ee65e98b2ddecea8f5dde8d9d1a815d']
220+
scnt: ['scnt-1234567890']
221+
method: PUT
222+
uri: https://idmsa.apple.com/appleauth/auth/verify/trusteddevice/securitycode
223+
response:
224+
body:
225+
string: ''
226+
headers:
227+
Content-Type: ['application/json']
228+
status:
229+
code: 200
230+
message: OK
204231
- request:
205232
body: !!python/unicode '{"securityCode": {"code": "054321"}}'
206233
headers:

0 commit comments

Comments
 (0)