Skip to content
42 changes: 42 additions & 0 deletions icloudpy/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
mandarons marked this conversation as resolved.

def validate_2fa_code(self, code):
"""Verifies a verification code received via Apple's 2FA system (HSA2)."""
data = {"securityCode": {"code": code}}
Expand Down
9 changes: 9 additions & 0 deletions icloudpy/cmdline.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
5 changes: 5 additions & 0 deletions tests/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
63 changes: 61 additions & 2 deletions tests/test_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
ICloudPyServiceNotActivatedException,
)

from . import ICloudPyServiceMock
from . import ICloudPyServiceMock, ResponseMock
from .const import (
AUTHENTICATED_USER,
REQUIRES_2FA_USER,
Expand Down Expand Up @@ -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."""
Expand Down Expand Up @@ -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)

Expand Down
Loading