From ead942b12246819a51736f457671ec292f4131e6 Mon Sep 17 00:00:00 2001 From: sstent Date: Sun, 7 Sep 2025 12:14:19 -0700 Subject: [PATCH] porting - part 4 done --- garmin_session.json | 2 +- garmin_session.jsonold | 5 ++ garth/client/auth.go | 37 +++++++++++ garth/client/client.go | 7 +- garth/data/base.go | 142 ++++++++++++++++++++++++++++++++++++++++ garth/data/base_test.go | 74 +++++++++++++++++++++ garth/oauth/oauth.go | 6 +- garth/sso/sso.go | 99 +++++++++++++++++++++++----- garth/types/types.go | 3 +- garth/utils/utils.go | 9 +++ go.mod | 6 +- go.sum | 2 + 12 files changed, 368 insertions(+), 24 deletions(-) create mode 100644 garmin_session.jsonold create mode 100644 garth/client/auth.go create mode 100644 garth/data/base.go create mode 100644 garth/data/base_test.go diff --git a/garmin_session.json b/garmin_session.json index 2c2ad79..fe58f47 100644 --- a/garmin_session.json +++ b/garmin_session.json @@ -1,5 +1,5 @@ { "domain": "garmin.com", "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" } \ No newline at end of file diff --git a/garmin_session.jsonold b/garmin_session.jsonold new file mode 100644 index 0000000..01b8645 --- /dev/null +++ b/garmin_session.jsonold @@ -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" +} \ No newline at end of file diff --git a/garth/client/auth.go b/garth/client/auth.go new file mode 100644 index 0000000..cc96eef --- /dev/null +++ b/garth/client/auth.go @@ -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 +} diff --git a/garth/client/client.go b/garth/client/client.go index cda10b1..33661ee 100644 --- a/garth/client/client.go +++ b/garth/client/client.go @@ -52,11 +52,16 @@ func NewClient(domain string) (*Client, error) { // Login authenticates to Garmin Connect using SSO func (c *Client) Login(email, password string) error { ssoClient := sso.NewClient(c.Domain) - oauth2Token, err := ssoClient.Login(email, password) + oauth2Token, mfaContext, err := ssoClient.Login(email, password) if err != nil { 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.AuthToken = fmt.Sprintf("%s %s", oauth2Token.TokenType, oauth2Token.AccessToken) diff --git a/garth/data/base.go b/garth/data/base.go new file mode 100644 index 0000000..63882b2 --- /dev/null +++ b/garth/data/base.go @@ -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 +} diff --git a/garth/data/base_test.go b/garth/data/base_test.go new file mode 100644 index 0000000..53d8bd1 --- /dev/null +++ b/garth/data/base_test.go @@ -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 +} diff --git a/garth/oauth/oauth.go b/garth/oauth/oauth.go index 9bbe8b7..8ba782b 100644 --- a/garth/oauth/oauth.go +++ b/garth/oauth/oauth.go @@ -145,8 +145,10 @@ func ExchangeToken(oauth1Token *types.OAuth1Token) (*types.OAuth2Token, error) { return nil, fmt.Errorf("failed to decode OAuth2 token: %w", err) } - // Set creation time for expiration tracking - oauth2Token.CreatedAt = time.Now() + // Set expiration time + if oauth2Token.ExpiresIn > 0 { + oauth2Token.ExpiresAt = time.Now().Add(time.Duration(oauth2Token.ExpiresIn) * time.Second) + } return &oauth2Token, nil } diff --git a/garth/sso/sso.go b/garth/sso/sso.go index c3c5834..82fe79a 100644 --- a/garth/sso/sso.go +++ b/garth/sso/sso.go @@ -19,6 +19,13 @@ var ( 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 type Client struct { Domain string @@ -34,7 +41,7 @@ func NewClient(domain string) *Client { } // 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) // 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()) req, err := http.NewRequest("GET", embedURL, 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") resp, err := c.HTTPClient.Do(req) 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() @@ -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()) req, err = http.NewRequest("GET", signinURL, 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("Referer", embedURL) resp, err = c.HTTPClient.Do(req) 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() body, err := io.ReadAll(resp.Body) 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 csrfToken := extractCSRFToken(string(body)) 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]+"...") @@ -111,7 +118,7 @@ func (c *Client) Login(email, password string) (*types.OAuth2Token, error) { req, err = http.NewRequest("POST", signinURL, strings.NewReader(formData.Encode())) 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("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) 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() body, err = io.ReadAll(resp.Body) 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 title := extractTitle(string(body)) fmt.Printf("Login response title: %s\n", title) + // Handle MFA requirement 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" { - 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 fmt.Println("Extracting OAuth ticket...") ticket := extractTicket(string(body)) 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]+"...") // Step 6: Get OAuth1 token oauth1Token, err := oauth.GetOAuth1Token(c.Domain, ticket) 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") // Step 7: Exchange for OAuth2 token oauth2Token, err := oauth.ExchangeToken(oauth1Token) 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) - 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 diff --git a/garth/types/types.go b/garth/types/types.go index bbf9f66..6726ea0 100644 --- a/garth/types/types.go +++ b/garth/types/types.go @@ -72,7 +72,8 @@ type OAuth2Token struct { ExpiresIn int `json:"expires_in"` RefreshToken string `json:"refresh_token"` 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 diff --git a/garth/utils/utils.go b/garth/utils/utils.go index 3d1706e..2b1b843 100644 --- a/garth/utils/utils.go +++ b/garth/utils/utils.go @@ -143,3 +143,12 @@ func Min(a, b int) int { } 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 +} diff --git a/go.mod b/go.mod index fbf3f53..f3ba4e4 100644 --- a/go.mod +++ b/go.mod @@ -2,6 +2,6 @@ module garmin-connect go 1.24.2 -require ( - github.com/joho/godotenv v1.5.1 -) +require github.com/joho/godotenv v1.5.1 + +require github.com/stretchr/testify v1.11.1 // indirect diff --git a/go.sum b/go.sum index d61b19e..80696c2 100644 --- a/go.sum +++ b/go.sum @@ -1,2 +1,4 @@ github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= 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=