Skip to content

Commit 21aea2d

Browse files
cyberjunkyclaude
andcommitted
Replace garth dependency with native DI OAuth authentication
Switch from garth-based OAuth/cookie login to the same mobile SSO flow used by the official Garmin Connect Android app. Tokens are now stored as garmin_tokens.json with auto-refresh via DI OAuth Bearer tokens. - Add garminconnect/client.py with native SSO login, token exchange, and auto-refresh logic - Add garminconnect/exceptions.py for standalone exception classes - Update __init__.py to use new client instead of garth - Simplify example.py and update demo.py error handling - Update README with new auth docs and install instructions - Update pyproject.toml dependencies (drop garth, add curl_cffi) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 40f4b63 commit 21aea2d

8 files changed

Lines changed: 1142 additions & 567 deletions

File tree

.gitignore

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
# Custom
22
your_data/
33
pdm.lock
4-
garth/
4+
.claude/
55

66
# Virtual environments
77
.venv/

README.md

Lines changed: 40 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,13 @@
88

99
# Python: Garmin Connect
1010

11+
> **Note:** Garmin has made significant changes to their authentication and API infrastructure.
12+
> The old `garth`-based OAuth/cookie login no longer works. This library now authenticates
13+
> using the same mobile SSO flow as the official Garmin Connect Android app, obtaining native
14+
> DI OAuth Bearer tokens. Saved tokens are stored in a new format (`garmin_tokens.json`) —
15+
> a fresh login is required after upgrading.
16+
> All existing API methods remain unchanged — no code changes needed on your end.
17+
1118
The Garmin Connect API library comes with two examples:
1219

1320
- **`example.py`** - Simple getting-started example showing authentication, token storage, and basic API calls
@@ -88,8 +95,7 @@ Compatible with all Garmin Connect accounts. See <https://connect.garmin.com/>
8895
Install from PyPI:
8996

9097
```bash
91-
python3 -m pip install --upgrade pip
92-
python3 -m pip install garminconnect
98+
pip install --upgrade garminconnect curl_cffi
9399
```
94100

95101
## Run demo software (recommended)
@@ -180,41 +186,34 @@ Run these commands before submitting PRs to ensure code quality standards.
180186

181187
## 🔐 Authentication
182188

183-
The library uses the same OAuth authentication as the official Garmin Connect app via [Garth](https://github.com/matin/garth).
189+
Authentication uses the same mobile SSO flow as the official Garmin Connect Android app.
190+
No browser is needed.
184191

185-
**Key Features:**
186-
- Login credentials valid for one year (no repeated logins)
187-
- Secure OAuth token storage
188-
- Same authentication flow as official app
192+
**How it works:**
189193

190-
**Advanced Configuration:**
191-
```python
192-
# Optional: Custom OAuth consumer (before login)
193-
import os
194-
import garth
195-
garth.sso.OAUTH_CONSUMER = {
196-
'key': os.getenv('GARTH_OAUTH_KEY', '<YOUR_KEY>'),
197-
'secret': os.getenv('GARTH_OAUTH_SECRET', '<YOUR_SECRET>'),
198-
}
199-
# Note: Set these env vars securely; placeholders are non-sensitive.
200-
```
194+
1. **First login**: Authenticates via `sso.garmin.com/mobile/api/login` using the Android
195+
app's client ID. If MFA is required, a callback (`prompt_mfa`) prompts for the one-time code.
196+
2. **Token exchange**: The service ticket is exchanged for DI OAuth Bearer tokens
197+
(`access_token` + `refresh_token`) via `diauth.garmin.com`. Tokens are stored at
198+
`~/.garminconnect/garmin_tokens.json`.
199+
3. **Auto-refresh**: Before each API request the library checks whether the DI token is about
200+
to expire and refreshes it automatically — no user interaction required.
201201

202-
**Token Storage:**
203-
Tokens are automatically saved to `~/.garminconnect` directory for persistent authentication.
204-
For security, ensure restrictive permissions:
202+
**Session lifetime:**
203+
- DI tokens auto-refresh indefinitely as long as the refresh token remains valid.
204+
- A full re-login with credentials (and possibly MFA) is only needed if the refresh token
205+
itself expires or is revoked.
205206

207+
**Token storage:**
206208
```bash
207-
chmod 700 ~/.garminconnect
208-
chmod 600 ~/.garminconnect/* 2>/dev/null || true
209+
~/.garminconnect/garmin_tokens.json # saved automatically, mode 0600
209210
```
210211

211212
## 🧪 Testing
212213

213-
Run the test suite to verify functionality:
214-
215214
**Prerequisites:**
216215

217-
Create tokens in ~/.garminconnect by running the example program.
216+
Run `example.py` once to create saved tokens in `~/.garminconnect`.
218217

219218
```bash
220219
# Install development dependencies
@@ -232,13 +231,13 @@ Optional: keep test tokens isolated
232231

233232
```bash
234233
export GARMINTOKENS="$(mktemp -d)"
235-
python3 ./example.py # create fresh tokens for tests
234+
python3 ./example.py # create a fresh token file for tests
236235
pdm run test
237236
```
238237

239-
**Note:** Tests automatically use `~/.garminconnect` as the default token file location. You can override this by setting the `GARMINTOKENS` environment variable. Run `example.py` first to generate authentication tokens for testing.
240-
241-
**For Developers:** Tests use VCR cassettes to record/replay HTTP interactions. If tests fail with authentication errors, ensure valid tokens exist in `~/.garminconnect`
238+
**Note:** Tests use VCR cassettes to record/replay API responses. If tests fail with
239+
authentication errors, ensure valid tokens exist in `~/.garminconnect` (run
240+
`example.py` first).
242241

243242
## 📦 Publishing
244243

@@ -326,23 +325,25 @@ Explore the API interactively with our [reference notebook](https://github.com/c
326325
### Python Code Examples
327326

328327
```python
329-
from garminconnect import Garmin
330328
import os
329+
from datetime import date
330+
from garminconnect import Garmin
331331

332-
# Initialize and login
332+
# First run: logs in and saves tokens to ~/.garminconnect
333+
# Subsequent runs: loads saved tokens and auto-refreshes
333334
client = Garmin(
334-
os.getenv("GARMIN_EMAIL", "<YOUR_EMAIL>"),
335-
os.getenv("GARMIN_PASSWORD", "<YOUR_PASSWORD>")
335+
os.getenv("EMAIL"),
336+
os.getenv("PASSWORD"),
337+
prompt_mfa=lambda: input("MFA code: "),
336338
)
337-
client.login()
339+
client.login("~/.garminconnect")
338340

339341
# Get today's stats
340-
from datetime import date
341-
_today = date.today().strftime('%Y-%m-%d')
342-
stats = client.get_stats(_today)
342+
today = date.today().isoformat()
343+
stats = client.get_stats(today)
343344

344345
# Get heart rate data
345-
hr_data = client.get_heart_rates(_today)
346+
hr_data = client.get_heart_rates(today)
346347
print(f"Resting HR: {hr_data.get('restingHeartRate', 'n/a')}")
347348
```
348349

demo.py

Lines changed: 22 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,11 @@
88
For a simple getting-started example, see example.py
99
1010
Dependencies:
11-
pip3 install garth requests readchar
11+
pip3 install requests readchar
1212
1313
Environment Variables (optional):
14-
export EMAIL=<your garmin email address>
15-
export PASSWORD=<your garmin password>
14+
export GARMIN_EMAIL=<your garmin email address>
15+
export GARMIN_PASSWORD=<your garmin password>
1616
export GARMINTOKENS=<path to token storage>
1717
"""
1818

@@ -29,7 +29,6 @@
2929

3030
import readchar
3131
import requests
32-
from garth.exc import GarthException, GarthHTTPError
3332

3433
from garminconnect import (
3534
Garmin,
@@ -73,12 +72,9 @@ class Config:
7372

7473
def __init__(self):
7574
# Load environment variables
76-
self.email = os.getenv("EMAIL")
77-
self.password = os.getenv("PASSWORD")
75+
self.email = os.getenv("GARMIN_EMAIL") or os.getenv("EMAIL")
76+
self.password = os.getenv("GARMIN_PASSWORD") or os.getenv("PASSWORD")
7877
self.tokenstore = os.getenv("GARMINTOKENS") or "~/.garminconnect"
79-
self.tokenstore_base64 = (
80-
os.getenv("GARMINTOKENS_BASE64") or "~/.garminconnect_base64"
81-
)
8278

8379
# Date settings
8480
self.today = datetime.date.today()
@@ -1094,7 +1090,7 @@ def safe_api_call(api_method, *args, method_name: str | None = None, **kwargs):
10941090
result = api_method(*args, **kwargs)
10951091
return True, result, None
10961092

1097-
except GarthHTTPError as e:
1093+
except GarminConnectConnectionError as e:
10981094
# Handle specific HTTP errors more gracefully
10991095
error_str = str(e)
11001096

@@ -1145,7 +1141,7 @@ def safe_api_call(api_method, *args, method_name: str | None = None, **kwargs):
11451141
print(f"⚠️ {method_name} failed: {error_msg}")
11461142
return False, None, error_msg
11471143

1148-
except GarminConnectConnectionError as e:
1144+
except GarminConnectConnectionError as e: # noqa: B025
11491145
error_str = str(e)
11501146
# Extract a clean message by detecting common HTTP status codes
11511147
if "410" in error_str:
@@ -3425,16 +3421,16 @@ def get_virtual_challenges_data(api: Garmin) -> None:
34253421
# that don't have virtual challenges enabled, so handle it quietly
34263422
try:
34273423
challenges = api.get_inprogress_virtual_challenges(
3428-
config.start, config.default_limit
3424+
config.start_badge, config.default_limit
34293425
)
34303426
if challenges:
34313427
print("✅ Virtual challenges data retrieved successfully")
34323428
call_and_display(
34333429
api.get_inprogress_virtual_challenges,
3434-
config.start,
3430+
config.start_badge,
34353431
config.default_limit,
34363432
method_name="get_inprogress_virtual_challenges",
3437-
api_call_desc=f"api.get_inprogress_virtual_challenges({config.start}, {config.default_limit})",
3433+
api_call_desc=f"api.get_inprogress_virtual_challenges({config.start_badge}, {config.default_limit})",
34383434
)
34393435
return
34403436
print("ℹ️ No in-progress virtual challenges found")
@@ -4152,7 +4148,6 @@ def init_api(email: str | None = None, password: str | None = None) -> Garmin |
41524148

41534149
except (
41544150
FileNotFoundError,
4155-
GarthHTTPError,
41564151
GarminConnectAuthenticationError,
41574152
GarminConnectConnectionError,
41584153
):
@@ -4182,31 +4177,24 @@ def init_api(email: str | None = None, password: str | None = None) -> Garmin |
41824177
garmin.resume_login(result2, mfa_code)
41834178
print("✅ MFA authentication successful!")
41844179

4185-
except GarthHTTPError as garth_error:
4186-
# Handle specific HTTP errors from MFA
4187-
error_str = str(garth_error)
4180+
except GarminConnectTooManyRequestsError:
4181+
print("❌ Too many MFA attempts")
4182+
print("💡 Please wait 30 minutes before trying again")
4183+
sys.exit(1)
4184+
except GarminConnectAuthenticationError as mfa_error:
4185+
# Handle specific errors from MFA
4186+
error_str = str(mfa_error)
41884187
print(f"🔍 Debug: MFA error details: {error_str}")
4189-
4190-
if "429" in error_str and "Too Many Requests" in error_str:
4191-
print("❌ Too many MFA attempts")
4192-
print("💡 Please wait 30 minutes before trying again")
4193-
sys.exit(1)
4194-
elif "401" in error_str or "403" in error_str:
4188+
if "401" in error_str or "403" in error_str:
41954189
print("❌ Invalid MFA code")
41964190
print("💡 Please verify your MFA code and try again")
41974191
continue
4198-
else:
4199-
# Other HTTP errors - don't retry
4200-
print(f"❌ MFA authentication failed: {garth_error}")
4201-
sys.exit(1)
4202-
4203-
except GarthException as garth_error:
4204-
print(f"❌ MFA authentication failed: {garth_error}")
4205-
print("💡 Please verify your MFA code and try again")
4206-
continue
4192+
# Other HTTP errors - don't retry
4193+
print(f"❌ MFA authentication failed: {mfa_error}")
4194+
sys.exit(1)
42074195

42084196
# Save tokens for future use
4209-
garmin.garth.dump(config.tokenstore)
4197+
garmin.client.dump(config.tokenstore)
42104198
print(f"Login successful! Tokens saved to: {config.tokenstore}")
42114199

42124200
return garmin
@@ -4225,8 +4213,6 @@ def init_api(email: str | None = None, password: str | None = None) -> Garmin |
42254213

42264214
except (
42274215
FileNotFoundError,
4228-
GarthHTTPError,
4229-
GarthException,
42304216
GarminConnectConnectionError,
42314217
requests.exceptions.HTTPError,
42324218
) as err:

0 commit comments

Comments
 (0)