Skip to content

Commit 14bc574

Browse files
cyberjunkyclaude
andcommitted
feat: self-healing login — validate tokens & recover from poisoned cache
Makes authentication failure- and future-proof against the #369 class of problems where a strategy obtains a DI token the API tier later rejects ("Token is not active"). Fully backwards compatible for end-user calls. In-chain token validation (client.login): After a strategy obtains a token, verify it with one lightweight authenticated call before accepting it. A token the API rejects (401/403) is discarded and the chain continues to the next strategy. Only definitive auth rejections fall through — transient 5xx/network errors are treated as inconclusive so a flaky network never blocks a working login. This is what makes a widget+cffi token that works on one account but is rejected on another auto-fall-through to portal+cffi, with no skip_strategies needed. Opt out via Garmin(verify_login=False) for legacy "first token wins". Disk-token self-healing (Garmin.login): If cached tokens load but the API rejects them, the cache is discarded and a full credential login runs automatically, then the profile fetch retries. Previously a poisoned cache silently short-circuited the strategy chain on every run (the load-before-chain footgun documented by issue #369). logout() helper: Upgraded the deprecated no-op into a real helper that clears in-memory auth state and removes cached tokens on disk. Still callable with no args. Also: - get_morning_training_readiness: restore defensive dict handling (a single dict response is passed through), fixing a regression from the #361 fix. - typed.get_training_readiness: empty {} / None now normalizes to []. - 12 new in-process tests covering all of the above. Refs #369 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent eab98de commit 14bc574

5 files changed

Lines changed: 373 additions & 50 deletions

File tree

garminconnect/__init__.py

Lines changed: 111 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import os
88
import random
99
import re
10+
import shutil
1011
import time
1112
from collections.abc import Callable
1213
from datetime import UTC, date, datetime, timedelta
@@ -282,6 +283,7 @@ def __init__(
282283
retry_attempts: int = 3,
283284
retry_min_wait: float = 1.0,
284285
retry_max_wait: float = 10.0,
286+
verify_login: bool = True,
285287
) -> None:
286288
"""Create a new class instance.
287289
@@ -292,6 +294,11 @@ def __init__(
292294
:param retry_min_wait: Initial backoff in seconds; grows exponentially
293295
with jitter between attempts.
294296
:param retry_max_wait: Upper bound on the backoff in seconds.
297+
:param verify_login: When ``True`` (default), each login strategy's
298+
token is validated against the API tier before the chain accepts
299+
it, so a token the API rejects (401/403) is discarded and the next
300+
strategy is tried. Set ``False`` for the legacy "first token wins"
301+
behavior.
295302
"""
296303
# Validate input types
297304
if email is not None and not isinstance(email, str):
@@ -306,6 +313,8 @@ def __init__(
306313
raise ValueError("retry_attempts must be an int")
307314
if retry_attempts < 0:
308315
raise ValueError("retry_attempts must be non-negative")
316+
if not isinstance(verify_login, bool):
317+
raise ValueError("verify_login must be a boolean")
309318

310319
self.username = email
311320
self.password = password
@@ -315,6 +324,7 @@ def __init__(
315324
self.retry_attempts = retry_attempts
316325
self.retry_min_wait = float(retry_min_wait)
317326
self.retry_max_wait = float(retry_max_wait)
327+
self.verify_login = verify_login
318328

319329
self.garmin_connect_user_settings_url = (
320330
"/userprofile-service/userprofile/user-settings"
@@ -505,6 +515,7 @@ def __init__(
505515
domain="garmin.cn" if is_cn else "garmin.com",
506516
pool_connections=20,
507517
pool_maxsize=20,
518+
verify_login=verify_login,
508519
)
509520

510521
self.display_name: str | None = None
@@ -629,49 +640,37 @@ def login(self, /, tokenstore: str | None = None) -> tuple[str | None, str | Non
629640
self.client.dump(tokenstore_path)
630641
# Continue to load profile/settings below
631642

632-
# Ensure profile is loaded (tokenstore path may not populate it)
633-
prof = None
634-
for attempt in range(3):
635-
try:
636-
prof = self.client.connectapi("/userprofile-service/socialProfile")
637-
if isinstance(prof, dict):
638-
break
639-
except Exception as e:
640-
if attempt == 2:
641-
raise GarminConnectAuthenticationError(
642-
"Failed to retrieve social profile"
643-
) from e
644-
logger.debug("Retrying social profile fetch: %s", e)
645-
time.sleep(1)
646-
else:
647-
raise GarminConnectAuthenticationError("Invalid profile data found")
648-
649-
self.display_name = prof.get("displayName", self.username)
650-
self.full_name = prof.get("fullName", "")
651-
652-
settings = None
653-
for attempt in range(3):
654-
try:
655-
settings = self.client.connectapi(
656-
self.garmin_connect_user_settings_url
657-
)
658-
if (
659-
settings
660-
and isinstance(settings, dict)
661-
and "userData" in settings
662-
):
663-
break
664-
except Exception as e:
665-
if attempt == 2:
666-
raise GarminConnectAuthenticationError(
667-
"Failed to retrieve user settings"
668-
) from e
669-
logger.debug("Retrying user settings fetch: %s", e)
670-
time.sleep(1)
671-
else:
672-
raise GarminConnectAuthenticationError("Invalid user settings found")
673-
674-
self.unit_system = settings["userData"].get("measurementSystem")
643+
# Ensure profile is loaded (tokenstore path may not populate it).
644+
try:
645+
self._load_profile_and_settings()
646+
except GarminConnectAuthenticationError:
647+
# If we resumed from cached tokens and the API rejects them,
648+
# the cache is stale/poisoned (e.g. a token whose audience the
649+
# API tier no longer accepts — see #369). Discard it and run a
650+
# full credential login so the strategy chain can find a token
651+
# the API accepts. Without this, a poisoned cache silently
652+
# short-circuits the chain on every run.
653+
username, password = self.username, self.password
654+
if not (
655+
tokens_loaded and username and password and not self.return_on_mfa
656+
):
657+
raise
658+
logger.warning(
659+
"Cached tokens were rejected by the API; discarding and "
660+
"performing a fresh login."
661+
)
662+
self.client._clear_auth_state()
663+
if tokenstore_path is not None:
664+
self.client._tokenstore_path = tokenstore_path
665+
mfa_status, _legacy_token = self.client.login(
666+
username,
667+
password,
668+
prompt_mfa=self.prompt_mfa,
669+
)
670+
if tokenstore_path is not None:
671+
with contextlib.suppress(Exception):
672+
self.client.dump(tokenstore_path)
673+
self._load_profile_and_settings()
675674

676675
return mfa_status, _legacy_token
677676

@@ -723,6 +722,48 @@ def login(self, /, tokenstore: str | None = None) -> tuple[str | None, str | Non
723722
logger.debug("Login failed: %s", e)
724723
raise GarminConnectConnectionError(f"Login failed: {e}") from e
725724

725+
def _load_profile_and_settings(self) -> None:
726+
"""Fetch social profile and user settings, populating display name,
727+
full name and unit system. Raises ``GarminConnectAuthenticationError``
728+
if either cannot be retrieved (e.g. the token is rejected).
729+
"""
730+
prof = None
731+
for attempt in range(3):
732+
try:
733+
prof = self.client.connectapi("/userprofile-service/socialProfile")
734+
if isinstance(prof, dict):
735+
break
736+
except Exception as e:
737+
if attempt == 2:
738+
raise GarminConnectAuthenticationError(
739+
"Failed to retrieve social profile"
740+
) from e
741+
logger.debug("Retrying social profile fetch: %s", e)
742+
time.sleep(1)
743+
else:
744+
raise GarminConnectAuthenticationError("Invalid profile data found")
745+
746+
self.display_name = prof.get("displayName", self.username)
747+
self.full_name = prof.get("fullName", "")
748+
749+
settings = None
750+
for attempt in range(3):
751+
try:
752+
settings = self.client.connectapi(self.garmin_connect_user_settings_url)
753+
if settings and isinstance(settings, dict) and "userData" in settings:
754+
break
755+
except Exception as e:
756+
if attempt == 2:
757+
raise GarminConnectAuthenticationError(
758+
"Failed to retrieve user settings"
759+
) from e
760+
logger.debug("Retrying user settings fetch: %s", e)
761+
time.sleep(1)
762+
else:
763+
raise GarminConnectAuthenticationError("Invalid user settings found")
764+
765+
self.unit_system = settings["userData"].get("measurementSystem")
766+
726767
def resume_login(
727768
self, client_state: dict[str, Any], mfa_code: str
728769
) -> tuple[Any, Any]:
@@ -1719,6 +1760,11 @@ def get_morning_training_readiness(self, cdate: str) -> dict[str, Any] | None:
17191760
if not data:
17201761
return None
17211762

1763+
# The endpoint normally returns a list of snapshots, but stay defensive:
1764+
# some responses (or callers stubbing this) hand back a single dict.
1765+
if isinstance(data, dict):
1766+
return data
1767+
17221768
morning_entry = next(
17231769
(
17241770
entry
@@ -2996,11 +3042,28 @@ def query_garmin_graphql(self, query: dict[str, Any]) -> dict[str, Any]:
29963042
"connectapi", self.garmin_graphql_endpoint, json=query
29973043
).json()
29983044

2999-
def logout(self) -> None:
3000-
"""Log user out of session."""
3001-
logger.warning(
3002-
"Deprecated: Alternative is to delete the login tokens to logout."
3003-
)
3045+
def logout(self, tokenstore: str | None = None) -> None:
3046+
"""Clear in-memory auth state and any cached tokens on disk.
3047+
3048+
Call this after an authentication failure to guarantee the next
3049+
``login()`` runs the full strategy chain instead of resuming
3050+
stale/poisoned cached tokens. ``login()`` already self-heals from
3051+
rejected cached tokens, so this is only needed for manual control.
3052+
3053+
:param tokenstore: Path to the token directory/file to remove. Falls
3054+
back to the ``GARMINTOKENS`` environment variable. Token strings
3055+
passed inline (length > 512) are ignored — nothing to delete.
3056+
"""
3057+
self.client._clear_auth_state()
3058+
tokenstore = tokenstore or os.getenv("GARMINTOKENS")
3059+
if not tokenstore or len(tokenstore) > 512:
3060+
return
3061+
path = Path(tokenstore).expanduser()
3062+
with contextlib.suppress(Exception):
3063+
if path.is_dir():
3064+
shutil.rmtree(path)
3065+
elif path.exists():
3066+
path.unlink()
30043067

30053068
def get_training_plans(self) -> dict[str, Any]:
30063069
"""Return all available training plans."""

garminconnect/client.py

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -208,11 +208,50 @@ def __init__(self, domain: str = "garmin.com", **kwargs: Any) -> None:
208208
# Valid names: mobile+cffi, mobile+requests, widget+cffi,
209209
# portal+cffi, portal+requests
210210
self.skip_strategies: set[str] = set()
211+
# When True (default), each login strategy's token is validated against
212+
# the API tier before the chain accepts it; a token the API rejects
213+
# (401/403) is discarded and the next strategy is tried. Set False to
214+
# restore the legacy "first token wins" behavior.
215+
self.verify_login: bool = kwargs.get("verify_login", True)
211216

212217
@property
213218
def is_authenticated(self) -> bool:
214219
return bool(self.di_token or self.jwt_web)
215220

221+
def _clear_auth_state(self) -> None:
222+
"""Wipe all in-memory auth tokens so the next login starts clean."""
223+
self.di_token = None
224+
self.di_refresh_token = None
225+
self.di_client_id = None
226+
self.jwt_web = None
227+
self.csrf_token = None
228+
229+
def _verify_token(self) -> bool:
230+
"""Check that the current token is actually accepted by the API tier.
231+
232+
A strategy can obtain a DI token from the auth host (HTTP 200) that the
233+
API tier (connectapi) then rejects with 401 "Token is not active" —
234+
this is account/region dependent (see issue #369). We confirm the token
235+
works with one lightweight authenticated call.
236+
237+
Returns True if accepted, or if the check is inconclusive. Only a
238+
definitive auth rejection (401/403) returns False, so a transient
239+
network error or 5xx never blocks an otherwise-working login.
240+
"""
241+
try:
242+
self.connectapi("/userprofile-service/socialProfile")
243+
return True
244+
except GarminConnectConnectionError as e:
245+
msg = str(e)
246+
if "401" in msg or "403" in msg:
247+
_LOGGER.warning("Token rejected by API tier: %s", msg)
248+
return False
249+
_LOGGER.debug("Token validation inconclusive (kept): %s", msg)
250+
return True
251+
except Exception as e:
252+
_LOGGER.debug("Token validation inconclusive (kept): %s", e)
253+
return True
254+
216255
def get_api_headers(self) -> dict[str, str]:
217256
if not self.is_authenticated:
218257
raise GarminConnectAuthenticationError("Not authenticated")
@@ -284,7 +323,18 @@ def login(
284323
try:
285324
_LOGGER.debug("Trying login strategy: %s", name)
286325
run()
287-
# Strategy succeeded — login complete
326+
# Strategy got a token — make sure the API tier accepts it
327+
# before declaring success (else fall through to the next).
328+
if self.verify_login and not self._verify_token():
329+
_LOGGER.warning(
330+
"%s obtained a token the API rejected; trying next strategy",
331+
name,
332+
)
333+
self._clear_auth_state()
334+
last_err = GarminConnectConnectionError(
335+
f"{name}: token rejected by API tier"
336+
)
337+
continue
288338
return None, None
289339
except GarminConnectAuthenticationError:
290340
# Wrong credentials — stop immediately, no point trying further
@@ -296,6 +346,17 @@ def login(
296346
if prompt_mfa:
297347
mfa_code = prompt_mfa()
298348
self._complete_mfa(mfa_code)
349+
if self.verify_login and not self._verify_token():
350+
_LOGGER.warning(
351+
"%s obtained a token the API rejected after MFA; "
352+
"trying next strategy",
353+
name,
354+
)
355+
self._clear_auth_state()
356+
last_err = GarminConnectConnectionError(
357+
f"{name}: token rejected by API tier"
358+
)
359+
continue
299360
return None, None
300361
raise GarminConnectAuthenticationError( # noqa: B904
301362
"MFA Required but no prompt_mfa mechanism supplied"

garminconnect/typed.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -543,6 +543,9 @@ def get_training_readiness(self, cdate: str) -> list[TrainingReadiness]:
543543
wrapper normalizes both shapes to ``list[TrainingReadiness]``.
544544
"""
545545
raw = self._garmin.get_training_readiness(cdate)
546+
if not raw:
547+
# Empty list / empty dict / None — no snapshots available.
548+
return []
546549
if isinstance(raw, list):
547550
return [
548551
self._validate(TrainingReadiness, item, "get_training_readiness")

pyproject.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "garminconnect"
3-
version = "0.3.5"
3+
version = "0.3.6"
44
description = "Python 3 API wrapper for Garmin Connect"
55
authors = [
66
{name = "Ron Klinkien", email = "ron@cyberjunky.nl"},
@@ -55,6 +55,7 @@ module = [
5555
"test_garmin_unit",
5656
"test_retry_decorator",
5757
"test_workout_constants",
58+
"test_login_recovery",
5859
"conftest",
5960
]
6061
disallow_untyped_defs = false

0 commit comments

Comments
 (0)