|
| 1 | +#!/usr/bin/env python3 |
| 2 | +"""Interactive login strategy tester. |
| 3 | +
|
| 4 | +Runs a single login strategy in isolation (or all of them) so you can see |
| 5 | +exactly which authentication path works and which fails on your account / |
| 6 | +network. Handy for diagnosing login issues: rate limits (429), Cloudflare |
| 7 | +bot challenges (403 / CAPTCHA), MFA, or token-audience rejections. |
| 8 | +
|
| 9 | +The five strategies, in the order the library tries them: |
| 10 | + 1. mobile+cffi iOS mobile API via curl_cffi (TLS impersonation) |
| 11 | + 2. mobile+requests iOS mobile API via plain requests |
| 12 | + 3. widget+cffi SSO embed widget via curl_cffi |
| 13 | + 4. portal+cffi Connect portal via curl_cffi |
| 14 | + 5. portal+requests Connect portal via plain requests |
| 15 | +
|
| 16 | +Usage: |
| 17 | + python3 test_strategy.py |
| 18 | + EMAIL=you@example.com PASSWORD=secret python3 test_strategy.py |
| 19 | + GARMINTOKENS=~/.garminconnect python3 test_strategy.py |
| 20 | +
|
| 21 | +All output (console + debug logs) is also written to strategy_<name>.log. |
| 22 | +""" |
| 23 | + |
| 24 | +import logging |
| 25 | +import os |
| 26 | +import shutil |
| 27 | +import sys |
| 28 | +from datetime import datetime |
| 29 | +from getpass import getpass |
| 30 | +from pathlib import Path |
| 31 | + |
| 32 | +import garminconnect |
| 33 | + |
| 34 | +ALL_STRATEGIES = [ |
| 35 | + "mobile+cffi", |
| 36 | + "mobile+requests", |
| 37 | + "widget+cffi", |
| 38 | + "portal+cffi", |
| 39 | + "portal+requests", |
| 40 | +] |
| 41 | + |
| 42 | + |
| 43 | +class _Tee: |
| 44 | + """Write to both a stream and a file simultaneously.""" |
| 45 | + |
| 46 | + def __init__(self, stream: object, file: object) -> None: |
| 47 | + self._stream = stream |
| 48 | + self._file = file |
| 49 | + |
| 50 | + def write(self, data: str) -> None: |
| 51 | + self._stream.write(data) |
| 52 | + self._file.write(data) |
| 53 | + |
| 54 | + def flush(self) -> None: |
| 55 | + self._stream.flush() |
| 56 | + self._file.flush() |
| 57 | + |
| 58 | + def __getattr__(self, attr: str) -> object: |
| 59 | + return getattr(self._stream, attr) |
| 60 | + |
| 61 | + |
| 62 | +def _setup_logging(strategy: str) -> Path: |
| 63 | + slug = strategy.replace("+", "_") |
| 64 | + ts = datetime.now().strftime("%Y%m%d_%H%M%S") |
| 65 | + log_path = Path(f"strategy_{slug}_{ts}.log") |
| 66 | + log_file = log_path.open("w", buffering=1) |
| 67 | + |
| 68 | + sys.stdout = _Tee(sys.__stdout__, log_file) # type: ignore[assignment] |
| 69 | + sys.stderr = _Tee(sys.__stderr__, log_file) # type: ignore[assignment] |
| 70 | + |
| 71 | + logging.basicConfig( |
| 72 | + level=logging.DEBUG, |
| 73 | + format="%(asctime)s %(levelname)-8s %(name)s %(message)s", |
| 74 | + stream=sys.stderr, |
| 75 | + ) |
| 76 | + return log_path |
| 77 | + |
| 78 | + |
| 79 | +def pick_strategy() -> str: |
| 80 | + print("\nAvailable strategies:") |
| 81 | + for i, name in enumerate(ALL_STRATEGIES, 1): |
| 82 | + print(f" {i}. {name}") |
| 83 | + print(f" {len(ALL_STRATEGIES) + 1}. Run ALL (normal login, no skipping)") |
| 84 | + while True: |
| 85 | + raw = input("\nPick a number: ").strip() |
| 86 | + if raw.isdigit(): |
| 87 | + n = int(raw) |
| 88 | + if 1 <= n <= len(ALL_STRATEGIES): |
| 89 | + return ALL_STRATEGIES[n - 1] |
| 90 | + if n == len(ALL_STRATEGIES) + 1: |
| 91 | + return "all" |
| 92 | + print("Invalid choice, try again.") |
| 93 | + |
| 94 | + |
| 95 | +def get_credentials() -> tuple[str, str]: |
| 96 | + email = os.environ.get("EMAIL") or input("Garmin email: ").strip() |
| 97 | + password = os.environ.get("PASSWORD") or getpass("Garmin password: ") |
| 98 | + return email, password |
| 99 | + |
| 100 | + |
| 101 | +def get_tokenstore() -> str: |
| 102 | + default = str(Path("~/.garminconnect").expanduser()) |
| 103 | + path = os.environ.get("GARMINTOKENS", default) |
| 104 | + print(f"Token store: {path}") |
| 105 | + return path |
| 106 | + |
| 107 | + |
| 108 | +def clear_tokens(tokenstore: str) -> None: |
| 109 | + p = Path(tokenstore).expanduser() |
| 110 | + if not p.exists(): |
| 111 | + print(f"No token store at {p} (will do a fresh login anyway)") |
| 112 | + elif p.is_dir(): |
| 113 | + shutil.rmtree(p) |
| 114 | + print(f"Deleted directory {p}") |
| 115 | + else: |
| 116 | + p.unlink() |
| 117 | + print(f"Deleted {p}") |
| 118 | + |
| 119 | + |
| 120 | +def run(strategy: str) -> None: |
| 121 | + email, password = get_credentials() |
| 122 | + tokenstore = get_tokenstore() |
| 123 | + |
| 124 | + answer = ( |
| 125 | + input("\nDelete existing tokens to force a fresh login? [Y/n]: ") |
| 126 | + .strip() |
| 127 | + .lower() |
| 128 | + ) |
| 129 | + if answer != "n": |
| 130 | + clear_tokens(tokenstore) |
| 131 | + |
| 132 | + g = garminconnect.Garmin( |
| 133 | + email, password, prompt_mfa=lambda: input("MFA code: ").strip() |
| 134 | + ) |
| 135 | + |
| 136 | + if strategy != "all": |
| 137 | + skip = set(ALL_STRATEGIES) - {strategy} |
| 138 | + g.client.skip_strategies = skip |
| 139 | + print(f"\nRunning ONLY: {strategy}") |
| 140 | + print(f"Skipping: {', '.join(sorted(skip))}\n") |
| 141 | + else: |
| 142 | + print("\nRunning all strategies (normal login)\n") |
| 143 | + |
| 144 | + try: |
| 145 | + g.login(tokenstore) |
| 146 | + print(f"\n✓ Login succeeded via {strategy}") |
| 147 | + print(f" display_name : {g.display_name}") |
| 148 | + print(f" full_name : {g.full_name}") |
| 149 | + print(f" di_token set : {bool(g.client.di_token)}") |
| 150 | + print(f" jwt_web set : {bool(g.client.jwt_web)}") |
| 151 | + except garminconnect.GarminConnectAuthenticationError as e: |
| 152 | + print(f"\n✗ Authentication error: {e}", file=sys.stderr) |
| 153 | + except garminconnect.GarminConnectTooManyRequestsError as e: |
| 154 | + print(f"\n✗ Rate limited (429): {e}", file=sys.stderr) |
| 155 | + except garminconnect.GarminConnectConnectionError as e: |
| 156 | + print(f"\n✗ Connection/API error: {e}", file=sys.stderr) |
| 157 | + except Exception as e: |
| 158 | + print(f"\n✗ Unexpected error ({type(e).__name__}): {e}", file=sys.stderr) |
| 159 | + |
| 160 | + |
| 161 | +if __name__ == "__main__": |
| 162 | + # Pick strategy before setting up logging so the menu stays clean |
| 163 | + strategy = pick_strategy() |
| 164 | + log_path = _setup_logging(strategy) |
| 165 | + print(f"Logging to: {log_path}\n") |
| 166 | + run(strategy) |
0 commit comments