"""Authentication engine for Garmin Connect. Strategy chain (each strategy is tried in order; only auth errors stop the chain): 1. Mobile iOS + curl_cffi (TLS fingerprint rotation, no delay needed) 2. Mobile iOS + requests (plain HTTP fallback) 3. SSO embed widget + cffi (HTML form flow, bypasses clientId rate limits) 4. Portal web + curl_cffi (TLS fingerprint rotation, 10-20s anti-WAF delay) 5. Portal web + requests (plain HTTP last resort) """ import base64 import contextlib import http.cookiejar import json import logging import math import os import random import re import secrets import threading import time from collections.abc import Iterator, Mapping from pathlib import Path from typing import Any, cast from urllib.parse import unquote import requests from requests.adapters import HTTPAdapter try: from curl_cffi import requests as cffi_requests HAS_CFFI = True except ImportError: HAS_CFFI = False try: from ua_generator import generate as _generate_ua HAS_UA_GEN = True except ImportError: HAS_UA_GEN = False from .exceptions import ( GarminConnectAuthenticationError, GarminConnectConnectionError, GarminConnectNotFoundError, GarminConnectTooManyRequestsError, ) _LOGGER = logging.getLogger(__name__) # Detect ~username expansion that would point into another user's home directory. _OTHER_USER_HOME_RE = re.compile(r"^~[^/\\]") def token_file_path(path: str) -> Path: """Return the token file represented by a directory or JSON path. Rejects paths that expand into another user's home directory via ``~username`` syntax. Bare ``~`` and ``~/...`` are allowed because they resolve to the current user's home. Also rejects symlinked tokenstore paths so a pre-planted symlink cannot redirect load/dump/logout to an attacker-controlled location. """ if _OTHER_USER_HOME_RE.match(path): raise ValueError( f"Token path must not reference another user's home directory: {path!r}" ) token_path = Path(path).expanduser() # Reject symlinks anywhere in the tokenstore ancestry (e.g. # ~/.garminconnect -> /attacker/dir). O_NOFOLLOW on the final open() # only covers the last component; an intermediate symlinked directory # would still redirect load/dump/logout into an attacker-controlled tree. for check_path in (token_path, *token_path.parents): try: if check_path.is_symlink(): raise ValueError(f"Token path must not be a symlink: {path!r}") except OSError as e: raise ValueError( f"Token path cannot be checked for symlinks: {path!r}" ) from e if token_path.is_dir() or token_path.suffix.casefold() != ".json": return token_path / "garmin_tokens.json" return token_path # -- Domain allowlist -- # Only official Garmin domains are valid for authentication and API traffic. # Arbitrary values would let a malicious caller redirect credentials elsewhere. ALLOWED_DOMAINS = {"garmin.com", "garmin.cn"} # -- iOS mobile app constants (Strategy 1 & 2) -- IOS_SSO_CLIENT_ID = "GCM_IOS_DARK" IOS_SERVICE_URL = "https://mobile.integration.garmin.com/gcm/ios" IOS_LOGIN_UA = ( "Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) " "AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148" ) # -- Android mobile app constants (legacy alias, kept for backward compat) -- MOBILE_SSO_CLIENT_ID = "GCM_ANDROID_DARK" MOBILE_SSO_SERVICE_URL = "https://mobile.integration.garmin.com/gcm/android" MOBILE_SSO_USER_AGENT = ( "Mozilla/5.0 (Linux; Android 14; Pixel 8 Pro) " "AppleWebKit/537.36 (KHTML, like Gecko) " "Chrome/131.0.0.0 Mobile Safari/537.36" ) # -- Portal (fallback) constants -- PORTAL_SSO_CLIENT_ID = "GarminConnect" PORTAL_SSO_SERVICE_URL = "https://connect.garmin.com/app" DESKTOP_USER_AGENT = ( "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " "AppleWebKit/537.36 (KHTML, like Gecko) " "Chrome/131.0.0.0 Safari/537.36" ) # -- Anti-WAF delay bounds (seconds) -- # Cloudflare flags rapid GET→POST sequences as bot-like. LOGIN_DELAY_MIN_S = 10.0 LOGIN_DELAY_MAX_S = 20.0 # Widget flow uses a shorter delay (different rate-limit bucket). WIDGET_DELAY_MIN_S = 3.0 WIDGET_DELAY_MAX_S = 8.0 # -- TLS impersonation profiles -- MOBILE_IMPERSONATIONS: tuple[str, ...] = ("safari_ios", "safari", "chrome120") PORTAL_IMPERSONATIONS: tuple[str, ...] = ( "safari", "safari_ios", "chrome120", "edge101", "chrome", ) # -- Regex helpers for HTML parsing (widget flow) -- _CSRF_RE = re.compile(r'name="_csrf"\s+value="(.+?)"') _TITLE_RE = re.compile(r"(.+?)") # Garmin's widget MFA page exposes these variables in inline