Files
python-garminconnect/garminconnect/client.py

1735 lines
67 KiB
Python

"""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"<title>(.+?)</title>")
# Garmin's widget MFA page exposes these variables in inline <script> tags.
_WIDGET_MFA_VARS_RE = re.compile(
r'var\s+(customerGuid|mfaMethod|locale|clientId|codeSentTo)\s*=\s*"([^"]*)"\s*;'
)
def _parse_widget_mfa_vars(html: str) -> dict[str, str]:
"""Extract the inline JS variables Garmin uses for widget MFA."""
return {m.group(1): m.group(2) for m in _WIDGET_MFA_VARS_RE.finditer(html)}
def _random_browser_headers() -> dict[str, str]:
"""Generate a random browser User-Agent + sec-ch-ua headers.
Falls back to a static desktop Chrome UA if ua_generator is not installed.
"""
if HAS_UA_GEN:
ua = _generate_ua()
return dict(ua.headers.get())
return {"User-Agent": DESKTOP_USER_AGENT}
NATIVE_API_USER_AGENT = "GCM-Android-5.23"
NATIVE_X_GARMIN_USER_AGENT = (
"com.garmin.android.apps.connectmobile/5.23; ; Google/sdk_gphone64_arm64/google; "
"Android/33; Dalvik/2.1.0"
)
DI_TOKEN_URL = "https://diauth.garmin.com/di-oauth2-service/oauth/token" # noqa: S105
DI_GRANT_TYPE = (
"https://connectapi.garmin.com/di-oauth2-service/oauth/grant/service_ticket"
)
DI_CLIENT_IDS = (
"GARMIN_CONNECT_MOBILE_ANDROID_DI_2025Q2",
"GARMIN_CONNECT_MOBILE_ANDROID_DI_2024Q4",
"GARMIN_CONNECT_MOBILE_ANDROID_DI",
"GARMIN_CONNECT_MOBILE_IOS_DI",
)
class _MFARequired(Exception):
"""Internal sentinel — raised by login strategies when MFA is needed.
Stops the strategy chain immediately. The caller (login()) handles it via
prompt_mfa / return_on_mfa.
"""
def _build_basic_auth(client_id: str) -> str:
return "Basic " + base64.b64encode(f"{client_id}:".encode()).decode()
_QUERY_VALUE_RE = re.compile(r"([?&][\w.-]+=)[^&\s)'\"]+")
def _sanitize_exception_text(err: Exception) -> str:
"""Render an exception for logs/messages with URL query values redacted.
``requests`` embeds the full request URL — query string included — in its
exception text. On the login fallback path that URL carries the CAS
service ticket (``?ticket=ST-...``), so logging or re-raising the raw
exception would leak a credential into application logs and bug reports.
"""
return _QUERY_VALUE_RE.sub(r"\1<redacted>", f"{type(err).__name__}: {err}")
def _iter_file_objects(kwargs: dict[str, Any]) -> Iterator[Any]:
"""Yield file-like objects referenced by request kwargs (files/data)."""
files = kwargs.get("files")
if isinstance(files, Mapping):
values = list(files.values())
elif isinstance(files, list | tuple):
values = [v for _, v in files]
else:
values = []
for value in values:
# requests accepts fileobj or (name, fileobj[, content_type[, headers]])
fileobj = (
value[1] if isinstance(value, tuple | list) and len(value) >= 2 else value
)
if hasattr(fileobj, "read"):
yield fileobj
data = kwargs.get("data")
if hasattr(data, "read"):
yield data
def _capture_file_positions(
kwargs: dict[str, Any],
) -> list[tuple[Any, int]] | None:
"""Record the stream position of each file-like body before a request.
Returns None when any body cannot be repositioned, meaning a retry
would re-read from EOF and silently send empty/truncated content.
"""
positions: list[tuple[Any, int]] = []
for fileobj in _iter_file_objects(kwargs):
try:
if not fileobj.seekable():
return None
positions.append((fileobj, fileobj.tell()))
except (OSError, ValueError, AttributeError):
return None
data = kwargs.get("data")
if data is not None and not hasattr(data, "read") and isinstance(data, Iterator):
# Streamed iterable body (e.g. a generator): attempt #1 consumes it
# and it cannot be rewound, so a retry must not be attempted.
return None
return positions
def _restore_file_positions(positions: list[tuple[Any, int]]) -> bool:
"""Rewind file-like bodies to their pre-request positions. False on failure."""
try:
for fileobj, pos in positions:
fileobj.seek(pos)
except (OSError, ValueError, AttributeError):
return False
return True
def _decode_jwt_payload(token: str) -> dict[str, Any] | None:
"""Decode a JWT payload without verifying the signature.
Garmin signs tokens on the server side; the client does not have the
signing key, so full signature verification is not possible here. We do
reject tokens that claim ``alg: none`` or otherwise look structurally
invalid, preventing the most trivial client-side spoofing of unsigned
payloads.
"""
try:
parts = token.split(".")
if len(parts) < 2:
return None
header_b64 = parts[0] + "=" * (-len(parts[0]) % 4)
header = json.loads(base64.urlsafe_b64decode(header_b64).decode())
if header.get("alg") == "none":
return None
payload_b64 = parts[1] + "=" * (-len(parts[1]) % 4)
return json.loads(base64.urlsafe_b64decode(payload_b64).decode())
except Exception:
return None
def _native_headers(extra: dict[str, str] | None = None) -> dict[str, str]:
headers: dict[str, str] = {
"User-Agent": NATIVE_API_USER_AGENT,
"X-Garmin-User-Agent": NATIVE_X_GARMIN_USER_AGENT,
"X-Garmin-Paired-App-Version": "10861",
"X-Garmin-Client-Platform": "Android",
"X-App-Ver": "10861",
"X-Lang": "en",
"X-GCExperience": "GC5",
"Accept-Language": "en-US,en;q=0.9",
}
if extra:
headers.update(extra)
return headers
class Client:
"""A client to communicate with Garmin Connect."""
def __init__(self, domain: str = "garmin.com", **kwargs: Any) -> None:
if domain not in ALLOWED_DOMAINS:
raise ValueError(
f"Invalid domain {domain!r}; must be one of {sorted(ALLOWED_DOMAINS)}"
)
self.domain = domain
self._sso = f"https://sso.{domain}"
self._connect = f"https://connect.{domain}"
self._connectapi = f"https://connectapi.{domain}"
# Portal service URL is domain-aware for CN support
# Mobile service URLs are domain-aware for CN support
self._ios_service_url = f"https://mobile.integration.{domain}/gcm/ios"
self._mobile_sso_service_url = (
f"https://mobile.integration.{domain}/gcm/android"
)
self._portal_service_url = f"https://connect.{domain}/app"
# DI auth host is domain-aware too — CN users live on diauth.garmin.cn
# and don't exist in the .com user database. Without this, token refresh
# for CN accounts fails with 400 invalid_grant.
self._di_token_url = f"https://diauth.{domain}/di-oauth2-service/oauth/token"
# Native Bearer tokens (primary auth)
self.di_token: str | None = None
self.di_refresh_token: str | None = None
self.di_client_id: str | None = None
# JWT_WEB cookie auth (fallback when DI token exchange fails)
self.jwt_web: str | None = None
self.csrf_token: str | None = None
# Plain session for JWT_WEB fallback and session refresh
self.cs: Any = requests.Session()
adapter = HTTPAdapter(
pool_connections=kwargs.get("pool_connections", 20),
pool_maxsize=kwargs.get("pool_maxsize", 20),
)
self.cs.mount("https://", adapter)
# Dedicated keep-alive session for API calls. Kept separate from
# self.cs so auth cookies (JWT_WEB jar, CAS TGT) don't leak into API
# requests — API auth travels in headers per-call via get_api_headers.
# Reusing one session lets urllib3 pool TCP/TLS across calls instead
# of doing a fresh handshake every request (~1 s saved per call).
self._api_session: requests.Session = requests.Session()
# Reject all cookies so server-set cookies can't accumulate in the jar
# and collide with the explicit Cookie header used on the JWT_WEB path.
# Matches the old fresh-session-per-call guarantee: empty cookie state.
self._api_session.cookies.set_policy(
http.cookiejar.DefaultCookiePolicy(allowed_domains=[])
)
api_adapter = HTTPAdapter(
pool_connections=kwargs.get("pool_connections", 20),
pool_maxsize=kwargs.get("pool_maxsize", 20),
)
self._api_session.mount("https://", api_adapter)
self._tokenstore_path: str | None = None
# Serialize token refresh and state mutation so concurrent API calls can't
# race on the same refresh token or observe half-updated state.
self._token_lock: threading.RLock = threading.RLock()
# True after login(return_on_mfa=True) returns "needs_mfa" until
# resume_login() finishes. Prevents interleaving another login on the
# same instance while MFA state is held on self.
self._mfa_pending: bool = False
# Set of strategy names to skip during login, e.g. {"mobile+cffi"}.
# Valid names: mobile+cffi, mobile+requests, widget+cffi,
# portal+cffi, portal+requests
self.skip_strategies: set[str] = set()
# When True (default), each login strategy's token is validated against
# the API tier before the chain accepts it; a token the API rejects
# (401/403) is discarded and the next strategy is tried. Set False to
# restore the legacy "first token wins" behavior.
self.verify_login: bool = kwargs.get("verify_login", True)
@property
def is_authenticated(self) -> bool:
return bool(self.di_token or self.jwt_web)
def _clear_auth_state(self, *, keep_tokenstore_path: bool = False) -> None:
"""Wipe all in-memory auth tokens and session state so the next login starts clean.
``keep_tokenstore_path`` preserves the persistence target: login() sets
it via the Garmin wrapper *before* the credential flow runs, and a
fresh login should keep persisting to the same store.
"""
self.di_token = None
self.di_refresh_token = None
self.di_client_id = None
self.jwt_web = None
self.csrf_token = None
if not keep_tokenstore_path:
self._tokenstore_path = None
self._mfa_pending = False
for attr in (
"_mfa_session",
"_mfa_login_params",
"_mfa_post_headers",
"_mfa_service_url",
"_mfa_flow",
"_mfa_method",
"_widget_last_resp",
):
setattr(self, attr, None)
# Drop any SSO / CAS / JWT_WEB cookies so a stale session cannot be
# silently refreshed after logout.
self.cs.cookies.clear()
def _verify_token(self) -> bool:
"""Check that the current token is actually accepted by the API tier.
A strategy can obtain a DI token from the auth host (HTTP 200) that the
API tier (connectapi) then rejects with 401 "Token is not active" —
this is account/region dependent (see issue #369). We confirm the token
works with one lightweight authenticated call.
Returns True if accepted, or if the check is inconclusive. Only a
definitive auth rejection (401/403) returns False, so a transient
network error or 5xx never blocks an otherwise-working login.
"""
try:
self.connectapi("/userprofile-service/socialProfile")
return True
except GarminConnectConnectionError as e:
msg = str(e)
if "401" in msg or "403" in msg:
_LOGGER.warning("Token rejected by API tier: %s", msg)
return False
_LOGGER.debug("Token validation inconclusive (kept): %s", msg)
return True
except Exception as e:
_LOGGER.debug(
"Token validation inconclusive (kept): %s",
_sanitize_exception_text(e),
)
return True
def get_api_headers(self) -> dict[str, str]:
if not self.is_authenticated:
raise GarminConnectAuthenticationError("Not authenticated")
if self.di_token:
return _native_headers(
{
"Authorization": f"Bearer {self.di_token}",
"Accept": "application/json",
}
)
# JWT_WEB fallback
headers: dict[str, str] = {
"Accept": "application/json",
"NK": "NT",
"Origin": self._connect,
"Referer": f"{self._connect}/modern/",
"DI-Backend": f"connectapi.{self.domain}",
"Cookie": f"JWT_WEB={self.jwt_web}",
}
if self.csrf_token:
headers["connect-csrf-token"] = str(self.csrf_token)
return headers
def login(
self,
email: str,
password: str,
prompt_mfa: Any = None,
return_on_mfa: bool = False,
) -> tuple[str | None, Any]:
"""Log in using a cascading 5-strategy chain (ha-garmin order).
Tries each strategy in order. Only credential errors (GarminConnectAuthenticationError)
and MFA requirements stop the chain immediately — all other failures
(429 rate limits, transport errors, HTML challenges) fall through to
the next strategy. MFA requirements are resolved immediately via
prompt_mfa / return_on_mfa.
Args:
email: Garmin account email.
password: Garmin account password.
prompt_mfa: Callable that returns an MFA code string when invoked.
return_on_mfa: When True, return ("needs_mfa", None) instead of
calling prompt_mfa; caller must call resume_login().
Returns:
(None, None) on success; ("needs_mfa", None) when return_on_mfa=True.
"""
if self._mfa_pending:
raise GarminConnectAuthenticationError(
"MFA login already in progress; complete it with resume_login() "
"or call logout() first"
)
# Start every credential login from a clean slate. A stale di_token
# from a previous login/token-load must not survive this call: it
# would keep is_authenticated True after all strategies fail, and
# get_api_headers() prefers di_token, so it would silently shadow a
# fresh jwt_web obtained by a web strategy (and _verify_token would
# then "verify" the old identity instead of the new login).
self._clear_auth_state(keep_tokenstore_path=True)
strategies: list[tuple[str, Any]] = [
("mobile+cffi", lambda: self._mobile_login_cffi(email, password)),
("mobile+requests", lambda: self._mobile_login_requests(email, password)),
("widget+cffi", lambda: self._widget_web_login(email, password)),
("portal+cffi", lambda: self._portal_web_login_cffi(email, password)),
(
"portal+requests",
lambda: self._portal_web_login_requests(email, password),
),
]
if self.skip_strategies:
strategies = [
(n, fn) for n, fn in strategies if n not in self.skip_strategies
]
_LOGGER.debug("Skipping login strategies: %s", self.skip_strategies)
last_err: Exception | None = None
rate_limited_count = 0
def resolve_mfa(name: str) -> tuple[str | None, Any]:
if return_on_mfa:
self._mfa_pending = True
return "needs_mfa", None
if prompt_mfa:
mfa_code = prompt_mfa()
self._complete_mfa(mfa_code)
if self.verify_login and not self._verify_token():
self._clear_auth_state(keep_tokenstore_path=True)
raise GarminConnectConnectionError(
f"{name}: token rejected by API tier after MFA"
)
self._mfa_pending = False
return None, None
raise GarminConnectAuthenticationError(
"MFA Required but no prompt_mfa mechanism supplied"
)
for name, run in strategies:
try:
_LOGGER.debug("Trying login strategy: %s", name)
run()
# Strategy got a token — make sure the API tier accepts it
# before declaring success (else fall through to the next).
if self.verify_login and not self._verify_token():
_LOGGER.warning(
"%s obtained a token the API rejected; trying next strategy",
name,
)
self._clear_auth_state(keep_tokenstore_path=True)
last_err = GarminConnectConnectionError(
f"{name}: token rejected by API tier"
)
continue
return None, None
except GarminConnectAuthenticationError:
# Wrong credentials — stop immediately, no point trying further
raise
except _MFARequired:
# Resolve MFA immediately; the strategy is responsible for
# triggering code delivery (widget flow does so explicitly).
try:
return resolve_mfa(name)
except GarminConnectConnectionError as e:
last_err = e
continue
except GarminConnectTooManyRequestsError as e:
_LOGGER.warning(
"%s returned 429: %s", name, _sanitize_exception_text(e)
)
rate_limited_count += 1
last_err = e
continue
except Exception as e:
_LOGGER.warning("%s failed: %s", name, _sanitize_exception_text(e))
last_err = e
continue
if rate_limited_count == len(strategies):
raise GarminConnectTooManyRequestsError(
"All login strategies rate limited (429). "
"Try again later or check your IP/network."
)
raise GarminConnectConnectionError(
"All login strategies exhausted: "
+ (_sanitize_exception_text(last_err) if last_err else "no strategies ran")
)
# ------------------------------------------------------------------ #
# STRATEGY 1 — Mobile iOS + curl_cffi (TLS fingerprint rotation) #
# ------------------------------------------------------------------ #
def _mobile_login_cffi(self, email: str, password: str) -> None:
"""Mobile login with curl_cffi TLS fingerprint rotation.
Different TLS fingerprints land in different Cloudflare rate-limit
buckets, so rotating through them gives multiple shots.
"""
if not HAS_CFFI:
raise GarminConnectConnectionError("curl_cffi not available")
last_err: Exception | None = None
for imp in MOBILE_IMPERSONATIONS:
try:
_LOGGER.debug("mobile+cffi trying impersonation=%s", imp)
sess: Any = cffi_requests.Session(impersonate=cast("Any", imp))
self._do_mobile_login(sess, email, password)
return
except (GarminConnectAuthenticationError, _MFARequired):
raise
except GarminConnectTooManyRequestsError as e:
_LOGGER.debug(
"mobile+cffi(%s) 429: %s", imp, _sanitize_exception_text(e)
)
last_err = e
continue
except Exception as e:
_LOGGER.debug(
"mobile+cffi(%s) failed: %s", imp, _sanitize_exception_text(e)
)
last_err = e
continue
if last_err:
raise last_err
raise GarminConnectConnectionError("mobile+cffi: no impersonations available")
# ------------------------------------------------------------------ #
# STRATEGY 2 — Mobile iOS + plain requests #
# ------------------------------------------------------------------ #
def _mobile_login_requests(self, email: str, password: str) -> None:
"""Mobile login with plain requests (no TLS fingerprinting)."""
sess = requests.Session()
self._do_mobile_login(sess, email, password)
# ------------------------------------------------------------------ #
# Shared mobile login logic #
# ------------------------------------------------------------------ #
def _do_mobile_login(self, sess: Any, email: str, password: str) -> None:
"""Login via sso.garmin.com/mobile/api/login (iOS app flow)."""
login_url = f"{self._sso}/mobile/api/login"
login_params = {
"clientId": IOS_SSO_CLIENT_ID,
"locale": "en-US",
"service": self._ios_service_url,
}
login_headers = {
"User-Agent": IOS_LOGIN_UA,
"Accept": "application/json, text/plain, */*",
"Content-Type": "application/json",
"Origin": self._sso,
}
r = sess.post(
login_url,
params=login_params,
headers=login_headers,
json={
"username": email,
"password": password,
"rememberMe": True,
"captchaToken": "",
},
timeout=30,
)
if r.status_code == 429:
raise GarminConnectTooManyRequestsError(
"Mobile login returned 429 — IP rate limited by Garmin"
)
if r.status_code == 403:
raise GarminConnectConnectionError(
"Mobile login: HTTP 403 (Cloudflare bot challenge) — "
"falling through to next strategy"
)
try:
res = r.json()
except Exception as err:
raise GarminConnectConnectionError(
f"Mobile login failed (non-JSON): HTTP {r.status_code}"
) from err
resp_type = res.get("responseStatus", {}).get("type")
if resp_type == "MFA_REQUIRED":
self._mfa_method = res.get("customerMfaInfo", {}).get(
"mfaLastMethodUsed", "email"
)
self._mfa_session = sess
self._mfa_login_params = login_params
self._mfa_post_headers = login_headers
self._mfa_service_url = self._ios_service_url
self._mfa_flow = "ios"
raise _MFARequired()
if resp_type == "SUCCESSFUL":
ticket = res["serviceTicketId"]
self._establish_session(
ticket, sess=sess, service_url=self._ios_service_url
)
return
if resp_type == "INVALID_USERNAME_PASSWORD":
raise GarminConnectAuthenticationError(
"401 Unauthorized (Invalid Username or Password)"
)
# Check for 429 buried inside JSON error body
if res.get("error", {}).get("status-code") == "429":
raise GarminConnectTooManyRequestsError("Mobile login: 429 in JSON body")
if resp_type == "CAPTCHA_REQUIRED":
raise GarminConnectConnectionError(
"Mobile login: CAPTCHA required (bot challenge) — "
"falling through to next strategy"
)
_LOGGER.debug("Mobile login unexpected response: %s", res)
raise GarminConnectConnectionError(
f"Mobile login failed: HTTP {r.status_code}, "
f"responseStatus={resp_type or 'unknown'}"
)
# ------------------------------------------------------------------ #
# STRATEGY 3 — SSO Embed Widget + curl_cffi #
# ------------------------------------------------------------------ #
def _widget_web_login(self, email: str, password: str) -> None:
"""Login via the SSO embed HTML widget.
Uses HTML form flow which bypasses clientId-based rate limits.
Uses curl_cffi for TLS fingerprinting.
"""
if not HAS_CFFI:
raise GarminConnectConnectionError("curl_cffi not available")
sess: Any = cffi_requests.Session(impersonate="chrome", timeout=30)
sso_base = f"{self._sso}/sso"
sso_embed = f"{sso_base}/embed"
embed_params = {
"id": "gauth-widget",
"embedWidget": "true",
"gauthHost": sso_base,
}
signin_params = {
**embed_params,
"gauthHost": sso_embed,
"service": sso_embed,
"source": sso_embed,
"redirectAfterAccountLoginUrl": sso_embed,
"redirectAfterAccountCreationUrl": sso_embed,
}
# Step 1: GET embed page to establish session cookies
r = sess.get(sso_embed, params=embed_params)
if r.status_code == 429:
raise GarminConnectTooManyRequestsError("Widget embed GET returned 429")
if not r.ok:
raise GarminConnectConnectionError(f"Widget embed returned {r.status_code}")
# Step 2: GET signin page for CSRF token
r = sess.get(
f"{sso_base}/signin",
params=signin_params,
headers={"Referer": sso_embed},
)
if r.status_code == 429:
raise GarminConnectTooManyRequestsError("Widget signin GET returned 429")
csrf_match = _CSRF_RE.search(r.text)
if not csrf_match:
raise GarminConnectConnectionError("Widget login: missing CSRF token")
# Anti-WAF delay between GET and POST
delay_s = random.uniform(WIDGET_DELAY_MIN_S, WIDGET_DELAY_MAX_S) # noqa: S311
_LOGGER.debug("Widget login: waiting %.0fs anti-WAF delay...", delay_s)
time.sleep(delay_s)
# Step 3: POST credentials
r = sess.post(
f"{sso_base}/signin",
params=signin_params,
headers={"Referer": r.url},
data={
"username": email,
"password": password,
"embed": "true",
"_csrf": csrf_match.group(1),
},
timeout=30,
)
if r.status_code == 429:
raise GarminConnectTooManyRequestsError("Widget signin POST returned 429")
title_match = _TITLE_RE.search(r.text)
title = title_match.group(1) if title_match else ""
# Detect server/infrastructure errors — fall through to next strategy
title_lower = title.lower()
if any(
hint in title_lower
for hint in (
"bad gateway",
"service unavailable",
"cloudflare",
"502",
"503",
)
):
raise GarminConnectConnectionError(f"Widget login: server error '{title}'")
# Early credential detection — don't waste remaining strategies
if any(
hint in title_lower
for hint in ("locked", "invalid", "incorrect", "account error")
):
raise GarminConnectAuthenticationError(
f"Widget authentication failed: '{title}'"
)
# Child/family accounts are restricted from web SSO — log clearly and
# fall through so the remaining strategies still get a chance.
if "unable to sign in" in title_lower or "unable to login" in title_lower:
_LOGGER.warning(
"Widget login: '%s' — account may be a Garmin child/family account "
"restricted from web SSO; child accounts are not supported.",
title,
)
raise GarminConnectConnectionError(
f"Widget login: account restricted '{title}'"
)
# MFA challenge. The signin page itself is also titled "GARMIN
# Authentication Application", so rely on the inline JS variables
# Garmin emits for the MFA page (mfaMethod, customerGuid, etc.) rather
# than the title alone. Email/SMS MFA pages may not actually send the
# code during the credential POST, so we explicitly request delivery via
# the same endpoint the browser's "Request a new code" link uses.
mfa_vars = _parse_widget_mfa_vars(r.text)
mfa_method = mfa_vars.get("mfaMethod", "").lower()
looks_like_mfa = "mfa" in title_lower or (
"authentication application" in title_lower and mfa_method
)
if looks_like_mfa:
self._widget_request_mfa_code(sess, r.text, r.url)
self._mfa_session = sess
self._mfa_login_params = signin_params
self._mfa_post_headers = {"Referer": r.url}
self._mfa_flow = "widget"
self._mfa_method = mfa_vars.get("mfaMethod", "")
self._widget_last_resp = r
raise _MFARequired()
if title != "Success":
raise GarminConnectConnectionError(
f"Widget login: unexpected title '{title}'"
)
# Step 4: Extract service ticket — ticket may appear under any service URL
ticket_match = re.search(r'\?ticket=(ST-[^"&\s]+)', r.text)
if not ticket_match:
raise GarminConnectConnectionError("Widget login: missing service ticket")
self._establish_session(ticket_match.group(1), sess=sess, service_url=sso_embed)
def _complete_mfa_widget(self, mfa_code: str) -> None:
"""Complete MFA for widget flow."""
sess = getattr(self, "_mfa_session", None)
r = getattr(self, "_widget_last_resp", None)
if not sess or not r:
raise GarminConnectAuthenticationError("Missing widget MFA context")
csrf_match = _CSRF_RE.search(r.text)
if not csrf_match:
raise GarminConnectAuthenticationError("Widget MFA: missing CSRF token")
r = sess.post(
f"{self._sso}/sso/verifyMFA/loginEnterMfaCode",
params=getattr(self, "_mfa_login_params", {}),
headers=getattr(self, "_mfa_post_headers", {}),
data={
"mfa-code": mfa_code,
"embed": "true",
"_csrf": csrf_match.group(1),
"fromPage": "setupEnterMfaCode",
},
timeout=30,
)
if r.status_code == 429:
raise GarminConnectTooManyRequestsError("Widget MFA verify returned 429")
title_match = _TITLE_RE.search(r.text)
title = title_match.group(1) if title_match else ""
if title != "Success":
raise GarminConnectAuthenticationError(f"Widget MFA failed: {title}")
ticket_match = re.search(r'\?ticket=(ST-[^"&\s]+)', r.text)
if not ticket_match:
raise GarminConnectAuthenticationError("Widget MFA: missing service ticket")
self._establish_session(
ticket_match.group(1),
sess=sess,
service_url=f"{self._sso}/sso/embed",
)
def _widget_request_mfa_code(self, sess: Any, page_html: str, referer: str) -> None:
"""Request Garmin to send an email/SMS MFA code for this widget session.
The widget's own JavaScript calls ``/sso/verifyMFA/mfaCode`` when the
user clicks "Request a new code". We use the same endpoint to ensure
the code is actually delivered before prompting the user.
"""
mfa_vars = _parse_widget_mfa_vars(page_html)
mfa_method = mfa_vars.get("mfaMethod", "").lower()
if mfa_method not in ("email", "sms"):
# Nothing to trigger for TOTP/authenticator apps.
return
if mfa_vars.get("codeSentTo"):
# Garmin already sent a code during the signin POST.
return
client_id = mfa_vars.get("clientId", "")
payload = {
"customerGuid": mfa_vars.get("customerGuid", ""),
"mfaMethod": mfa_vars.get("mfaMethod", ""),
"locale": mfa_vars.get("locale", ""),
}
_LOGGER.debug(
"Widget MFA: explicitly requesting %s code from Garmin", mfa_method
)
r = sess.post(
f"{self._sso}/sso/verifyMFA/mfaCode",
params={"clientId": client_id},
headers={
"Content-Type": "application/json",
"Accept": "application/json, text/plain, */*",
"Referer": referer,
},
json=payload,
timeout=30,
)
if r.status_code == 429:
raise GarminConnectTooManyRequestsError(
"Widget MFA code request returned 429"
)
if not r.ok:
raise GarminConnectConnectionError(
f"Widget MFA code request returned {r.status_code}"
)
# ------------------------------------------------------------------ #
# STRATEGY 4 — Portal web + curl_cffi (TLS fingerprint rotation) #
# ------------------------------------------------------------------ #
def _portal_web_login_cffi(self, email: str, password: str) -> None:
"""Portal login with curl_cffi TLS fingerprint rotation.
Different TLS fingerprints land in different Cloudflare rate-limit
buckets, so rotating through them gives multiple shots.
"""
if not HAS_CFFI:
raise GarminConnectConnectionError("curl_cffi not available")
last_err: Exception | None = None
for imp in PORTAL_IMPERSONATIONS:
try:
_LOGGER.debug("portal+cffi trying impersonation=%s", imp)
sess: Any = cffi_requests.Session(impersonate=cast("Any", imp))
self._do_portal_web_login(sess, email, password)
return
except (GarminConnectAuthenticationError, _MFARequired):
raise
except GarminConnectTooManyRequestsError as e:
_LOGGER.debug(
"portal+cffi(%s) 429: %s", imp, _sanitize_exception_text(e)
)
last_err = e
continue
except Exception as e:
_LOGGER.debug(
"portal+cffi(%s) failed: %s", imp, _sanitize_exception_text(e)
)
last_err = e
continue
if last_err:
raise last_err
raise GarminConnectConnectionError("portal+cffi: no impersonations available")
# ------------------------------------------------------------------ #
# STRATEGY 5 — Portal web + plain requests #
# ------------------------------------------------------------------ #
def _portal_web_login_requests(self, email: str, password: str) -> None:
"""Portal login with plain requests (no TLS fingerprinting)."""
sess = requests.Session()
self._do_portal_web_login(sess, email, password)
# ------------------------------------------------------------------ #
# Shared portal login logic #
# ------------------------------------------------------------------ #
def _do_portal_web_login(self, sess: Any, email: str, password: str) -> None:
"""Login via /portal/api/login — desktop browser flow."""
signin_url = f"{self._sso}/portal/sso/en-US/sign-in"
browser_hdrs = _random_browser_headers()
# Step 1: GET the signin page to grab initial cookies
get_resp = sess.get(
signin_url,
params={
"clientId": PORTAL_SSO_CLIENT_ID,
"service": self._portal_service_url,
},
headers={
**browser_hdrs,
"Accept": (
"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"
),
"Accept-Language": "en-US,en;q=0.9",
},
timeout=30,
)
if get_resp.status_code == 429:
raise GarminConnectTooManyRequestsError(
"Portal login GET returned 429 — Cloudflare blocking this request."
)
# Anti-WAF delay: 10-20s mimics real browser "read then type" behaviour.
delay_s = random.uniform(LOGIN_DELAY_MIN_S, LOGIN_DELAY_MAX_S) # noqa: S311
_LOGGER.info(
"Portal login: waiting %.0fs to avoid Cloudflare rate limiting...",
delay_s,
)
time.sleep(delay_s)
# Step 2: POST credentials
login_params = {
"clientId": PORTAL_SSO_CLIENT_ID,
"locale": "en-US",
"service": self._portal_service_url,
}
post_headers = {
**browser_hdrs,
"Accept": "application/json, text/plain, */*",
"Accept-Language": "en-US,en;q=0.9",
"Content-Type": "application/json",
"Origin": self._sso,
"Referer": (
f"{signin_url}?clientId={PORTAL_SSO_CLIENT_ID}"
f"&service={self._portal_service_url}"
),
}
r = sess.post(
f"{self._sso}/portal/api/login",
params=login_params,
headers=post_headers,
json={
"username": email,
"password": password,
"rememberMe": True,
"captchaToken": "",
},
timeout=30,
)
if r.status_code == 429:
raise GarminConnectTooManyRequestsError(
"Portal login POST returned 429 — Cloudflare blocking this request."
)
if r.status_code == 403:
raise GarminConnectConnectionError(
"Portal login: HTTP 403 (Cloudflare bot challenge) — "
"falling through to next strategy"
)
try:
res = r.json()
except Exception as err:
raise GarminConnectConnectionError(
f"Portal login failed (non-JSON): HTTP {r.status_code}"
) from err
resp_type = res.get("responseStatus", {}).get("type")
if resp_type == "MFA_REQUIRED":
self._mfa_method = res.get("customerMfaInfo", {}).get(
"mfaLastMethodUsed", "email"
)
self._mfa_session = sess
self._mfa_login_params = login_params
self._mfa_post_headers = post_headers
self._mfa_service_url = self._portal_service_url
self._mfa_flow = "portal"
raise _MFARequired()
if resp_type == "SUCCESSFUL":
ticket = res["serviceTicketId"]
self._establish_session(
ticket, sess=sess, service_url=self._portal_service_url
)
return
if resp_type == "INVALID_USERNAME_PASSWORD":
raise GarminConnectAuthenticationError(
"401 Unauthorized (Invalid Username or Password)"
)
# Check for 429 buried inside JSON error body
if res.get("error", {}).get("status-code") == "429":
raise GarminConnectTooManyRequestsError("Portal login: 429 in JSON body")
if resp_type == "CAPTCHA_REQUIRED":
raise GarminConnectConnectionError(
"Portal login: CAPTCHA required (bot challenge) — "
"falling through to next strategy"
)
_LOGGER.debug("Portal web login unexpected response: %s", res)
raise GarminConnectConnectionError(
f"Portal web login failed: HTTP {r.status_code}, "
f"responseStatus={resp_type or 'unknown'}"
)
# ------------------------------------------------------------------ #
# MFA COMPLETION — dual-endpoint fallback #
# ------------------------------------------------------------------ #
def _complete_mfa(self, mfa_code: str) -> None:
"""Complete MFA — routes to the handler matching the login flow.
For portal/ios flows, tries both /portal and /mobile MFA verify
endpoints as they may be on different rate-limit buckets.
"""
flow = getattr(self, "_mfa_flow", "portal")
if flow == "widget":
self._complete_mfa_widget(mfa_code)
return
sess = self._mfa_session
mfa_json: dict[str, Any] = {
"mfaMethod": getattr(self, "_mfa_method", "email"),
"mfaVerificationCode": mfa_code,
"rememberMyBrowser": True,
"reconsentList": [],
"mfaSetup": False,
}
# Map flow name to SSO path segment ("ios" flow uses /mobile/ endpoint)
flow_path = "mobile" if flow == "ios" else flow
# Try both MFA endpoints — they share SSO session cookies but may be
# on different rate-limit buckets.
mfa_endpoints: list[tuple[str, dict[str, str], dict[str, str]]] = [
(
f"{self._sso}/{flow_path}/api/mfa/verifyCode",
self._mfa_login_params,
self._mfa_post_headers,
),
]
# Add the other path as fallback
if flow_path == "mobile":
alt_endpoint = f"{self._sso}/portal/api/mfa/verifyCode"
alt_params: dict[str, str] = {
"clientId": PORTAL_SSO_CLIENT_ID,
"locale": "en-US",
"service": self._portal_service_url,
}
else:
alt_endpoint = f"{self._sso}/mobile/api/mfa/verifyCode"
alt_params = {
"clientId": IOS_SSO_CLIENT_ID,
"locale": "en-US",
"service": self._ios_service_url,
}
mfa_endpoints.append((alt_endpoint, alt_params, self._mfa_post_headers))
failures: list[str] = []
rate_limited_count = 0
for mfa_url, params, headers in mfa_endpoints:
try:
r = sess.post(
mfa_url,
params=params,
headers=headers,
json=mfa_json,
timeout=30,
)
except Exception as e:
failures.append(f"{mfa_url}: connection error {e}")
continue
if r.status_code == 429:
failures.append(f"{mfa_url}: HTTP 429")
rate_limited_count += 1
continue
try:
res = r.json()
except Exception:
# Non-JSON response is almost always a Cloudflare HTML challenge
failures.append(f"{mfa_url}: HTTP {r.status_code} non-JSON")
continue
if res.get("error", {}).get("status-code") == "429":
failures.append(f"{mfa_url}: 429 in JSON body")
rate_limited_count += 1
continue
if res.get("responseStatus", {}).get("type") == "SUCCESSFUL":
ticket = res["serviceTicketId"]
svc_url = (
self._ios_service_url
if flow == "ios"
else getattr(self, "_mfa_service_url", self._portal_service_url)
)
self._establish_session(ticket, sess=sess, service_url=svc_url)
return
# Non-success JSON response — could be auth failure. Log the full
# response at DEBUG but keep exception messages free of sensitive
# SSO metadata such as serviceTicketId, customerGuid, or URLs.
_LOGGER.debug("MFA verify non-success response from %s: %s", mfa_url, res)
status = res.get("responseStatus", {}).get("type") or res.get(
"error", {}
).get("status-code", "unknown")
failures.append(f"{mfa_url}: {status}")
# All endpoints failed
if rate_limited_count == len(mfa_endpoints):
raise GarminConnectTooManyRequestsError(
f"MFA verification rate limited on all endpoints: {failures}"
)
raise GarminConnectAuthenticationError(f"MFA verification failed: {failures}")
# ------------------------------------------------------------------ #
# SESSION ESTABLISHMENT — DI token first, JWT_WEB fallback #
# ------------------------------------------------------------------ #
def _establish_session(
self, ticket: str, sess: Any = None, service_url: str | None = None
) -> None:
"""Consume a CAS service ticket — DI token exchange first,
fall back to JWT_WEB cookie auth.
"""
try:
self._exchange_service_ticket(ticket, service_url=service_url)
return
except Exception as e:
_LOGGER.warning(
"DI token exchange failed (%s), falling back to JWT_WEB",
_sanitize_exception_text(e),
)
# Fallback: consume ticket via connect.garmin.com for JWT_WEB cookie
if sess is not None:
self.cs = sess
svc = service_url or self._ios_service_url
self.cs.get(
svc,
params={"ticket": ticket},
allow_redirects=True,
timeout=30,
)
jwt_web = None
for c in self.cs.cookies.jar:
if c.name == "JWT_WEB":
jwt_web = c.value
break
if not jwt_web:
raise GarminConnectAuthenticationError(
"JWT_WEB cookie not set after ticket consumption"
)
self.jwt_web = jwt_web
def _http_post(self, url: str, **kwargs: Any) -> Any:
"""POST using curl_cffi if available, else plain requests."""
timeout = kwargs.pop("timeout", 30)
if HAS_CFFI:
return cffi_requests.post(
url, impersonate="chrome", timeout=timeout, **kwargs
)
return requests.post(url, timeout=timeout, **kwargs)
def _exchange_service_ticket(
self, ticket: str, service_url: str | None = None
) -> None:
"""Exchange a CAS service ticket for native DI + IT Bearer tokens.
POST to diauth.garmin.com to get a DI OAuth2 token, then exchange
for an IT token via services.garmin.com.
"""
# service_url must match the one used during SSO login
svc_url = service_url or self._mobile_sso_service_url
di_token = None
di_refresh = None
di_client_id = None
for client_id in DI_CLIENT_IDS:
r = self._http_post(
self._di_token_url,
headers=_native_headers(
{
"Authorization": _build_basic_auth(client_id),
"Accept": "application/json,text/html;q=0.9,*/*;q=0.8",
"Content-Type": "application/x-www-form-urlencoded",
"Cache-Control": "no-cache",
}
),
data={
"client_id": client_id,
"service_ticket": ticket,
"grant_type": DI_GRANT_TYPE,
"service_url": svc_url,
},
timeout=30,
)
if r.status_code == 429:
raise GarminConnectTooManyRequestsError(
"DI token exchange rate limited"
)
if not r.ok:
_LOGGER.debug(
"DI exchange failed for %s: %s %s",
client_id,
r.status_code,
r.text[:200],
)
continue
try:
data = r.json()
di_token = data["access_token"]
di_refresh = data.get("refresh_token")
di_client_id = self._extract_client_id_from_jwt(di_token) or client_id
break
except Exception as e:
_LOGGER.debug("DI token parse failed for %s: %s", client_id, e)
continue
if not di_token:
raise GarminConnectAuthenticationError(
"DI token exchange failed for all client IDs"
)
self.di_token = di_token
self.di_refresh_token = di_refresh
self.di_client_id = di_client_id
def _refresh_di_token(self) -> None:
"""Refresh the DI Bearer token using the stored refresh token."""
if not self.di_refresh_token or not self.di_client_id:
raise GarminConnectAuthenticationError("No DI refresh token available")
with self._token_lock:
# Re-check under the lock in case another thread already refreshed
# while we were waiting.
if not self.di_refresh_token or not self.di_client_id:
raise GarminConnectAuthenticationError("No DI refresh token available")
r = self._http_post(
self._di_token_url,
headers=_native_headers(
{
"Authorization": _build_basic_auth(self.di_client_id),
"Accept": "application/json",
"Content-Type": "application/x-www-form-urlencoded",
"Cache-Control": "no-cache",
}
),
data={
"grant_type": "refresh_token",
"client_id": self.di_client_id,
"refresh_token": self.di_refresh_token,
},
timeout=30,
)
if not r.ok:
_LOGGER.debug(
"DI token refresh failed: status=%s body=%r",
r.status_code,
r.text[:200],
)
raise GarminConnectAuthenticationError(
f"DI token refresh failed: {r.status_code}"
)
data = r.json()
access_token = data["access_token"]
self.di_token = access_token
self.di_refresh_token = data.get("refresh_token", self.di_refresh_token)
self.di_client_id = (
self._extract_client_id_from_jwt(access_token) or self.di_client_id
)
def _extract_client_id_from_jwt(self, token: str) -> str | None:
payload = _decode_jwt_payload(token)
if not payload:
return None
value = payload.get("client_id")
return str(value) if value else None
def _token_expires_soon(self) -> bool:
token = self.di_token or self.jwt_web
if not token:
return False
payload = _decode_jwt_payload(str(token))
if not payload:
return False
# 'exp' is a server-controlled claim from an unverified JWT payload, so
# coerce it defensively: a non-numeric string, container, boolean,
# non-finite or overflowing value must not raise (TypeError/ValueError/
# OverflowError) and take down every request.
raw_exp = payload.get("exp")
if isinstance(raw_exp, bool):
return False
try:
exp = float(raw_exp) # type: ignore[arg-type]
except (TypeError, ValueError, OverflowError):
return False
if not math.isfinite(exp):
return False
return time.time() > exp - 900
def _refresh_session(self) -> None:
"""Refresh auth — DI token refresh or legacy JWT_WEB CAS refresh."""
with self._token_lock:
if self.di_token:
try:
self._refresh_di_token()
if self._tokenstore_path:
with contextlib.suppress(Exception):
self.dump(self._tokenstore_path)
except Exception as err:
_LOGGER.debug(
"DI token refresh failed: %s", _sanitize_exception_text(err)
)
return
# JWT_WEB refresh via CAS TGT
if not self.is_authenticated:
return
try:
self.cs.get(
f"{self._sso}/mobile/sso/en_US/sign-in",
params={
"clientId": MOBILE_SSO_CLIENT_ID,
"service": self._mobile_sso_service_url,
},
allow_redirects=True,
timeout=15,
)
for c in self.cs.cookies.jar:
if c.name == "JWT_WEB":
self.jwt_web = c.value
_LOGGER.debug("Session refreshed via CAS TGT")
if self._tokenstore_path:
with contextlib.suppress(Exception):
self.dump(self._tokenstore_path)
return
with contextlib.suppress(Exception):
self.cs.post(
f"{self._connect}/services/auth/token/di-oauth/refresh",
headers={
"Accept": "application/json",
"NK": "NT",
"Referer": f"{self._connect}/modern/",
},
timeout=10,
)
for c in self.cs.cookies.jar:
if c.name == "JWT_WEB":
self.jwt_web = c.value
break
except Exception as err:
_LOGGER.debug("Refresh failed: %s", _sanitize_exception_text(err))
def dumps(self) -> str:
"""Serialize session state to JSON string."""
data: dict[str, Any] = {
"di_token": self.di_token,
"di_refresh_token": self.di_refresh_token,
"di_client_id": self.di_client_id,
}
return json.dumps(data)
def dump(self, path: str) -> None:
"""Write tokens safely to disk with owner-only permissions.
The token file contains the DI refresh token, which grants persistent
account access. It is written as 0o600 inside a 0o700 directory so a
permissive process umask can't leave it world-readable on a shared host
(GHSA-wjhr-76vg-2hvc).
Writes to a sibling temporary file and atomically replaces the target so
a concurrent reader never sees a truncated or partial token file.
"""
p = token_file_path(path)
# Serialize with token mutation so the serialized snapshot is consistent
# (e.g. not torn across a concurrent refresh).
with self._token_lock:
payload = self.dumps()
p.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
# mkdir's mode is subject to umask and a no-op if the dir already
# exists; chmod enforces 0o700 unconditionally.
with contextlib.suppress(OSError):
p.parent.chmod(0o700)
# Open with O_CREAT|O_EXCL mode 0o600 (and O_NOFOLLOW where available)
# instead of write_text, which would create the file under the umask
# first. The temp name is unpredictable and O_EXCL is required (not
# just O_TRUNC): a fixed sibling name is guessable, and without
# O_EXCL a pre-planted file's inode would be reused for the write
# instead of a fresh one being created, letting whoever holds a
# descriptor on that pre-planted inode observe the token payload.
tmp = p.with_name(f".{p.name}.{secrets.token_hex(8)}.tmp")
try:
flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL
if hasattr(os, "O_NOFOLLOW"):
flags |= os.O_NOFOLLOW
fd = os.open(tmp, flags, 0o600)
with os.fdopen(fd, "w", encoding="utf-8") as token_file:
token_file.write(payload)
# Enforce 0o600 even if the file pre-existed with looser permissions.
with contextlib.suppress(OSError):
tmp.chmod(0o600)
tmp.replace(p)
except Exception:
with contextlib.suppress(OSError):
tmp.unlink()
raise
def load(self, path: str) -> None:
"""Read tokens from disk, refusing to follow symlinks.
A pre-planted symlink must not redirect the read to an attacker-controlled
file, so this mirrors the write-side hardening in ``dump()``.
"""
try:
self._tokenstore_path = path
p = token_file_path(path)
flags = os.O_RDONLY
if hasattr(os, "O_NOFOLLOW"):
flags |= os.O_NOFOLLOW
fd = os.open(p, flags)
with os.fdopen(fd, encoding="utf-8") as token_file:
self.loads(token_file.read())
except Exception as e:
raise GarminConnectConnectionError(
f"Token path not loading cleanly: {e}"
) from e
def loads(self, tokenstore: str) -> None:
try:
data = json.loads(tokenstore)
self.di_token = data.get("di_token")
self.di_refresh_token = data.get("di_refresh_token")
self.di_client_id = data.get("di_client_id")
if not self.is_authenticated:
raise GarminConnectAuthenticationError("Missing tokens from dict load")
except Exception as e:
_LOGGER.debug("Token extraction loads() structurally failed: %s", e)
raise GarminConnectConnectionError(
"Token extraction loads() structurally failed"
) from e
def connectapi(self, path: str, **kwargs: Any) -> Any:
return self._run_request("GET", path, **kwargs).json()
def request(self, method: str, _domain: str, path: str, **kwargs: Any) -> Any:
kwargs.pop("api", None)
return self._run_request(method, path, **kwargs)
def post(self, _domain: str, path: str, **kwargs: Any) -> Any:
api = kwargs.pop("api", False)
resp = self._run_request("POST", path, **kwargs)
if api:
return resp.json() if hasattr(resp, "json") else None
return resp
def put(self, _domain: str, path: str, **kwargs: Any) -> Any:
api = kwargs.pop("api", False)
resp = self._run_request("PUT", path, **kwargs)
if api:
return resp.json() if hasattr(resp, "json") else None
return resp
def delete(self, _domain: str, path: str, **kwargs: Any) -> Any:
api = kwargs.pop("api", False)
resp = self._run_request("DELETE", path, **kwargs)
if api:
return resp.json() if hasattr(resp, "json") else None
return resp
def resume_login(self, _client_state: Any, mfa_code: str) -> tuple[str | None, Any]:
"""Complete a previously initiated MFA login."""
try:
self._complete_mfa(mfa_code)
if self.verify_login and not self._verify_token():
self._clear_auth_state()
raise GarminConnectConnectionError(
"token rejected by API tier after MFA"
)
return None, None
finally:
# Always clear the pending MFA flag and per-attempt state so a new
# login can start after resume_login() finishes (success or failure).
self._mfa_pending = False
for attr in (
"_mfa_session",
"_mfa_login_params",
"_mfa_post_headers",
"_mfa_service_url",
"_mfa_flow",
"_mfa_method",
"_widget_last_resp",
):
setattr(self, attr, None)
def download(self, path: str, **kwargs: Any) -> bytes:
if "headers" not in kwargs:
kwargs["headers"] = {}
kwargs["headers"].update({"Accept": "*/*"})
return self._run_request("GET", path, **kwargs).content
def _run_request(self, method: str, path: str, **kwargs: Any) -> Any:
with self._token_lock:
if self.is_authenticated and self._token_expires_soon():
self._refresh_session()
# Defense-in-depth: callers must pass clean path components; query strings
# belong in the `params` kwarg, not embedded in the path. Validate the
# percent-decoded form: requests' requote_uri() decodes unreserved
# characters (e.g. %2e -> .) after this check, so a literal-only match
# would let a %2e%2e traversal slip through.
decoded_path = unquote(path)
# A quoted display name may legitimately contain a run of dots (e.g.
# "first..last"); only a path *segment* that is exactly ".." (or
# "..;<matrix-params>", a known filter-bypass trick) is traversal.
has_traversal_segment = any(
segment.split(";", 1)[0] == ".." for segment in decoded_path.split("/")
)
if (
has_traversal_segment
or "?" in decoded_path
or "#" in decoded_path
or "\\" in decoded_path
):
raise ValueError(f"Invalid API path: {path!r}")
url = f"{self._connectapi}/{path.lstrip('/')}"
if "timeout" not in kwargs:
kwargs["timeout"] = 15
headers = self.get_api_headers()
custom_headers = kwargs.pop("headers", {})
headers.update(custom_headers)
sess = self._api_session
# Snapshot stream positions of any file bodies so a 401 retry can
# rewind them; attempt #1 reads file handles to EOF, and re-sending
# the same kwargs would otherwise upload an empty/truncated body.
file_positions = _capture_file_positions(kwargs)
resp = sess.request(method, url, headers=headers, **kwargs)
if resp.status_code == 401:
with self._token_lock:
self._refresh_session()
if file_positions is not None and _restore_file_positions(file_positions):
headers = self.get_api_headers()
headers.update(custom_headers)
resp = sess.request(method, url, headers=headers, **kwargs)
else:
# Unseekable/unrewindable file body: retrying would silently
# send an empty part. Fall through so the 401 raises below.
_LOGGER.debug("Skipping 401 retry: request body is not rewindable")
if resp.status_code == 204:
class EmptyJSONResp:
status_code = 204
content = b""
def json(self) -> Any:
return {}
def __repr__(self) -> str:
return "{}"
def __str__(self) -> str:
return "{}"
return EmptyJSONResp()
if resp.status_code >= 400:
error_msg = f"API Error {resp.status_code}"
safe_detail = ""
try:
error_data = resp.json()
if isinstance(error_data, dict):
safe_detail = (
error_data.get("message")
or error_data.get("content")
or error_data.get("detailedImportResult", {})
.get("failures", [{}])[0]
.get("messages", [""])[0]
) or ""
except Exception as e:
_LOGGER.debug("Could not extract safe error detail: %s", e)
if safe_detail:
error_msg += f" - {safe_detail}"
_LOGGER.debug(
"API error response: status=%s body=%r", resp.status_code, resp.text
)
# A 404 is a missing resource, not a connectivity failure.
if resp.status_code == 404:
raise GarminConnectNotFoundError(error_msg)
raise GarminConnectConnectionError(error_msg)
return resp