diff --git a/README.md b/README.md index 908c9b3..3111320 100644 --- a/README.md +++ b/README.md @@ -56,7 +56,7 @@ Sections are only included when data is available. - macOS: `brew install uv` - Linux/WSL: `curl -LsSf https://astral.sh/uv/install.sh | sh` - Windows: `powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex"` -- A Garmin Connect account (two-factor authentication must be disabled — see [Troubleshooting](#troubleshooting)) +- A Garmin Connect account (2FA/MFA is supported) ### One-time setup @@ -66,7 +66,15 @@ Authenticate and cache OAuth tokens. This only needs to happen once (~1 year tok uv run scripts/sync_garmin.py --setup --email you@example.com ``` -After setup succeeds, the password is no longer needed. All subsequent syncs use cached tokens only. +If your account has MFA/2FA enabled, you will be prompted to enter your OTP code during setup: + +``` +Garmin Connect password: +Enter MFA/OTP code from your authenticator app: 123456 +Success! Tokens cached in /Users/you/.garminconnect +``` + +After setup succeeds, the password and MFA code are no longer needed. All subsequent syncs use cached tokens only. ### Run it @@ -110,14 +118,15 @@ This is the most common setup error. It usually means Garmin's servers are tempo 2. **Double-check your password.** The error can also appear for wrong credentials — Garmin doesn't always return a clear "wrong password" message. 3. **Check if Garmin Connect is down.** Try logging in at [connect.garmin.com](https://connect.garmin.com) in a browser. -### Two-factor authentication (2FA) +### Two-factor authentication (2FA / MFA) -If your Garmin account has 2FA enabled, authentication will fail. The `garminconnect` library does not support 2FA/MFA flows. You'll need to disable 2FA on your Garmin account to use this skill: +MFA is supported. During `--setup`, after entering your password, you will be prompted: + +``` +Enter MFA/OTP code from your authenticator app: +``` -1. Log in to [connect.garmin.com](https://connect.garmin.com) -2. Go to Account Settings → Security -3. Disable two-step verification -4. Re-run setup: `uv run scripts/sync_garmin.py --setup --email you@example.com` +Enter the 6-digit code from your authenticator app (Google Authenticator, Authy, etc.). The code is only needed once — subsequent syncs use cached tokens. ### Cloudflare / random auth failures @@ -135,5 +144,5 @@ Cached tokens last about a year. When they expire, the sync will tell you to re- The script uses [garminconnect](https://github.com/cyberjunky/python-garminconnect) with [cloudscraper](https://github.com/VeNoMouS/cloudscraper) to bypass Cloudflare protection on Garmin's SSO. Authentication is split into two phases: -1. **Setup** (`--setup`): Run once in a terminal to authenticate. `getpass` prompts for the password (never echoed to screen or stored in shell history). OAuth tokens are cached in `~/.garminconnect/` (~1 year validity). The password is used once and then discarded. +1. **Setup** (`--setup`): Run once in a terminal to authenticate. `getpass` prompts for the password (never echoed to screen or stored in shell history). If MFA is enabled, a second prompt collects the OTP code. OAuth tokens are cached in `~/.garminconnect/` (~1 year validity). The password and OTP are used once and then discarded. 2. **Sync** (default): Uses cached tokens only — no credentials needed. Token refresh is automatic (OAuth1 → OAuth2 exchange, no password required). If tokens expire or are revoked by Garmin, re-run setup. diff --git a/scripts/sync_garmin.py b/scripts/sync_garmin.py index 61bb209..21a8adc 100644 --- a/scripts/sync_garmin.py +++ b/scripts/sync_garmin.py @@ -1,6 +1,6 @@ # /// script # requires-python = ">=3.10" -# dependencies = ["garminconnect>=0.2.38", "cloudscraper"] +# dependencies = ["garminconnect==0.2.38", "cloudscraper"] # /// """Sync daily health data from Garmin Connect into markdown files.""" @@ -20,6 +20,11 @@ VERBOSE = False +def get_mfa() -> str: + """Prompt the user for their MFA/OTP code.""" + return input("Enter MFA/OTP code from your authenticator app: ") + + def setup(email: str) -> None: """One-time interactive setup: authenticate with email/password and cache tokens.""" password = getpass("Garmin Connect password: ") @@ -27,25 +32,30 @@ def setup(email: str) -> None: print("Error: Password cannot be empty.", file=sys.stderr) sys.exit(1) - client = Garmin(email, password) + client = Garmin(email, password, prompt_mfa=get_mfa) client.garth.sess = cloudscraper.create_scraper() TOKEN_DIR.mkdir(parents=True, exist_ok=True) tokenstore = str(TOKEN_DIR) last_exc: Exception | None = None - for attempt in range(3): - try: - client.login() - client.garth.dump(tokenstore) - last_exc = None - break - except Exception as e: - last_exc = e - if attempt < 2 and "no profile" in str(e).lower(): - time.sleep(2**attempt) - continue - break + try: + client.login() + client.garth.dump(tokenstore) + except Exception as e: + last_exc = e + msg = str(e).lower() + # "No profile from connectapi" means Cloudflare blocked the profile + # check AFTER a successful OAuth flow. The tokens are valid — save them. + if "no profile" in msg or "assertionerror" in msg or "connectapi" in msg: + try: + client.garth.dump(tokenstore) + print(f"Warning: Garmin's profile endpoint was temporarily unavailable,") + print(f"but your OAuth tokens were saved to {TOKEN_DIR}.") + print("Run the sync to verify everything works.") + return + except Exception: + pass # garth didn't have tokens — fall through to error if last_exc is not None: msg = str(last_exc).lower() @@ -58,9 +68,7 @@ def setup(email: str) -> None: ) elif "401" in msg or "unauthorized" in msg or "credentials" in msg: print( - "\nDouble-check your email and password. If you have two-factor\n" - "authentication (2FA) enabled on your Garmin account, you may need\n" - "to disable it — the garminconnect library does not support 2FA.", + "\nDouble-check your email and password.", file=sys.stderr, ) elif "cloudflare" in msg or "captcha" in msg or "403" in msg: