porting - part 1 and 2 completed and working

This commit is contained in:
2025-09-07 07:05:01 -07:00
parent 0c7d5b62a9
commit 35bbf228e9
3 changed files with 572 additions and 713 deletions

4
go.mod
View File

@@ -2,4 +2,6 @@ module garmin-connect
go 1.24.2
require github.com/joho/godotenv v1.5.1 // indirect
require (
github.com/joho/godotenv v1.5.1
)

View File

@@ -0,0 +1,550 @@
# Implementation Plan for Steps 1 & 2: Project Structure and Client Refactoring
## Overview
This document provides a detailed implementation plan for refactoring the existing Go code from `main.go` into a proper modular structure as outlined in the porting plan.
## Current State Analysis
### Existing Code in main.go (Lines 1-761)
The current `main.go` contains:
- **Client struct** (lines 24-30) with domain, httpClient, username, authToken
- **Data models**: SessionData, ActivityType, EventType, Activity, OAuth1Token, OAuth2Token, OAuthConsumer
- **OAuth functions**: loadOAuthConsumer, generateNonce, generateTimestamp, percentEncode, createSignatureBaseString, createSigningKey, signRequest, createOAuth1AuthorizationHeader
- **SSO functions**: getCSRFToken, extractTicket, exchangeOAuth1ForOAuth2, Login, loadEnvCredentials
- **Client methods**: NewClient, getUserProfile, GetActivities, SaveSession, LoadSession
- **Main function** with authentication flow and activity retrieval
## Step 1: Project Structure Setup
### Directory Structure to Create
```
garmin-connect/
├── client/
│ ├── client.go # Core client logic
│ ├── auth.go # Authentication handling
│ └── sso.go # SSO authentication
├── data/
│ └── base.go # Base data models and interfaces
├── types/
│ └── tokens.go # Token structures
├── utils/
│ └── utils.go # Utility functions
├── errors/
│ └── errors.go # Custom error types
├── cmd/
│ └── garth/
│ └── main.go # CLI tool (refactored from current main.go)
└── main.go # Keep original temporarily for testing
```
## Step 2: Core Client Refactoring - Detailed Implementation
### 2.1 Create `types/tokens.go`
**Purpose**: Centralize all token-related structures
```go
package types
import "time"
// OAuth1Token represents OAuth1 token response
type OAuth1Token struct {
OAuthToken string `json:"oauth_token"`
OAuthTokenSecret string `json:"oauth_token_secret"`
MFAToken string `json:"mfa_token,omitempty"`
Domain string `json:"domain"`
}
// OAuth2Token represents OAuth2 token response
type OAuth2Token struct {
AccessToken string `json:"access_token"`
TokenType string `json:"token_type"`
ExpiresIn int `json:"expires_in"`
RefreshToken string `json:"refresh_token"`
Scope string `json:"scope"`
CreatedAt time.Time // Added for expiration tracking
}
// OAuthConsumer represents OAuth consumer credentials
type OAuthConsumer struct {
ConsumerKey string `json:"consumer_key"`
ConsumerSecret string `json:"consumer_secret"`
}
// SessionData represents saved session information
type SessionData struct {
Domain string `json:"domain"`
Username string `json:"username"`
AuthToken string `json:"auth_token"`
}
```
### 2.2 Create `client/client.go`
**Purpose**: Core client functionality and HTTP operations
```go
package client
import (
"crypto/hmac"
"crypto/rand"
"crypto/sha1"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/cookiejar"
"net/url"
"os"
"regexp"
"sort"
"strconv"
"strings"
"time"
"github.com/joho/godotenv"
"garmin-connect/types"
)
// Client represents the Garmin Connect client
type Client struct {
domain string
httpClient *http.Client
username string
authToken string
oauth1Token *types.OAuth1Token
oauth2Token *types.OAuth2Token
}
// ConfigOption represents a client configuration option
type ConfigOption func(*Client)
// NewClient creates a new Garmin Connect client
func NewClient(domain string) (*Client, error) {
jar, err := cookiejar.New(nil)
if err != nil {
return nil, fmt.Errorf("failed to create cookie jar: %w", err)
}
return &Client{
domain: domain,
httpClient: &http.Client{
Jar: jar,
Timeout: 30 * time.Second,
},
}, nil
}
// Configure applies configuration options to the client
func (c *Client) Configure(opts ...ConfigOption) error {
for _, opt := range opts {
opt(c)
}
return nil
}
// ConnectAPI makes authenticated API calls to Garmin Connect
func (c *Client) ConnectAPI(path, method string, data interface{}) (interface{}, error) {
// Implementation based on Python http.py Client.connectapi()
// Should handle authentication, retries, and error responses
}
// Download downloads data from Garmin Connect
func (c *Client) Download(path string) ([]byte, error) {
// Implementation for downloading files/data
}
// Upload uploads data to Garmin Connect
func (c *Client) Upload(filePath, uploadPath string) (map[string]interface{}, error) {
// Implementation for uploading files/data
}
// GetUserProfile retrieves the current user's profile
func (c *Client) GetUserProfile() error {
// Extracted from main.go getUserProfile method
}
// GetActivities retrieves recent activities
func (c *Client) GetActivities(limit int) ([]Activity, error) {
// Extracted from main.go GetActivities method
}
// SaveSession saves the current session to a file
func (c *Client) SaveSession(filename string) error {
// Extracted from main.go SaveSession method
}
// LoadSession loads a session from a file
func (c *Client) LoadSession(filename string) error {
// Extracted from main.go LoadSession method
}
```
### 2.3 Create `client/auth.go`
**Purpose**: Authentication and token management
```go
package client
import (
"crypto/hmac"
"crypto/rand"
"crypto/sha1"
"encoding/base64"
"encoding/json"
"fmt"
"net/http"
"net/url"
"sort"
"strconv"
"strings"
"time"
"garmin-connect/types"
)
var oauthConsumer *types.OAuthConsumer
// loadOAuthConsumer loads OAuth consumer credentials
func loadOAuthConsumer() (*types.OAuthConsumer, error) {
// Extracted from main.go loadOAuthConsumer function
}
// OAuth1 signing functions (extract from main.go)
func generateNonce() string
func generateTimestamp() string
func percentEncode(s string) string
func createSignatureBaseString(method, baseURL string, params map[string]string) string
func createSigningKey(consumerSecret, tokenSecret string) string
func signRequest(consumerSecret, tokenSecret, baseString string) string
func createOAuth1AuthorizationHeader(method, requestURL string, params map[string]string, consumerKey, consumerSecret, token, tokenSecret string) string
// Token expiration checking
func (t *types.OAuth2Token) IsExpired() bool {
return time.Since(t.CreatedAt) > time.Duration(t.ExpiresIn)*time.Second
}
// MFA support placeholder
func (c *Client) HandleMFA(mfaToken string) error {
// Placeholder for MFA handling
return fmt.Errorf("MFA not yet implemented")
}
```
### 2.4 Create `client/sso.go`
**Purpose**: SSO authentication flow
```go
package client
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
"regexp"
"strings"
"github.com/joho/godotenv"
"garmin-connect/types"
)
var (
csrfRegex = regexp.MustCompile(`name="_csrf"\s+value="(.+?)"`)
titleRegex = regexp.MustCompile(`<title>(.+?)</title>`)
ticketRegex = regexp.MustCompile(`embed\?ticket=([^"]+)"`)
)
// Login performs SSO login with email and password
func (c *Client) Login(email, password string) error {
// Extracted from main.go Login method
}
// ResumeLogin resumes login after MFA
func (c *Client) ResumeLogin(mfaToken string) error {
// New method for MFA completion
}
// SSO helper functions (extract from main.go)
func getCSRFToken(respBody string) string
func extractTicket(respBody string) string
func exchangeOAuth1ForOAuth2(oauth1Token *types.OAuth1Token, domain string) (*types.OAuth2Token, error)
func loadEnvCredentials() (email, password, domain string, err error)
```
### 2.5 Create `data/base.go`
**Purpose**: Base data models and interfaces
```go
package data
import (
"time"
"garmin-connect/client"
)
// ActivityType represents the type of activity
type ActivityType struct {
TypeID int `json:"typeId"`
TypeKey string `json:"typeKey"`
ParentTypeID *int `json:"parentTypeId,omitempty"`
}
// EventType represents the event type of an activity
type EventType struct {
TypeID int `json:"typeId"`
TypeKey string `json:"typeKey"`
}
// Activity represents a Garmin Connect activity
type Activity struct {
ActivityID int64 `json:"activityId"`
ActivityName string `json:"activityName"`
Description string `json:"description"`
StartTimeLocal string `json:"startTimeLocal"`
StartTimeGMT string `json:"startTimeGMT"`
ActivityType ActivityType `json:"activityType"`
EventType EventType `json:"eventType"`
Distance float64 `json:"distance"`
Duration float64 `json:"duration"`
ElapsedDuration float64 `json:"elapsedDuration"`
MovingDuration float64 `json:"movingDuration"`
ElevationGain float64 `json:"elevationGain"`
ElevationLoss float64 `json:"elevationLoss"`
AverageSpeed float64 `json:"averageSpeed"`
MaxSpeed float64 `json:"maxSpeed"`
Calories float64 `json:"calories"`
AverageHR float64 `json:"averageHR"`
MaxHR float64 `json:"maxHR"`
}
// Data interface for all data models
type Data interface {
Get(day time.Time, client *client.Client) (interface{}, error)
List(end time.Time, days int, client *client.Client, maxWorkers int) ([]interface{}, error)
}
```
### 2.6 Create `errors/errors.go`
**Purpose**: Custom error types for better error handling
```go
package errors
import "fmt"
// GarthError represents a general Garth error
type GarthError struct {
Message string
Cause error
}
func (e *GarthError) Error() string {
if e.Cause != nil {
return fmt.Sprintf("%s: %v", e.Message, e.Cause)
}
return e.Message
}
// GarthHTTPError represents an HTTP-related error
type GarthHTTPError struct {
GarthError
StatusCode int
Response string
}
func (e *GarthHTTPError) Error() string {
return fmt.Sprintf("HTTP %d: %s", e.StatusCode, e.GarthError.Error())
}
```
### 2.7 Create `utils/utils.go`
**Purpose**: Utility functions
```go
package utils
import (
"strings"
"time"
"unicode"
)
// CamelToSnake converts CamelCase to snake_case
func CamelToSnake(s string) string {
var result []rune
for i, r := range s {
if unicode.IsUpper(r) && i > 0 {
result = append(result, '_')
}
result = append(result, unicode.ToLower(r))
}
return string(result)
}
// CamelToSnakeDict converts map keys from camelCase to snake_case
func CamelToSnakeDict(m map[string]interface{}) map[string]interface{} {
result := make(map[string]interface{})
for k, v := range m {
result[CamelToSnake(k)] = v
}
return result
}
// FormatEndDate formats an end date interface to time.Time
func FormatEndDate(end interface{}) time.Time {
switch v := end.(type) {
case time.Time:
return v
case string:
if t, err := time.Parse("2006-01-02", v); err == nil {
return t
}
}
return time.Now()
}
// DateRange generates a range of dates
func DateRange(end time.Time, days int) []time.Time {
var dates []time.Time
for i := 0; i < days; i++ {
dates = append(dates, end.AddDate(0, 0, -i))
}
return dates
}
// GetLocalizedDateTime converts timestamps to localized time
func GetLocalizedDateTime(gmtTimestamp, localTimestamp int64) time.Time {
// Implementation based on timezone offset
return time.Unix(localTimestamp, 0)
}
```
### 2.8 Refactor `main.go`
**Purpose**: Simplified main function using the new client package
```go
package main
import (
"fmt"
"log"
"os"
"garmin-connect/client"
"garmin-connect/data"
)
func main() {
// Load credentials from .env file
email, password, domain, err := loadEnvCredentials()
if err != nil {
log.Fatalf("Failed to load credentials: %v", err)
}
// Create client
garminClient, err := client.NewClient(domain)
if err != nil {
log.Fatalf("Failed to create client: %v", err)
}
// Try to load existing session first
sessionFile := "garmin_session.json"
if err := garminClient.LoadSession(sessionFile); err != nil {
fmt.Println("No existing session found, logging in with credentials from .env...")
if err := garminClient.Login(email, password); err != nil {
log.Fatalf("Login failed: %v", err)
}
// Save session for future use
if err := garminClient.SaveSession(sessionFile); err != nil {
fmt.Printf("Failed to save session: %v\n", err)
}
} else {
fmt.Println("Loaded existing session")
}
// Test getting activities
activities, err := garminClient.GetActivities(5)
if err != nil {
log.Fatalf("Failed to get activities: %v", err)
}
// Display activities
displayActivities(activities)
}
func displayActivities(activities []data.Activity) {
fmt.Printf("\n=== Recent Activities ===\n")
for i, activity := range activities {
fmt.Printf("%d. %s\n", i+1, activity.ActivityName)
fmt.Printf(" Type: %s\n", activity.ActivityType.TypeKey)
fmt.Printf(" Date: %s\n", activity.StartTimeLocal)
if activity.Distance > 0 {
fmt.Printf(" Distance: %.2f km\n", activity.Distance/1000)
}
if activity.Duration > 0 {
duration := time.Duration(activity.Duration) * time.Second
fmt.Printf(" Duration: %v\n", duration.Round(time.Second))
}
fmt.Println()
}
}
func loadEnvCredentials() (email, password, domain string, err error) {
// This function should be moved to client package eventually
// For now, keep it here to maintain functionality
if err := godotenv.Load(); err != nil {
return "", "", "", fmt.Errorf("failed to load .env file: %w", err)
}
email = os.Getenv("GARMIN_EMAIL")
password = os.Getenv("GARMIN_PASSWORD")
domain = os.Getenv("GARMIN_DOMAIN")
if domain == "" {
domain = "garmin.com"
}
if email == "" || password == "" {
return "", "", "", fmt.Errorf("GARMIN_EMAIL and GARMIN_PASSWORD must be set in .env file")
}
return email, password, domain, nil
}
```
## Implementation Order
1. **Create directory structure** first
2. **Create types/tokens.go** - Move all token structures
3. **Create errors/errors.go** - Define custom error types
4. **Create utils/utils.go** - Add utility functions
5. **Create client/auth.go** - Extract authentication logic
6. **Create client/sso.go** - Extract SSO logic
7. **Create data/base.go** - Extract data models
8. **Create client/client.go** - Extract client logic
9. **Refactor main.go** - Update to use new packages
10. **Test the refactored code** - Ensure functionality is preserved
## Testing Strategy
After each major step:
1. Run `go build` to check for compilation errors
2. Test authentication flow if SSO logic was modified
3. Test activity retrieval if client methods were changed
4. Verify session save/load functionality
## Key Considerations
1. **Maintain backward compatibility** - Ensure existing functionality works
2. **Error handling** - Use new custom error types appropriately
3. **Package imports** - Update import paths correctly
4. **Visibility** - Export only necessary functions/types (capitalize appropriately)
5. **Documentation** - Add package and function documentation
This plan provides a systematic approach to refactoring the existing code while maintaining functionality and preparing for the addition of new features from the Python library.

731
main.go
View File

@@ -1,735 +1,39 @@
package main
import (
"crypto/hmac"
"crypto/rand"
"crypto/sha1"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/cookiejar"
"net/url"
"os"
"regexp"
"sort"
"strconv"
"strings"
"log"
"time"
"github.com/joho/godotenv"
"garmin-connect/garth/client"
"garmin-connect/garth/credentials"
"garmin-connect/garth/types"
)
// Client represents the Garmin Connect client
type Client struct {
domain string
httpClient *http.Client
username string
authToken string
}
// SessionData represents saved session information
type SessionData struct {
Domain string `json:"domain"`
Username string `json:"username"`
AuthToken string `json:"auth_token"`
}
// ActivityType represents the type of activity
type ActivityType struct {
TypeID int `json:"typeId"`
TypeKey string `json:"typeKey"`
ParentTypeID *int `json:"parentTypeId,omitempty"`
}
// EventType represents the event type of an activity
type EventType struct {
TypeID int `json:"typeId"`
TypeKey string `json:"typeKey"`
}
// Activity represents a Garmin Connect activity
type Activity struct {
ActivityID int64 `json:"activityId"`
ActivityName string `json:"activityName"`
Description string `json:"description"`
StartTimeLocal string `json:"startTimeLocal"`
StartTimeGMT string `json:"startTimeGMT"`
ActivityType ActivityType `json:"activityType"`
EventType EventType `json:"eventType"`
Distance float64 `json:"distance"`
Duration float64 `json:"duration"`
ElapsedDuration float64 `json:"elapsedDuration"`
MovingDuration float64 `json:"movingDuration"`
ElevationGain float64 `json:"elevationGain"`
ElevationLoss float64 `json:"elevationLoss"`
AverageSpeed float64 `json:"averageSpeed"`
MaxSpeed float64 `json:"maxSpeed"`
Calories float64 `json:"calories"`
AverageHR float64 `json:"averageHR"`
MaxHR float64 `json:"maxHR"`
}
// OAuth1Token represents OAuth1 token response
type OAuth1Token struct {
OAuthToken string `json:"oauth_token"`
OAuthTokenSecret string `json:"oauth_token_secret"`
MFAToken string `json:"mfa_token,omitempty"`
Domain string `json:"domain"`
}
// OAuth2Token represents OAuth2 token response
type OAuth2Token struct {
AccessToken string `json:"access_token"`
TokenType string `json:"token_type"`
ExpiresIn int `json:"expires_in"`
RefreshToken string `json:"refresh_token"`
Scope string `json:"scope"`
}
// OAuth consumer credentials
type OAuthConsumer struct {
ConsumerKey string `json:"consumer_key"`
ConsumerSecret string `json:"consumer_secret"`
}
var (
csrfRegex = regexp.MustCompile(`name="_csrf"\s+value="(.+?)"`)
titleRegex = regexp.MustCompile(`<title>(.+?)</title>`)
ticketRegex = regexp.MustCompile(`embed\?ticket=([^"]+)"`)
oauthConsumer *OAuthConsumer
)
// loadOAuthConsumer loads OAuth consumer credentials
func loadOAuthConsumer() (*OAuthConsumer, error) {
if oauthConsumer != nil {
return oauthConsumer, nil
}
// First try to get from S3 (like the Python library)
resp, err := http.Get("https://thegarth.s3.amazonaws.com/oauth_consumer.json")
if err == nil {
defer resp.Body.Close()
if resp.StatusCode == 200 {
var consumer OAuthConsumer
if err := json.NewDecoder(resp.Body).Decode(&consumer); err == nil {
oauthConsumer = &consumer
return oauthConsumer, nil
}
}
}
// Fallback to hardcoded values (these are the same ones used by garth)
// These are not secret - they're used by the Garmin Connect mobile app
oauthConsumer = &OAuthConsumer{
ConsumerKey: "fc320c35-fbdc-4308-b5c6-8e41a8b2e0c8",
ConsumerSecret: "8b344b8c-5bd5-4b7b-9c98-ad76a6bbf0e7",
}
return oauthConsumer, nil
}
// OAuth1 signing functions
func generateNonce() string {
b := make([]byte, 32)
rand.Read(b)
return base64.StdEncoding.EncodeToString(b)
}
func generateTimestamp() string {
return strconv.FormatInt(time.Now().Unix(), 10)
}
func percentEncode(s string) string {
return url.QueryEscape(s)
}
func createSignatureBaseString(method, baseURL string, params map[string]string) string {
var keys []string
for k := range params {
keys = append(keys, k)
}
sort.Strings(keys)
var paramStrs []string
for _, key := range keys {
paramStrs = append(paramStrs, percentEncode(key)+"="+percentEncode(params[key]))
}
paramString := strings.Join(paramStrs, "&")
return method + "&" + percentEncode(baseURL) + "&" + percentEncode(paramString)
}
func createSigningKey(consumerSecret, tokenSecret string) string {
return percentEncode(consumerSecret) + "&" + percentEncode(tokenSecret)
}
func signRequest(consumerSecret, tokenSecret, baseString string) string {
signingKey := createSigningKey(consumerSecret, tokenSecret)
mac := hmac.New(sha1.New, []byte(signingKey))
mac.Write([]byte(baseString))
return base64.StdEncoding.EncodeToString(mac.Sum(nil))
}
func createOAuth1AuthorizationHeader(method, requestURL string, params map[string]string, consumerKey, consumerSecret, token, tokenSecret string) string {
oauthParams := map[string]string{
"oauth_consumer_key": consumerKey,
"oauth_nonce": generateNonce(),
"oauth_signature_method": "HMAC-SHA1",
"oauth_timestamp": generateTimestamp(),
"oauth_version": "1.0",
}
if token != "" {
oauthParams["oauth_token"] = token
}
// Combine OAuth params with request params for signature
allParams := make(map[string]string)
for k, v := range oauthParams {
allParams[k] = v
}
for k, v := range params {
allParams[k] = v
}
// Parse URL to get base URL without query params
parsedURL, _ := url.Parse(requestURL)
baseURL := parsedURL.Scheme + "://" + parsedURL.Host + parsedURL.Path
// Create signature base string
baseString := createSignatureBaseString(method, baseURL, allParams)
// Sign the request
signature := signRequest(consumerSecret, tokenSecret, baseString)
oauthParams["oauth_signature"] = signature
// Build authorization header
var headerParts []string
for key, value := range oauthParams {
headerParts = append(headerParts, percentEncode(key)+"=\""+percentEncode(value)+"\"")
}
sort.Strings(headerParts)
return "OAuth " + strings.Join(headerParts, ", ")
}
// loadEnvCredentials loads credentials from .env file
func loadEnvCredentials() (email, password, domain string, err error) {
// Load .env file
if err := godotenv.Load(); err != nil {
return "", "", "", fmt.Errorf("error loading .env file: %w", err)
}
email = os.Getenv("GARMIN_EMAIL")
password = os.Getenv("GARMIN_PASSWORD")
domain = os.Getenv("GARMIN_DOMAIN")
if email == "" {
return "", "", "", fmt.Errorf("GARMIN_EMAIL not found in .env file")
}
if password == "" {
return "", "", "", fmt.Errorf("GARMIN_PASSWORD not found in .env file")
}
if domain == "" {
domain = "garmin.com" // default value
}
return email, password, domain, nil
}
// NewClient creates a new Garmin Connect client
func NewClient(domain string) (*Client, error) {
if domain == "" {
domain = "garmin.com"
}
jar, err := cookiejar.New(nil)
if err != nil {
return nil, fmt.Errorf("failed to create cookie jar: %w", err)
}
return &Client{
domain: domain,
httpClient: &http.Client{
Jar: jar,
Timeout: 30 * time.Second,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
// Allow up to 10 redirects
if len(via) >= 10 {
return fmt.Errorf("too many redirects")
}
return nil
},
},
}, nil
}
// Login authenticates using Garmin's SSO flow (matching Python garth implementation)
func (c *Client) Login(email, password string) error {
fmt.Printf("Logging in to Garmin Connect (%s) using SSO flow...\n", c.domain)
// Step 1: Set up SSO parameters
ssoURL := fmt.Sprintf("https://sso.%s/sso", c.domain)
ssoEmbedURL := fmt.Sprintf("%s/embed", ssoURL)
ssoEmbedParams := url.Values{
"id": {"gauth-widget"},
"embedWidget": {"true"},
"gauthHost": {ssoURL},
}
signinParams := url.Values{
"id": {"gauth-widget"},
"embedWidget": {"true"},
"gauthHost": {ssoEmbedURL},
"service": {ssoEmbedURL},
"source": {ssoEmbedURL},
"redirectAfterAccountLoginUrl": {ssoEmbedURL},
"redirectAfterAccountCreationUrl": {ssoEmbedURL},
}
// Step 2: Initialize SSO session
fmt.Println("Initializing SSO session...")
embedURL := fmt.Sprintf("https://sso.%s/sso/embed?%s", c.domain, ssoEmbedParams.Encode())
req, err := http.NewRequest("GET", embedURL, nil)
if err != nil {
return 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 fmt.Errorf("failed to initialize SSO: %w", err)
}
resp.Body.Close()
// Step 3: Get signin page and CSRF token
fmt.Println("Getting signin page...")
signinURL := fmt.Sprintf("https://sso.%s/sso/signin?%s", c.domain, signinParams.Encode())
req, err = http.NewRequest("GET", signinURL, nil)
if err != nil {
return 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 fmt.Errorf("failed to get signin page: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("failed to read signin response: %w", err)
}
// Extract CSRF token
csrfToken := c.extractCSRFToken(string(body))
if csrfToken == "" {
return fmt.Errorf("failed to find CSRF token")
}
fmt.Printf("Found CSRF token: %s\n", csrfToken[:10]+"...")
// Step 4: Submit login form
fmt.Println("Submitting login credentials...")
formData := url.Values{
"username": {email},
"password": {password},
"embed": {"true"},
"_csrf": {csrfToken},
}
req, err = http.NewRequest("POST", signinURL, strings.NewReader(formData.Encode()))
if err != nil {
return 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")
req.Header.Set("Referer", signinURL)
resp, err = c.httpClient.Do(req)
if err != nil {
return fmt.Errorf("failed to submit login: %w", err)
}
defer resp.Body.Close()
body, err = io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("failed to read login response: %w", err)
}
// Check login result
title := c.extractTitle(string(body))
fmt.Printf("Login response title: %s\n", title)
if strings.Contains(title, "MFA") {
return fmt.Errorf("MFA required - not implemented yet")
}
if title != "Success" {
return fmt.Errorf("login failed, unexpected title: %s", title)
}
// Step 5: Extract ticket for OAuth flow
fmt.Println("Extracting OAuth ticket...")
ticket := c.extractTicket(string(body))
if ticket == "" {
return fmt.Errorf("failed to find OAuth ticket")
}
fmt.Printf("Found ticket: %s\n", ticket[:10]+"...")
// Step 6: Get OAuth1 token
oauth1Token, err := c.getOAuth1Token(ticket)
if err != nil {
return fmt.Errorf("failed to get OAuth1 token: %w", err)
}
fmt.Println("Got OAuth1 token")
// Step 7: Exchange for OAuth2 token
oauth2Token, err := c.exchangeToken(oauth1Token)
if err != nil {
return fmt.Errorf("failed to exchange for OAuth2 token: %w", err)
}
fmt.Printf("Got OAuth2 token: %s\n", oauth2Token.TokenType)
// Step 8: Set auth token and get user profile
c.authToken = fmt.Sprintf("%s %s", oauth2Token.TokenType, oauth2Token.AccessToken)
if err := c.getUserProfile(); err != nil {
return fmt.Errorf("failed to get user profile: %w", err)
}
fmt.Println("SSO authentication successful!")
return nil
}
func (c *Client) extractCSRFToken(html string) string {
matches := csrfRegex.FindStringSubmatch(html)
if len(matches) > 1 {
return matches[1]
}
return ""
}
func (c *Client) extractTitle(html string) string {
matches := titleRegex.FindStringSubmatch(html)
if len(matches) > 1 {
return matches[1]
}
return ""
}
func (c *Client) extractTicket(html string) string {
matches := ticketRegex.FindStringSubmatch(html)
if len(matches) > 1 {
return matches[1]
}
return ""
}
func (c *Client) getOAuth1Token(ticket string) (*OAuth1Token, error) {
consumer, err := loadOAuthConsumer()
if err != nil {
return nil, fmt.Errorf("failed to load OAuth consumer: %w", err)
}
baseURL := fmt.Sprintf("https://connectapi.%s/oauth-service/oauth/", c.domain)
loginURL := fmt.Sprintf("https://sso.%s/sso/embed", c.domain)
tokenURL := fmt.Sprintf("%spreauthorized?ticket=%s&login-url=%s&accepts-mfa-tokens=true",
baseURL, ticket, url.QueryEscape(loginURL))
// Parse URL to extract query parameters for signing
parsedURL, err := url.Parse(tokenURL)
if err != nil {
return nil, err
}
// Extract query parameters for OAuth signing
queryParams := make(map[string]string)
for key, values := range parsedURL.Query() {
if len(values) > 0 {
queryParams[key] = values[0]
}
}
// Create OAuth1 signed request
baseURLForSigning := parsedURL.Scheme + "://" + parsedURL.Host + parsedURL.Path
authHeader := createOAuth1AuthorizationHeader("GET", baseURLForSigning, queryParams,
consumer.ConsumerKey, consumer.ConsumerSecret, "", "")
req, err := http.NewRequest("GET", tokenURL, nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", authHeader)
req.Header.Set("User-Agent", "com.garmin.android.apps.connectmobile")
fmt.Printf("OAuth1 request URL: %s\n", tokenURL)
fmt.Printf("OAuth1 authorization header: %s\n", authHeader[:50]+"...")
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
bodyStr := string(body)
fmt.Printf("OAuth1 response status: %d\n", resp.StatusCode)
fmt.Printf("OAuth1 response: %s\n", bodyStr[:min(200, len(bodyStr))])
if resp.StatusCode != 200 {
return nil, fmt.Errorf("OAuth1 request failed with status %d: %s", resp.StatusCode, bodyStr)
}
// Parse query string response - handle both & and ; separators
bodyStr = strings.ReplaceAll(bodyStr, ";", "&")
values, err := url.ParseQuery(bodyStr)
if err != nil {
return nil, fmt.Errorf("failed to parse OAuth1 response: %w", err)
}
oauthToken := values.Get("oauth_token")
oauthTokenSecret := values.Get("oauth_token_secret")
if oauthToken == "" || oauthTokenSecret == "" {
return nil, fmt.Errorf("missing oauth_token or oauth_token_secret in response")
}
return &OAuth1Token{
OAuthToken: oauthToken,
OAuthTokenSecret: oauthTokenSecret,
MFAToken: values.Get("mfa_token"),
Domain: c.domain,
}, nil
}
func (c *Client) exchangeToken(oauth1Token *OAuth1Token) (*OAuth2Token, error) {
consumer, err := loadOAuthConsumer()
if err != nil {
return nil, fmt.Errorf("failed to load OAuth consumer: %w", err)
}
exchangeURL := fmt.Sprintf("https://connectapi.%s/oauth-service/oauth/exchange/user/2.0", c.domain)
// Prepare form data
formData := url.Values{}
if oauth1Token.MFAToken != "" {
formData.Set("mfa_token", oauth1Token.MFAToken)
}
// Convert form data to map for OAuth signing
formParams := make(map[string]string)
for key, values := range formData {
if len(values) > 0 {
formParams[key] = values[0]
}
}
// Create OAuth1 signed request
authHeader := createOAuth1AuthorizationHeader("POST", exchangeURL, formParams,
consumer.ConsumerKey, consumer.ConsumerSecret, oauth1Token.OAuthToken, oauth1Token.OAuthTokenSecret)
req, err := http.NewRequest("POST", exchangeURL, strings.NewReader(formData.Encode()))
if err != nil {
return nil, err
}
req.Header.Set("Authorization", authHeader)
req.Header.Set("User-Agent", "com.garmin.android.apps.connectmobile")
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
fmt.Println("Attempting OAuth2 token exchange with signed request...")
fmt.Printf("OAuth2 authorization header: %s\n", authHeader[:50]+"...")
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
fmt.Printf("OAuth2 exchange response status: %d\n", resp.StatusCode)
fmt.Printf("OAuth2 exchange response: %s\n", string(body)[:min(500, len(string(body)))])
if resp.StatusCode != 200 {
return nil, fmt.Errorf("OAuth2 exchange failed with status %d: %s", resp.StatusCode, string(body))
}
var oauth2Token OAuth2Token
if err := json.Unmarshal(body, &oauth2Token); err != nil {
return nil, fmt.Errorf("failed to decode OAuth2 token: %w", err)
}
return &oauth2Token, nil
}
// getUserProfile gets user profile information
func (c *Client) getUserProfile() error {
fmt.Println("Getting user profile...")
profileURL := fmt.Sprintf("https://connectapi.%s/userprofile-service/socialProfile", c.domain)
req, err := http.NewRequest("GET", profileURL, nil)
if err != nil {
return fmt.Errorf("failed to create profile request: %w", err)
}
req.Header.Set("Authorization", c.authToken)
req.Header.Set("User-Agent", "com.garmin.android.apps.connectmobile")
resp, err := c.httpClient.Do(req)
if err != nil {
return fmt.Errorf("failed to get user profile: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
body, _ := io.ReadAll(resp.Body)
fmt.Printf("Profile request failed. Status: %d, Response: %s\n", resp.StatusCode, string(body))
return fmt.Errorf("profile request failed with status: %d", resp.StatusCode)
}
var profile map[string]interface{}
if err := json.NewDecoder(resp.Body).Decode(&profile); err != nil {
return fmt.Errorf("failed to parse profile: %w", err)
}
if username, ok := profile["userName"].(string); ok {
c.username = username
fmt.Printf("Username: %s\n", c.username)
} else {
return fmt.Errorf("failed to extract username from profile")
}
return nil
}
// GetActivities retrieves recent activities
func (c *Client) GetActivities(limit int) ([]Activity, error) {
if limit <= 0 {
limit = 10
}
fmt.Printf("Getting last %d activities...\n", limit)
activitiesURL := fmt.Sprintf("https://connectapi.%s/activitylist-service/activities/search/activities?limit=%d&start=0", c.domain, limit)
req, err := http.NewRequest("GET", activitiesURL, nil)
if err != nil {
return nil, fmt.Errorf("failed to create activities request: %w", err)
}
req.Header.Set("Authorization", c.authToken)
req.Header.Set("User-Agent", "com.garmin.android.apps.connectmobile")
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to get activities: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
body, _ := io.ReadAll(resp.Body)
fmt.Printf("Activities request failed. Status: %d, Response: %s\n", resp.StatusCode, string(body))
return nil, fmt.Errorf("activities request failed with status: %d", resp.StatusCode)
}
var activities []Activity
if err := json.NewDecoder(resp.Body).Decode(&activities); err != nil {
return nil, fmt.Errorf("failed to parse activities: %w", err)
}
fmt.Printf("Retrieved %d activities\n", len(activities))
return activities, nil
}
// SaveSession saves the current session to a file
func (c *Client) SaveSession(filename string) error {
session := SessionData{
Domain: c.domain,
Username: c.username,
AuthToken: c.authToken,
}
data, err := json.MarshalIndent(session, "", " ")
if err != nil {
return fmt.Errorf("failed to marshal session: %w", err)
}
if err := os.WriteFile(filename, data, 0600); err != nil {
return fmt.Errorf("failed to write session file: %w", err)
}
fmt.Printf("Session saved to %s\n", filename)
return nil
}
// LoadSession loads a session from a file
func (c *Client) LoadSession(filename string) error {
data, err := os.ReadFile(filename)
if err != nil {
return fmt.Errorf("failed to read session file: %w", err)
}
var session SessionData
if err := json.Unmarshal(data, &session); err != nil {
return fmt.Errorf("failed to unmarshal session: %w", err)
}
c.domain = session.Domain
c.username = session.Username
c.authToken = session.AuthToken
fmt.Printf("Session loaded from %s\n", filename)
return nil
}
// Helper function for min (Go 1.21+ has this built-in)
func min(a, b int) int {
if a < b {
return a
}
return b
}
func main() {
// Load credentials from .env file
email, password, domain, err := loadEnvCredentials()
email, password, domain, err := credentials.LoadEnvCredentials()
if err != nil {
fmt.Printf("Failed to load credentials: %v\n", err)
fmt.Println("Please create a .env file with GARMIN_EMAIL and GARMIN_PASSWORD")
return
log.Fatalf("Failed to load credentials: %v", err)
}
client, err := NewClient(domain)
// Create client
garminClient, err := client.NewClient(domain)
if err != nil {
fmt.Printf("Failed to create client: %v\n", err)
return
log.Fatalf("Failed to create client: %v", err)
}
// Try to load existing session first
sessionFile := "garmin_session.json"
if err := client.LoadSession(sessionFile); err != nil {
if err := garminClient.LoadSession(sessionFile); err != nil {
fmt.Println("No existing session found, logging in with credentials from .env...")
if err := client.Login(email, password); err != nil {
fmt.Printf("Login failed: %v\n", err)
return
if err := garminClient.Login(email, password); err != nil {
log.Fatalf("Login failed: %v", err)
}
// Save session for future use
if err := client.SaveSession(sessionFile); err != nil {
if err := garminClient.SaveSession(sessionFile); err != nil {
fmt.Printf("Failed to save session: %v\n", err)
}
} else {
@@ -737,13 +41,16 @@ func main() {
}
// Test getting activities
activities, err := client.GetActivities(5)
activities, err := garminClient.GetActivities(5)
if err != nil {
fmt.Printf("Failed to get activities: %v\n", err)
return
log.Fatalf("Failed to get activities: %v", err)
}
// Display activities
displayActivities(activities)
}
func displayActivities(activities []types.Activity) {
fmt.Printf("\n=== Recent Activities ===\n")
for i, activity := range activities {
fmt.Printf("%d. %s\n", i+1, activity.ActivityName)