Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 18 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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

Expand Down Expand Up @@ -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

Expand All @@ -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.
42 changes: 25 additions & 17 deletions scripts/sync_garmin.py
Original file line number Diff line number Diff line change
@@ -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."""

Expand All @@ -20,32 +20,42 @@
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: ")
if not password:
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()
Expand All @@ -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:
Expand Down