porting - part 4 done

This commit is contained in:
2025-09-07 12:14:19 -07:00
parent 6e4b12afa7
commit ead942b122
12 changed files with 368 additions and 24 deletions

37
garth/client/auth.go Normal file
View 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
}

View File

@@ -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)

142
garth/data/base.go Normal file
View 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
View 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
}

View File

@@ -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
}

View File

@@ -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

View File

@@ -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

View File

@@ -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
}