77import os
88import random
99import re
10+ import shutil
1011import time
1112from collections .abc import Callable
1213from 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."""
0 commit comments