Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Migrate to new auth flow and use Garth, fixes #103 #104

Closed
wants to merge 3 commits into from
Closed
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
1 change: 0 additions & 1 deletion garminexport/backup.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
import json
import logging
import os
from datetime import datetime

log = logging.getLogger(__name__)

Expand Down
101 changes: 16 additions & 85 deletions garminexport/garminclient.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,9 @@
import logging
import os
import os.path
import re
import sys
import zipfile
import garth
from datetime import timedelta, datetime
from builtins import range
from functools import wraps
Expand Down Expand Up @@ -134,84 +134,15 @@ def disconnect(self):
def _authenticate(self):
log.info("authenticating user ...")

form_data = {
"username": self.username,
"password": self.password,
"embed": "false",
"_csrf": self._get_csrf_token(),
}
headers = {
'origin': 'https://sso.garmin.com',
}
if self._user_agent_fn:
user_agent = self._user_agent_fn()
if not user_agent:
raise ValueError("user_agent_fn didn't produce a value")
headers['User-Agent'] = user_agent

auth_response = self.session.post(
SSO_SIGNIN_URL, headers=headers, params=self._auth_params(), data=form_data)
log.debug("got auth response: %s", auth_response.text)
if auth_response.status_code != 200:
raise ValueError("authentication failure: did you enter valid credentials?")
auth_ticket_url = self._extract_auth_ticket_url(auth_response.text)
log.debug("auth ticket url: '%s'", auth_ticket_url)

log.info("claiming auth ticket ...")
response = self.session.get(auth_ticket_url)
if response.status_code != 200:
raise RuntimeError(
"auth failure: failed to claim auth ticket: {}: {}\n{}".format(
auth_ticket_url, response.status_code, response.text))
try:
garth.login(self.username, self.password)
except Exception as ex:
raise ValueError("Authentication failure: {}. Did you enter correct credentials?".format(ex))

# appears like we need to touch base with the main page to complete the
# login ceremony.
self.session.get('https://connect.garmin.com/modern')
# This header appears to be needed on subsequent session requests or we
# end up with a 402 response from Garmin.
self.session.headers.update({'NK': 'NT'})

def _get_csrf_token(self):
"""Retrieves a Cross-Site Request Forgery (CSRF) token from Garmin's login
page. The token is passed along in the login form for increased
security."""
log.info("fetching CSRF token ...")
resp = self.session.get(SSO_LOGIN_URL, params=self._auth_params())
if resp.status_code != 200:
raise ValueError("auth failure: could not load {}".format(SSO_LOGIN_URL))
# extract CSRF token
csrf_token = re.search(r'<input type="hidden" name="_csrf" value="(\w+)"',
resp.content.decode('utf-8'))
if not csrf_token:
raise ValueError("auth failure: no CSRF token in {}".format(SSO_LOGIN_URL))
return csrf_token.group(1)

def _auth_params(self):
"""A set of request query parameters that need to be present for Garmin to
accept our login attempt.
"""
return {
"service": "https://connect.garmin.com/modern/",
"gauthHost": "https://sso.garmin.com/sso",
}

self.session.headers.update({'NK': 'NT', 'authorization': garth.client.oauth2_token.__str__(), 'di-backend': 'connectapi.garmin.com'})

@staticmethod
def _extract_auth_ticket_url(auth_response):
"""Extracts an authentication ticket URL from the response of an
authentication form submission. The auth ticket URL is typically
of form:

https://connect.garmin.com/modern?ticket=ST-0123456-aBCDefgh1iJkLmN5opQ9R-cas

:param auth_response: HTML response from an auth form submission.
"""
match = re.search(r'response_url\s*=\s*"(https:[^"]+)"', auth_response)
if not match:
raise RuntimeError(
"auth failure: unable to extract auth ticket URL. did you provide a correct username/password?")
auth_ticket_url = match.group(1).replace("\\", "")
return auth_ticket_url

@require_session
def list_activities(self):
Expand Down Expand Up @@ -250,7 +181,7 @@ def _fetch_activity_ids_and_ts(self, start_index, max_limit=100):
"""
log.debug("fetching activities %d through %d ...", start_index, start_index + max_limit - 1)
response = self.session.get(
"https://connect.garmin.com/proxy/activitylist-service/activities/search/activities",
"https://connect.garmin.com/activitylist-service/activities/search/activities",
params={"start": start_index, "limit": max_limit})
if response.status_code != 200:
raise Exception(
Expand Down Expand Up @@ -283,7 +214,7 @@ def get_activity_summary(self, activity_id):
:rtype: dict
"""
response = self.session.get(
"https://connect.garmin.com/proxy/activity-service/activity/{}".format(activity_id))
"https://connect.garmin.com/activity-service/activity/{}".format(activity_id))
if response.status_code != 200:
log.error(u"failed to fetch json summary for activity %s: %d\n%s",
activity_id, response.status_code, response.text)
Expand All @@ -304,7 +235,7 @@ def get_activity_details(self, activity_id):
"""
# mounted at xml or json depending on result encoding
response = self.session.get(
"https://connect.garmin.com/proxy/activity-service/activity/{}/details".format(activity_id))
"https://connect.garmin.com/activity-service/activity/{}/details".format(activity_id))
if response.status_code != 200:
raise Exception(u"failed to fetch json activityDetails for {}: {}\n{}".format(
activity_id, response.status_code, response.text))
Expand All @@ -324,11 +255,11 @@ def get_activity_gpx(self, activity_id):
:rtype: str
"""
response = self.session.get(
"https://connect.garmin.com/proxy/download-service/export/gpx/activity/{}".format(activity_id))
"https://connect.garmin.com/download-service/export/gpx/activity/{}".format(activity_id))
# An alternate URL that seems to produce the same results
# and is the one used when exporting through the Garmin
# Connect web page.
# response = self.session.get("https://connect.garmin.com/proxy/activity-service-1.1/gpx/activity/{}?full=true".format(activity_id))
# response = self.session.get("https://connect.garmin.com/activity-service-1.1/gpx/activity/{}?full=true".format(activity_id))

# A 404 (Not Found) or 204 (No Content) response are both indicators
# of a gpx file not being available for the activity. It may, for
Expand Down Expand Up @@ -356,7 +287,7 @@ def get_activity_tcx(self, activity_id):
"""

response = self.session.get(
"https://connect.garmin.com/proxy/download-service/export/tcx/activity/{}".format(activity_id))
"https://connect.garmin.com/download-service/export/tcx/activity/{}".format(activity_id))
if response.status_code == 404:
return None
if response.status_code != 200:
Expand All @@ -377,7 +308,7 @@ def get_original_activity(self, activity_id):
:rtype: (str, str)
"""
response = self.session.get(
"https://connect.garmin.com/proxy/download-service/files/activity/{}".format(activity_id))
"https://connect.garmin.com/download-service/files/activity/{}".format(activity_id))
# A 404 (Not Found) response is a clear indicator of a missing .fit
# file. As of lately, the endpoint appears to have started to
# respond with 500 "NullPointerException" on attempts to download a
Expand Down Expand Up @@ -433,7 +364,7 @@ def _poll_upload_completion(self, uuid, creation_date):
:obj:`None` if upload is still processing.
:rtype: int
"""
response = self.session.get("https://connect.garmin.com/proxy/activity-service/activity/status/{}/{}?_={}".format(
response = self.session.get("https://connect.garmin.com/activity-service/activity/status/{}/{}?_={}".format(
creation_date[:10], uuid.replace("-",""), int(datetime.now().timestamp()*1000)), headers={"nk": "NT"})
if response.status_code == 201 and response.headers["location"]:
# location should be https://connectapi.garmin.com/activity-service/activity/ACTIVITY_ID
Expand Down Expand Up @@ -476,7 +407,7 @@ def upload_activity(self, file, format=None, name=None, description=None, activi

# upload it
files = dict(data=(fn, file))
response = self.session.post("https://connect.garmin.com/proxy/upload-service/upload/.{}".format(format),
response = self.session.post("https://connect.garmin.com/upload-service/upload/.{}".format(format),
files=files, headers={"nk": "NT"})

# check response and get activity ID
Expand Down Expand Up @@ -529,7 +460,7 @@ def upload_activity(self, file, format=None, name=None, description=None, activi
data['activityId'] = activity_id
encoding_headers = {"Content-Type": "application/json; charset=UTF-8"} # see Tapiriik
response = self.session.put(
"https://connect.garmin.com/proxy/activity-service/activity/{}".format(activity_id),
"https://connect.garmin.com/activity-service/activity/{}".format(activity_id),
data=json.dumps(data), headers=encoding_headers)
if response.status_code != 204:
raise Exception(u"failed to set metadata for activity {}: {}\n{}".format(
Expand Down
25 changes: 22 additions & 3 deletions requirements-dev.txt
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
#
# This file is autogenerated by pip-compile with Python 3.9
# This file is autogenerated by pip-compile with Python 3.10
# by the following command:
#
# pip-compile --extra=test --output-file=requirements-dev.txt --resolver=backtracking
# pip-compile --extra=test --output-file=requirements-dev.txt
#
annotated-types==0.5.0
# via pydantic
certifi==2023.5.7
# via requests
charset-normalizer==3.1.0
Expand All @@ -12,14 +14,22 @@ coverage[toml]==7.2.5
# via pytest-cov
exceptiongroup==1.1.1
# via pytest
garth==0.4.29
# via garminexport (setup.py)
idna==3.4
# via requests
iniconfig==2.0.0
# via pytest
oauthlib==3.2.2
# via requests-oauthlib
packaging==23.1
# via pytest
pluggy==1.0.0
# via pytest
pydantic==2.4.1
# via garth
pydantic-core==2.10.1
# via pydantic
pytest==7.3.1
# via
# garminexport (setup.py)
Expand All @@ -29,12 +39,21 @@ pytest-cov==4.0.0
python-dateutil==2.8.2
# via garminexport (setup.py)
requests==2.30.0
# via garminexport (setup.py)
# via
# garminexport (setup.py)
# garth
# requests-oauthlib
requests-oauthlib==1.3.1
# via garth
six==1.16.0
# via python-dateutil
tomli==2.0.1
# via
# coverage
# pytest
typing-extensions==4.8.0
# via
# pydantic
# pydantic-core
urllib3==2.0.2
# via requests
25 changes: 22 additions & 3 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -1,20 +1,39 @@
#
# This file is autogenerated by pip-compile with Python 3.9
# This file is autogenerated by pip-compile with Python 3.10
# by the following command:
#
# pip-compile --output-file=requirements.txt --resolver=backtracking
# pip-compile --output-file=requirements.txt
#
annotated-types==0.5.0
# via pydantic
certifi==2023.5.7
# via requests
charset-normalizer==3.1.0
# via requests
garth==0.4.29
# via garminexport (setup.py)
idna==3.4
# via requests
oauthlib==3.2.2
# via requests-oauthlib
pydantic==2.4.1
# via garth
pydantic-core==2.10.1
# via pydantic
python-dateutil==2.8.2
# via garminexport (setup.py)
requests==2.30.0
# via garminexport (setup.py)
# via
# garminexport (setup.py)
# garth
# requests-oauthlib
requests-oauthlib==1.3.1
# via garth
six==1.16.0
# via python-dateutil
typing-extensions==4.8.0
# via
# pydantic
# pydantic-core
urllib3==2.0.2
# via requests
1 change: 1 addition & 0 deletions setup.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ python_requires = >=3.5
install_requires =
requests>=2.0,<3
python-dateutil~=2.4
garth~=0.4

[options.extras_require]
cloudflare = cloudscraper~=1.2
Expand Down