From 5b087c6850e75549def8cb554d57e0bb51b59047 Mon Sep 17 00:00:00 2001 From: Eric Pheterson Date: Wed, 27 May 2026 09:25:30 -0700 Subject: [PATCH 1/5] fix: trigger 2FA push notification on iOS 26.4+ (closes #426) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Apple's auth flow changed around iOS 26 / early 2026. The 2FA prompt no longer arrives on trusted devices unless the client first sends a PUT to /verify/trusteddevice/securitycode (no body) to explicitly request the push. Without this, requires_2fa returns True but the user sees no code on any device — auth stalls. Adds a new public method `ICloudPyService.trigger_2fa_push_notification()` that does the PUT. Callers should invoke it before prompting the user for the code. Failure is non-fatal (returns False) — a code may still arrive via SMS or another path. Wired into the bundled `icloudpy` CLI (cmdline.py) so it works out of the box. Other callers (mandarons/icloud-docker, etc.) just need to add a single `api.trigger_2fa_push_notification()` call before their existing code prompt. Ported from icloud_photos_downloader PR #1335 (which solved the same issue in their fork of pyicloud). The fix has been validated by the boredazfcuk/docker-icloudpd community since 2026-05-04 and is the known working solution. Tests: - New trigger_2fa_push_notification test (HTTP 204 success path) - New scnt + session_id headers test (forwarding correctness) - New API exception non-fatal test (graceful failure) - Existing 212 tests still pass (only pre-existing test_storage ordering issue remains, unrelated) --- icloudpy/base.py | 36 ++++++++++++++++++++++++++++++++++ icloudpy/cmdline.py | 9 +++++++++ tests/__init__.py | 5 +++++ tests/test_auth.py | 48 +++++++++++++++++++++++++++++++++++++++++++-- 4 files changed, 96 insertions(+), 2 deletions(-) diff --git a/icloudpy/base.py b/icloudpy/base.py index 5df471aa..a88c2c3d 100644 --- a/icloudpy/base.py +++ b/icloudpy/base.py @@ -552,6 +552,42 @@ 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: + self.session.put( + f"{self.auth_endpoint}/verify/trusteddevice/securitycode", + headers=headers, + ) + LOGGER.debug("2FA push notification triggered.") + return True + except ICloudPyAPIResponseException: + LOGGER.debug("Failed to trigger 2FA push notification.") + 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..c92d7582 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..1c39fb9d 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,48 @@ 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.""" + from unittest.mock import patch + + 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_failure_is_non_fatal(self): + """Failure to trigger push must return False, not raise.""" + from unittest.mock import patch + + from icloudpy.exceptions import ICloudPyAPIResponseException + + 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 + class TestTwoStepAuthentication(TestCase): """Test 2SA (HSA1) authentication flows.""" @@ -507,7 +549,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) From 4b30f9ceabe8c7eaf244de5e2537e7d0b40065a9 Mon Sep 17 00:00:00 2001 From: Eric Pheterson Date: Wed, 27 May 2026 10:18:15 -0700 Subject: [PATCH 2/5] review: broaden push-notification exception catch + add network-failure test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses two code-review items on the iOS 26.4 fix: 1. trigger_2fa_push_notification() previously caught only ICloudPyAPIResponseException. Network/transport errors (ConnectionError, Timeout, SSLError) would propagate to callers despite the docstring promising non-fatal behavior. Broadened the catch to `except Exception` so all transient failures return False as advertised. The push is best-effort anyway — a code may still arrive via SMS or other paths. 2. Added test_trigger_2fa_push_notification_network_failure_is_non_fatal exercising three real requests exceptions (ConnectionError, Timeout, SSLError) to enforce the broadened contract. Also dropped two duplicate `from unittest.mock import patch` re-imports inside test methods — `patch` is already imported at module level. 213 passed + 1 pre-existing unrelated failure (test_storage). --- icloudpy/base.py | 36 ++++++++++++++++++++++++++++++++++++ tests/test_auth.py | 31 +++++++++++++++++++++++-------- 2 files changed, 59 insertions(+), 8 deletions(-) diff --git a/icloudpy/base.py b/icloudpy/base.py index a88c2c3d..ca2094ab 100644 --- a/icloudpy/base.py +++ b/icloudpy/base.py @@ -588,6 +588,42 @@ def trigger_2fa_push_notification(self): LOGGER.debug("Failed to trigger 2FA push notification.") return False + 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: + self.session.put( + f"{self.auth_endpoint}/verify/trusteddevice/securitycode", + headers=headers, + ) + 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/tests/test_auth.py b/tests/test_auth.py index 1c39fb9d..3e8303cc 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -156,8 +156,6 @@ def test_trigger_2fa_push_notification_success(self): def test_trigger_2fa_push_notification_includes_session_headers(self): """trigger_2fa_push_notification must forward scnt + session_id headers.""" - from unittest.mock import patch - service = ICloudPyServiceMock(REQUIRES_2FA_USER, VALID_PASSWORD) service.session_data["scnt"] = "scnt-value" service.session_data["session_id"] = "session-id-value" @@ -174,12 +172,8 @@ def test_trigger_2fa_push_notification_includes_session_headers(self): assert call_headers["scnt"] == "scnt-value" assert call_headers["X-Apple-ID-Session-Id"] == "session-id-value" - def test_trigger_2fa_push_notification_failure_is_non_fatal(self): - """Failure to trigger push must return False, not raise.""" - from unittest.mock import patch - - from icloudpy.exceptions import ICloudPyAPIResponseException - + 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: @@ -188,6 +182,27 @@ def test_trigger_2fa_push_notification_failure_is_non_fatal(self): 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.""" From 11c68e7e81851f21d5b45f1bc75341436b54da2d Mon Sep 17 00:00:00 2001 From: Eric Pheterson Date: Wed, 27 May 2026 14:43:24 -0700 Subject: [PATCH 3/5] fix: remove duplicate trigger_2fa_push_notification definition CRITICAL-1 from pre-submission review. The earlier review-fix commit (broaden exception catch) accidentally ADDED a second copy of the method instead of REPLACING the original. Python silently took the second (broader-catch) definition; the first was dead code that would produce a SyntaxWarning in Python 3.12+ and would have been the first thing a maintainer flagged on the upstream PR. Removes the first (narrow-catch) definition. The remaining (broad-catch) version is the one the tests already exercise and the one wired into cmdline.py. --- icloudpy/base.py | 36 ------------------------------------ 1 file changed, 36 deletions(-) diff --git a/icloudpy/base.py b/icloudpy/base.py index ca2094ab..60ea219a 100644 --- a/icloudpy/base.py +++ b/icloudpy/base.py @@ -552,42 +552,6 @@ 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: - self.session.put( - f"{self.auth_endpoint}/verify/trusteddevice/securitycode", - headers=headers, - ) - LOGGER.debug("2FA push notification triggered.") - return True - except ICloudPyAPIResponseException: - LOGGER.debug("Failed to trigger 2FA push notification.") - return False - def trigger_2fa_push_notification(self): """Asks Apple to push a 2FA verification code to trusted devices. From 9dfa3eca07d22b39d92b67e166bccd86e7ad074b Mon Sep 17 00:00:00 2001 From: Eric Pheterson Date: Sat, 30 May 2026 08:51:30 -0700 Subject: [PATCH 4/5] =?UTF-8?q?test:=20ruff-clean=20=E2=80=94=20two=20miss?= =?UTF-8?q?ing=20trailing=20commas=20(COM812)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI runs `ruff check && pytest`. The recent review-driven commits introduced two trailing-comma issues: - icloudpy/cmdline.py:241 — multi-line print() inside the iOS 26.4 push-trigger fallback. - tests/test_auth.py:568 — multi-line call in TestErrorCodeHandling. Auto-fixed with `ruff check --fix`. No semantic change. Verified on a python:3.10 docker that mirrors mandarons' CI: 214 passed, 80.60% coverage (above the 78% gate). Co-Authored-By: Claude Opus 4.7 (1M context) --- icloudpy/cmdline.py | 2 +- tests/test_auth.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/icloudpy/cmdline.py b/icloudpy/cmdline.py index c92d7582..81e7d8dc 100644 --- a/icloudpy/cmdline.py +++ b/icloudpy/cmdline.py @@ -238,7 +238,7 @@ def main(args=None): if not api.trigger_2fa_push_notification(): print( "(Could not trigger push notification — " - "a code may still arrive via SMS or another path.)" + "a code may still arrive via SMS or another path.)", ) # fmt: off diff --git a/tests/test_auth.py b/tests/test_auth.py index 3e8303cc..a3e0d277 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -565,7 +565,7 @@ def test_authentication_failed_error(self): with pytest.raises(ICloudPyServiceNotActivatedException) as exc_info: service.session._raise_error( - "AUTHENTICATION_FAILED", "Authentication failed" + "AUTHENTICATION_FAILED", "Authentication failed", ) assert "manually finish setting up" in str(exc_info.value) From 12073db6b60060e5310214792cd69bbcf1257405 Mon Sep 17 00:00:00 2001 From: Mandar Patil Date: Sat, 30 May 2026 09:47:49 -0700 Subject: [PATCH 5/5] treat non-2xx responses (or unexpected redirects/HTML 200s) as failure and return False Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- icloudpy/base.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/icloudpy/base.py b/icloudpy/base.py index 60ea219a..d8cb6aef 100644 --- a/icloudpy/base.py +++ b/icloudpy/base.py @@ -578,10 +578,16 @@ def trigger_2fa_push_notification(self): headers["X-Apple-ID-Session-Id"] = self.session_data.get("session_id") try: - self.session.put( + 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