Skip to content
This repository was archived by the owner on Apr 30, 2026. It is now read-only.

Commit 39d6d3d

Browse files
a-dealclaude
andcommitted
Harden connect(): never fall through to Garmin SSO login
connect() previously fell through to Garmin().login() when cached tokens failed AND email/password were set in config. This meant the 4h garmin-pull cron could hammer Garmin SSO repeatedly on token expiry. Now connect() always raises RuntimeError on cache failure. SSO login only happens through explicit auth paths (auth_interactive, auth_garmin MCP tool). Removed dead code for SSO fallback. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent a5547c7 commit 39d6d3d

2 files changed

Lines changed: 43 additions & 20 deletions

File tree

engine/integrations/garmin.py

Lines changed: 6 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -158,27 +158,13 @@ def connect(self):
158158
return client
159159
except Exception as e:
160160
print(f"Cached token auth failed: {e}", file=sys.stderr)
161-
# Never fall through to SSO login from automated context.
162-
# That causes rate limits. Require interactive re-auth.
163-
if not self.email or not self.password:
164-
raise RuntimeError(
165-
f"Token auth failed: {e}. Run `python3 cli.py auth garmin` to re-authenticate."
166-
) from e
167-
168-
if not self.email or not self.password:
169-
raise RuntimeError(
170-
"No tokens found. Run `python3 cli.py auth garmin` to authenticate."
171-
)
161+
raise RuntimeError(
162+
f"Token auth failed: {e}. Run `python3 cli.py auth garmin` or use auth_garmin MCP tool to re-authenticate."
163+
) from e
172164

173-
print("Logging in to Garmin Connect...")
174-
client = Garmin(self.email, self.password)
175-
client.login()
176-
self.token_dir.mkdir(parents=True, exist_ok=True)
177-
client.garth.dump(str(self.token_dir))
178-
self._sync_to_store()
179-
print("Authenticated and token cached.")
180-
self._client = client
181-
return client
165+
raise RuntimeError(
166+
"No Garmin tokens found. Run `python3 cli.py auth garmin` or use auth_garmin MCP tool to authenticate."
167+
)
182168

183169
@property
184170
def client(self):

tests/test_garmin.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -601,6 +601,43 @@ def test_connect_without_store_no_sync(self, tmp_path):
601601
assert client.token_store is None
602602

603603

604+
class TestConnectNeverHitsSSO:
605+
"""connect() must never fall through to SSO login, even with credentials set."""
606+
607+
def test_connect_raises_when_cached_tokens_fail_with_credentials(self, tmp_path):
608+
"""If cached tokens fail and email/password are set, connect() should
609+
raise RuntimeError, NOT fall through to Garmin().login().
610+
611+
This prevents the 4h garmin-pull cron from hammering Garmin SSO
612+
when tokens expire and credentials happen to be in config.
613+
"""
614+
token_dir = tmp_path / "tokens"
615+
token_dir.mkdir()
616+
# Put a bad token file so the cached path is attempted and fails
617+
(token_dir / "oauth1_token.json").write_text('{"bad": "token"}')
618+
619+
client = GarminClient(
620+
email="test@example.com",
621+
password="secret",
622+
token_dir=str(token_dir),
623+
)
624+
625+
# Make garth.load succeed but profile check fail (simulates corrupted cache)
626+
mock_garth = type("MockGarth", (), {
627+
"load": lambda self, d: None,
628+
"oauth2_token": type("T", (), {"expired": False, "refresh_expired": False})(),
629+
"profile": {}, # empty profile triggers RuntimeError
630+
})()
631+
mock_garmin_obj = type("MockGarmin", (), {
632+
"garth": mock_garth,
633+
"display_name": None,
634+
})()
635+
636+
with patch("garminconnect.Garmin", return_value=mock_garmin_obj):
637+
with pytest.raises(RuntimeError, match="Token auth failed"):
638+
client.connect()
639+
640+
604641
class TestSleepTimeTimezone:
605642
"""Verify sleep_start/sleep_end are extracted correctly from Garmin timestamps.
606643

0 commit comments

Comments
 (0)