Skip to content
Draft
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,7 @@ icloud auth status
icloud auth login --username jappleseed@apple.com
icloud auth login --username jappleseed@apple.com --china-mainland
icloud auth login --username jappleseed@apple.com --accept-terms
icloud auth login --username jappleseed@apple.com --one-factor
icloud account summary
icloud account summary --format json
icloud devices list --locate
Expand Down Expand Up @@ -189,6 +190,31 @@ icloud auth logout --remove-keyring
icloud auth keyring delete --username jappleseed@apple.com
```

### Signing in for Find My only

Apple lets one service through with the password alone: Find My. Of the apps it
advertises for an account, `find` is the only one flagged
`canLaunchWithOneFactor`, so locating a device or playing a sound needs no
two-factor code -- the same reason `icloud.com/find` works in a private window
after just a password.

```console
icloud auth login --username jappleseed@apple.com --one-factor
icloud devices list
```

The session this creates is deliberately untrusted, and **only `icloud devices`
will work with it**. Every other command still needs a full session:

```console
icloud auth login --username jappleseed@apple.com
```

`icloud auth status` reports which kind you have, under `Trusted Session`.

If your trust token is still valid, `--one-factor` costs you nothing: the login
comes back fully trusted and the flag changes nothing.

If you would like to delete a password stored in your system keyring,
use the dedicated keyring subcommand:

Expand Down
3 changes: 3 additions & 0 deletions pyicloud/cli/commands/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
InteractiveOption,
LogLevelOption,
NoVerifySslOption,
OneFactorOption,
OutputFormatOption,
PasswordOption,
SessionDirOption,
Expand Down Expand Up @@ -201,6 +202,7 @@ def auth_login(
china_mainland: ChinaMainlandOption = None,
interactive: InteractiveOption = True,
accept_terms: AcceptTermsOption = False,
one_factor: OneFactorOption = False,
http_proxy: HttpProxyOption = None,
https_proxy: HttpsProxyOption = None,
no_verify_ssl: NoVerifySslOption = False,
Expand All @@ -217,6 +219,7 @@ def auth_login(
china_mainland=china_mainland,
interactive=interactive,
accept_terms=accept_terms,
one_factor=one_factor,
http_proxy=http_proxy,
https_proxy=https_proxy,
no_verify_ssl=no_verify_ssl,
Expand Down
26 changes: 25 additions & 1 deletion pyicloud/cli/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ class CLICommandOptions:
china_mainland: bool | None = None
interactive: bool = True
accept_terms: bool = False
one_factor: bool = False
with_family: bool = False
session_dir: str | None = None
http_proxy: str | None = None
Expand All @@ -94,6 +95,7 @@ def __init__(
china_mainland: bool | None,
interactive: bool,
accept_terms: bool,
one_factor: bool,
with_family: bool,
session_dir: str | None,
http_proxy: str | None,
Expand All @@ -108,6 +110,7 @@ def __init__(
self.china_mainland = china_mainland
self.interactive = interactive
self.accept_terms = accept_terms
self.one_factor = one_factor
self.with_family = with_family
self.session_dir = session_dir
self.http_proxy = http_proxy
Expand All @@ -133,6 +136,7 @@ def from_options(cls, options: CLICommandOptions) -> CLIState:
china_mainland=options.china_mainland,
interactive=options.interactive,
accept_terms=options.accept_terms,
one_factor=options.one_factor,
with_family=options.with_family,
session_dir=options.session_dir,
http_proxy=options.http_proxy,
Expand Down Expand Up @@ -429,6 +433,20 @@ def _handle_2sa(self, api: PyiCloudService) -> None:
if not api.validate_verification_code(device, code):
raise CLIAbort("Failed to verify the 2SA code.")

def _warn_one_factor_session(self, api: PyiCloudService) -> None:
"""Tell the user what a one-factor session can and cannot do."""

if api.is_trusted_session:
# A still-valid trust token means the session came back fully
# trusted; --one-factor cost the user nothing and limits nothing.
return
self.err_console.print(
"[yellow]Signed in without two-factor authentication.[/yellow] "
"Apple grants this to Find My only, so `icloud devices` will work "
"and every other command will ask you to authenticate again. "
"Run `icloud auth login` without --one-factor for a full session."
)

def get_login_api(self) -> PyiCloudService:
"""Return a PyiCloudService, bootstrapping login if needed."""

Expand All @@ -450,6 +468,7 @@ def get_login_api(self) -> PyiCloudService:
cookie_directory=self.session_dir,
accept_terms=self.accept_terms,
with_family=self.with_family,
pause_2fa=self.one_factor,
)
except PyiCloudFailedLoginException as err:
if password_source == "keyring" and utils.password_exists_in_keyring(
Expand All @@ -466,7 +485,12 @@ def get_login_api(self) -> PyiCloudService:
):
utils.store_password_in_keyring(username, password)

if api.requires_2fa:
if self.one_factor:
# Apple grants a password-only session to Find My alone, so there is
# no code to ask for. Anything else will fail until the user logs in
# again without --one-factor.
self._warn_one_factor_session(api)
elif api.requires_2fa:
self._handle_2fa(api)
elif api.requires_2sa:
self._handle_2sa(api)
Expand Down
15 changes: 15 additions & 0 deletions pyicloud/cli/options.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,11 @@
CHINA_MAINLAND_OPTION_HELP = "Use China mainland Apple web service endpoints."
INTERACTIVE_OPTION_HELP = "Enable or disable interactive prompts."
ACCEPT_TERMS_OPTION_HELP = "Automatically accept pending Apple iCloud web terms."
ONE_FACTOR_OPTION_HELP = (
"Sign in with the password alone and skip the two-factor prompt. Apple "
"allows this for Find My only, so the resulting session serves "
"`icloud devices` and nothing else."
)
WITH_FAMILY_OPTION_HELP = "Include family devices in Find My device listings."
SESSION_DIR_OPTION_HELP = "Directory to store session and cookie files."
HTTP_PROXY_OPTION_HELP = "HTTP proxy URL for requests."
Expand Down Expand Up @@ -82,6 +87,14 @@
rich_help_panel=AUTHENTICATION_PANEL,
),
]
OneFactorOption = Annotated[
bool,
typer.Option(
"--one-factor",
help=ONE_FACTOR_OPTION_HELP,
rich_help_panel=AUTHENTICATION_PANEL,
),
]
HttpProxyOption = Annotated[
str | None,
typer.Option(
Expand Down Expand Up @@ -142,6 +155,7 @@ def store_command_options(
china_mainland: bool | None = None,
interactive: bool = True,
accept_terms: bool = False,
one_factor: bool = False,
with_family: bool = False,
session_dir: str | None = None,
http_proxy: str | None = None,
Expand All @@ -158,6 +172,7 @@ def store_command_options(
"china_mainland": china_mainland,
"interactive": interactive,
"accept_terms": accept_terms,
"one_factor": one_factor,
"with_family": with_family,
"session_dir": session_dir,
"http_proxy": http_proxy,
Expand Down
7 changes: 7 additions & 0 deletions pyicloud/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,13 @@
ACCOUNT_NAME = "accountName"


#: Find My's key in the ``apps`` map Apple returns from /validate. It is *not*
#: ``findme``, which is this service's key in the ``webservices`` map -- the two
#: maps name the same service differently, and a one-factor login looked up
#: under the wrong key fails silently as "not one-factor capable".
FIND_MY_APP_KEY = "find"


ERROR_ACCESS_DENIED = "ACCESS_DENIED"
ERROR_ZONE_NOT_FOUND = "ZONE_NOT_FOUND"
ERROR_AUTHENTICATION_FAILED = "AUTHENTICATION_FAILED"
Expand Down
10 changes: 9 additions & 1 deletion pyicloud/services/findmyiphone.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

from requests import Response

from pyicloud.const import FIND_MY_APP_KEY
from pyicloud.exceptions import (
PyiCloudAuthRequiredException,
PyiCloudNoDevicesException,
Expand Down Expand Up @@ -111,7 +112,14 @@ def _refresh_client_with_reauth(self, locate: bool, retry: bool = False) -> None

_LOGGER.debug("Re-authenticating session")
self._server_ctx = None
self.session.service.authenticate(force_refresh=True)
# Name the service so Apple's one-factor grant applies: Find My is
# the only app flagged ``canLaunchWithOneFactor``, so this recovers
# a 450 with the password alone instead of escalating to a 2FA
# challenge the user never asked for. Accounts or sessions that do
# not qualify fall through to the full login as before.
self.session.service.authenticate(
force_refresh=True, service=FIND_MY_APP_KEY
)
self._refresh_client_with_reauth(locate=locate, retry=True)
return

Expand Down
78 changes: 76 additions & 2 deletions tests/services/test_findmyiphone.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import pytest

from pyicloud import PyiCloudService
from pyicloud.const import FIND_MY_APP_KEY
from pyicloud.exceptions import (
PyiCloudAuthRequiredException,
PyiCloudNoDevicesException,
Expand Down Expand Up @@ -578,7 +579,9 @@ def test_refresh_client_with_reauth_auth_required(
patch.object(manager, "_with_family", False),
):
manager._refresh_client_with_reauth(locate=True)
mock_authenticate.assert_called_once_with(force_refresh=True)
mock_authenticate.assert_called_once_with(
force_refresh=True, service=FIND_MY_APP_KEY
)
assert mock_refresh.call_count == 2
mock_refresh.assert_has_calls([call(locate=True), call(locate=True)])

Expand Down Expand Up @@ -607,7 +610,9 @@ def test_refresh_client_with_reauth_failed(
):
with pytest.raises(PyiCloudAuthRequiredException):
manager._refresh_client_with_reauth(locate=True)
mock_authenticate.assert_called_once_with(force_refresh=True)
mock_authenticate.assert_called_once_with(
force_refresh=True, service=FIND_MY_APP_KEY
)
assert mock_refresh.call_count == 2
mock_refresh.assert_has_calls([call(locate=True), call(locate=True)])

Expand Down Expand Up @@ -1012,3 +1017,72 @@ def test_monitor_thread_multiple_intervals() -> None:
# Should call func twice
assert mock_func.call_count == 2
mock_func.assert_has_calls([call(True), call(True)])


def test_find_my_reauth_uses_the_apps_key_not_the_webservices_key() -> None:
"""The one-factor lookup needs 'find'; 'findme' would silently not match.

Apple names this service 'find' in the /validate ``apps`` map and 'findme'
in the ``webservices`` map. ``_try_service_one_factor_login`` looks in the
former, so passing the latter falls through to a full 2FA login instead.
"""

assert FIND_MY_APP_KEY == "find"
assert FIND_MY_APP_KEY != "findme"


def test_reauth_falls_back_to_full_login_when_apple_denies_one_factor(
pyicloud_service_working: PyiCloudService,
) -> None:
"""An account without the one-factor grant still re-authenticates fully."""

api = pyicloud_service_working
api.data = {"apps": {FIND_MY_APP_KEY: {"canLaunchWithOneFactor": False}}}

with (
patch.object(api, "_try_reuse_cached_session", return_value=False),
patch.object(api, "_authenticate_with_credentials_service") as one_factor,
patch.object(api, "_authenticate") as full_login,
):
api.authenticate(force_refresh=True, service=FIND_MY_APP_KEY)

one_factor.assert_not_called()
full_login.assert_called_once()


def test_reauth_uses_one_factor_when_apple_grants_it(
pyicloud_service_working: PyiCloudService,
) -> None:
"""The one-factor grant short-circuits the full login."""

api = pyicloud_service_working
api.data = {"apps": {FIND_MY_APP_KEY: {"canLaunchWithOneFactor": True}}}

with (
patch.object(api, "_try_reuse_cached_session", return_value=False),
patch.object(api, "_authenticate_with_credentials_service") as one_factor,
patch.object(api, "_authenticate") as full_login,
):
api.authenticate(force_refresh=True, service=FIND_MY_APP_KEY)

one_factor.assert_called_once_with(FIND_MY_APP_KEY)
full_login.assert_not_called()


def test_reauth_with_the_webservices_key_misses_the_one_factor_grant(
pyicloud_service_working: PyiCloudService,
) -> None:
"""Guard the trap: 'findme' does not match the grant keyed under 'find'."""

api = pyicloud_service_working
api.data = {"apps": {FIND_MY_APP_KEY: {"canLaunchWithOneFactor": True}}}

with (
patch.object(api, "_try_reuse_cached_session", return_value=False),
patch.object(api, "_authenticate_with_credentials_service") as one_factor,
patch.object(api, "_authenticate") as full_login,
):
api.authenticate(force_refresh=True, service="findme")

one_factor.assert_not_called()
full_login.assert_called_once()
Loading