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" "time" "github.com/joho/godotenv" ) // 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(`(.+?)`) 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() 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 } client, err := NewClient(domain) if err != nil { fmt.Printf("Failed to create client: %v\n", err) return } // Try to load existing session first sessionFile := "garmin_session.json" if err := client.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 } // Save session for future use if err := client.SaveSession(sessionFile); err != nil { fmt.Printf("Failed to save session: %v\n", err) } } else { fmt.Println("Loaded existing session") } // Test getting activities activities, err := client.GetActivities(5) if err != nil { fmt.Printf("Failed to get activities: %v\n", err) return } // Display activities 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() } }