diff --git a/icloudpy/base.py b/icloudpy/base.py index 5df471aa..d8cb6aef 100644 --- a/icloudpy/base.py +++ b/icloudpy/base.py @@ -552,6 +552,48 @@ def validate_verification_code(self, device, code): return not self.requires_2sa + def trigger_2fa_push_notification(self): + """Asks Apple to push a 2FA verification code to trusted devices. + + Apple's auth flow (changed around iOS 26 / early 2026) requires a PUT + to ``/verify/trusteddevice/securitycode`` (no body) to initiate code + delivery to trusted devices. Without this step, the user is prompted + for a code but no code is ever pushed to any device — auth stalls. + + Callers should invoke this method before prompting the user for the + 2FA code (i.e., right after ``requires_2fa`` returns True). Failure + is non-fatal — a code may still arrive via SMS or another path. + + See: https://github.com/mandarons/icloud-docker/issues/426 + Ported from icloud_photos_downloader PR #1335. + + :returns: True if the push request succeeded, False otherwise. + """ + headers = self._get_auth_headers({"Accept": "application/json"}) + + if self.session_data.get("scnt"): + headers["scnt"] = self.session_data.get("scnt") + + if self.session_data.get("session_id"): + headers["X-Apple-ID-Session-Id"] = self.session_data.get("session_id") + + try: + response = self.session.put( + f"{self.auth_endpoint}/verify/trusteddevice/securitycode", + headers=headers, + ) + if not (200 <= response.status_code < 300): + LOGGER.debug( + "2FA push notification trigger returned status %s", + response.status_code, + ) + return False + LOGGER.debug("2FA push notification triggered.") + return True + except Exception as error: # network, SSL, or Apple API errors are all non-fatal here + LOGGER.debug("Failed to trigger 2FA push notification: %s", error) + return False + def validate_2fa_code(self, code): """Verifies a verification code received via Apple's 2FA system (HSA2).""" data = {"securityCode": {"code": code}} diff --git a/icloudpy/cmdline.py b/icloudpy/cmdline.py index a06e3e60..81e7d8dc 100644 --- a/icloudpy/cmdline.py +++ b/icloudpy/cmdline.py @@ -232,6 +232,15 @@ def main(args=None): utils.store_password_in_keyring(username, password) if api.requires_2fa: + # Apple's auth flow (2026+) requires an explicit PUT to push + # the code to trusted devices. Without this, the user is + # prompted but no code is ever delivered. + if not api.trigger_2fa_push_notification(): + print( + "(Could not trigger push notification — " + "a code may still arrive via SMS or another path.)", + ) + # fmt: off print( "\nTwo-step authentication required.", diff --git a/tests/__init__.py b/tests/__init__.py index d4f08f28..10e81ff4 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -158,6 +158,11 @@ def request(self, method, url, **kwargs): self.service.session_data["session_token"] = VALID_TOKEN return ResponseMock("", status_code=204) + if "securitycode" in url and method == "PUT": + # PUT (no body) triggers Apple's push notification flow for 2FA. + # Added 2026+ via icloudpy fix/ios-26.4-auth. + return ResponseMock("", status_code=204) + if "trust" in url and method == "GET": return ResponseMock("", status_code=204) diff --git a/tests/test_auth.py b/tests/test_auth.py index 2b349c71..a3e0d277 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -15,7 +15,7 @@ ICloudPyServiceNotActivatedException, ) -from . import ICloudPyServiceMock +from . import ICloudPyServiceMock, ResponseMock from .const import ( AUTHENTICATED_USER, REQUIRES_2FA_USER, @@ -146,6 +146,63 @@ def test_validate_2fa_code_wrong(self): result = service.validate_2fa_code("000001") # Wrong code assert result is False + def test_trigger_2fa_push_notification_success(self): + """trigger_2fa_push_notification PUTs to the securitycode endpoint.""" + service = ICloudPyServiceMock(REQUIRES_2FA_USER, VALID_PASSWORD) + # Mock returns 204 for PUT to /verify/trusteddevice/securitycode + # See tests/__init__.py mock dispatch for the PUT branch. + result = service.trigger_2fa_push_notification() + assert result is True + + def test_trigger_2fa_push_notification_includes_session_headers(self): + """trigger_2fa_push_notification must forward scnt + session_id headers.""" + service = ICloudPyServiceMock(REQUIRES_2FA_USER, VALID_PASSWORD) + service.session_data["scnt"] = "scnt-value" + service.session_data["session_id"] = "session-id-value" + + with patch.object(service.session, "put") as fake_put: + fake_put.return_value = ResponseMock("", status_code=204) + result = service.trigger_2fa_push_notification() + + assert result is True + fake_put.assert_called_once() + call_url = fake_put.call_args.args[0] + call_headers = fake_put.call_args.kwargs["headers"] + assert call_url.endswith("/verify/trusteddevice/securitycode") + assert call_headers["scnt"] == "scnt-value" + assert call_headers["X-Apple-ID-Session-Id"] == "session-id-value" + + def test_trigger_2fa_push_notification_api_failure_is_non_fatal(self): + """Apple API errors must return False, not raise.""" + service = ICloudPyServiceMock(REQUIRES_2FA_USER, VALID_PASSWORD) + + with patch.object(service.session, "put") as fake_put: + fake_put.side_effect = ICloudPyAPIResponseException("Bad gateway") + result = service.trigger_2fa_push_notification() + + assert result is False + + def test_trigger_2fa_push_notification_network_failure_is_non_fatal(self): + """Network/transport errors (timeout, SSL, connection) must return False, not raise. + + The docstring promises non-fatal behavior — the implementation must + deliver it for all exception types, not only Apple API errors. + """ + import requests + + service = ICloudPyServiceMock(REQUIRES_2FA_USER, VALID_PASSWORD) + + for exc in ( + requests.exceptions.ConnectionError("network down"), + requests.exceptions.Timeout("too slow"), + requests.exceptions.SSLError("cert error"), + ): + with patch.object(service.session, "put") as fake_put: + fake_put.side_effect = exc + # Must not raise + result = service.trigger_2fa_push_notification() + assert result is False, f"network exception {type(exc).__name__} should return False" + class TestTwoStepAuthentication(TestCase): """Test 2SA (HSA1) authentication flows.""" @@ -507,7 +564,9 @@ def test_authentication_failed_error(self): service = ICloudPyServiceMock(AUTHENTICATED_USER, VALID_PASSWORD) with pytest.raises(ICloudPyServiceNotActivatedException) as exc_info: - service.session._raise_error("AUTHENTICATION_FAILED", "Authentication failed") + service.session._raise_error( + "AUTHENTICATION_FAILED", "Authentication failed", + ) assert "manually finish setting up" in str(exc_info.value)