From a20050f40fba472924f5b3554062ed267386d17b Mon Sep 17 00:00:00 2001 From: Eugene Kosyakov Date: Sat, 18 Apr 2026 19:59:20 -0500 Subject: [PATCH] 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 #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 --- src/icloudpd/authentication.py | 10 +++++++ src/pyicloud_ipd/base.py | 29 +++++++++++++++++++ src/pyicloud_ipd/sms.py | 13 ++++++--- tests/vcr_cassettes/2fa_flow_invalid_code.yml | 27 +++++++++++++++++ tests/vcr_cassettes/2fa_flow_valid_code.yml | 27 +++++++++++++++++ .../2fa_flow_valid_code_zero_lead.yml | 27 +++++++++++++++++ 6 files changed, 129 insertions(+), 4 deletions(-) diff --git a/src/icloudpd/authentication.py b/src/icloudpd/authentication.py index d6a7d1568..ff67a1134 100644 --- a/src/icloudpd/authentication.py +++ b/src/icloudpd/authentication.py @@ -152,6 +152,16 @@ def request_2sa(icloud: PyiCloudService, logger: logging.Logger) -> None: def request_2fa(icloud: PyiCloudService, logger: logging.Logger) -> None: """Request two-factor authentication.""" devices = icloud.get_trusted_phone_numbers() + + # Trigger push notification to trusted devices before prompting for code. + # Apple's auth flow (2026+) requires a PUT to /verify/trusteddevice/securitycode + # to initiate code delivery. Failure is non-fatal — the user can still enter + # a code if it arrives via another path. + if not icloud.trigger_push_notification(): + logger.debug("Failed to trigger 2FA push notification, continuing anyway") + else: + logger.debug("2FA push notification triggered") + devices_count = len(devices) device_index_alphabet = "abcdefghijklmnopqrstuvwxyz" if devices_count > 0: diff --git a/src/pyicloud_ipd/base.py b/src/pyicloud_ipd/base.py index d279a81d7..409460e3a 100644 --- a/src/pyicloud_ipd/base.py +++ b/src/pyicloud_ipd/base.py @@ -694,6 +694,35 @@ def get_trusted_phone_numbers(self) -> Sequence[TrustedDevice]: return parse_trusted_phone_numbers_response(response) + def trigger_push_notification(self) -> bool: + """Triggers a push notification to trusted devices for 2FA code entry. + + Sends PUT to /verify/trusteddevice/securitycode (no body). Apple's new + auth flow (2026+) uses this endpoint to push a code to trusted devices. + """ + headers = self._get_auth_headers({"Accept": "application/json"}) + try: + if self.response_observer: + rules = list( + chain( + self.cookie_obfuscate_rules, + self.header_obfuscate_rules, + self.header_pass_rules, + self.header_drop_rules, + ) + ) + else: + rules = [] + + with self.use_rules(rules): + self.session.put( + f"{self.AUTH_ENDPOINT}/verify/trusteddevice/securitycode", + headers=headers, + ) + return True + except PyiCloudAPIResponseException: + return False + def send_2fa_code_sms(self, device_id: int) -> bool: """Requests that a verification code is sent to the given device""" diff --git a/src/pyicloud_ipd/sms.py b/src/pyicloud_ipd/sms.py index 0a78e2b4f..0f4ce434b 100644 --- a/src/pyicloud_ipd/sms.py +++ b/src/pyicloud_ipd/sms.py @@ -65,12 +65,17 @@ def parse_trusted_phone_numbers_payload(content: str) -> Sequence[TrustedDevice] parser = _SMSParser() parser.feed(content) parser.close() + twoSV = parser.sms_data.get("direct", {}).get("twoSV", {}) numbers: Sequence[Mapping[str, Any]] = ( - parser.sms_data.get("direct", {}) - .get("twoSV", {}) - .get("phoneNumberVerification", {}) - .get("trustedPhoneNumbers", []) + twoSV.get("phoneNumberVerification", {}).get("trustedPhoneNumbers", []) ) + if not numbers: + # Apple moved trustedPhoneNumbers into bridgeInitiateData.phoneNumberVerification (2026+) + numbers = ( + twoSV.get("bridgeInitiateData", {}) + .get("phoneNumberVerification", {}) + .get("trustedPhoneNumbers", []) + ) return list(item for item in map(_map_to_trusted_device, numbers) if item is not None) diff --git a/tests/vcr_cassettes/2fa_flow_invalid_code.yml b/tests/vcr_cassettes/2fa_flow_invalid_code.yml index 4c81536d0..ea6488ce9 100644 --- a/tests/vcr_cassettes/2fa_flow_invalid_code.yml +++ b/tests/vcr_cassettes/2fa_flow_invalid_code.yml @@ -201,6 +201,33 @@ interactions: status: code: 200 message: OK +- request: + body: null + headers: + Accept: ['application/json'] + Accept-Encoding: ['gzip, deflate'] + Connection: ['keep-alive'] + Content-Length: ['0'] + X-Apple-ID-Session-Id: ['session-1234567890'] + X-Apple-OAuth-Client-Id: ['d39ba9916b7251055b22c7f910e2ea796ee65e98b2ddecea8f5dde8d9d1a815d'] + X-Apple-OAuth-Client-Type: ['firstPartyAuth'] + X-Apple-OAuth-Redirect-URI: ['https://www.icloud.com'] + X-Apple-OAuth-Require-Grant-Code: ['true'] + X-Apple-OAuth-Response-Mode: ['web_message'] + X-Apple-OAuth-Response-Type: ['code'] + X-Apple-OAuth-State: ['DE309E26-942E-11E8-92F5-14109FE0B321'] + X-Apple-Widget-Key: ['d39ba9916b7251055b22c7f910e2ea796ee65e98b2ddecea8f5dde8d9d1a815d'] + scnt: ['scnt-1234567890'] + method: PUT + uri: https://idmsa.apple.com/appleauth/auth/verify/trusteddevice/securitycode + response: + body: + string: '' + headers: + Content-Type: ['application/json'] + status: + code: 200 + message: OK - request: body: !!python/unicode '{"securityCode": {"code": "901431"}}' headers: diff --git a/tests/vcr_cassettes/2fa_flow_valid_code.yml b/tests/vcr_cassettes/2fa_flow_valid_code.yml index ac84a2287..6cca6fdef 100644 --- a/tests/vcr_cassettes/2fa_flow_valid_code.yml +++ b/tests/vcr_cassettes/2fa_flow_valid_code.yml @@ -201,6 +201,33 @@ interactions: status: code: 200 message: OK +- request: + body: null + headers: + Accept: ['application/json'] + Accept-Encoding: ['gzip, deflate'] + Connection: ['keep-alive'] + Content-Length: ['0'] + X-Apple-ID-Session-Id: ['session-1234567890'] + X-Apple-OAuth-Client-Id: ['d39ba9916b7251055b22c7f910e2ea796ee65e98b2ddecea8f5dde8d9d1a815d'] + X-Apple-OAuth-Client-Type: ['firstPartyAuth'] + X-Apple-OAuth-Redirect-URI: ['https://www.icloud.com'] + X-Apple-OAuth-Require-Grant-Code: ['true'] + X-Apple-OAuth-Response-Mode: ['web_message'] + X-Apple-OAuth-Response-Type: ['code'] + X-Apple-OAuth-State: ['DE309E26-942E-11E8-92F5-14109FE0B321'] + X-Apple-Widget-Key: ['d39ba9916b7251055b22c7f910e2ea796ee65e98b2ddecea8f5dde8d9d1a815d'] + scnt: ['scnt-1234567890'] + method: PUT + uri: https://idmsa.apple.com/appleauth/auth/verify/trusteddevice/securitycode + response: + body: + string: '' + headers: + Content-Type: ['application/json'] + status: + code: 200 + message: OK - request: body: !!python/unicode '{"securityCode": {"code": "654321"}}' headers: diff --git a/tests/vcr_cassettes/2fa_flow_valid_code_zero_lead.yml b/tests/vcr_cassettes/2fa_flow_valid_code_zero_lead.yml index f5a303478..de898c551 100644 --- a/tests/vcr_cassettes/2fa_flow_valid_code_zero_lead.yml +++ b/tests/vcr_cassettes/2fa_flow_valid_code_zero_lead.yml @@ -201,6 +201,33 @@ interactions: status: code: 200 message: OK +- request: + body: null + headers: + Accept: ['application/json'] + Accept-Encoding: ['gzip, deflate'] + Connection: ['keep-alive'] + Content-Length: ['0'] + X-Apple-ID-Session-Id: ['session-1234567890'] + X-Apple-OAuth-Client-Id: ['d39ba9916b7251055b22c7f910e2ea796ee65e98b2ddecea8f5dde8d9d1a815d'] + X-Apple-OAuth-Client-Type: ['firstPartyAuth'] + X-Apple-OAuth-Redirect-URI: ['https://www.icloud.com'] + X-Apple-OAuth-Require-Grant-Code: ['true'] + X-Apple-OAuth-Response-Mode: ['web_message'] + X-Apple-OAuth-Response-Type: ['code'] + X-Apple-OAuth-State: ['DE309E26-942E-11E8-92F5-14109FE0B321'] + X-Apple-Widget-Key: ['d39ba9916b7251055b22c7f910e2ea796ee65e98b2ddecea8f5dde8d9d1a815d'] + scnt: ['scnt-1234567890'] + method: PUT + uri: https://idmsa.apple.com/appleauth/auth/verify/trusteddevice/securitycode + response: + body: + string: '' + headers: + Content-Type: ['application/json'] + status: + code: 200 + message: OK - request: body: !!python/unicode '{"securityCode": {"code": "054321"}}' headers: