From 12c41e5c5b23e44289ae9c35128d850cec6ecd77 Mon Sep 17 00:00:00 2001
From: Simon Baars <simon.mailadres@gmail.com>
Date: Wed, 27 Sep 2023 16:07:21 +0100
Subject: [PATCH 1/3] Migrate Garmin authentication to Garth OAuth

---
 requirements.txt        |   1 +
 withings_sync/garmin.py | 171 ++--------------------------------------
 2 files changed, 9 insertions(+), 163 deletions(-)

diff --git a/requirements.txt b/requirements.txt
index 3e0c1de..c2cd2ea 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,3 +1,4 @@
 lxml
 requests
 cloudscraper
+garth
diff --git a/withings_sync/garmin.py b/withings_sync/garmin.py
index bdba85b..256edbc 100644
--- a/withings_sync/garmin.py
+++ b/withings_sync/garmin.py
@@ -1,11 +1,7 @@
 """This module handles the Garmin connectivity."""
-import urllib.request
-import urllib.error
-import urllib.parse
-import re
-import json
 import logging
 import cloudscraper
+import garth
 
 
 log = logging.getLogger("garmin")
@@ -26,27 +22,7 @@ class APIException(Exception):
 class GarminConnect:
     """Main GarminConnect class"""
 
-    LOGIN_URL = "https://connect.garmin.com/signin"
-    UPLOAD_URL = "https://connect.garmin.com/modern/proxy/upload-service/upload/.fit"
-
-    def create_opener(self, cookie):
-        """Garmin opener"""
-        this = self
-
-        class _HTTPRedirectHandler(urllib.request.HTTPRedirectHandler):
-            def http_error_302(
-                self, req, fp, code, msg, headers
-            ):  # pylint: disable=too-many-arguments
-                if req.get_full_url() == this.LOGIN_URL:
-                    raise LoginSucceeded
-
-                return urllib.request.HTTPRedirectHandler.http_error_302(
-                    self, req, fp, code, msg, headers
-                )
-
-        return urllib.request.build_opener(
-            _HTTPRedirectHandler, urllib.request.HTTPCookieProcessor(cookie)
-        )
+    UPLOAD_URL = "https://connect.garmin.com/upload-service/upload/.fit"
 
     # From https://github.com/cpfair/tapiriik
     @staticmethod
@@ -54,149 +30,18 @@ def get_session(email=None, password=None):
         """tapiriik get_session code"""
         session = cloudscraper.CloudScraper()
 
-        data = {
-            "username": email,
-            "password": password,
-            "_eventId": "submit",
-            "embed": "true",
-        }
-        params = {
-            "service": "https://connect.garmin.com/modern",
-            "clientId": "GarminConnect",
-            "gauthHost": "https://sso.garmin.com/sso",
-            "consumeServiceTicket": "false",
-        }
-
-        headers = {
-            "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_10_1) AppleWebKit/537.36 "
-            + "(KHTML, like Gecko) Chrome/39.0.2171.95 Safari/537.36",
-            "Referer": "https://jhartman.pl",
-            "origin": "https://sso.garmin.com",
-        }
-
-        # I may never understand what motivates people to mangle a perfectly
-        # good protocol like HTTP in the ways they do...
-        preresp = session.get(
-            "https://sso.garmin.com/sso/signin", params=params, headers=headers
-        )
-        if preresp.status_code != 200:
-            raise APIException(
-                f"SSO prestart error {preresp.status_code} {preresp.text}"
-            )
-
-        ssoresp = session.post(
-            "https://sso.garmin.com/sso/login",
-            params=params,
-            data=data,
-            allow_redirects=False,
-            headers=headers,
-        )
-
-        if ssoresp.status_code == 429:
-            raise APIException(
-                "SSO error 429: You are being rate limited: "
-                + "The owner of this website (sso.garmin.com) "
-                + "has banned you temporarily from accessing this website."
-            )
-
-        if ssoresp.status_code != 200 or "temporarily unavailable" in ssoresp.text:
-            raise APIException(f"SSO error {ssoresp.status_code} {ssoresp.text}")
-
-        if ">sendEvent('FAIL')" in ssoresp.text:
-            raise APIException("Invalid login")
-
-        if ">sendEvent('ACCOUNT_LOCKED')" in ssoresp.text:
-            raise APIException("Account Locked")
-
-        if "renewPassword" in ssoresp.text:
-            raise APIException("Reset password")
-
-        # self.print_cookies(cookies=session.cookies)
-
-        # ...AND WE'RE NOT DONE YET!
-
-        gcredeemresp = session.get(
-            "https://connect.garmin.com/modern", allow_redirects=False, headers=headers
-        )
-        if gcredeemresp.status_code != 302:
-            raise APIException(
-                f"GC redeem-start error {gcredeemresp.status_code} {gcredeemresp.text}"
-            )
-
-        url_prefix = "https://connect.garmin.com"
-
-        # There are 6 redirects that need to be followed to get the correct cookie
-        # ... :(
-        max_redirect_count = 7
-        current_redirect_count = 1
-        while True:
-            url = gcredeemresp.headers["location"]
-
-            # Fix up relative redirects.
-            if url.startswith("/"):
-                url = url_prefix + url
-            url_prefix = "/".join(url.split("/")[:3])
-            gcredeemresp = session.get(url, allow_redirects=False)
-
-            if (
-                current_redirect_count >= max_redirect_count
-                and gcredeemresp.status_code != 200
-            ):
-                raise APIException(
-                    f"GC redeem {current_redirect_count}/"
-                    "{max_redirect_count} error "
-                    "{gcredeemresp.status_code} "
-                    "{gcredeemresp.text}"
-                )
-
-            if gcredeemresp.status_code in [200, 404]:
-                break
-
-            current_redirect_count += 1
-            if current_redirect_count > max_redirect_count:
-                break
-
-        # GarminConnect.print_cookies(session.cookies)
-        session.headers.update(headers)
+        try:
+            garth.login(email, password)
+        except Exception as ex:
+            raise APIException("Authentication failure: {}. Did you enter correct credentials?".format(ex))
 
+        session.headers.update({'NK': 'NT', 'authorization': garth.client.oauth2_token.__str__(), 'di-backend': 'connectapi.garmin.com'})
         return session
 
-    @staticmethod
-    def get_json(page_html, key):
-        """Return json from text."""
-        found = re.search(key + r" = (\{.*\});", page_html, re.M)
-        if found:
-            json_text = found.group(1).replace('\\"', '"')
-            return json.loads(json_text)
-        return None
-
-    @staticmethod
-    def print_cookies(cookies):
-        """print cookies"""
-        log.debug("Cookies: ")
-        for key, value in list(cookies.items()):
-            log.debug(" %s = %s", key, value)
-
     @staticmethod
     def login(username, password):
         """login to Garmin"""
-        session = GarminConnect.get_session(email=username, password=password)
-        try:
-            dashboard = session.get("http://connect.garmin.com/modern")
-            userdata = GarminConnect.get_json(dashboard.text, "VIEWER_SOCIAL_PROFILE")
-            username = userdata["displayName"]
-
-            log.info("Garmin Connect User Name: %s", username)
-
-        except Exception as exception:  # pylint: disable=broad-except
-            log.error(exception)
-            log.error(
-                "Unable to retrieve Garmin username! Most likely: "
-                "incorrect Garmin login or password!"
-            )
-            log.debug(dashboard.text)
-
-        return session
+        return GarminConnect.get_session(email=username, password=password)
 
     def upload_file(self, ffile, session):
         """upload fit file to Garmin connect"""

From 9a9bbbca9c56eb33efe3cf7698fda4671e1fba61 Mon Sep 17 00:00:00 2001
From: Simon Baars <simon.mailadres@gmail.com>
Date: Wed, 27 Sep 2023 17:12:33 +0100
Subject: [PATCH 2/3] Add dependency to setup.py

---
 setup.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/setup.py b/setup.py
index bf706ff..6bff740 100644
--- a/setup.py
+++ b/setup.py
@@ -26,7 +26,7 @@ def read(fname):
         "Topic :: Utilities",
         "License :: OSI Approved :: MIT License",
     ],
-    install_requires=["lxml", "requests", "cloudscraper"],
+    install_requires=["lxml", "requests", "cloudscraper", "garth"],
     entry_points={
         "console_scripts": ["withings-sync=withings_sync.sync:main"],
     },

From 547360d12077079687cf2b34cd6ffe8d8ce9ca03 Mon Sep 17 00:00:00 2001
From: longstone <2110765+longstone@users.noreply.github.com>
Date: Thu, 28 Sep 2023 10:44:40 +0200
Subject: [PATCH 3/3] (chore) set release version

Bump version and set it to release major change due to incompatibility
---
 setup.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/setup.py b/setup.py
index 6bff740..e00068f 100644
--- a/setup.py
+++ b/setup.py
@@ -12,7 +12,7 @@ def read(fname):
 
 setup(
     name="withings-sync",
-    version="3.6.2",
+    version="4.0.0",
     author="Masayuki Hamasaki, Steffen Vogel",
     author_email="post@steffenvogel.de",
     description="A tool for synchronisation of Withings (ex. Nokia Health Body) to Garmin Connect and Trainer Road.",