mirror of
https://github.com/sstent/go-garth.git
synced 2026-01-25 08:35:06 +00:00
porting - part 4 done
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"domain": "garmin.com",
|
"domain": "garmin.com",
|
||||||
"username": "fbleagh",
|
"username": "fbleagh",
|
||||||
"auth_token": "bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImRpLW9hdXRoLXNpZ25lci1wcm9kLTIwMjQtcTEifQ.eyJzY29wZSI6WyJBVFBfUkVBRCIsIkFUUF9XUklURSIsIkNPTU1VTklUWV9DT1VSU0VfUkVBRCIsIkNPTU1VTklUWV9DT1VSU0VfV1JJVEUiLCJDT05ORUNUX01DVF9EQUlMWV9MT0dfUkVBRCIsIkNPTk5FQ1RfUkVBRCIsIkNPTk5FQ1RfV1JJVEUiLCJESVZFX0FQSV9SRUFEIiwiRElfT0FVVEhfMl9BVVRIT1JJWkFUSU9OX0NPREVfQ1JFQVRFIiwiRFRfQ0xJRU5UX0FOQUxZVElDU19XUklURSIsIkdBUk1JTlBBWV9SRUFEIiwiR0FSTUlOUEFZX1dSSVRFIiwiR0NPRkZFUl9SRUFEIiwiR0NPRkZFUl9XUklURSIsIkdIU19TQU1EIiwiR0hTX1VQTE9BRCIsIkdPTEZfQVBJX1JFQUQiLCJHT0xGX0FQSV9XUklURSIsIklOU0lHSFRTX1JFQUQiLCJJTlNJR0hUU19XUklURSIsIk9NVF9DQU1QQUlHTl9SRUFEIiwiT01UX1NVQlNDUklQVElPTl9SRUFEIiwiUFJPRFVDVF9TRUFSQ0hfUkVBRCJdLCJpc3MiOiJodHRwczovL2RpYXV0aC5nYXJtaW4uY29tIiwicmV2b2NhdGlvbl9lbGlnaWJpbGl0eSI6WyJHTE9CQUxfU0lHTk9VVCIsIk1BTkFHRURfU1RBVFVTIl0sImNsaWVudF90eXBlIjoiVU5ERUZJTkVEIiwiZXhwIjoxNzU3MjkxMDE0LCJpYXQiOjE3NTcyMTE0MTUsImdhcm1pbl9ndWlkIjoiNTZlZTk1ZmMtMzY0MC00NGU2LTg1ODAtNDc4NDEwZDQwZGFhIiwianRpIjoiMjJiNzI5ZWUtNjU2OS00OGJkLWI3ZWEtYzk2MDA0N2EzMGUzIiwiY2xpZW50X2lkIjoiR0FSTUlOX0NPTk5FQ1RfTU9CSUxFX0FORFJPSURfREkifQ.X6aPeccjXy1bjmIuwRKf0V1Owo7EaiUbICO99Ae1JAoKDPHczswttd1Oo64wFg0DhGVstRMv9tx5OOZ4UUgA4Asj3NO8npkC17clIUeQQU7SCLM2FtiDT5FuMyLC7Ad2TA1PndWzCCov3cUouhDXXJkfnsve7On4vgDugV-v4nNzrKv3ro9wpVgZ331fzGs6pJ19eZJSdj6r_g30VD3qEjx3spCu9VBZZdgRRyuTnYqwHlbX2OwM8V6NZ0s-1A_YFgOZu8x7bW-Ndvh6u3v4TGi5LSk4Gjtua1f4eGC0R565ZuqtS84tddLxPoItYxqT69Ixw5DEfTisrBZsAdTXIQ"
|
"auth_token": "bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImRpLW9hdXRoLXNpZ25lci1wcm9kLTIwMjQtcTEifQ.eyJzY29wZSI6WyJBVFBfUkVBRCIsIkFUUF9XUklURSIsIkNPTU1VTklUWV9DT1VSU0VfUkVBRCIsIkNPTU1VTklUWV9DT1VSU0VfV1JJVEUiLCJDT05ORUNUX01DVF9EQUlMWV9MT0dfUkVBRCIsIkNPTk5FQ1RfUkVBRCIsIkNPTk5FQ1RfV1JJVEUiLCJESVZFX0FQSV9SRUFEIiwiRElfT0FVVEhfMl9BVVRIT1JJWkFUSU9OX0NPREVfQ1JFQVRFIiwiRFRfQ0xJRU5UX0FOQUxZVElDU19XUklURSIsIkdBUk1JTlBBWV9SRUFEIiwiR0FSTUlOUEFZX1dSSVRFIiwiR0NPRkZFUl9SRUFEIiwiR0NPRkZFUl9XUklURSIsIkdIU19TQU1EIiwiR0hTX1VQTE9BRCIsIkdPTEZfQVBJX1JFQUQiLCJHT0xGX0FQSV9XUklURSIsIklOU0lHSFRTX1JFQUQiLCJJTlNJR0hUU19XUklURSIsIk9NVF9DQU1QQUlHTl9SRUFEIiwiT01UX1NVQlNDUklQVElPTl9SRUFEIiwiUFJPRFVDVF9TRUFSQ0hfUkVBRCJdLCJpc3MiOiJodHRwczovL2RpYXV0aC5nYXJtaW4uY29tIiwicmV2b2NhdGlvbl9lbGlnaWJpbGl0eSI6WyJHTE9CQUxfU0lHTk9VVCIsIk1BTkFHRURfU1RBVFVTIl0sImNsaWVudF90eXBlIjoiVU5ERUZJTkVEIiwiZXhwIjoxNzU3MzYyMzI3LCJpYXQiOjE3NTcyNzI0NDEsImdhcm1pbl9ndWlkIjoiNTZlZTk1ZmMtMzY0MC00NGU2LTg1ODAtNDc4NDEwZDQwZGFhIiwianRpIjoiZjVmYzFhMzAtZGVkZi00N2FmLTg5YjgtM2QwNjFjZjkxMTMxIiwiY2xpZW50X2lkIjoiR0FSTUlOX0NPTk5FQ1RfTU9CSUxFX0FORFJPSURfREkifQ.cdgNSDtkYnySkPdHTHxvck3BZXVZ0H6mGcqU0fJqqRO5cuh0_exgGM_VBLxoos_MqEYeryZqkw__UfwA1dvamoClooPpUFZIcPmsTl_uSILd8IIiWFjhgXJnTybE3mI_hPEaILzWnVDzQX4lv1K_oTzCVx0I7moonRAk3mbccKpj_kWcIm-CFVbuGbApTCJzRoOr46yFPUnbOxeA0eJl8BbPFmPWK0z_FvcLS8q7ZKuksBWW2gorQovqesIG63k-wK1PFOvm2EDosSFW0RTCFY7cBMx3nz_f7jFG9E5qt971z8EcKCq83pWs2CHIqy64KkVoub3CD0LQRKIjilNsEA"
|
||||||
}
|
}
|
||||||
5
garmin_session.jsonold
Normal file
5
garmin_session.jsonold
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"domain": "garmin.com",
|
||||||
|
"username": "fbleagh",
|
||||||
|
"auth_token": "bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImRpLW9hdXRoLXNpZ25lci1wcm9kLTIwMjQtcTEifQ.eyJzY29wZSI6WyJBVFBfUkVBRCIsIkFUUF9XUklURSIsIkNPTU1VTklUWV9DT1VSU0VfUkVBRCIsIkNPTU1VTklUWV9DT1VSU0VfV1JJVEUiLCJDT05ORUNUX01DVF9EQUlMWV9MT0dfUkVBRCIsIkNPTk5FQ1RfUkVBRCIsIkNPTk5FQ1RfV1JJVEUiLCJESVZFX0FQSV9SRUFEIiwiRElfT0FVVEhfMl9BVVRIT1JJWkFUSU9OX0NPREVfQ1JFQVRFIiwiRFRfQ0xJRU5UX0FOQUxZVElDU19XUklURSIsIkdBUk1JTlBBWV9SRUFEIiwiR0FSTUlOUEFZX1dSSVRFIiwiR0NPRkZFUl9SRUFEIiwiR0NPRkZFUl9XUklURSIsIkdIU19TQU1EIiwiR0hTX1VQTE9BRCIsIkdPTEZfQVBJX1JFQUQiLCJHT0xGX0FQSV9XUklURSIsIklOU0lHSFRTX1JFQUQiLCJJTlNJR0hUU19XUklURSIsIk9NVF9DQU1QQUlHTl9SRUFEIiwiT01UX1NVQlNDUklQVElPTl9SRUFEIiwiUFJPRFVDVF9TRUFSQ0hfUkVBRCJdLCJpc3MiOiJodHRwczovL2RpYXV0aC5nYXJtaW4uY29tIiwicmV2b2NhdGlvbl9lbGlnaWJpbGl0eSI6WyJHTE9CQUxfU0lHTk9VVCIsIk1BTkFHRURfU1RBVFVTIl0sImNsaWVudF90eXBlIjoiVU5ERUZJTkVEIiwiZXhwIjoxNzU3MzMxMzUyLCJpYXQiOjE3NTcyNTg4NjUsImdhcm1pbl9ndWlkIjoiNTZlZTk1ZmMtMzY0MC00NGU2LTg1ODAtNDc4NDEwZDQwZGFhIiwianRpIjoiYTExYThkOGUtZTk3NS00ZmYzLWI5ZGUtMTgxNDRlZmI3NDIwIiwiY2xpZW50X2lkIjoiR0FSTUlOX0NPTk5FQ1RfTU9CSUxFX0FORFJPSURfREkifQ.aHN1exAr4j-_rjZ06OS9D7o4CpDYR1l09geAK6jh-NmJl4dsy3vWM1sv6c-9yiIO8FmuKhvBA44yKAhFCWOTNIJ3yUG-t8IFbYRMrPxBW4WZ4zqMN8XgVPI9Z_iLR0cEP6AaaAtzpVMWcwHn8wLhDUrpLMeCz7n8jMU0S-caXkByCa4zF1PhVs69hYH89Yn48lA_bFiJtbgx0aINnuu-0JHCj22NRjBTKPGBDcQg2fNapCrHoqZ1y-5BOfyB96u6VFXZZ6JNd-ar1EaVOw4G7zUhQCCDeilqjwQB68yIvbWoOhAyda93yB-_AcBU3wHrGHUaYqULEPSRex8zPxYH7A"
|
||||||
|
}
|
||||||
37
garth/client/auth.go
Normal file
37
garth/client/auth.go
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
package client
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// OAuth1Token represents OAuth 1.0a credentials
|
||||||
|
type OAuth1Token struct {
|
||||||
|
Token string
|
||||||
|
TokenSecret string
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expired checks if token is expired (OAuth1 tokens typically don't expire but we'll implement for consistency)
|
||||||
|
func (t *OAuth1Token) Expired() bool {
|
||||||
|
return false // OAuth1 tokens don't typically expire
|
||||||
|
}
|
||||||
|
|
||||||
|
// OAuth2Token represents OAuth 2.0 credentials
|
||||||
|
type OAuth2Token struct {
|
||||||
|
AccessToken string
|
||||||
|
RefreshToken string
|
||||||
|
TokenType string
|
||||||
|
ExpiresIn int
|
||||||
|
ExpiresAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expired checks if token is expired
|
||||||
|
func (t *OAuth2Token) Expired() bool {
|
||||||
|
return time.Now().After(t.ExpiresAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RefreshIfNeeded refreshes token if expired (implementation pending)
|
||||||
|
func (t *OAuth2Token) RefreshIfNeeded(client *Client) error {
|
||||||
|
// Placeholder for token refresh logic
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -52,11 +52,16 @@ func NewClient(domain string) (*Client, error) {
|
|||||||
// Login authenticates to Garmin Connect using SSO
|
// Login authenticates to Garmin Connect using SSO
|
||||||
func (c *Client) Login(email, password string) error {
|
func (c *Client) Login(email, password string) error {
|
||||||
ssoClient := sso.NewClient(c.Domain)
|
ssoClient := sso.NewClient(c.Domain)
|
||||||
oauth2Token, err := ssoClient.Login(email, password)
|
oauth2Token, mfaContext, err := ssoClient.Login(email, password)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("SSO login failed: %w", err)
|
return fmt.Errorf("SSO login failed: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle MFA required
|
||||||
|
if mfaContext != nil {
|
||||||
|
return fmt.Errorf("MFA required - not implemented yet")
|
||||||
|
}
|
||||||
|
|
||||||
c.OAuth2Token = oauth2Token
|
c.OAuth2Token = oauth2Token
|
||||||
c.AuthToken = fmt.Sprintf("%s %s", oauth2Token.TokenType, oauth2Token.AccessToken)
|
c.AuthToken = fmt.Sprintf("%s %s", oauth2Token.TokenType, oauth2Token.AccessToken)
|
||||||
|
|
||||||
|
|||||||
142
garth/data/base.go
Normal file
142
garth/data/base.go
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
package data
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"garmin-connect/garth/client"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Data defines the interface for Garmin Connect data types.
|
||||||
|
// Concrete data types (BodyBattery, HRV, Sleep, etc.) must implement this interface.
|
||||||
|
//
|
||||||
|
// The Get method retrieves data for a single day.
|
||||||
|
// The List method concurrently retrieves data for a range of days.
|
||||||
|
type Data interface {
|
||||||
|
Get(day time.Time, c *client.Client) (interface{}, error)
|
||||||
|
List(end time.Time, days int, c *client.Client, maxWorkers int) ([]interface{}, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// BaseData provides a reusable implementation for data types to embed.
|
||||||
|
// It handles the concurrent List() implementation while allowing concrete types
|
||||||
|
// to focus on implementing the Get() method for their specific data structure.
|
||||||
|
//
|
||||||
|
// Usage:
|
||||||
|
//
|
||||||
|
// type BodyBatteryData struct {
|
||||||
|
// data.BaseData
|
||||||
|
// // ... additional fields
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// func NewBodyBatteryData() *BodyBatteryData {
|
||||||
|
// bb := &BodyBatteryData{}
|
||||||
|
// bb.GetFunc = bb.get // Assign the concrete Get implementation
|
||||||
|
// return bb
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// func (bb *BodyBatteryData) get(day time.Time, c *client.Client) (interface{}, error) {
|
||||||
|
// // Implementation specific to body battery data
|
||||||
|
// }
|
||||||
|
type BaseData struct {
|
||||||
|
// GetFunc must be set by concrete types to implement the Get method.
|
||||||
|
// This function pointer allows BaseData to call the concrete implementation.
|
||||||
|
GetFunc func(day time.Time, c *client.Client) (interface{}, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get implements the Data interface by calling the configured GetFunc.
|
||||||
|
// Returns an error if GetFunc is not set.
|
||||||
|
func (b *BaseData) Get(day time.Time, c *client.Client) (interface{}, error) {
|
||||||
|
if b.GetFunc == nil {
|
||||||
|
return nil, errors.New("GetFunc not implemented for this data type")
|
||||||
|
}
|
||||||
|
return b.GetFunc(day, c)
|
||||||
|
}
|
||||||
|
|
||||||
|
// List implements concurrent data fetching using a worker pool pattern.
|
||||||
|
// This method efficiently retrieves data for multiple days by distributing
|
||||||
|
// work across a configurable number of workers (goroutines).
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
//
|
||||||
|
// end: The end date of the range (inclusive)
|
||||||
|
// days: Number of days to fetch (going backwards from end date)
|
||||||
|
// c: Client instance for API access
|
||||||
|
// maxWorkers: Maximum concurrent workers (minimum 1)
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
//
|
||||||
|
// []interface{}: Slice of results (order matches date range)
|
||||||
|
// error: First error encountered during processing, if any
|
||||||
|
func (b *BaseData) List(end time.Time, days int, c *client.Client, maxWorkers int) ([]interface{}, error) {
|
||||||
|
if maxWorkers < 1 {
|
||||||
|
maxWorkers = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate date range (end backwards for 'days' days)
|
||||||
|
dates := make([]time.Time, days)
|
||||||
|
for i := 0; i < days; i++ {
|
||||||
|
dates[i] = end.AddDate(0, 0, -i)
|
||||||
|
}
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
workCh := make(chan time.Time, days)
|
||||||
|
resultsCh := make(chan interface{}, days)
|
||||||
|
errCh := make(chan error, 1)
|
||||||
|
done := make(chan bool)
|
||||||
|
|
||||||
|
// Worker function
|
||||||
|
worker := func() {
|
||||||
|
defer wg.Done()
|
||||||
|
for date := range workCh {
|
||||||
|
result, err := b.Get(date, c)
|
||||||
|
if err != nil {
|
||||||
|
select {
|
||||||
|
case errCh <- err:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resultsCh <- result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start workers
|
||||||
|
wg.Add(maxWorkers)
|
||||||
|
for i := 0; i < maxWorkers; i++ {
|
||||||
|
go worker()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send work to channel
|
||||||
|
go func() {
|
||||||
|
for _, date := range dates {
|
||||||
|
workCh <- date
|
||||||
|
}
|
||||||
|
close(workCh)
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Close results channel when all workers finish
|
||||||
|
go func() {
|
||||||
|
wg.Wait()
|
||||||
|
close(resultsCh)
|
||||||
|
done <- true
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Collect results
|
||||||
|
var results []interface{}
|
||||||
|
var err error
|
||||||
|
|
||||||
|
collect:
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case result := <-resultsCh:
|
||||||
|
results = append(results, result)
|
||||||
|
case err = <-errCh:
|
||||||
|
break collect
|
||||||
|
case <-done:
|
||||||
|
break collect
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return results, err
|
||||||
|
}
|
||||||
74
garth/data/base_test.go
Normal file
74
garth/data/base_test.go
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
package data
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"garmin-connect/garth/client"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MockData implements Data interface for testing
|
||||||
|
type MockData struct {
|
||||||
|
BaseData
|
||||||
|
}
|
||||||
|
|
||||||
|
// MockClient simulates API client for tests
|
||||||
|
type MockClient struct{}
|
||||||
|
|
||||||
|
func (mc *MockClient) Get(endpoint string) (interface{}, error) {
|
||||||
|
if endpoint == "error" {
|
||||||
|
return nil, errors.New("mock API error")
|
||||||
|
}
|
||||||
|
return "data for " + endpoint, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBaseData_List(t *testing.T) {
|
||||||
|
// Setup mock data type
|
||||||
|
mockData := &MockData{}
|
||||||
|
mockData.GetFunc = func(day time.Time, c *client.Client) (interface{}, error) {
|
||||||
|
return "data for " + day.Format("2006-01-02"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test parameters
|
||||||
|
end := time.Date(2023, 6, 15, 0, 0, 0, 0, time.UTC)
|
||||||
|
days := 5
|
||||||
|
c := &client.Client{}
|
||||||
|
maxWorkers := 3
|
||||||
|
|
||||||
|
// Execute
|
||||||
|
results, err := mockData.List(end, days, c, maxWorkers)
|
||||||
|
|
||||||
|
// Verify
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Len(t, results, days)
|
||||||
|
assert.Contains(t, results, "data for 2023-06-15")
|
||||||
|
assert.Contains(t, results, "data for 2023-06-11")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBaseData_List_ErrorHandling(t *testing.T) {
|
||||||
|
// Setup mock data type that returns error on specific date
|
||||||
|
mockData := &MockData{}
|
||||||
|
mockData.GetFunc = func(day time.Time, c *client.Client) (interface{}, error) {
|
||||||
|
if day.Day() == 13 {
|
||||||
|
return nil, errors.New("bad luck day")
|
||||||
|
}
|
||||||
|
return "data for " + day.Format("2006-01-02"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test parameters
|
||||||
|
end := time.Date(2023, 6, 15, 0, 0, 0, 0, time.UTC)
|
||||||
|
days := 5
|
||||||
|
c := &client.Client{}
|
||||||
|
maxWorkers := 2
|
||||||
|
|
||||||
|
// Execute
|
||||||
|
results, err := mockData.List(end, days, c, maxWorkers)
|
||||||
|
|
||||||
|
// Verify
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Equal(t, "bad luck day", err.Error())
|
||||||
|
assert.Len(t, results, 4) // Should have some results before error
|
||||||
|
}
|
||||||
@@ -145,8 +145,10 @@ func ExchangeToken(oauth1Token *types.OAuth1Token) (*types.OAuth2Token, error) {
|
|||||||
return nil, fmt.Errorf("failed to decode OAuth2 token: %w", err)
|
return nil, fmt.Errorf("failed to decode OAuth2 token: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set creation time for expiration tracking
|
// Set expiration time
|
||||||
oauth2Token.CreatedAt = time.Now()
|
if oauth2Token.ExpiresIn > 0 {
|
||||||
|
oauth2Token.ExpiresAt = time.Now().Add(time.Duration(oauth2Token.ExpiresIn) * time.Second)
|
||||||
|
}
|
||||||
|
|
||||||
return &oauth2Token, nil
|
return &oauth2Token, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,13 @@ var (
|
|||||||
ticketRegex = regexp.MustCompile(`embed\?ticket=([^"]+)"`)
|
ticketRegex = regexp.MustCompile(`embed\?ticket=([^"]+)"`)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// MFAContext preserves state for resuming MFA login
|
||||||
|
type MFAContext struct {
|
||||||
|
SigninURL string
|
||||||
|
CSRFToken string
|
||||||
|
Ticket string
|
||||||
|
}
|
||||||
|
|
||||||
// Client represents an SSO client
|
// Client represents an SSO client
|
||||||
type Client struct {
|
type Client struct {
|
||||||
Domain string
|
Domain string
|
||||||
@@ -34,7 +41,7 @@ func NewClient(domain string) *Client {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Login performs the SSO authentication flow
|
// Login performs the SSO authentication flow
|
||||||
func (c *Client) Login(email, password string) (*types.OAuth2Token, error) {
|
func (c *Client) Login(email, password string) (*types.OAuth2Token, *MFAContext, error) {
|
||||||
fmt.Printf("Logging in to Garmin Connect (%s) using SSO flow...\n", c.Domain)
|
fmt.Printf("Logging in to Garmin Connect (%s) using SSO flow...\n", c.Domain)
|
||||||
|
|
||||||
// Step 1: Set up SSO parameters
|
// Step 1: Set up SSO parameters
|
||||||
@@ -62,13 +69,13 @@ func (c *Client) Login(email, password string) (*types.OAuth2Token, error) {
|
|||||||
embedURL := fmt.Sprintf("https://sso.%s/sso/embed?%s", c.Domain, ssoEmbedParams.Encode())
|
embedURL := fmt.Sprintf("https://sso.%s/sso/embed?%s", c.Domain, ssoEmbedParams.Encode())
|
||||||
req, err := http.NewRequest("GET", embedURL, nil)
|
req, err := http.NewRequest("GET", embedURL, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to create embed request: %w", err)
|
return nil, nil, fmt.Errorf("failed to create embed request: %w", err)
|
||||||
}
|
}
|
||||||
req.Header.Set("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36")
|
req.Header.Set("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36")
|
||||||
|
|
||||||
resp, err := c.HTTPClient.Do(req)
|
resp, err := c.HTTPClient.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to initialize SSO: %w", err)
|
return nil, nil, fmt.Errorf("failed to initialize SSO: %w", err)
|
||||||
}
|
}
|
||||||
resp.Body.Close()
|
resp.Body.Close()
|
||||||
|
|
||||||
@@ -77,26 +84,26 @@ func (c *Client) Login(email, password string) (*types.OAuth2Token, error) {
|
|||||||
signinURL := fmt.Sprintf("https://sso.%s/sso/signin?%s", c.Domain, signinParams.Encode())
|
signinURL := fmt.Sprintf("https://sso.%s/sso/signin?%s", c.Domain, signinParams.Encode())
|
||||||
req, err = http.NewRequest("GET", signinURL, nil)
|
req, err = http.NewRequest("GET", signinURL, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to create signin request: %w", err)
|
return nil, nil, fmt.Errorf("failed to create signin request: %w", err)
|
||||||
}
|
}
|
||||||
req.Header.Set("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36")
|
req.Header.Set("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36")
|
||||||
req.Header.Set("Referer", embedURL)
|
req.Header.Set("Referer", embedURL)
|
||||||
|
|
||||||
resp, err = c.HTTPClient.Do(req)
|
resp, err = c.HTTPClient.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to get signin page: %w", err)
|
return nil, nil, fmt.Errorf("failed to get signin page: %w", err)
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
body, err := io.ReadAll(resp.Body)
|
body, err := io.ReadAll(resp.Body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to read signin response: %w", err)
|
return nil, nil, fmt.Errorf("failed to read signin response: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract CSRF token
|
// Extract CSRF token
|
||||||
csrfToken := extractCSRFToken(string(body))
|
csrfToken := extractCSRFToken(string(body))
|
||||||
if csrfToken == "" {
|
if csrfToken == "" {
|
||||||
return nil, fmt.Errorf("failed to find CSRF token")
|
return nil, nil, fmt.Errorf("failed to find CSRF token")
|
||||||
}
|
}
|
||||||
fmt.Printf("Found CSRF token: %s\n", csrfToken[:10]+"...")
|
fmt.Printf("Found CSRF token: %s\n", csrfToken[:10]+"...")
|
||||||
|
|
||||||
@@ -111,7 +118,7 @@ func (c *Client) Login(email, password string) (*types.OAuth2Token, error) {
|
|||||||
|
|
||||||
req, err = http.NewRequest("POST", signinURL, strings.NewReader(formData.Encode()))
|
req, err = http.NewRequest("POST", signinURL, strings.NewReader(formData.Encode()))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to create login request: %w", err)
|
return nil, nil, fmt.Errorf("failed to create login request: %w", err)
|
||||||
}
|
}
|
||||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
req.Header.Set("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36")
|
req.Header.Set("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36")
|
||||||
@@ -119,50 +126,110 @@ func (c *Client) Login(email, password string) (*types.OAuth2Token, error) {
|
|||||||
|
|
||||||
resp, err = c.HTTPClient.Do(req)
|
resp, err = c.HTTPClient.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to submit login: %w", err)
|
return nil, nil, fmt.Errorf("failed to submit login: %w", err)
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
body, err = io.ReadAll(resp.Body)
|
body, err = io.ReadAll(resp.Body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to read login response: %w", err)
|
return nil, nil, fmt.Errorf("failed to read login response: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check login result
|
// Check login result
|
||||||
title := extractTitle(string(body))
|
title := extractTitle(string(body))
|
||||||
fmt.Printf("Login response title: %s\n", title)
|
fmt.Printf("Login response title: %s\n", title)
|
||||||
|
|
||||||
|
// Handle MFA requirement
|
||||||
if strings.Contains(title, "MFA") {
|
if strings.Contains(title, "MFA") {
|
||||||
return nil, fmt.Errorf("MFA required - not implemented yet")
|
fmt.Println("MFA required - returning context for ResumeLogin")
|
||||||
|
ticket := extractTicket(string(body))
|
||||||
|
return nil, &MFAContext{
|
||||||
|
SigninURL: signinURL,
|
||||||
|
CSRFToken: csrfToken,
|
||||||
|
Ticket: ticket,
|
||||||
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if title != "Success" {
|
if title != "Success" {
|
||||||
return nil, fmt.Errorf("login failed, unexpected title: %s", title)
|
return nil, nil, fmt.Errorf("login failed, unexpected title: %s", title)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 5: Extract ticket for OAuth flow
|
// Step 5: Extract ticket for OAuth flow
|
||||||
fmt.Println("Extracting OAuth ticket...")
|
fmt.Println("Extracting OAuth ticket...")
|
||||||
ticket := extractTicket(string(body))
|
ticket := extractTicket(string(body))
|
||||||
if ticket == "" {
|
if ticket == "" {
|
||||||
return nil, fmt.Errorf("failed to find OAuth ticket")
|
return nil, nil, fmt.Errorf("failed to find OAuth ticket")
|
||||||
}
|
}
|
||||||
fmt.Printf("Found ticket: %s\n", ticket[:10]+"...")
|
fmt.Printf("Found ticket: %s\n", ticket[:10]+"...")
|
||||||
|
|
||||||
// Step 6: Get OAuth1 token
|
// Step 6: Get OAuth1 token
|
||||||
oauth1Token, err := oauth.GetOAuth1Token(c.Domain, ticket)
|
oauth1Token, err := oauth.GetOAuth1Token(c.Domain, ticket)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to get OAuth1 token: %w", err)
|
return nil, nil, fmt.Errorf("failed to get OAuth1 token: %w", err)
|
||||||
}
|
}
|
||||||
fmt.Println("Got OAuth1 token")
|
fmt.Println("Got OAuth1 token")
|
||||||
|
|
||||||
// Step 7: Exchange for OAuth2 token
|
// Step 7: Exchange for OAuth2 token
|
||||||
oauth2Token, err := oauth.ExchangeToken(oauth1Token)
|
oauth2Token, err := oauth.ExchangeToken(oauth1Token)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to exchange for OAuth2 token: %w", err)
|
return nil, nil, fmt.Errorf("failed to exchange for OAuth2 token: %w", err)
|
||||||
}
|
}
|
||||||
fmt.Printf("Got OAuth2 token: %s\n", oauth2Token.TokenType)
|
fmt.Printf("Got OAuth2 token: %s\n", oauth2Token.TokenType)
|
||||||
|
|
||||||
return oauth2Token, nil
|
return oauth2Token, nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResumeLogin completes authentication after MFA challenge
|
||||||
|
func (c *Client) ResumeLogin(mfaCode string, ctx *MFAContext) (*types.OAuth2Token, error) {
|
||||||
|
fmt.Println("Resuming login with MFA code...")
|
||||||
|
|
||||||
|
// Submit MFA form
|
||||||
|
formData := url.Values{
|
||||||
|
"mfa-code": {mfaCode},
|
||||||
|
"embed": {"true"},
|
||||||
|
"_csrf": {ctx.CSRFToken},
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequest("POST", ctx.SigninURL, strings.NewReader(formData.Encode()))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create MFA request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
req.Header.Set("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36")
|
||||||
|
req.Header.Set("Referer", ctx.SigninURL)
|
||||||
|
|
||||||
|
resp, err := c.HTTPClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to submit MFA: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read MFA response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify MFA success
|
||||||
|
title := extractTitle(string(body))
|
||||||
|
if title != "Success" {
|
||||||
|
return nil, fmt.Errorf("MFA failed, unexpected title: %s", title)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Continue with ticket flow
|
||||||
|
fmt.Println("Extracting OAuth ticket after MFA...")
|
||||||
|
ticket := extractTicket(string(body))
|
||||||
|
if ticket == "" {
|
||||||
|
return nil, fmt.Errorf("failed to find OAuth ticket after MFA")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get OAuth1 token
|
||||||
|
oauth1Token, err := oauth.GetOAuth1Token(c.Domain, ticket)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get OAuth1 token: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exchange for OAuth2 token
|
||||||
|
return oauth.ExchangeToken(oauth1Token)
|
||||||
}
|
}
|
||||||
|
|
||||||
// extractCSRFToken extracts CSRF token from HTML
|
// extractCSRFToken extracts CSRF token from HTML
|
||||||
|
|||||||
@@ -72,7 +72,8 @@ type OAuth2Token struct {
|
|||||||
ExpiresIn int `json:"expires_in"`
|
ExpiresIn int `json:"expires_in"`
|
||||||
RefreshToken string `json:"refresh_token"`
|
RefreshToken string `json:"refresh_token"`
|
||||||
Scope string `json:"scope"`
|
Scope string `json:"scope"`
|
||||||
CreatedAt time.Time // Added for expiration tracking
|
CreatedAt time.Time // Used for expiration tracking
|
||||||
|
ExpiresAt time.Time // Computed expiration time
|
||||||
}
|
}
|
||||||
|
|
||||||
// OAuthConsumer represents OAuth consumer credentials
|
// OAuthConsumer represents OAuth consumer credentials
|
||||||
|
|||||||
@@ -143,3 +143,12 @@ func Min(a, b int) int {
|
|||||||
}
|
}
|
||||||
return b
|
return b
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DateRange generates a date range from end date backwards for n days
|
||||||
|
func DateRange(end time.Time, days int) []time.Time {
|
||||||
|
dates := make([]time.Time, days)
|
||||||
|
for i := 0; i < days; i++ {
|
||||||
|
dates[i] = end.AddDate(0, 0, -i)
|
||||||
|
}
|
||||||
|
return dates
|
||||||
|
}
|
||||||
|
|||||||
6
go.mod
6
go.mod
@@ -2,6 +2,6 @@ module garmin-connect
|
|||||||
|
|
||||||
go 1.24.2
|
go 1.24.2
|
||||||
|
|
||||||
require (
|
require github.com/joho/godotenv v1.5.1
|
||||||
github.com/joho/godotenv v1.5.1
|
|
||||||
)
|
require github.com/stretchr/testify v1.11.1 // indirect
|
||||||
|
|||||||
2
go.sum
2
go.sum
@@ -1,2 +1,4 @@
|
|||||||
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||||
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
|
|||||||
Reference in New Issue
Block a user