working auth!

This commit is contained in:
2025-09-07 06:02:28 -07:00
parent a1cc209e46
commit 15cc49f1b6
77 changed files with 756 additions and 6101 deletions

View File

@@ -1 +0,0 @@
# go-garth

View File

@@ -1,306 +0,0 @@
package garth
import (
"bytes"
"context"
"encoding/json"
"io"
"io/ioutil"
"net/http"
"net/url"
"strconv"
"time"
)
// Activity represents a summary of a Garmin activity
type Activity struct {
ActivityID int64 `json:"activityId"`
Name string `json:"activityName"`
Type string `json:"activityType"`
StartTime time.Time `json:"startTime"`
Distance float64 `json:"distance"`
Duration float64 `json:"duration"`
Calories int `json:"calories"`
}
// ActivityDetails contains detailed information about an activity
type ActivityDetails struct {
ActivityID int64 `json:"activityId"`
Name string `json:"activityName"`
Description string `json:"description"`
Type string `json:"activityType"`
StartTime time.Time `json:"startTime"`
Distance float64 `json:"distance"`
Duration float64 `json:"duration"`
Calories int `json:"calories"`
ElevationGain float64 `json:"elevationGain"`
ElevationLoss float64 `json:"elevationLoss"`
MaxHeartRate int `json:"maxHeartRate"`
AvgHeartRate int `json:"avgHeartRate"`
MaxSpeed float64 `json:"maxSpeed"`
AvgSpeed float64 `json:"avgSpeed"`
Steps int `json:"steps"`
Stress int `json:"stress"`
TotalSteps int `json:"totalSteps"`
Device json.RawMessage `json:"device"`
Location json.RawMessage `json:"location"`
Weather json.RawMessage `json:"weather"`
HeartRateZones json.RawMessage `json:"heartRateZones"`
TrainingEffect json.RawMessage `json:"trainingEffect"`
ActivityMetrics json.RawMessage `json:"activityMetrics"`
}
// ActivityListOptions provides filtering options for listing activities
type ActivityListOptions struct {
Limit int
StartDate time.Time
EndDate time.Time
ActivityType string
NameContains string
}
// ActivityUpdate represents fields that can be updated on an activity
type ActivityUpdate struct {
Name string `json:"activityName,omitempty"`
Description string `json:"description,omitempty"`
Type string `json:"activityType,omitempty"`
StartTime time.Time `json:"startTime,omitempty"`
Distance float64 `json:"distance,omitempty"`
Duration float64 `json:"duration,omitempty"`
}
// ActivityService provides access to activity operations
type ActivityService struct {
client *APIClient
}
// NewActivityService creates a new ActivityService instance
func NewActivityService(client *APIClient) *ActivityService {
return &ActivityService{client: client}
}
// List retrieves a list of activities for the current user with optional filters
func (s *ActivityService) List(ctx context.Context, opts ActivityListOptions) ([]Activity, error) {
params := url.Values{}
if opts.Limit > 0 {
params.Set("limit", strconv.Itoa(opts.Limit))
}
if !opts.StartDate.IsZero() {
params.Set("startDate", opts.StartDate.Format(time.RFC3339))
}
if !opts.EndDate.IsZero() {
params.Set("endDate", opts.EndDate.Format(time.RFC3339))
}
if opts.ActivityType != "" {
params.Set("activityType", opts.ActivityType)
}
if opts.NameContains != "" {
params.Set("nameContains", opts.NameContains)
}
path := "/activitylist-service/activities/search/activities"
if len(params) > 0 {
path += "?" + params.Encode()
}
resp, err := s.client.Get(ctx, path)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, &APIError{
StatusCode: resp.StatusCode,
Message: "Failed to get activities list",
}
}
body, err := ioutil.ReadAll(resp.Body)
if err != nil {
return nil, &APIError{
StatusCode: http.StatusInternalServerError,
Message: "Failed to read activities response",
Cause: err,
}
}
var activities []Activity
if err := json.Unmarshal(body, &activities); err != nil {
return nil, &APIError{
StatusCode: http.StatusInternalServerError,
Message: "Failed to parse activities data",
Cause: err,
}
}
return activities, nil
}
// Create creates a new activity
func (s *ActivityService) Create(ctx context.Context, activity Activity) (*Activity, error) {
jsonBody, err := json.Marshal(activity)
if err != nil {
return nil, &APIError{
StatusCode: http.StatusInternalServerError,
Message: "Failed to marshal activity",
Cause: err,
}
}
resp, err := s.client.Post(ctx, "/activity-service/activity", bytes.NewReader(jsonBody))
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusCreated {
return nil, &APIError{
StatusCode: resp.StatusCode,
Message: "Failed to create activity",
}
}
body, err := ioutil.ReadAll(resp.Body)
if err != nil {
return nil, &APIError{
StatusCode: http.StatusInternalServerError,
Message: "Failed to read activity response",
Cause: err,
}
}
var createdActivity Activity
if err := json.Unmarshal(body, &createdActivity); err != nil {
return nil, &APIError{
StatusCode: http.StatusInternalServerError,
Message: "Failed to parse activity data",
Cause: err,
}
}
return &createdActivity, nil
}
// Update updates an existing activity
func (s *ActivityService) Update(ctx context.Context, activityID int64, update ActivityUpdate) (*Activity, error) {
jsonBody, err := json.Marshal(update)
if err != nil {
return nil, &APIError{
StatusCode: http.StatusInternalServerError,
Message: "Failed to marshal activity update",
Cause: err,
}
}
path := "/activity-service/activity/" + strconv.FormatInt(activityID, 10)
resp, err := s.client.Put(ctx, path, bytes.NewReader(jsonBody))
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, &APIError{
StatusCode: resp.StatusCode,
Message: "Failed to update activity",
}
}
body, err := ioutil.ReadAll(resp.Body)
if err != nil {
return nil, &APIError{
StatusCode: http.StatusInternalServerError,
Message: "Failed to read activity response",
Cause: err,
}
}
var updatedActivity Activity
if err := json.Unmarshal(body, &updatedActivity); err != nil {
return nil, &APIError{
StatusCode: http.StatusInternalServerError,
Message: "Failed to parse activity data",
Cause: err,
}
}
return &updatedActivity, nil
}
// Delete deletes an existing activity
func (s *ActivityService) Delete(ctx context.Context, activityID int64) error {
path := "/activity-service/activity/" + strconv.FormatInt(activityID, 10)
resp, err := s.client.Delete(ctx, path, nil)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusOK {
return &APIError{
StatusCode: resp.StatusCode,
Message: "Failed to delete activity",
}
}
return nil
}
// Get retrieves detailed information about a specific activity
func (s *ActivityService) Get(ctx context.Context, activityID int64) (*ActivityDetails, error) {
path := "/activity-service/activity/" + strconv.FormatInt(activityID, 10)
resp, err := s.client.Get(ctx, path)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, &APIError{
StatusCode: resp.StatusCode,
Message: "Failed to get activity details",
}
}
body, err := ioutil.ReadAll(resp.Body)
if err != nil {
return nil, &APIError{
StatusCode: http.StatusInternalServerError,
Message: "Failed to read activity response",
Cause: err,
}
}
var details ActivityDetails
if err := json.Unmarshal(body, &details); err != nil {
return nil, &APIError{
StatusCode: http.StatusInternalServerError,
Message: "Failed to parse activity data",
Cause: err,
}
}
return &details, nil
}
// Export exports an activity in the specified format (gpx, tcx, original)
func (s *ActivityService) Export(ctx context.Context, activityID int64, format string) (io.ReadCloser, error) {
path := "/download-service/export/" + format + "/activity/" + strconv.FormatInt(activityID, 10)
resp, err := s.client.Get(ctx, path)
if err != nil {
return nil, err
}
if resp.StatusCode != http.StatusOK {
defer resp.Body.Close()
return nil, &APIError{
StatusCode: resp.StatusCode,
Message: "Failed to export activity",
}
}
return resp.Body, nil
}

View File

@@ -1,90 +0,0 @@
package garth
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
)
func TestActivityService_List(t *testing.T) {
// Create test server
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`[{
"activityId": 123456789,
"activityName": "Morning Run",
"activityType": "running",
"startTime": "2025-08-29T06:00:00Z",
"distance": 5000,
"duration": 1800,
"calories": 350
}]`))
}))
defer ts.Close()
// Create client
apiClient := NewAPIClient(ts.URL, http.DefaultClient)
activityService := NewActivityService(apiClient)
// Test List method with filters
startDate := time.Date(2025, time.August, 1, 0, 0, 0, 0, time.UTC)
endDate := time.Date(2025, time.August, 31, 0, 0, 0, 0, time.UTC)
opts := ActivityListOptions{
Limit: 10,
StartDate: startDate,
EndDate: endDate,
}
activities, err := activityService.List(context.Background(), opts)
if err != nil {
t.Fatalf("Unexpected error: %v", err)
}
// Verify activity data
if len(activities) != 1 {
t.Fatalf("Expected 1 activity, got %d", len(activities))
}
if activities[0].Name != "Morning Run" {
t.Errorf("Expected activity name 'Morning Run', got '%s'", activities[0].Name)
}
if activities[0].ActivityID != 123456789 {
t.Errorf("Expected activity ID 123456789, got %d", activities[0].ActivityID)
}
}
func TestActivityService_Get(t *testing.T) {
// Create test server
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{
"activityId": 987654321,
"activityName": "Evening Ride",
"activityType": "cycling",
"startTime": "2025-08-29T18:30:00Z",
"distance": 25000,
"duration": 3600,
"calories": 650
}`))
}))
defer ts.Close()
// Create client
apiClient := NewAPIClient(ts.URL, http.DefaultClient)
activityService := NewActivityService(apiClient)
// Test Get method
activity, err := activityService.Get(context.Background(), 987654321)
if err != nil {
t.Fatalf("Unexpected error: %v", err)
}
// Verify activity details
if activity.Name != "Evening Ride" {
t.Errorf("Expected activity name 'Evening Ride', got '%s'", activity.Name)
}
if activity.ActivityID != 987654321 {
t.Errorf("Expected activity ID 987654321, got %d", activity.ActivityID)
}
}

557
auth.go
View File

@@ -1,557 +0,0 @@
package garth
import (
"context"
"crypto/tls"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"math/rand"
"net/http"
"net/url"
"os"
"regexp"
"strconv"
"strings"
"time"
"net/http/cookiejar"
)
type GarthAuthenticator struct {
client *http.Client
tokenURL string
storage TokenStorage
userAgent string
csrfToken string
oauth1Token string
domain string
}
func NewAuthenticator(opts ClientOptions) Authenticator {
// Set default domain if not provided
if opts.Domain == "" {
opts.Domain = "garmin.com"
}
// Enhanced transport with better TLS settings and compression
baseTransport := &http.Transport{
TLSClientConfig: &tls.Config{
MinVersion: tls.VersionTLS12,
PreferServerCipherSuites: true,
CipherSuites: []uint16{
tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384,
tls.TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305,
tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,
},
},
Proxy: http.ProxyFromEnvironment,
DisableCompression: false, // Enable compression
MaxIdleConns: 10,
IdleConnTimeout: 30 * time.Second,
TLSHandshakeTimeout: 10 * time.Second,
}
jar, err := cookiejar.New(nil)
if err != nil {
jar = nil
}
client := &http.Client{
Timeout: opts.Timeout,
Transport: baseTransport,
Jar: jar,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
if len(via) >= 10 {
return errors.New("stopped after 10 redirects")
}
// Preserve headers during redirects
if len(via) > 0 {
for key, values := range via[0].Header {
if key == "Authorization" || key == "Cookie" {
continue // Let the jar handle cookies
}
req.Header[key] = values
}
}
if jar != nil {
for _, v := range via {
if v.Response != nil {
if cookies := v.Response.Cookies(); len(cookies) > 0 {
jar.SetCookies(req.URL, cookies)
}
}
}
}
return nil
},
}
auth := &GarthAuthenticator{
client: client,
tokenURL: opts.TokenURL,
storage: opts.Storage,
userAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", // More realistic user agent
domain: opts.Domain,
}
if setter, ok := opts.Storage.(interface{ SetAuthenticator(a Authenticator) }); ok {
setter.SetAuthenticator(auth)
}
return auth
}
// Enhanced browser headers to bypass Cloudflare
func (a *GarthAuthenticator) getRealisticBrowserHeaders(referer string) http.Header {
headers := http.Header{
"User-Agent": {a.userAgent},
"Accept": {"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7"},
"Accept-Language": {"en-US,en;q=0.9"},
"Accept-Encoding": {"gzip, deflate, br"},
"Cache-Control": {"max-age=0"},
"Sec-Ch-Ua": {`"Not_A Brand";v="8", "Chromium";v="120", "Google Chrome";v="120"`},
"Sec-Ch-Ua-Mobile": {"?0"},
"Sec-Ch-Ua-Platform": {`"Windows"`},
"Sec-Fetch-Dest": {"document"},
"Sec-Fetch-Mode": {"navigate"},
"Sec-Fetch-Site": {"none"},
"Sec-Fetch-User": {"?1"},
"Upgrade-Insecure-Requests": {"1"},
"DNT": {"1"},
}
if referer != "" {
headers.Set("Referer", referer)
headers.Set("Sec-Fetch-Site", "same-origin")
}
return headers
}
func (a *GarthAuthenticator) Login(ctx context.Context, username, password, mfaToken string) (*Token, error) {
// Add delay to simulate human behavior
time.Sleep(time.Duration(500+rand.Intn(1000)) * time.Millisecond)
// Step 1: Get login ticket (lt) from SSO signin page
authToken, tokenType, err := a.getLoginTicket(ctx)
if err != nil {
return nil, fmt.Errorf("failed to get login ticket: %w", err)
}
// Step 2: Authenticate with credentials
serviceTicket, err := a.authenticate(ctx, username, password, "", authToken, tokenType)
if err != nil {
// Check if MFA is required
if authErr, ok := err.(*AuthError); ok && authErr.Type == "mfa_required" {
if mfaToken == "" {
return nil, errors.New("MFA required but no token provided")
}
log.Printf("MFA required, handling with token: %s", mfaToken)
// Handle MFA authentication
serviceTicket, err = a.handleMFA(ctx, username, password, mfaToken, authErr.CSRF)
if err != nil {
return nil, fmt.Errorf("MFA authentication failed: %w", err)
}
log.Printf("MFA authentication successful, service ticket obtained")
} else {
return nil, err
}
}
// Step 3: Exchange service ticket for access token
token, err := a.exchangeServiceTicketForToken(ctx, serviceTicket)
if err != nil {
return nil, fmt.Errorf("failed to exchange service ticket for token: %w", err)
}
if err := a.storage.StoreToken(token); err != nil {
return nil, fmt.Errorf("failed to save token: %w", err)
}
return token, nil
}
// exchangeServiceTicketForToken exchanges service ticket for access token
func (a *GarthAuthenticator) exchangeServiceTicketForToken(ctx context.Context, ticket string) (*Token, error) {
callbackURL := fmt.Sprintf("https://connect.%s/oauthConfirm?ticket=%s", a.domain, ticket)
req, err := http.NewRequestWithContext(ctx, "GET", callbackURL, nil)
if err != nil {
return nil, fmt.Errorf("failed to create callback request: %w", err)
}
// Use realistic browser headers
req.Header = a.getRealisticBrowserHeaders("https://sso.garmin.com")
resp, err := a.client.Do(req)
if err != nil {
return nil, fmt.Errorf("callback request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("callback failed with status: %d", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read callback response: %w", err)
}
// Extract tokens from embedded JavaScript
accessToken, err := extractParam(`"accessToken":"([^"]+)"`, string(body))
if err != nil {
return nil, fmt.Errorf("failed to extract access token: %w", err)
}
refreshToken, err := extractParam(`"refreshToken":"([^"]+)"`, string(body))
if err != nil {
return nil, fmt.Errorf("failed to extract refresh token: %w", err)
}
expiresAt, err := extractParam(`"expiresAt":(\d+)`, string(body))
if err != nil {
return nil, fmt.Errorf("failed to extract expiresAt: %w", err)
}
expiresAtInt, err := strconv.ParseInt(expiresAt, 10, 64)
if err != nil {
return nil, fmt.Errorf("failed to parse expiresAt: %w", err)
}
return &Token{
AccessToken: accessToken,
RefreshToken: refreshToken,
ExpiresAt: expiresAtInt,
Domain: a.domain,
}, nil
}
func (a *GarthAuthenticator) getLoginTicket(ctx context.Context) (string, string, error) {
params := url.Values{}
params.Set("id", "gauth-widget")
params.Set("embedWidget", "true")
params.Set("gauthHost", fmt.Sprintf("https://sso.%s/sso", a.domain))
params.Set("service", fmt.Sprintf("https://connect.%s/oauthConfirm", a.domain))
params.Set("source", fmt.Sprintf("https://sso.%s/sso", a.domain))
params.Set("redirectAfterAccountLoginUrl", fmt.Sprintf("https://connect.%s/oauthConfirm", a.domain))
params.Set("redirectAfterAccountCreationUrl", fmt.Sprintf("https://connect.%s/oauthConfirm", a.domain))
params.Set("consumeServiceTicket", "false")
params.Set("generateExtraServiceTicket", "true")
params.Set("clientId", "GarminConnect")
params.Set("locale", "en_US")
if a.oauth1Token != "" {
params.Set("oauth_token", a.oauth1Token)
}
loginURL := "https://sso.garmin.com/sso/signin?" + params.Encode()
req, err := http.NewRequestWithContext(ctx, "GET", loginURL, nil)
if err != nil {
return "", "", fmt.Errorf("failed to create login page request: %w", err)
}
// Use realistic browser headers with proper referer chain
for key, values := range a.getRealisticBrowserHeaders("") {
req.Header[key] = values
}
// Add some randomness to the request timing
time.Sleep(time.Duration(100+rand.Intn(200)) * time.Millisecond)
resp, err := a.client.Do(req)
if err != nil {
return "", "", fmt.Errorf("login page request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode == 403 {
// Cloudflare blocked us, try with different headers
return "", "", fmt.Errorf("blocked by Cloudflare - try using different IP or wait before retrying")
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", "", fmt.Errorf("failed to read login page response: %w", err)
}
return getCSRFToken(string(body))
}
func (a *GarthAuthenticator) authenticate(ctx context.Context, username, password, mfaToken, authToken, tokenType string) (string, error) {
data := url.Values{}
data.Set("username", username)
data.Set("password", password)
data.Set("embed", "true")
data.Set("rememberme", "on")
if tokenType == "lt" {
data.Set("lt", authToken)
} else {
data.Set("_csrf", authToken)
}
data.Set("_eventId", "submit")
data.Set("geolocation", "")
data.Set("clientId", "GarminConnect")
data.Set("service", fmt.Sprintf("https://connect.%s/oauthConfirm", a.domain))
data.Set("webhost", fmt.Sprintf("https://connect.%s", a.domain))
data.Set("fromPage", "oauth")
data.Set("locale", "en_US")
data.Set("id", "gauth-widget")
data.Set("redirectAfterAccountLoginUrl", fmt.Sprintf("https://connect.%s/oauthConfirm", a.domain))
data.Set("redirectAfterAccountCreationUrl", fmt.Sprintf("https://connect.%s/oauthConfirm", a.domain))
req, err := http.NewRequestWithContext(ctx, "POST", fmt.Sprintf("https://sso.%s/sso/signin", a.domain), strings.NewReader(data.Encode()))
if err != nil {
return "", fmt.Errorf("failed to create SSO request: %w", err)
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Accept", "application/json")
req.Header.Set("User-Agent", a.userAgent)
req.Header.Set("Referer", fmt.Sprintf("https://sso.%s/sso/signin", a.domain))
req.Header.Set("Origin", fmt.Sprintf("https://sso.%s", a.domain))
// Add some delay to simulate typing
time.Sleep(time.Duration(800+rand.Intn(400)) * time.Millisecond)
resp, err := a.client.Do(req)
if err != nil {
return "", fmt.Errorf("SSO request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusPreconditionFailed {
body, _ := io.ReadAll(resp.Body)
csrfToken, err := extractParam(`name="_csrf"\s+value="([^"]+)"`, string(body))
if err != nil {
return "", &AuthError{
StatusCode: http.StatusPreconditionFailed,
Message: "MFA CSRF token not found",
Cause: err,
Type: "mfa_required",
CSRF: "", // Will be set below
}
}
return "", &AuthError{
StatusCode: http.StatusPreconditionFailed,
Message: "MFA required",
Type: "mfa_required",
CSRF: csrfToken,
}
}
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return "", fmt.Errorf("authentication failed with status: %d, response: %s", resp.StatusCode, body)
}
var authResponse struct {
Ticket string `json:"ticket"`
}
if err := json.NewDecoder(resp.Body).Decode(&authResponse); err != nil {
return "", fmt.Errorf("failed to parse SSO response: %w", err)
}
if authResponse.Ticket == "" {
return "", errors.New("empty ticket in SSO response")
}
return authResponse.Ticket, nil
}
func (a *GarthAuthenticator) getEnhancedBrowserHeaders(referrer string) http.Header {
u, _ := url.Parse(referrer)
origin := fmt.Sprintf("%s://%s", u.Scheme, u.Host)
return http.Header{
"User-Agent": {a.userAgent},
"Accept": {"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7"},
"Accept-Language": {"en-US,en;q=0.9"},
"Connection": {"keep-alive"},
"Cache-Control": {"max-age=0"},
"Origin": {origin},
"Referer": {referrer},
"Sec-Fetch-Site": {"same-origin"},
"Sec-Fetch-Mode": {"navigate"},
"Sec-Fetch-User": {"?1"},
"Sec-Fetch-Dest": {"document"},
"DNT": {"1"},
"Upgrade-Insecure-Requests": {"1"},
}
}
func getCSRFToken(html string) (string, string, error) {
// More robust regex patterns to handle variations in HTML structure
patterns := []struct {
regex string
tokenType string
}{
// Pattern for login ticket (lt)
{`<input[^>]*name="lt"[^>]*value="([^"]+)"`, "lt"},
// Pattern for CSRF token in hidden input
{`<input[^>]*name="_csrf"[^>]*value="([^"]+)"`, "_csrf"},
// Pattern for CSRF token in meta tag
{`<meta[^>]*name="_csrf"[^>]*content="([^"]+)"`, "_csrf"},
// Pattern for CSRF token in JSON payload
{`"csrfToken"\s*:\s*"([^"]+)"`, "_csrf"},
}
for _, p := range patterns {
re := regexp.MustCompile(p.regex)
matches := re.FindStringSubmatch(html)
if len(matches) > 1 {
return matches[1], p.tokenType, nil
}
}
// If we get here, we didn't find a token
// Log and save the response for debugging
log.Printf("Failed to find authentication token in HTML response")
debugFilename := fmt.Sprintf("debug/oauth1_response_%d.html", time.Now().UnixNano())
if err := os.WriteFile(debugFilename, []byte(html), 0644); err != nil {
log.Printf("Failed to write debug file: %v", err)
}
return "", "", fmt.Errorf("no authentication token found in HTML response; response written to %s", debugFilename)
}
// RefreshToken implements token refresh functionality
func (a *GarthAuthenticator) RefreshToken(ctx context.Context, refreshToken string) (*Token, error) {
data := url.Values{}
data.Set("grant_type", "refresh_token")
data.Set("refresh_token", refreshToken)
req, err := http.NewRequestWithContext(ctx, "POST", a.tokenURL, strings.NewReader(data.Encode()))
if err != nil {
return nil, fmt.Errorf("failed to create refresh request: %w", err)
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("User-Agent", a.userAgent)
req.SetBasicAuth("garmin-connect", "garmin-connect-secret")
resp, err := a.client.Do(req)
if err != nil {
return nil, fmt.Errorf("refresh request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("refresh failed: %d %s", resp.StatusCode, body)
}
var response struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
ExpiresIn int `json:"expires_in"`
}
if err := json.NewDecoder(resp.Body).Decode(&response); err != nil {
return nil, fmt.Errorf("failed to parse refresh response: %w", err)
}
expiresAt := time.Now().Add(time.Duration(response.ExpiresIn) * time.Second).Unix()
return &Token{
AccessToken: response.AccessToken,
RefreshToken: response.RefreshToken,
ExpiresAt: expiresAt,
Domain: a.domain,
}, nil
}
// GetClient returns the HTTP client used for authentication
func (a *GarthAuthenticator) GetClient() *http.Client {
return a.client
}
// handleMFA processes multi-factor authentication
func (a *GarthAuthenticator) handleMFA(ctx context.Context, username, password, mfaToken, csrfToken string) (string, error) {
data := url.Values{}
data.Set("username", username)
data.Set("password", password)
data.Set("mfaToken", mfaToken)
data.Set("embed", "true")
data.Set("rememberme", "on")
data.Set("_csrf", csrfToken)
data.Set("_eventId", "submit")
data.Set("geolocation", "")
data.Set("clientId", "GarminConnect")
data.Set("service", fmt.Sprintf("https://connect.%s", a.domain))
data.Set("webhost", fmt.Sprintf("https://connect.%s", a.domain))
data.Set("fromPage", "oauth")
data.Set("locale", "en_US")
data.Set("id", "gauth-widget")
data.Set("redirectAfterAccountLoginUrl", fmt.Sprintf("https://connect.%s/oauthConfirm", a.domain))
data.Set("redirectAfterAccountCreationUrl", fmt.Sprintf("https://connect.%s/oauthConfirm", a.domain))
req, err := http.NewRequestWithContext(ctx, "POST", fmt.Sprintf("https://sso.%s/sso/signin", a.domain), strings.NewReader(data.Encode()))
if err != nil {
return "", &AuthError{
StatusCode: http.StatusInternalServerError,
Message: "Failed to create MFA request",
Cause: err,
}
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Accept", "application/json")
req.Header.Set("User-Agent", a.userAgent)
resp, err := a.client.Do(req)
if err != nil {
return "", &AuthError{
StatusCode: http.StatusBadGateway,
Message: "MFA request failed",
Cause: err,
}
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return "", &AuthError{
StatusCode: resp.StatusCode,
Message: fmt.Sprintf("MFA failed: %s", body),
Type: "mfa_failure",
}
}
var mfaResponse struct {
Ticket string `json:"ticket"`
}
if err := json.NewDecoder(resp.Body).Decode(&mfaResponse); err != nil {
return "", &AuthError{
StatusCode: http.StatusInternalServerError,
Message: "Failed to parse MFA response",
Cause: err,
}
}
if mfaResponse.Ticket == "" {
return "", &AuthError{
StatusCode: http.StatusUnauthorized,
Message: "Invalid MFA response - ticket missing",
Type: "invalid_mfa_response",
}
}
return mfaResponse.Ticket, nil
}
// Configure updates authenticator settings
func (a *GarthAuthenticator) Configure(domain string) {
a.domain = domain
}
// extractParam helper to extract regex pattern
func extractParam(pattern, body string) (string, error) {
re := regexp.MustCompile(pattern)
matches := re.FindStringSubmatch(body)
if len(matches) < 2 {
return "", fmt.Errorf("pattern not found: %s", pattern)
}
return matches[1], nil
}

View File

@@ -1,88 +0,0 @@
package garth
import (
"context"
"log"
"os"
"testing"
"time"
"github.com/joho/godotenv"
)
func TestRealAuthentication(t *testing.T) {
// Load environment variables from .env file
if err := godotenv.Load(); err != nil {
t.Fatalf("Error loading .env file: %v", err)
}
// Get credentials from environment
username := os.Getenv("GARMIN_USERNAME")
password := os.Getenv("GARMIN_PASSWORD")
mfaToken := os.Getenv("GARMIN_MFA_TOKEN") // Optional MFA token
if username == "" || password == "" {
t.Fatal("GARMIN_USERNAME or GARMIN_PASSWORD not set in .env")
}
// Add timeout to prevent hanging
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
// Create token storage (using memory storage for this test)
storage := NewMemoryStorage()
// Create authenticator
auth := NewAuthenticator(ClientOptions{
Storage: storage,
TokenURL: "https://connectapi.garmin.com/oauth-service/oauth/token",
Timeout: 30 * time.Second,
})
// Test authentication with and without MFA
testCases := []struct {
name string
mfaToken string
}{
{"Without MFA", ""},
{"With MFA", mfaToken},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
// Perform authentication
token, err := auth.Login(ctx, username, password, tc.mfaToken)
if err != nil {
if tc.mfaToken != "" && err.Error() == "MFA required but no token provided" {
t.Skip("Skipping MFA test since no token provided")
}
t.Fatalf("Authentication failed: %v", err)
}
log.Printf("Authentication successful! Token details:")
log.Printf("Access Token: %s", token.AccessToken)
log.Printf("Expires At: %d", token.ExpiresAt)
log.Printf("Refresh Token: %s", token.RefreshToken)
// Verify token storage
storedToken, err := storage.GetToken()
if err != nil {
t.Fatalf("Token storage verification failed: %v", err)
}
if storedToken.AccessToken != token.AccessToken {
t.Fatal("Stored token doesn't match authenticated token")
}
log.Println("Token storage verification successful")
// Test token refresh
newToken, err := auth.RefreshToken(ctx, token.RefreshToken)
if err != nil {
t.Fatalf("Token refresh failed: %v", err)
}
if newToken.AccessToken == token.AccessToken {
t.Fatal("Refreshed token should be different from original")
}
log.Println("Token refresh successful")
})
}
}

224
claude.md
View File

@@ -1,224 +0,0 @@
// Fixed authentication flow based on Python garth implementation
// fetchOAuth1Token should be the first step and get the initial OAuth1 token
func (a *GarthAuthenticator) fetchOAuth1Token(ctx context.Context) (string, error) {
// Step 1: Initial OAuth1 request - this should NOT have parameters initially
oauth1URL := "https://connect.garmin.com/oauthConfirm"
req, err := http.NewRequestWithContext(ctx, "GET", oauth1URL, nil)
if err != nil {
return "", fmt.Errorf("failed to create OAuth1 request: %w", err)
}
// Set proper headers
req.Header.Set("User-Agent", a.userAgent)
req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
resp, err := a.client.Do(req)
if err != nil {
return "", fmt.Errorf("OAuth1 request failed: %w", err)
}
defer resp.Body.Close()
// Handle redirect case - OAuth1 token often comes from redirect location
if resp.StatusCode >= 300 && resp.StatusCode < 400 {
location := resp.Header.Get("Location")
if location != "" {
if u, err := url.Parse(location); err == nil {
if token := u.Query().Get("oauth_token"); token != "" {
return token, nil
}
}
}
}
// If no redirect, parse response body
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("failed to read OAuth1 response: %w", err)
}
// Look for oauth_token in various formats
patterns := []string{
`oauth_token=([^&\s"']+)`,
`"oauth_token":\s*"([^"]+)"`,
`'oauth_token':\s*'([^']+)'`,
`oauth_token["']?\s*[:=]\s*["']?([^"'\s&]+)`,
}
for _, pattern := range patterns {
re := regexp.MustCompile(pattern)
if matches := re.FindStringSubmatch(string(body)); len(matches) > 1 {
return matches[1], nil
}
}
// Debug: save response for analysis
filename := fmt.Sprintf("oauth1_response_%d.html", time.Now().Unix())
if writeErr := os.WriteFile(filename, body, 0644); writeErr == nil {
return "", fmt.Errorf("OAuth1 token not found (response saved to %s)", filename)
}
return "", fmt.Errorf("OAuth1 token not found in response")
}
// Updated Login method with correct flow
func (a *GarthAuthenticator) Login(ctx context.Context, username, password, mfaToken string) (*Token, error) {
// Step 1: Get OAuth1 token FIRST
oauth1Token, err := a.fetchOAuth1Token(ctx)
if err != nil {
return nil, fmt.Errorf("failed to get OAuth1 token: %w", err)
}
a.oauth1Token = oauth1Token
// Step 2: Now get login parameters with OAuth1 context
authToken, tokenType, err := a.fetchLoginParamsWithOAuth1(ctx)
if err != nil {
return nil, fmt.Errorf("failed to get login params: %w", err)
}
a.csrfToken = authToken
// Step 3: Authenticate with all tokens
token, err := a.authenticate(ctx, username, password, mfaToken, authToken, tokenType)
if err != nil {
return nil, err
}
// Save token to storage
if err := a.storage.SaveToken(token); err != nil {
return nil, fmt.Errorf("failed to save token: %w", err)
}
return token, nil
}
// New method to fetch login params with OAuth1 context
func (a *GarthAuthenticator) fetchLoginParamsWithOAuth1(ctx context.Context) (token string, tokenType string, err error) {
// Build login URL with OAuth1 context
params := url.Values{}
params.Set("id", "gauth-widget")
params.Set("embedWidget", "true")
params.Set("gauthHost", "https://sso.garmin.com/sso")
params.Set("service", "https://connect.garmin.com/oauthConfirm")
params.Set("source", "https://sso.garmin.com/sso")
params.Set("redirectAfterAccountLoginUrl", "https://connect.garmin.com/oauthConfirm")
params.Set("redirectAfterAccountCreationUrl", "https://connect.garmin.com/oauthConfirm")
params.Set("consumeServiceTicket", "false")
params.Set("generateExtraServiceTicket", "true")
params.Set("clientId", "GarminConnect")
params.Set("locale", "en_US")
// Add OAuth1 token if we have it
if a.oauth1Token != "" {
params.Set("oauth_token", a.oauth1Token)
}
loginURL := "https://sso.garmin.com/sso/signin?" + params.Encode()
req, err := http.NewRequestWithContext(ctx, "GET", loginURL, nil)
if err != nil {
return "", "", fmt.Errorf("failed to create login page request: %w", err)
}
// Set headers with proper referrer chain
req.Header.Set("User-Agent", a.userAgent)
req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
req.Header.Set("Accept-Language", "en-US,en;q=0.9")
req.Header.Set("Accept-Encoding", "gzip, deflate, br")
req.Header.Set("Referer", "https://connect.garmin.com/oauthConfirm")
req.Header.Set("Origin", "https://connect.garmin.com")
resp, err := a.client.Do(req)
if err != nil {
return "", "", fmt.Errorf("login page request failed: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", "", fmt.Errorf("failed to read login page response: %w", err)
}
bodyStr := string(body)
// Extract CSRF/lt token
token, tokenType, err = getCSRFTokenWithType(bodyStr)
if err != nil {
// Save for debugging
filename := fmt.Sprintf("login_page_oauth1_%d.html", time.Now().Unix())
if writeErr := os.WriteFile(filename, body, 0644); writeErr == nil {
return "", "", fmt.Errorf("authentication token not found with OAuth1 context: %w (HTML saved to %s)", err, filename)
}
return "", "", fmt.Errorf("authentication token not found with OAuth1 context: %w", err)
}
return token, tokenType, nil
}
// Enhanced authentication method
func (a *GarthAuthenticator) authenticate(ctx context.Context, username, password, mfaToken, authToken, tokenType string) (*Token, error) {
data := url.Values{}
data.Set("username", username)
data.Set("password", password)
data.Set("embed", "true")
data.Set("rememberme", "on")
// Set the correct token field based on type
if tokenType == "lt" {
data.Set("lt", authToken)
} else {
data.Set("_csrf", authToken)
}
data.Set("_eventId", "submit")
data.Set("geolocation", "")
data.Set("clientId", "GarminConnect")
data.Set("service", "https://connect.garmin.com/oauthConfirm") // This should match OAuth1 context
data.Set("webhost", "https://connect.garmin.com")
data.Set("fromPage", "oauth")
data.Set("locale", "en_US")
data.Set("id", "gauth-widget")
data.Set("redirectAfterAccountLoginUrl", "https://connect.garmin.com/oauthConfirm")
data.Set("redirectAfterAccountCreationUrl", "https://connect.garmin.com/oauthConfirm")
loginURL := "https://sso.garmin.com/sso/signin"
req, err := http.NewRequestWithContext(ctx, "POST", loginURL, strings.NewReader(data.Encode()))
if err != nil {
return nil, fmt.Errorf("failed to create SSO request: %w", err)
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Accept", "application/json")
req.Header.Set("User-Agent", a.userAgent)
req.Header.Set("Referer", "https://sso.garmin.com/sso/signin?id=gauth-widget&embedWidget=true&gauthHost=https://sso.garmin.com/sso")
resp, err := a.client.Do(req)
if err != nil {
return nil, fmt.Errorf("SSO request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusPreconditionFailed {
body, _ := io.ReadAll(resp.Body)
return a.handleMFA(ctx, username, password, mfaToken, string(body))
}
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("authentication failed with status: %d, response: %s", resp.StatusCode, body)
}
var authResponse struct {
Ticket string `json:"ticket"`
}
if err := json.NewDecoder(resp.Body).Decode(&authResponse); err != nil {
return nil, fmt.Errorf("failed to parse SSO response: %w", err)
}
if authResponse.Ticket == "" {
return nil, errors.New("empty ticket in SSO response")
}
return a.exchangeTicketForToken(ctx, authResponse.Ticket)
}

159
client.go
View File

@@ -1,159 +0,0 @@
package garth
import (
"context"
"net/http"
"sync"
"time"
)
// AuthTransport implements http.RoundTripper to inject authentication headers
type AuthTransport struct {
base http.RoundTripper
auth *GarthAuthenticator
storage TokenStorage
userAgent string
mutex sync.Mutex // Protects refreshing token
}
// NewAuthTransport creates a new authenticated transport with specified storage
func NewAuthTransport(auth *GarthAuthenticator, storage TokenStorage, base http.RoundTripper) *AuthTransport {
if base == nil {
base = http.DefaultTransport
}
if storage == nil {
storage = NewFileStorage("garmin_session.json")
}
return &AuthTransport{
base: base,
auth: auth,
storage: storage,
userAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36",
}
}
// RoundTrip executes a single HTTP transaction with authentication
func (t *AuthTransport) RoundTrip(req *http.Request) (*http.Response, error) {
// Clone request to avoid modifying the original
req = cloneRequest(req)
// Get current token
token, err := t.storage.GetToken()
if err != nil {
return nil, &AuthError{
StatusCode: http.StatusUnauthorized,
Message: "Token not available",
Cause: err,
}
}
// Refresh token if expired
if token.IsExpired() {
newToken, err := t.refreshToken(req.Context(), token)
if err != nil {
return nil, err
}
token = newToken
}
// Add Authorization header
req.Header.Set("Authorization", "Bearer "+token.AccessToken)
req.Header.Set("User-Agent", t.userAgent)
req.Header.Set("Referer", "https://sso.garmin.com/sso/signin")
// Execute request with retry logic
var resp *http.Response
maxRetries := 3
backoff := 200 * time.Millisecond // Initial backoff duration
for attempt := 0; attempt < maxRetries; attempt++ {
resp, err = t.base.RoundTrip(req)
if err != nil {
// Network error, retry with backoff
time.Sleep(backoff)
backoff *= 2 // Exponential backoff
continue
}
// Handle token expiration during request (e.g. token revoked)
if resp.StatusCode == http.StatusUnauthorized {
resp.Body.Close()
// Refresh token and update request
token, err = t.refreshToken(req.Context(), token)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+token.AccessToken)
continue
}
// Retry server errors (5xx) and rate limits (429)
if resp.StatusCode >= 500 && resp.StatusCode < 600 || resp.StatusCode == http.StatusTooManyRequests {
resp.Body.Close()
time.Sleep(backoff)
backoff *= 2
continue
}
// Successful response
return resp, nil
}
// Return last error or response if max retries exceeded
if err != nil {
return nil, err
}
return resp, nil
}
// refreshToken handles token refresh with mutex protection
func (t *AuthTransport) refreshToken(ctx context.Context, token *Token) (*Token, error) {
t.mutex.Lock()
defer t.mutex.Unlock()
// Check again in case another goroutine refreshed while waiting
currentToken, err := t.storage.GetToken()
if err != nil {
return nil, err
}
if !currentToken.IsExpired() {
return currentToken, nil
}
// Perform refresh
newToken, err := t.auth.RefreshToken(ctx, token.RefreshToken)
if err != nil {
return nil, err
}
// Save new token
if err := t.storage.StoreToken(newToken); err != nil {
return nil, err
}
return newToken, nil
}
// NewDefaultAuthTransport creates a transport with persistent storage
func NewDefaultAuthTransport(auth *GarthAuthenticator) *AuthTransport {
return NewAuthTransport(auth, NewFileStorage("garmin_session.json"), nil)
}
// NewMemoryAuthTransport creates a transport with in-memory storage (for testing)
func NewMemoryAuthTransport(auth *GarthAuthenticator) *AuthTransport {
return NewAuthTransport(auth, NewMemoryStorage(), nil)
}
// cloneRequest returns a clone of the provided HTTP request
func cloneRequest(r *http.Request) *http.Request {
// Shallow copy of the struct
clone := *r
// Deep copy of the headers
clone.Header = make(http.Header, len(r.Header))
for k, v := range r.Header {
clone.Header[k] = v
}
return &clone
}

View File

@@ -1,157 +0,0 @@
package garth
import (
"context"
"fmt"
"io"
"math/rand"
"net/http"
"time"
)
// CloudflareBypass provides methods to handle Cloudflare protection
type CloudflareBypass struct {
client *http.Client
userAgent string
maxRetries int
}
// NewCloudflareBypass creates a new CloudflareBypass instance
func NewCloudflareBypass(client *http.Client) *CloudflareBypass {
return &CloudflareBypass{
client: client,
userAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
maxRetries: 3,
}
}
// MakeRequest performs a request with Cloudflare bypass techniques
func (cf *CloudflareBypass) MakeRequest(ctx context.Context, req *http.Request) (*http.Response, error) {
var resp *http.Response
var err error
for attempt := 0; attempt < cf.maxRetries; attempt++ {
// Clone the request to avoid modifying the original
clonedReq := cf.cloneRequest(req)
// Apply bypass headers
cf.applyBypassHeaders(clonedReq)
// Add random delay to simulate human behavior
if attempt > 0 {
delay := time.Duration(1000+rand.Intn(2000)) * time.Millisecond
time.Sleep(delay)
}
resp, err = cf.client.Do(clonedReq)
if err != nil {
continue
}
// Check if we got blocked by Cloudflare
if cf.isCloudflareBlocked(resp) {
resp.Body.Close()
if attempt < cf.maxRetries-1 {
// Try different user agent on retry
cf.rotateUserAgent()
continue
}
return nil, fmt.Errorf("blocked by Cloudflare after %d attempts", cf.maxRetries)
}
return resp, nil
}
return nil, err
}
// applyBypassHeaders adds headers to bypass Cloudflare
func (cf *CloudflareBypass) applyBypassHeaders(req *http.Request) {
req.Header.Set("User-Agent", cf.userAgent)
req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7")
req.Header.Set("Accept-Language", "en-US,en;q=0.9")
req.Header.Set("Accept-Encoding", "gzip, deflate, br")
req.Header.Set("Cache-Control", "max-age=0")
req.Header.Set("Sec-Ch-Ua", `"Not_A Brand";v="8", "Chromium";v="120", "Google Chrome";v="120"`)
req.Header.Set("Sec-Ch-Ua-Mobile", "?0")
req.Header.Set("Sec-Ch-Ua-Platform", `"Windows"`)
req.Header.Set("Sec-Fetch-Dest", "document")
req.Header.Set("Sec-Fetch-Mode", "navigate")
req.Header.Set("Sec-Fetch-Site", "none")
req.Header.Set("Sec-Fetch-User", "?1")
req.Header.Set("Upgrade-Insecure-Requests", "1")
req.Header.Set("DNT", "1")
// Add some randomized headers
if rand.Float32() < 0.7 {
req.Header.Set("Pragma", "no-cache")
}
if rand.Float32() < 0.5 {
req.Header.Set("Connection", "keep-alive")
}
}
// isCloudflareBlocked checks if the response indicates Cloudflare blocking
func (cf *CloudflareBypass) isCloudflareBlocked(resp *http.Response) bool {
if resp.StatusCode == 403 {
// Check for Cloudflare-specific headers or content
if resp.Header.Get("Server") == "cloudflare" {
return true
}
if resp.Header.Get("CF-Ray") != "" {
return true
}
// Check response body for Cloudflare indicators
if resp.ContentLength > 0 && resp.ContentLength < 50000 {
body, err := io.ReadAll(resp.Body)
if err == nil {
bodyStr := string(body)
if contains(bodyStr, "cloudflare") || contains(bodyStr, "Attention Required") {
return true
}
}
}
}
return false
}
// rotateUserAgent changes the user agent for retry attempts
func (cf *CloudflareBypass) rotateUserAgent() {
userAgents := []string{
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/121.0",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Safari/605.1.15",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 Edg/120.0.0.0",
}
cf.userAgent = userAgents[rand.Intn(len(userAgents))]
}
// cloneRequest creates a copy of the HTTP request
func (cf *CloudflareBypass) cloneRequest(req *http.Request) *http.Request {
cloned := req.Clone(req.Context())
if cloned.Header == nil {
cloned.Header = make(http.Header)
}
return cloned
}
// contains is a case-insensitive string contains check
func contains(s, substr string) bool {
return len(s) >= len(substr) &&
(s == substr ||
len(s) > len(substr) &&
(s[:len(substr)] == substr ||
s[len(s)-len(substr):] == substr ||
containsAt(s, substr)))
}
func containsAt(s, substr string) bool {
for i := 0; i <= len(s)-len(substr); i++ {
if s[i:i+len(substr)] == substr {
return true
}
}
return false
}

View File

@@ -1,57 +0,0 @@
package main
import (
"context"
"fmt"
"log"
"os"
"github.com/joho/godotenv"
"github.com/sstent/go-garth"
)
func main() {
// Load environment variables from project root
projectRoot := "../.."
if err := godotenv.Load(projectRoot + "/.env"); err != nil {
log.Fatalf("Error loading .env file: %v", err)
}
// Get credentials
username := os.Getenv("GARMIN_USERNAME")
password := os.Getenv("GARMIN_PASSWORD")
if username == "" || password == "" {
log.Fatal("GARMIN_USERNAME or GARMIN_PASSWORD not set in .env")
}
// Create storage and authenticator
storage := garth.NewMemoryStorage()
auth := garth.NewAuthenticator(garth.ClientOptions{
Storage: storage,
TokenURL: "https://connectapi.garmin.com/oauth-service/oauth/token",
Timeout: 120,
})
// Perform authentication
fmt.Println("Starting authentication...")
token, err := auth.Login(context.Background(), username, password, "")
if err != nil {
log.Fatalf("Authentication failed: %v", err)
}
fmt.Println("\nAuthentication successful! Token details:")
fmt.Printf("Access Token: %s\n", token.AccessToken)
fmt.Printf("Expires At: %d\n", token.ExpiresAt)
fmt.Printf("Refresh Token: %s\n", token.RefreshToken)
// Verify token storage
storedToken, err := storage.GetToken()
if err != nil {
log.Fatalf("Token storage verification failed: %v", err)
}
if storedToken.AccessToken != token.AccessToken {
log.Fatal("Stored token doesn't match authenticated token")
}
fmt.Println("Token storage verification successful")
}

View File

@@ -1,229 +0,0 @@
package garth
import (
"bytes"
"context"
"encoding/json"
"io"
"net/http"
"net/url"
"time"
)
// APIClient manages API requests to Garmin Connect
type APIClient struct {
baseURL string
httpClient *http.Client
rateLimit time.Duration
logger ErrorLogger // Optional error logger
}
// NewAPIClient creates a new API client instance
func NewAPIClient(baseURL string, httpClient *http.Client) *APIClient {
return &APIClient{
baseURL: baseURL,
httpClient: httpClient,
rateLimit: 500 * time.Millisecond, // Default rate limit
}
}
// SetRateLimit configures request rate limiting
func (c *APIClient) SetRateLimit(limit time.Duration) {
c.rateLimit = limit
}
// SetRequestsPerSecond configures the maximum number of requests per second
func (c *APIClient) SetRequestsPerSecond(rate float64) {
interval := time.Duration(float64(time.Second) / rate)
c.SetRateLimit(interval)
}
// Get executes a GET request
func (c *APIClient) Get(ctx context.Context, path string) (*http.Response, error) {
return c.request(ctx, http.MethodGet, path, nil)
}
// Post executes a POST request
func (c *APIClient) Post(ctx context.Context, path string, body io.Reader) (*http.Response, error) {
return c.request(ctx, http.MethodPost, path, body)
}
// Put executes a PUT request
func (c *APIClient) Put(ctx context.Context, path string, body io.Reader) (*http.Response, error) {
return c.request(ctx, http.MethodPut, path, body)
}
// Delete executes a DELETE request
func (c *APIClient) Delete(ctx context.Context, path string, body io.Reader) (*http.Response, error) {
return c.request(ctx, http.MethodDelete, path, body)
}
// handleResponse handles API response and error decoding
func handleResponse(resp *http.Response, result interface{}) error {
defer resp.Body.Close()
if resp.StatusCode >= 400 {
body, _ := io.ReadAll(resp.Body)
return &APIError{
StatusCode: resp.StatusCode,
Message: string(body),
}
}
if result != nil {
if err := json.NewDecoder(resp.Body).Decode(result); err != nil {
return &APIError{
StatusCode: http.StatusInternalServerError,
Message: "Failed to parse response",
Cause: err,
}
}
}
return nil
}
// GetJSON executes a GET request and decodes the JSON response
func (c *APIClient) GetJSON(ctx context.Context, path string, result interface{}) error {
resp, err := c.Get(ctx, path)
if err != nil {
return err
}
return handleResponse(resp, result)
}
// PostJSON executes a POST request with JSON body and decodes the JSON response
func (c *APIClient) PostJSON(ctx context.Context, path string, body interface{}, result interface{}) error {
jsonBody, err := json.Marshal(body)
if err != nil {
return &APIError{
StatusCode: http.StatusInternalServerError,
Message: "Failed to marshal request body",
Cause: err,
}
}
resp, err := c.Post(ctx, path, bytes.NewReader(jsonBody))
if err != nil {
return err
}
return handleResponse(resp, result)
}
// PutJSON executes a PUT request with JSON body and decodes the JSON response
func (c *APIClient) PutJSON(ctx context.Context, path string, body interface{}, result interface{}) error {
jsonBody, err := json.Marshal(body)
if err != nil {
return &APIError{
StatusCode: http.StatusInternalServerError,
Message: "Failed to marshal request body",
Cause: err,
}
}
resp, err := c.Put(ctx, path, bytes.NewReader(jsonBody))
if err != nil {
return err
}
return handleResponse(resp, result)
}
// DeleteJSON executes a DELETE request and decodes the JSON response
func (c *APIClient) DeleteJSON(ctx context.Context, path string, result interface{}) error {
resp, err := c.Delete(ctx, path, nil)
if err != nil {
return err
}
return handleResponse(resp, result)
}
// ErrorLogger defines an interface for logging errors
type ErrorLogger interface {
Errorf(format string, args ...interface{})
}
// SetLogger sets the error logger for the API client
func (c *APIClient) SetLogger(logger ErrorLogger) {
c.logger = logger
}
func (c *APIClient) request(ctx context.Context, method, path string, body io.Reader) (*http.Response, error) {
// Rate limiting using token bucket algorithm
if c.rateLimit > 0 {
time.Sleep(c.rateLimit)
}
var resp *http.Response
var err error
var req *http.Request
maxRetries := 3
backoff := 500 * time.Millisecond
for i := 0; i < maxRetries; i++ {
var createErr error
req, createErr = http.NewRequestWithContext(ctx, method, c.baseURL+path, body)
if createErr != nil {
return nil, &APIError{
StatusCode: http.StatusInternalServerError,
Message: "Failed to create request",
Cause: createErr,
}
}
// Set common headers
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
resp, err = c.httpClient.Do(req)
// Retry only on network errors or server-side issues
if err != nil || (resp != nil && resp.StatusCode >= 500) {
if i < maxRetries-1 {
// Exponential backoff with jitter
time.Sleep(backoff)
backoff = time.Duration(float64(backoff) * 2.5)
continue
}
}
break
}
// Extract query parameters for error context
var queryValues url.Values
if req != nil {
queryValues = req.URL.Query()
}
if err != nil {
apiErr := &APIError{
StatusCode: http.StatusBadGateway,
Message: "Request failed after retries",
Cause: err,
}
reqErr := NewRequestError(method, req.URL.String(), queryValues, http.StatusBadGateway, apiErr)
// Log error if logger is configured
if c.logger != nil {
c.logger.Errorf("API request failed: %v, Method: %s, URL: %s", reqErr, method, req.URL.String())
}
return nil, reqErr
}
if resp.StatusCode >= 400 {
apiErr := &APIError{
StatusCode: resp.StatusCode,
Message: "API request failed",
}
reqErr := NewRequestError(method, req.URL.String(), queryValues, resp.StatusCode, apiErr)
// Log error if logger is configured
if c.logger != nil {
c.logger.Errorf("API request failed with status %d: %s, Method: %s, URL: %s",
resp.StatusCode, apiErr.Message, method, req.URL.String())
}
return nil, reqErr
}
return resp, nil
}

View File

@@ -1,92 +0,0 @@
package garth
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
)
func TestAPIClient_Get(t *testing.T) {
// Create a test server
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("OK"))
}))
defer ts.Close()
// Create client
client := NewAPIClient(ts.URL, http.DefaultClient)
// Test successful request
resp, err := client.Get(context.Background(), "/test")
if err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if resp.StatusCode != http.StatusOK {
t.Errorf("Expected status OK, got %d", resp.StatusCode)
}
}
func TestAPIClient_Retry(t *testing.T) {
retryCount := 0
// Create a test server that fails first two requests
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
retryCount++
if retryCount < 3 {
w.WriteHeader(http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
}))
defer ts.Close()
// Create client with faster backoff for testing
client := NewAPIClient(ts.URL, http.DefaultClient)
client.SetRateLimit(10 * time.Millisecond)
// Test retry logic
resp, err := client.Get(context.Background(), "/retry-test")
if err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if resp.StatusCode != http.StatusOK {
t.Errorf("Expected status OK after retries, got %d", resp.StatusCode)
}
if retryCount != 3 {
t.Errorf("Expected 3 attempts, got %d", retryCount)
}
}
func TestAPIClient_ErrorHandling(t *testing.T) {
// Create a test server that returns 404
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
}))
defer ts.Close()
// Create client
client := NewAPIClient(ts.URL, http.DefaultClient)
// Test error handling
_, err := client.Get(context.Background(), "/not-found")
if err == nil {
t.Fatal("Expected error but got none")
}
// Check for RequestError wrapper
reqErr, ok := err.(*RequestError)
if !ok {
t.Fatalf("Expected RequestError, got %T", err)
}
// Check the wrapped APIError
apiErr, ok := reqErr.GetCause().(*APIError)
if !ok {
t.Fatalf("Expected APIError inside RequestError, got %T", reqErr.GetCause())
}
if apiErr.StatusCode != http.StatusNotFound {
t.Errorf("Expected 404 status, got %d", apiErr.StatusCode)
}
}

View File

@@ -1,13 +0,0 @@
Report-To: {"endpoints":[{"url":"https:\/\/a.nel.cloudflare.com\/report\/v4?s=LB4nTuybIbmdfmkjfbhGa%2FMJZapsS682xCWKJOOWqrhVV7CnzARk2sMV8ZeZUijqC9AcFQhhxv4W8Egdel5Asb9wivzQo0hCUo8%2B2oe2%2FHqZKCfXs5p5Pk623hXacIo8Vnf8cg%3D%3D"}],"group":"cf-nel","max_age":604800}
Nel: {"success_fraction":0.01,"report_to":"cf-nel","max_age":604800}
Server: cloudflare
Connection: keep-alive
Last-Modified: Mon, 28 Jul 2025 20:49:30 GMT
Cache-Control: no-cache
Cf-Cache-Status: DYNAMIC
Content-Type: text/html; charset=UTF-8
Server-Timing: cfCacheStatus;desc="DYNAMIC", cfOrigin;dur=13,cfEdge;dur=70
Set-Cookie: __cfruid=690cd828ea6910e26e5c8a6646ea53e1167f4fbd-1756949664; path=/; domain=.connect.garmin.com; HttpOnly; Secure; SameSite=None
Date: Thu, 04 Sep 2025 01:34:24 GMT
Cf-Ray: 9799be879b8c08d3-LAX
X-Frame-Options: deny

File diff suppressed because one or more lines are too long

View File

@@ -1,13 +0,0 @@
Cf-Ray: 97a5db741ab22efb-LAX
Date: Fri, 05 Sep 2025 12:51:17 GMT
Last-Modified: Mon, 28 Jul 2025 20:49:42 GMT
Cf-Cache-Status: DYNAMIC
Connection: keep-alive
Cache-Control: no-cache
Set-Cookie: __cfruid=2107ee6871a3cf02bd004445e95d2e77bd292984-1757076677; path=/; domain=.connect.garmin.com; HttpOnly; Secure; SameSite=None
Content-Type: text/html; charset=UTF-8
X-Frame-Options: deny
Server-Timing: cfCacheStatus;desc="DYNAMIC", cfOrigin;dur=3,cfEdge;dur=75
Nel: {"success_fraction":0.01,"report_to":"cf-nel","max_age":604800}
Server: cloudflare
Report-To: {"endpoints":[{"url":"https:\/\/a.nel.cloudflare.com\/report\/v4?s=Hv7AO1s60DI9JCEW%2F%2Bz16fzKmavV%2BZg8D7Ouwbop8oHlOuJd3ogudd%2Fo5U7ZKPYavncF6AOa%2Fk72KCsUS8l2qjRk%2B%2F5kV0HMgJurjZS53jTWuqxpehMyNPvqdnE7EOlocrh5NQ%3D%3D"}],"group":"cf-nel","max_age":604800}

File diff suppressed because one or more lines are too long

View File

@@ -1,13 +0,0 @@
Cf-Ray: 97a5dd2fbe1cc982-LAX
Report-To: {"endpoints":[{"url":"https:\/\/a.nel.cloudflare.com\/report\/v4?s=0CCGeDUIydmkb7dMMeIz85bxJdJITAb2XMoWWDEgTh7zhr6HkfCEdXWgYLJSFvpzmAp1F3I5wbJ2jocrjrymEMg5TF9GgWtPcTl7PMY2IZ14xFpMe1jUWVi0kcRfSRLtIOyEwA%3D%3D"}],"group":"cf-nel","max_age":604800}
Content-Type: text/html; charset=UTF-8
Cache-Control: no-cache
Cf-Cache-Status: DYNAMIC
Server-Timing: cfCacheStatus;desc="DYNAMIC", cfOrigin;dur=5,cfEdge;dur=51
Set-Cookie: __cfruid=0091461aff8318be73baddcab194a5eb97ce5bbd-1757076748; path=/; domain=.connect.garmin.com; HttpOnly; Secure; SameSite=None
Connection: keep-alive
Last-Modified: Mon, 28 Jul 2025 20:49:50 GMT
Nel: {"success_fraction":0.01,"report_to":"cf-nel","max_age":604800}
Server: cloudflare
Date: Fri, 05 Sep 2025 12:52:28 GMT
X-Frame-Options: deny

File diff suppressed because one or more lines are too long

View File

@@ -1,93 +0,0 @@
<!DOCTYPE html>
<!--[if lt IE 7]> <html class="no-js ie6 oldie" lang="en-US"> <![endif]-->
<!--[if IE 7]> <html class="no-js ie7 oldie" lang="en-US"> <![endif]-->
<!--[if IE 8]> <html class="no-js ie8 oldie" lang="en-US"> <![endif]-->
<!--[if gt IE 8]><!--> <html class="no-js" lang="en-US"> <!--<![endif]-->
<head>
<title>Attention Required! | Cloudflare</title>
<meta charset="UTF-8" />
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=Edge" />
<meta name="robots" content="noindex, nofollow" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<link rel="stylesheet" id="cf_styles-css" href="/cdn-cgi/styles/cf.errors.css" />
<!--[if lt IE 9]><link rel="stylesheet" id='cf_styles-ie-css' href="/cdn-cgi/styles/cf.errors.ie.css" /><![endif]-->
<style>body{margin:0;padding:0}</style>
<!--[if gte IE 10]><!-->
<script>
if (!navigator.cookieEnabled) {
window.addEventListener('DOMContentLoaded', function () {
var cookieEl = document.getElementById('cookie-alert');
cookieEl.style.display = 'block';
})
}
</script>
<!--<![endif]-->
</head>
<body>
<div id="cf-wrapper">
<div class="cf-alert cf-alert-error cf-cookie-error" id="cookie-alert" data-translate="enable_cookies">Please enable cookies.</div>
<div id="cf-error-details" class="cf-error-details-wrapper">
<div class="cf-wrapper cf-header cf-error-overview">
<h1 data-translate="block_headline">Sorry, you have been blocked</h1>
<h2 class="cf-subheadline"><span data-translate="unable_to_access">You are unable to access</span> sso.garmin.com</h2>
</div><!-- /.header -->
<div class="cf-section cf-highlight">
<div class="cf-wrapper">
<div class="cf-screenshot-container cf-screenshot-full">
<span class="cf-no-screenshot error"></span>
</div>
</div>
</div><!-- /.captcha-container -->
<div class="cf-section cf-wrapper">
<div class="cf-columns two">
<div class="cf-column">
<h2 data-translate="blocked_why_headline">Why have I been blocked?</h2>
<p data-translate="blocked_why_detail">This website is using a security service to protect itself from online attacks. The action you just performed triggered the security solution. There are several actions that could trigger this block including submitting a certain word or phrase, a SQL command or malformed data.</p>
</div>
<div class="cf-column">
<h2 data-translate="blocked_resolve_headline">What can I do to resolve this?</h2>
<p data-translate="blocked_resolve_detail">You can email the site owner to let them know you were blocked. Please include what you were doing when this page came up and the Cloudflare Ray ID found at the bottom of this page.</p>
</div>
</div>
</div><!-- /.section -->
<div class="cf-error-footer cf-wrapper w-240 lg:w-full py-10 sm:py-4 sm:px-8 mx-auto text-center sm:text-left border-solid border-0 border-t border-gray-300">
<p class="text-13">
<span class="cf-footer-item sm:block sm:mb-1">Cloudflare Ray ID: <strong class="font-semibold">97a86f0198f008fa</strong></span>
<span class="cf-footer-separator sm:hidden">&bull;</span>
<span id="cf-footer-item-ip" class="cf-footer-item hidden sm:block sm:mb-1">
Your IP:
<button type="button" id="cf-footer-ip-reveal" class="cf-footer-ip-reveal-btn">Click to reveal</button>
<span class="hidden" id="cf-footer-ip">47.150.230.21</span>
<span class="cf-footer-separator sm:hidden">&bull;</span>
</span>
<span class="cf-footer-item sm:block sm:mb-1"><span>Performance &amp; security by</span> <a rel="noopener noreferrer" href="https://www.cloudflare.com/5xx-error-landing" id="brand_link" target="_blank">Cloudflare</a></span>
</p>
<script>(function(){function d(){var b=a.getElementById("cf-footer-item-ip"),c=a.getElementById("cf-footer-ip-reveal");b&&"classList"in b&&(b.classList.remove("hidden"),c.addEventListener("click",function(){c.classList.add("hidden");a.getElementById("cf-footer-ip").classList.remove("hidden")}))}var a=document;document.addEventListener&&a.addEventListener("DOMContentLoaded",d)})();</script>
</div><!-- /.error-footer -->
</div><!-- /#cf-error-details -->
</div><!-- /#cf-wrapper -->
<script>
window._cf_translation = {};
</script>
<script>(function(){function c(){var b=a.contentDocument||a.contentWindow.document;if(b){var d=b.createElement('script');d.innerHTML="window.__CF$cv$params={r:'97a86f0198f008fa',t:'MTc1NzEwMzY5My4wMDAwMDA='};var a=document.createElement('script');a.nonce='';a.src='/cdn-cgi/challenge-platform/scripts/jsd/main.js';document.getElementsByTagName('head')[0].appendChild(a);";b.getElementsByTagName('head')[0].appendChild(d)}}if(document.body){var a=document.createElement('iframe');a.height=1;a.width=1;a.style.position='absolute';a.style.top=0;a.style.left=0;a.style.border='none';a.style.visibility='hidden';document.body.appendChild(a);if('loading'!==document.readyState)c();else if(window.addEventListener)document.addEventListener('DOMContentLoaded',c);else{var e=document.onreadystatechange||function(){};document.onreadystatechange=function(b){e(b);'loading'!==document.readyState&&(document.onreadystatechange=e,c())}}}})();</script></body>
</html>

View File

@@ -1,93 +0,0 @@
<!DOCTYPE html>
<!--[if lt IE 7]> <html class="no-js ie6 oldie" lang="en-US"> <![endif]-->
<!--[if IE 7]> <html class="no-js ie7 oldie" lang="en-US"> <![endif]-->
<!--[if IE 8]> <html class="no-js ie8 oldie" lang="en-US"> <![endif]-->
<!--[if gt IE 8]><!--> <html class="no-js" lang="en-US"> <!--<![endif]-->
<head>
<title>Attention Required! | Cloudflare</title>
<meta charset="UTF-8" />
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=Edge" />
<meta name="robots" content="noindex, nofollow" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<link rel="stylesheet" id="cf_styles-css" href="/cdn-cgi/styles/cf.errors.css" />
<!--[if lt IE 9]><link rel="stylesheet" id='cf_styles-ie-css' href="/cdn-cgi/styles/cf.errors.ie.css" /><![endif]-->
<style>body{margin:0;padding:0}</style>
<!--[if gte IE 10]><!-->
<script>
if (!navigator.cookieEnabled) {
window.addEventListener('DOMContentLoaded', function () {
var cookieEl = document.getElementById('cookie-alert');
cookieEl.style.display = 'block';
})
}
</script>
<!--<![endif]-->
</head>
<body>
<div id="cf-wrapper">
<div class="cf-alert cf-alert-error cf-cookie-error" id="cookie-alert" data-translate="enable_cookies">Please enable cookies.</div>
<div id="cf-error-details" class="cf-error-details-wrapper">
<div class="cf-wrapper cf-header cf-error-overview">
<h1 data-translate="block_headline">Sorry, you have been blocked</h1>
<h2 class="cf-subheadline"><span data-translate="unable_to_access">You are unable to access</span> sso.garmin.com</h2>
</div><!-- /.header -->
<div class="cf-section cf-highlight">
<div class="cf-wrapper">
<div class="cf-screenshot-container cf-screenshot-full">
<span class="cf-no-screenshot error"></span>
</div>
</div>
</div><!-- /.captcha-container -->
<div class="cf-section cf-wrapper">
<div class="cf-columns two">
<div class="cf-column">
<h2 data-translate="blocked_why_headline">Why have I been blocked?</h2>
<p data-translate="blocked_why_detail">This website is using a security service to protect itself from online attacks. The action you just performed triggered the security solution. There are several actions that could trigger this block including submitting a certain word or phrase, a SQL command or malformed data.</p>
</div>
<div class="cf-column">
<h2 data-translate="blocked_resolve_headline">What can I do to resolve this?</h2>
<p data-translate="blocked_resolve_detail">You can email the site owner to let them know you were blocked. Please include what you were doing when this page came up and the Cloudflare Ray ID found at the bottom of this page.</p>
</div>
</div>
</div><!-- /.section -->
<div class="cf-error-footer cf-wrapper w-240 lg:w-full py-10 sm:py-4 sm:px-8 mx-auto text-center sm:text-left border-solid border-0 border-t border-gray-300">
<p class="text-13">
<span class="cf-footer-item sm:block sm:mb-1">Cloudflare Ray ID: <strong class="font-semibold">97a87306fc145337</strong></span>
<span class="cf-footer-separator sm:hidden">&bull;</span>
<span id="cf-footer-item-ip" class="cf-footer-item hidden sm:block sm:mb-1">
Your IP:
<button type="button" id="cf-footer-ip-reveal" class="cf-footer-ip-reveal-btn">Click to reveal</button>
<span class="hidden" id="cf-footer-ip">47.150.230.21</span>
<span class="cf-footer-separator sm:hidden">&bull;</span>
</span>
<span class="cf-footer-item sm:block sm:mb-1"><span>Performance &amp; security by</span> <a rel="noopener noreferrer" href="https://www.cloudflare.com/5xx-error-landing" id="brand_link" target="_blank">Cloudflare</a></span>
</p>
<script>(function(){function d(){var b=a.getElementById("cf-footer-item-ip"),c=a.getElementById("cf-footer-ip-reveal");b&&"classList"in b&&(b.classList.remove("hidden"),c.addEventListener("click",function(){c.classList.add("hidden");a.getElementById("cf-footer-ip").classList.remove("hidden")}))}var a=document;document.addEventListener&&a.addEventListener("DOMContentLoaded",d)})();</script>
</div><!-- /.error-footer -->
</div><!-- /#cf-error-details -->
</div><!-- /#cf-wrapper -->
<script>
window._cf_translation = {};
</script>
<script>(function(){function c(){var b=a.contentDocument||a.contentWindow.document;if(b){var d=b.createElement('script');d.innerHTML="window.__CF$cv$params={r:'97a87306fc145337',t:'MTc1NzEwMzg1Ny4wMDAwMDA='};var a=document.createElement('script');a.nonce='';a.src='/cdn-cgi/challenge-platform/scripts/jsd/main.js';document.getElementsByTagName('head')[0].appendChild(a);";b.getElementsByTagName('head')[0].appendChild(d)}}if(document.body){var a=document.createElement('iframe');a.height=1;a.width=1;a.style.position='absolute';a.style.top=0;a.style.left=0;a.style.border='none';a.style.visibility='hidden';document.body.appendChild(a);if('loading'!==document.readyState)c();else if(window.addEventListener)document.addEventListener('DOMContentLoaded',c);else{var e=document.onreadystatechange||function(){};document.onreadystatechange=function(b){e(b);'loading'!==document.readyState&&(document.onreadystatechange=e,c())}}}})();</script></body>
</html>

View File

@@ -1,93 +0,0 @@
<!DOCTYPE html>
<!--[if lt IE 7]> <html class="no-js ie6 oldie" lang="en-US"> <![endif]-->
<!--[if IE 7]> <html class="no-js ie7 oldie" lang="en-US"> <![endif]-->
<!--[if IE 8]> <html class="no-js ie8 oldie" lang="en-US"> <![endif]-->
<!--[if gt IE 8]><!--> <html class="no-js" lang="en-US"> <!--<![endif]-->
<head>
<title>Attention Required! | Cloudflare</title>
<meta charset="UTF-8" />
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=Edge" />
<meta name="robots" content="noindex, nofollow" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<link rel="stylesheet" id="cf_styles-css" href="/cdn-cgi/styles/cf.errors.css" />
<!--[if lt IE 9]><link rel="stylesheet" id='cf_styles-ie-css' href="/cdn-cgi/styles/cf.errors.ie.css" /><![endif]-->
<style>body{margin:0;padding:0}</style>
<!--[if gte IE 10]><!-->
<script>
if (!navigator.cookieEnabled) {
window.addEventListener('DOMContentLoaded', function () {
var cookieEl = document.getElementById('cookie-alert');
cookieEl.style.display = 'block';
})
}
</script>
<!--<![endif]-->
</head>
<body>
<div id="cf-wrapper">
<div class="cf-alert cf-alert-error cf-cookie-error" id="cookie-alert" data-translate="enable_cookies">Please enable cookies.</div>
<div id="cf-error-details" class="cf-error-details-wrapper">
<div class="cf-wrapper cf-header cf-error-overview">
<h1 data-translate="block_headline">Sorry, you have been blocked</h1>
<h2 class="cf-subheadline"><span data-translate="unable_to_access">You are unable to access</span> sso.garmin.com</h2>
</div><!-- /.header -->
<div class="cf-section cf-highlight">
<div class="cf-wrapper">
<div class="cf-screenshot-container cf-screenshot-full">
<span class="cf-no-screenshot error"></span>
</div>
</div>
</div><!-- /.captcha-container -->
<div class="cf-section cf-wrapper">
<div class="cf-columns two">
<div class="cf-column">
<h2 data-translate="blocked_why_headline">Why have I been blocked?</h2>
<p data-translate="blocked_why_detail">This website is using a security service to protect itself from online attacks. The action you just performed triggered the security solution. There are several actions that could trigger this block including submitting a certain word or phrase, a SQL command or malformed data.</p>
</div>
<div class="cf-column">
<h2 data-translate="blocked_resolve_headline">What can I do to resolve this?</h2>
<p data-translate="blocked_resolve_detail">You can email the site owner to let them know you were blocked. Please include what you were doing when this page came up and the Cloudflare Ray ID found at the bottom of this page.</p>
</div>
</div>
</div><!-- /.section -->
<div class="cf-error-footer cf-wrapper w-240 lg:w-full py-10 sm:py-4 sm:px-8 mx-auto text-center sm:text-left border-solid border-0 border-t border-gray-300">
<p class="text-13">
<span class="cf-footer-item sm:block sm:mb-1">Cloudflare Ray ID: <strong class="font-semibold">97a8823fcfbc0904</strong></span>
<span class="cf-footer-separator sm:hidden">&bull;</span>
<span id="cf-footer-item-ip" class="cf-footer-item hidden sm:block sm:mb-1">
Your IP:
<button type="button" id="cf-footer-ip-reveal" class="cf-footer-ip-reveal-btn">Click to reveal</button>
<span class="hidden" id="cf-footer-ip">47.150.230.21</span>
<span class="cf-footer-separator sm:hidden">&bull;</span>
</span>
<span class="cf-footer-item sm:block sm:mb-1"><span>Performance &amp; security by</span> <a rel="noopener noreferrer" href="https://www.cloudflare.com/5xx-error-landing" id="brand_link" target="_blank">Cloudflare</a></span>
</p>
<script>(function(){function d(){var b=a.getElementById("cf-footer-item-ip"),c=a.getElementById("cf-footer-ip-reveal");b&&"classList"in b&&(b.classList.remove("hidden"),c.addEventListener("click",function(){c.classList.add("hidden");a.getElementById("cf-footer-ip").classList.remove("hidden")}))}var a=document;document.addEventListener&&a.addEventListener("DOMContentLoaded",d)})();</script>
</div><!-- /.error-footer -->
</div><!-- /#cf-error-details -->
</div><!-- /#cf-wrapper -->
<script>
window._cf_translation = {};
</script>
<script>(function(){function c(){var b=a.contentDocument||a.contentWindow.document;if(b){var d=b.createElement('script');d.innerHTML="window.__CF$cv$params={r:'97a8823fcfbc0904',t:'MTc1NzEwNDQ4MS4wMDAwMDA='};var a=document.createElement('script');a.nonce='';a.src='/cdn-cgi/challenge-platform/scripts/jsd/main.js';document.getElementsByTagName('head')[0].appendChild(a);";b.getElementsByTagName('head')[0].appendChild(d)}}if(document.body){var a=document.createElement('iframe');a.height=1;a.width=1;a.style.position='absolute';a.style.top=0;a.style.left=0;a.style.border='none';a.style.visibility='hidden';document.body.appendChild(a);if('loading'!==document.readyState)c();else if(window.addEventListener)document.addEventListener('DOMContentLoaded',c);else{var e=document.onreadystatechange||function(){};document.onreadystatechange=function(b){e(b);'loading'!==document.readyState&&(document.onreadystatechange=e,c())}}}})();</script></body>
</html>

View File

@@ -1,93 +0,0 @@
<!DOCTYPE html>
<!--[if lt IE 7]> <html class="no-js ie6 oldie" lang="en-US"> <![endif]-->
<!--[if IE 7]> <html class="no-js ie7 oldie" lang="en-US"> <![endif]-->
<!--[if IE 8]> <html class="no-js ie8 oldie" lang="en-US"> <![endif]-->
<!--[if gt IE 8]><!--> <html class="no-js" lang="en-US"> <!--<![endif]-->
<head>
<title>Attention Required! | Cloudflare</title>
<meta charset="UTF-8" />
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=Edge" />
<meta name="robots" content="noindex, nofollow" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<link rel="stylesheet" id="cf_styles-css" href="/cdn-cgi/styles/cf.errors.css" />
<!--[if lt IE 9]><link rel="stylesheet" id='cf_styles-ie-css' href="/cdn-cgi/styles/cf.errors.ie.css" /><![endif]-->
<style>body{margin:0;padding:0}</style>
<!--[if gte IE 10]><!-->
<script>
if (!navigator.cookieEnabled) {
window.addEventListener('DOMContentLoaded', function () {
var cookieEl = document.getElementById('cookie-alert');
cookieEl.style.display = 'block';
})
}
</script>
<!--<![endif]-->
</head>
<body>
<div id="cf-wrapper">
<div class="cf-alert cf-alert-error cf-cookie-error" id="cookie-alert" data-translate="enable_cookies">Please enable cookies.</div>
<div id="cf-error-details" class="cf-error-details-wrapper">
<div class="cf-wrapper cf-header cf-error-overview">
<h1 data-translate="block_headline">Sorry, you have been blocked</h1>
<h2 class="cf-subheadline"><span data-translate="unable_to_access">You are unable to access</span> sso.garmin.com</h2>
</div><!-- /.header -->
<div class="cf-section cf-highlight">
<div class="cf-wrapper">
<div class="cf-screenshot-container cf-screenshot-full">
<span class="cf-no-screenshot error"></span>
</div>
</div>
</div><!-- /.captcha-container -->
<div class="cf-section cf-wrapper">
<div class="cf-columns two">
<div class="cf-column">
<h2 data-translate="blocked_why_headline">Why have I been blocked?</h2>
<p data-translate="blocked_why_detail">This website is using a security service to protect itself from online attacks. The action you just performed triggered the security solution. There are several actions that could trigger this block including submitting a certain word or phrase, a SQL command or malformed data.</p>
</div>
<div class="cf-column">
<h2 data-translate="blocked_resolve_headline">What can I do to resolve this?</h2>
<p data-translate="blocked_resolve_detail">You can email the site owner to let them know you were blocked. Please include what you were doing when this page came up and the Cloudflare Ray ID found at the bottom of this page.</p>
</div>
</div>
</div><!-- /.section -->
<div class="cf-error-footer cf-wrapper w-240 lg:w-full py-10 sm:py-4 sm:px-8 mx-auto text-center sm:text-left border-solid border-0 border-t border-gray-300">
<p class="text-13">
<span class="cf-footer-item sm:block sm:mb-1">Cloudflare Ray ID: <strong class="font-semibold">97a9b3b9dbc89dea</strong></span>
<span class="cf-footer-separator sm:hidden">&bull;</span>
<span id="cf-footer-item-ip" class="cf-footer-item hidden sm:block sm:mb-1">
Your IP:
<button type="button" id="cf-footer-ip-reveal" class="cf-footer-ip-reveal-btn">Click to reveal</button>
<span class="hidden" id="cf-footer-ip">47.150.230.21</span>
<span class="cf-footer-separator sm:hidden">&bull;</span>
</span>
<span class="cf-footer-item sm:block sm:mb-1"><span>Performance &amp; security by</span> <a rel="noopener noreferrer" href="https://www.cloudflare.com/5xx-error-landing" id="brand_link" target="_blank">Cloudflare</a></span>
</p>
<script>(function(){function d(){var b=a.getElementById("cf-footer-item-ip"),c=a.getElementById("cf-footer-ip-reveal");b&&"classList"in b&&(b.classList.remove("hidden"),c.addEventListener("click",function(){c.classList.add("hidden");a.getElementById("cf-footer-ip").classList.remove("hidden")}))}var a=document;document.addEventListener&&a.addEventListener("DOMContentLoaded",d)})();</script>
</div><!-- /.error-footer -->
</div><!-- /#cf-error-details -->
</div><!-- /#cf-wrapper -->
<script>
window._cf_translation = {};
</script>
<script>(function(){function c(){var b=a.contentDocument||a.contentWindow.document;if(b){var d=b.createElement('script');d.innerHTML="window.__CF$cv$params={r:'97a9b3b9dbc89dea',t:'MTc1NzExNjk5My4wMDAwMDA='};var a=document.createElement('script');a.nonce='';a.src='/cdn-cgi/challenge-platform/scripts/jsd/main.js';document.getElementsByTagName('head')[0].appendChild(a);";b.getElementsByTagName('head')[0].appendChild(d)}}if(document.body){var a=document.createElement('iframe');a.height=1;a.width=1;a.style.position='absolute';a.style.top=0;a.style.left=0;a.style.border='none';a.style.visibility='hidden';document.body.appendChild(a);if('loading'!==document.readyState)c();else if(window.addEventListener)document.addEventListener('DOMContentLoaded',c);else{var e=document.onreadystatechange||function(){};document.onreadystatechange=function(b){e(b);'loading'!==document.readyState&&(document.onreadystatechange=e,c())}}}})();</script></body>
</html>

View File

@@ -1,93 +0,0 @@
<!DOCTYPE html>
<!--[if lt IE 7]> <html class="no-js ie6 oldie" lang="en-US"> <![endif]-->
<!--[if IE 7]> <html class="no-js ie7 oldie" lang="en-US"> <![endif]-->
<!--[if IE 8]> <html class="no-js ie8 oldie" lang="en-US"> <![endif]-->
<!--[if gt IE 8]><!--> <html class="no-js" lang="en-US"> <!--<![endif]-->
<head>
<title>Attention Required! | Cloudflare</title>
<meta charset="UTF-8" />
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=Edge" />
<meta name="robots" content="noindex, nofollow" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<link rel="stylesheet" id="cf_styles-css" href="/cdn-cgi/styles/cf.errors.css" />
<!--[if lt IE 9]><link rel="stylesheet" id='cf_styles-ie-css' href="/cdn-cgi/styles/cf.errors.ie.css" /><![endif]-->
<style>body{margin:0;padding:0}</style>
<!--[if gte IE 10]><!-->
<script>
if (!navigator.cookieEnabled) {
window.addEventListener('DOMContentLoaded', function () {
var cookieEl = document.getElementById('cookie-alert');
cookieEl.style.display = 'block';
})
}
</script>
<!--<![endif]-->
</head>
<body>
<div id="cf-wrapper">
<div class="cf-alert cf-alert-error cf-cookie-error" id="cookie-alert" data-translate="enable_cookies">Please enable cookies.</div>
<div id="cf-error-details" class="cf-error-details-wrapper">
<div class="cf-wrapper cf-header cf-error-overview">
<h1 data-translate="block_headline">Sorry, you have been blocked</h1>
<h2 class="cf-subheadline"><span data-translate="unable_to_access">You are unable to access</span> sso.garmin.com</h2>
</div><!-- /.header -->
<div class="cf-section cf-highlight">
<div class="cf-wrapper">
<div class="cf-screenshot-container cf-screenshot-full">
<span class="cf-no-screenshot error"></span>
</div>
</div>
</div><!-- /.captcha-container -->
<div class="cf-section cf-wrapper">
<div class="cf-columns two">
<div class="cf-column">
<h2 data-translate="blocked_why_headline">Why have I been blocked?</h2>
<p data-translate="blocked_why_detail">This website is using a security service to protect itself from online attacks. The action you just performed triggered the security solution. There are several actions that could trigger this block including submitting a certain word or phrase, a SQL command or malformed data.</p>
</div>
<div class="cf-column">
<h2 data-translate="blocked_resolve_headline">What can I do to resolve this?</h2>
<p data-translate="blocked_resolve_detail">You can email the site owner to let them know you were blocked. Please include what you were doing when this page came up and the Cloudflare Ray ID found at the bottom of this page.</p>
</div>
</div>
</div><!-- /.section -->
<div class="cf-error-footer cf-wrapper w-240 lg:w-full py-10 sm:py-4 sm:px-8 mx-auto text-center sm:text-left border-solid border-0 border-t border-gray-300">
<p class="text-13">
<span class="cf-footer-item sm:block sm:mb-1">Cloudflare Ray ID: <strong class="font-semibold">97a9b677e98ff8d5</strong></span>
<span class="cf-footer-separator sm:hidden">&bull;</span>
<span id="cf-footer-item-ip" class="cf-footer-item hidden sm:block sm:mb-1">
Your IP:
<button type="button" id="cf-footer-ip-reveal" class="cf-footer-ip-reveal-btn">Click to reveal</button>
<span class="hidden" id="cf-footer-ip">138.199.43.80</span>
<span class="cf-footer-separator sm:hidden">&bull;</span>
</span>
<span class="cf-footer-item sm:block sm:mb-1"><span>Performance &amp; security by</span> <a rel="noopener noreferrer" href="https://www.cloudflare.com/5xx-error-landing" id="brand_link" target="_blank">Cloudflare</a></span>
</p>
<script>(function(){function d(){var b=a.getElementById("cf-footer-item-ip"),c=a.getElementById("cf-footer-ip-reveal");b&&"classList"in b&&(b.classList.remove("hidden"),c.addEventListener("click",function(){c.classList.add("hidden");a.getElementById("cf-footer-ip").classList.remove("hidden")}))}var a=document;document.addEventListener&&a.addEventListener("DOMContentLoaded",d)})();</script>
</div><!-- /.error-footer -->
</div><!-- /#cf-error-details -->
</div><!-- /#cf-wrapper -->
<script>
window._cf_translation = {};
</script>
<script>(function(){function c(){var b=a.contentDocument||a.contentWindow.document;if(b){var d=b.createElement('script');d.innerHTML="window.__CF$cv$params={r:'97a9b677e98ff8d5',t:'MTc1NzExNzEwNS4wMDAwMDA='};var a=document.createElement('script');a.nonce='';a.src='/cdn-cgi/challenge-platform/scripts/jsd/main.js';document.getElementsByTagName('head')[0].appendChild(a);";b.getElementsByTagName('head')[0].appendChild(d)}}if(document.body){var a=document.createElement('iframe');a.height=1;a.width=1;a.style.position='absolute';a.style.top=0;a.style.left=0;a.style.border='none';a.style.visibility='hidden';document.body.appendChild(a);if('loading'!==document.readyState)c();else if(window.addEventListener)document.addEventListener('DOMContentLoaded',c);else{var e=document.onreadystatechange||function(){};document.onreadystatechange=function(b){e(b);'loading'!==document.readyState&&(document.onreadystatechange=e,c())}}}})();</script></body>
</html>

View File

@@ -1,93 +0,0 @@
<!DOCTYPE html>
<!--[if lt IE 7]> <html class="no-js ie6 oldie" lang="en-US"> <![endif]-->
<!--[if IE 7]> <html class="no-js ie7 oldie" lang="en-US"> <![endif]-->
<!--[if IE 8]> <html class="no-js ie8 oldie" lang="en-US"> <![endif]-->
<!--[if gt IE 8]><!--> <html class="no-js" lang="en-US"> <!--<![endif]-->
<head>
<title>Attention Required! | Cloudflare</title>
<meta charset="UTF-8" />
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=Edge" />
<meta name="robots" content="noindex, nofollow" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<link rel="stylesheet" id="cf_styles-css" href="/cdn-cgi/styles/cf.errors.css" />
<!--[if lt IE 9]><link rel="stylesheet" id='cf_styles-ie-css' href="/cdn-cgi/styles/cf.errors.ie.css" /><![endif]-->
<style>body{margin:0;padding:0}</style>
<!--[if gte IE 10]><!-->
<script>
if (!navigator.cookieEnabled) {
window.addEventListener('DOMContentLoaded', function () {
var cookieEl = document.getElementById('cookie-alert');
cookieEl.style.display = 'block';
})
}
</script>
<!--<![endif]-->
</head>
<body>
<div id="cf-wrapper">
<div class="cf-alert cf-alert-error cf-cookie-error" id="cookie-alert" data-translate="enable_cookies">Please enable cookies.</div>
<div id="cf-error-details" class="cf-error-details-wrapper">
<div class="cf-wrapper cf-header cf-error-overview">
<h1 data-translate="block_headline">Sorry, you have been blocked</h1>
<h2 class="cf-subheadline"><span data-translate="unable_to_access">You are unable to access</span> sso.garmin.com</h2>
</div><!-- /.header -->
<div class="cf-section cf-highlight">
<div class="cf-wrapper">
<div class="cf-screenshot-container cf-screenshot-full">
<span class="cf-no-screenshot error"></span>
</div>
</div>
</div><!-- /.captcha-container -->
<div class="cf-section cf-wrapper">
<div class="cf-columns two">
<div class="cf-column">
<h2 data-translate="blocked_why_headline">Why have I been blocked?</h2>
<p data-translate="blocked_why_detail">This website is using a security service to protect itself from online attacks. The action you just performed triggered the security solution. There are several actions that could trigger this block including submitting a certain word or phrase, a SQL command or malformed data.</p>
</div>
<div class="cf-column">
<h2 data-translate="blocked_resolve_headline">What can I do to resolve this?</h2>
<p data-translate="blocked_resolve_detail">You can email the site owner to let them know you were blocked. Please include what you were doing when this page came up and the Cloudflare Ray ID found at the bottom of this page.</p>
</div>
</div>
</div><!-- /.section -->
<div class="cf-error-footer cf-wrapper w-240 lg:w-full py-10 sm:py-4 sm:px-8 mx-auto text-center sm:text-left border-solid border-0 border-t border-gray-300">
<p class="text-13">
<span class="cf-footer-item sm:block sm:mb-1">Cloudflare Ray ID: <strong class="font-semibold">97a9b977caffdbc2</strong></span>
<span class="cf-footer-separator sm:hidden">&bull;</span>
<span id="cf-footer-item-ip" class="cf-footer-item hidden sm:block sm:mb-1">
Your IP:
<button type="button" id="cf-footer-ip-reveal" class="cf-footer-ip-reveal-btn">Click to reveal</button>
<span class="hidden" id="cf-footer-ip">47.150.230.21</span>
<span class="cf-footer-separator sm:hidden">&bull;</span>
</span>
<span class="cf-footer-item sm:block sm:mb-1"><span>Performance &amp; security by</span> <a rel="noopener noreferrer" href="https://www.cloudflare.com/5xx-error-landing" id="brand_link" target="_blank">Cloudflare</a></span>
</p>
<script>(function(){function d(){var b=a.getElementById("cf-footer-item-ip"),c=a.getElementById("cf-footer-ip-reveal");b&&"classList"in b&&(b.classList.remove("hidden"),c.addEventListener("click",function(){c.classList.add("hidden");a.getElementById("cf-footer-ip").classList.remove("hidden")}))}var a=document;document.addEventListener&&a.addEventListener("DOMContentLoaded",d)})();</script>
</div><!-- /.error-footer -->
</div><!-- /#cf-error-details -->
</div><!-- /#cf-wrapper -->
<script>
window._cf_translation = {};
</script>
<script>(function(){function c(){var b=a.contentDocument||a.contentWindow.document;if(b){var d=b.createElement('script');d.innerHTML="window.__CF$cv$params={r:'97a9b977caffdbc2',t:'MTc1NzExNzIyOC4wMDAwMDA='};var a=document.createElement('script');a.nonce='';a.src='/cdn-cgi/challenge-platform/scripts/jsd/main.js';document.getElementsByTagName('head')[0].appendChild(a);";b.getElementsByTagName('head')[0].appendChild(d)}}if(document.body){var a=document.createElement('iframe');a.height=1;a.width=1;a.style.position='absolute';a.style.top=0;a.style.left=0;a.style.border='none';a.style.visibility='hidden';document.body.appendChild(a);if('loading'!==document.readyState)c();else if(window.addEventListener)document.addEventListener('DOMContentLoaded',c);else{var e=document.onreadystatechange||function(){};document.onreadystatechange=function(b){e(b);'loading'!==document.readyState&&(document.onreadystatechange=e,c())}}}})();</script></body>
</html>

View File

@@ -1,93 +0,0 @@
<!DOCTYPE html>
<!--[if lt IE 7]> <html class="no-js ie6 oldie" lang="en-US"> <![endif]-->
<!--[if IE 7]> <html class="no-js ie7 oldie" lang="en-US"> <![endif]-->
<!--[if IE 8]> <html class="no-js ie8 oldie" lang="en-US"> <![endif]-->
<!--[if gt IE 8]><!--> <html class="no-js" lang="en-US"> <!--<![endif]-->
<head>
<title>Attention Required! | Cloudflare</title>
<meta charset="UTF-8" />
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=Edge" />
<meta name="robots" content="noindex, nofollow" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<link rel="stylesheet" id="cf_styles-css" href="/cdn-cgi/styles/cf.errors.css" />
<!--[if lt IE 9]><link rel="stylesheet" id='cf_styles-ie-css' href="/cdn-cgi/styles/cf.errors.ie.css" /><![endif]-->
<style>body{margin:0;padding:0}</style>
<!--[if gte IE 10]><!-->
<script>
if (!navigator.cookieEnabled) {
window.addEventListener('DOMContentLoaded', function () {
var cookieEl = document.getElementById('cookie-alert');
cookieEl.style.display = 'block';
})
}
</script>
<!--<![endif]-->
</head>
<body>
<div id="cf-wrapper">
<div class="cf-alert cf-alert-error cf-cookie-error" id="cookie-alert" data-translate="enable_cookies">Please enable cookies.</div>
<div id="cf-error-details" class="cf-error-details-wrapper">
<div class="cf-wrapper cf-header cf-error-overview">
<h1 data-translate="block_headline">Sorry, you have been blocked</h1>
<h2 class="cf-subheadline"><span data-translate="unable_to_access">You are unable to access</span> sso.garmin.com</h2>
</div><!-- /.header -->
<div class="cf-section cf-highlight">
<div class="cf-wrapper">
<div class="cf-screenshot-container cf-screenshot-full">
<span class="cf-no-screenshot error"></span>
</div>
</div>
</div><!-- /.captcha-container -->
<div class="cf-section cf-wrapper">
<div class="cf-columns two">
<div class="cf-column">
<h2 data-translate="blocked_why_headline">Why have I been blocked?</h2>
<p data-translate="blocked_why_detail">This website is using a security service to protect itself from online attacks. The action you just performed triggered the security solution. There are several actions that could trigger this block including submitting a certain word or phrase, a SQL command or malformed data.</p>
</div>
<div class="cf-column">
<h2 data-translate="blocked_resolve_headline">What can I do to resolve this?</h2>
<p data-translate="blocked_resolve_detail">You can email the site owner to let them know you were blocked. Please include what you were doing when this page came up and the Cloudflare Ray ID found at the bottom of this page.</p>
</div>
</div>
</div><!-- /.section -->
<div class="cf-error-footer cf-wrapper w-240 lg:w-full py-10 sm:py-4 sm:px-8 mx-auto text-center sm:text-left border-solid border-0 border-t border-gray-300">
<p class="text-13">
<span class="cf-footer-item sm:block sm:mb-1">Cloudflare Ray ID: <strong class="font-semibold">97a9c7bc4ed7ff84</strong></span>
<span class="cf-footer-separator sm:hidden">&bull;</span>
<span id="cf-footer-item-ip" class="cf-footer-item hidden sm:block sm:mb-1">
Your IP:
<button type="button" id="cf-footer-ip-reveal" class="cf-footer-ip-reveal-btn">Click to reveal</button>
<span class="hidden" id="cf-footer-ip">47.150.230.21</span>
<span class="cf-footer-separator sm:hidden">&bull;</span>
</span>
<span class="cf-footer-item sm:block sm:mb-1"><span>Performance &amp; security by</span> <a rel="noopener noreferrer" href="https://www.cloudflare.com/5xx-error-landing" id="brand_link" target="_blank">Cloudflare</a></span>
</p>
<script>(function(){function d(){var b=a.getElementById("cf-footer-item-ip"),c=a.getElementById("cf-footer-ip-reveal");b&&"classList"in b&&(b.classList.remove("hidden"),c.addEventListener("click",function(){c.classList.add("hidden");a.getElementById("cf-footer-ip").classList.remove("hidden")}))}var a=document;document.addEventListener&&a.addEventListener("DOMContentLoaded",d)})();</script>
</div><!-- /.error-footer -->
</div><!-- /#cf-error-details -->
</div><!-- /#cf-wrapper -->
<script>
window._cf_translation = {};
</script>
<script>(function(){function c(){var b=a.contentDocument||a.contentWindow.document;if(b){var d=b.createElement('script');d.innerHTML="window.__CF$cv$params={r:'97a9c7bc4ed7ff84',t:'MTc1NzExNzgxMy4wMDAwMDA='};var a=document.createElement('script');a.nonce='';a.src='/cdn-cgi/challenge-platform/scripts/jsd/main.js';document.getElementsByTagName('head')[0].appendChild(a);";b.getElementsByTagName('head')[0].appendChild(d)}}if(document.body){var a=document.createElement('iframe');a.height=1;a.width=1;a.style.position='absolute';a.style.top=0;a.style.left=0;a.style.border='none';a.style.visibility='hidden';document.body.appendChild(a);if('loading'!==document.readyState)c();else if(window.addEventListener)document.addEventListener('DOMContentLoaded',c);else{var e=document.onreadystatechange||function(){};document.onreadystatechange=function(b){e(b);'loading'!==document.readyState&&(document.onreadystatechange=e,c())}}}})();</script></body>
</html>

View File

@@ -1,93 +0,0 @@
<!DOCTYPE html>
<!--[if lt IE 7]> <html class="no-js ie6 oldie" lang="en-US"> <![endif]-->
<!--[if IE 7]> <html class="no-js ie7 oldie" lang="en-US"> <![endif]-->
<!--[if IE 8]> <html class="no-js ie8 oldie" lang="en-US"> <![endif]-->
<!--[if gt IE 8]><!--> <html class="no-js" lang="en-US"> <!--<![endif]-->
<head>
<title>Attention Required! | Cloudflare</title>
<meta charset="UTF-8" />
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=Edge" />
<meta name="robots" content="noindex, nofollow" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<link rel="stylesheet" id="cf_styles-css" href="/cdn-cgi/styles/cf.errors.css" />
<!--[if lt IE 9]><link rel="stylesheet" id='cf_styles-ie-css' href="/cdn-cgi/styles/cf.errors.ie.css" /><![endif]-->
<style>body{margin:0;padding:0}</style>
<!--[if gte IE 10]><!-->
<script>
if (!navigator.cookieEnabled) {
window.addEventListener('DOMContentLoaded', function () {
var cookieEl = document.getElementById('cookie-alert');
cookieEl.style.display = 'block';
})
}
</script>
<!--<![endif]-->
</head>
<body>
<div id="cf-wrapper">
<div class="cf-alert cf-alert-error cf-cookie-error" id="cookie-alert" data-translate="enable_cookies">Please enable cookies.</div>
<div id="cf-error-details" class="cf-error-details-wrapper">
<div class="cf-wrapper cf-header cf-error-overview">
<h1 data-translate="block_headline">Sorry, you have been blocked</h1>
<h2 class="cf-subheadline"><span data-translate="unable_to_access">You are unable to access</span> sso.garmin.com</h2>
</div><!-- /.header -->
<div class="cf-section cf-highlight">
<div class="cf-wrapper">
<div class="cf-screenshot-container cf-screenshot-full">
<span class="cf-no-screenshot error"></span>
</div>
</div>
</div><!-- /.captcha-container -->
<div class="cf-section cf-wrapper">
<div class="cf-columns two">
<div class="cf-column">
<h2 data-translate="blocked_why_headline">Why have I been blocked?</h2>
<p data-translate="blocked_why_detail">This website is using a security service to protect itself from online attacks. The action you just performed triggered the security solution. There are several actions that could trigger this block including submitting a certain word or phrase, a SQL command or malformed data.</p>
</div>
<div class="cf-column">
<h2 data-translate="blocked_resolve_headline">What can I do to resolve this?</h2>
<p data-translate="blocked_resolve_detail">You can email the site owner to let them know you were blocked. Please include what you were doing when this page came up and the Cloudflare Ray ID found at the bottom of this page.</p>
</div>
</div>
</div><!-- /.section -->
<div class="cf-error-footer cf-wrapper w-240 lg:w-full py-10 sm:py-4 sm:px-8 mx-auto text-center sm:text-left border-solid border-0 border-t border-gray-300">
<p class="text-13">
<span class="cf-footer-item sm:block sm:mb-1">Cloudflare Ray ID: <strong class="font-semibold">97a9f0c2588a72e0</strong></span>
<span class="cf-footer-separator sm:hidden">&bull;</span>
<span id="cf-footer-item-ip" class="cf-footer-item hidden sm:block sm:mb-1">
Your IP:
<button type="button" id="cf-footer-ip-reveal" class="cf-footer-ip-reveal-btn">Click to reveal</button>
<span class="hidden" id="cf-footer-ip">47.150.230.21</span>
<span class="cf-footer-separator sm:hidden">&bull;</span>
</span>
<span class="cf-footer-item sm:block sm:mb-1"><span>Performance &amp; security by</span> <a rel="noopener noreferrer" href="https://www.cloudflare.com/5xx-error-landing" id="brand_link" target="_blank">Cloudflare</a></span>
</p>
<script>(function(){function d(){var b=a.getElementById("cf-footer-item-ip"),c=a.getElementById("cf-footer-ip-reveal");b&&"classList"in b&&(b.classList.remove("hidden"),c.addEventListener("click",function(){c.classList.add("hidden");a.getElementById("cf-footer-ip").classList.remove("hidden")}))}var a=document;document.addEventListener&&a.addEventListener("DOMContentLoaded",d)})();</script>
</div><!-- /.error-footer -->
</div><!-- /#cf-error-details -->
</div><!-- /#cf-wrapper -->
<script>
window._cf_translation = {};
</script>
<script>(function(){function c(){var b=a.contentDocument||a.contentWindow.document;if(b){var d=b.createElement('script');d.innerHTML="window.__CF$cv$params={r:'97a9f0c2588a72e0',t:'MTc1NzExOTQ5My4wMDAwMDA='};var a=document.createElement('script');a.nonce='';a.src='/cdn-cgi/challenge-platform/scripts/jsd/main.js';document.getElementsByTagName('head')[0].appendChild(a);";b.getElementsByTagName('head')[0].appendChild(d)}}if(document.body){var a=document.createElement('iframe');a.height=1;a.width=1;a.style.position='absolute';a.style.top=0;a.style.left=0;a.style.border='none';a.style.visibility='hidden';document.body.appendChild(a);if('loading'!==document.readyState)c();else if(window.addEventListener)document.addEventListener('DOMContentLoaded',c);else{var e=document.onreadystatechange||function(){};document.onreadystatechange=function(b){e(b);'loading'!==document.readyState&&(document.onreadystatechange=e,c())}}}})();</script></body>
</html>

Binary file not shown.

View File

@@ -1,206 +0,0 @@
<!DOCTYPE html>
<html lang="en" class="no-js">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<meta name="viewport" content="width=device-width" />
<meta http-equiv="X-UA-Compatible" content="IE=edge;" />
<title>GARMIN Authentication Application</title>
<link href="/sso/css/GAuth.css?20210406" rel="stylesheet" type="text/css" media="all" />
<link rel="stylesheet" href=""/>
<script type="text/javascript" src="/sso/js/jquery/3.7.1/jquery.min.js?20210319"></script>
<script type="text/javascript">jQuery.noConflict();</script>
<script type="text/javascript" src="/sso/js/jquery-validate/1.16.0/jquery.validate.min.js?20210319"></script>
<script type="text/javascript" src="/sso/js/jsUtils.js?20210406"></script>
<script type="text/javascript" src="/sso/js/json2.js"></script>
<script type="text/javascript" src="/sso/js/consoleUtils.js?20210319"></script>
<script type="text/javascript" src="/sso/js/postmessage.js?20210319"></script>
<script type="text/javascript" src="/sso/js/popupWindow.js"></script>
<script type="text/javascript" src="/sso/js/base.js?20231020"></script>
<script type="text/javascript" src="/sso/js/gigyaUtils.js?20210319"></script>
<script type="text/javascript" src="/sso/js/login.js?20211102"></script>
<script type="text/javascript" src="/sso/js/reCaptchaUtil.js?20230706"></script>
<script>
var recaptchaSiteKey = null;
var reCaptchaURL = "\\\/sso\\\/reCaptcha?clientId=GarminConnect\u0026consumeServiceTicket=false\u0026embedWidget=true\u0026gauthHost=https%3A%2F%2Fsso.garmin.com%2Fsso\u0026generateExtraServiceTicket=true\u0026id=gauth-widget\u0026locale=en_US\u0026redirectAfterAccountCreationUrl=https%3A%2F%2Fconnect.garmin.com%2FoauthConfirm\u0026redirectAfterAccountLoginUrl=https%3A%2F%2Fconnect.garmin.com%2FoauthConfirm\u0026service=https%3A%2F%2Fconnect.garmin.com%2FoauthConfirm\u0026source=https%3A%2F%2Fsso.garmin.com%2Fsso";
var isRecaptchaEnabled = null;
var recaptchaToken = null;
</script>
<script type="text/javascript">
var parent_url = "https:\/\/sso.garmin.com\/sso";
var status = "";
var result = "";
var clientId = 'GarminConnect';
var embedWidget = true;
var isUsernameDefined = (false == true) || (false == true);
// Gigya callback to SocialSignInController for brand new social network users redirects to this page
// to popup Create or Link Social Account page, but has a possibly mangled source parameter
// where "?" is set as "<QM>", so translate it back to "?" here.
parent_url = parent_url.replace('<QM>', '?');
var parent_scheme = parent_url.substring(0, parent_url.indexOf("://"));
var parent_hostname = parent_url.substring(parent_scheme.length + 3, parent_url.length);
if (parent_hostname.indexOf("/") != -1) {
parent_hostname = parent_hostname.substring(0, parent_hostname.indexOf("/"));
}
var parentHost = parent_scheme + "://" + parent_hostname;
var createAccountConfigURL = '\/sso\/createNewAccount?clientId%3DGarminConnect%26consumeServiceTicket%3Dfalse%26embedWidget%3Dtrue%26gauthHost%3Dhttps%253A%252F%252Fsso.garmin.com%252Fsso%26generateExtraServiceTicket%3Dtrue%26id%3Dgauth-widget%26locale%3Den_US%26redirectAfterAccountCreationUrl%3Dhttps%253A%252F%252Fconnect.garmin.com%252FoauthConfirm%26redirectAfterAccountLoginUrl%3Dhttps%253A%252F%252Fconnect.garmin.com%252FoauthConfirm%26service%3Dhttps%253A%252F%252Fconnect.garmin.com%252FoauthConfirm%26source%3Dhttps%253A%252F%252Fsso.garmin.com%252Fsso';
var socialConfigURL = 'https://sso.garmin.com/sso/socialSignIn?clientId%3DGarminConnect%26consumeServiceTicket%3Dfalse%26embedWidget%3Dtrue%26gauthHost%3Dhttps%3A%2F%2Fsso.garmin.com%2Fsso%26generateExtraServiceTicket%3Dtrue%26id%3Dgauth-widget%26locale%3Den_US%26redirectAfterAccountCreationUrl%3Dhttps%3A%2F%2Fconnect.garmin.com%2FoauthConfirm%26redirectAfterAccountLoginUrl%3Dhttps%3A%2F%2Fconnect.garmin.com%2FoauthConfirm%26service%3Dhttps%3A%2F%2Fconnect.garmin.com%2FoauthConfirm%26source%3Dhttps%3A%2F%2Fsso.garmin.com%2Fsso';
var gigyaURL = "https://cdns.gigya.com/js/gigya.js?apiKey=2_R3ZGY8Bqlwwk3_63knoD9wA_m-Y19mAgW61bF_s5k9gymYnMEAtMrJiF5MjF-U7B";
if (createAccountConfigURL.indexOf('%253A%252F%252F') != -1) {
createAccountConfigURL = decodeURIComponent(createAccountConfigURL);
}
consoleInfo('signin.html embedWidget: true, createAccountConfigURL: \/sso\/createNewAccount?clientId%3DGarminConnect%26consumeServiceTicket%3Dfalse%26embedWidget%3Dtrue%26gauthHost%3Dhttps%253A%252F%252Fsso.garmin.com%252Fsso%26generateExtraServiceTicket%3Dtrue%26id%3Dgauth-widget%26locale%3Den_US%26redirectAfterAccountCreationUrl%3Dhttps%253A%252F%252Fconnect.garmin.com%252FoauthConfirm%26redirectAfterAccountLoginUrl%3Dhttps%253A%252F%252Fconnect.garmin.com%252FoauthConfirm%26service%3Dhttps%253A%252F%252Fconnect.garmin.com%252FoauthConfirm%26source%3Dhttps%253A%252F%252Fsso.garmin.com%252Fsso, socialEnabled: true, gigyaSupported: true, socialConfigURL(): https://sso.garmin.com/sso/socialSignIn?clientId%3DGarminConnect%26consumeServiceTicket%3Dfalse%26embedWidget%3Dtrue%26gauthHost%3Dhttps%3A%2F%2Fsso.garmin.com%2Fsso%26generateExtraServiceTicket%3Dtrue%26id%3Dgauth-widget%26locale%3Den_US%26redirectAfterAccountCreationUrl%3Dhttps%3A%2F%2Fconnect.garmin.com%2FoauthConfirm%26redirectAfterAccountLoginUrl%3Dhttps%3A%2F%2Fconnect.garmin.com%2FoauthConfirm%26service%3Dhttps%3A%2F%2Fconnect.garmin.com%2FoauthConfirm%26source%3Dhttps%3A%2F%2Fsso.garmin.com%2Fsso');
if (socialConfigURL.indexOf('%3A%2F%2F') != -1) {
socialConfigURL = decodeURIComponent(socialConfigURL);
}
if( status != null && status != ''){
send({'status':status});
}
jQuery(document).ready( function(){
consoleInfo("signin.html: setting field validation rules...");
jQuery("#username").rules("add",{
required: true,
messages: {
required: "Email is required."
}});
jQuery("#password").rules("add", {
required: true,
messages: {
required: "Password is required."
}
});
consoleInfo("signin.html: done setting field validation rules...");
});
XD.receiveMessage(function(m){
consoleInfo("signin.html: " + m.data + " received on " + window.location.host);
if (m && m.data) {
var md = m.data;
if (typeof(md) === 'string') {
md = JSON.parse(m.data);
}
if (md.setUsername) {
consoleInfo("signin.html: Setting username \"" + md.username + "\"...");
jQuery("#signInWithDiffLink").click(); // Ensure the normal login form is shown.
jQuery("#username").val(md.username);
jQuery("#password").focus();
}
}
}, parentHost);
</script>
</head>
<body>
<!-- begin GAuth component -->
<div id="GAuth-component">
<!-- begin login component-->
<div id="login-component" class="blueForm-basic">
<input type="hidden" id="queryString" value="clientId=GarminConnect&amp;consumeServiceTicket=false&amp;embedWidget=true&amp;gauthHost=https%3A%2F%2Fsso.garmin.com%2Fsso&amp;generateExtraServiceTicket=true&amp;id=gauth-widget&amp;locale=en_US&amp;redirectAfterAccountCreationUrl=https%3A%2F%2Fconnect.garmin.com%2FoauthConfirm&amp;redirectAfterAccountLoginUrl=https%3A%2F%2Fconnect.garmin.com%2FoauthConfirm&amp;service=https%3A%2F%2Fconnect.garmin.com%2FoauthConfirm&amp;source=https%3A%2F%2Fsso.garmin.com%2Fsso" />
<input type="hidden" id="contextPath" value="/sso" />
<!-- begin login form -->
<div id="login-state-default">
<h2>Sign In</h2>
<form method="post" id="login-form">
<div class="form-alert">
<div id="username-error" style="display:none;"></div>
<div id="password-error" style="display:none;"></div>
</div>
<div class="textfield">
<label for="username">Email</label>
<!-- If the lockToEmailAddress parameter is specified then we want to mark the field as readonly,
preload the email address, and disable the other input so that null isn't sent to the server. We'll
also style the field to have a darker grey background and disable the mouse pointer
-->
<!-- If the lockToEmailAddress parameter is NOT specified then keep the existing functionality and disable the readonly input field
-->
<input class="login_email" name="username" id="username" value="" type="email" spellcheck="false" autocorrect="off" autocapitalize="off"/>
</div>
<div class="textfield">
<label for="password">Password</label>
<a id="loginforgotpassword" class="login-forgot-password" style="cursor:pointer">(Forgot?)</a>
<input type="password" name="password" id="password" spellcheck="false" autocorrect="off" autocapitalize="off" />
<strong id="capslock-warning" class="information" title="Caps lock is on." style="display: none;">Caps lock is on.</strong>
</div>
<input type="hidden" name="embed" value="true"/>
<input type="hidden" name="_csrf" value="E40E9FF5C027499972C9CF65024778906C9844597CEA466BA7F12FD111B57968050F45514DAA93282D2DF3FB562B20866BD6" />
<button type="submit" id="login-btn-signin" class="btn1" accesskey="l">Sign In</button>
<!-- The existence of the "rememberme" parameter at all will remember the user! -->
</form>
</div>
<!-- end login form -->
<!-- begin Create Account message -->
<div id="login-create-account">
</div>
<!-- end Create Account message -->
<!-- begin Social Sign In component -->
<div id="SSI-component">
</div>
<!-- end Social Sign In component -->
<div class="clearfix"></div> <!-- Ensure that GAuth-component div's height is computed correctly. -->
</div>
<!-- end login component-->
</div>
<!-- end GAuth component -->
<script type="text/javascript">
jQuery(document).ready(function(){
resizePageOnLoad(jQuery("#GAuth-component").height());
if(isUsernameDefined == true){
// If the user's login just failed, redisplay the email/username specified, and focus them in the password field.
jQuery("#password").focus();
} else if(false == true && result != "PASSWORD_RESET_RESULT"){
// Otherwise focus them in the username field of the login dialog.
jQuery("#username").focus();
}
// Scroll to top of iframe to fix problem where Firefox 3.0-3.6 browsers initially show top of iframe cutoff.
location.href="#";
if(!embedWidget){
jQuery('.createAccountLink').click(function(){
send({'openLiteBox':'createAccountLink', 'popupUrl': createAccountConfigURL, 'popupTitle':'Create An Account', 'clientId':clientId});
});
}
});
</script>
<script>(function(){function c(){var b=a.contentDocument||a.contentWindow.document;if(b){var d=b.createElement('script');d.innerHTML="window.__CF$cv$params={r:'97a8141f193919db',t:'MTc1NzA5OTk3MC4wMDAwMDA='};var a=document.createElement('script');a.nonce='';a.src='/cdn-cgi/challenge-platform/scripts/jsd/main.js';document.getElementsByTagName('head')[0].appendChild(a);";b.getElementsByTagName('head')[0].appendChild(d)}}if(document.body){var a=document.createElement('iframe');a.height=1;a.width=1;a.style.position='absolute';a.style.top=0;a.style.left=0;a.style.border='none';a.style.visibility='hidden';document.body.appendChild(a);if('loading'!==document.readyState)c();else if(window.addEventListener)document.addEventListener('DOMContentLoaded',c);else{var e=document.onreadystatechange||function(){};document.onreadystatechange=function(b){e(b);'loading'!==document.readyState&&(document.onreadystatechange=e,c())}}}})();</script></body>
</html>

View File

@@ -1,82 +0,0 @@
package garth
import (
"net/http"
"net/url"
)
// RequestError captures request context for enhanced error reporting
type RequestError struct {
Method string
URL string
Query url.Values
StatusCode int
Cause error
}
// NewRequestError creates a new RequestError instance
func NewRequestError(method, url string, query url.Values, statusCode int, cause error) *RequestError {
return &RequestError{
Method: method,
URL: url,
Query: query,
StatusCode: statusCode,
Cause: cause,
}
}
// Error implements the error interface for RequestError
func (e *RequestError) Error() string {
return e.Cause.Error()
}
// GetStatusCode returns the HTTP status code
func (e *RequestError) GetStatusCode() int {
return e.StatusCode
}
// GetType returns the error category
func (e *RequestError) GetType() string {
return "request_error"
}
// GetCause returns the underlying error
func (e *RequestError) GetCause() error {
return e.Cause
}
// Unwrap returns the underlying error
func (e *RequestError) Unwrap() error {
return e.Cause
}
// RequestContext returns the request context details
func (e *RequestError) RequestContext() (method, url string, query url.Values) {
return e.Method, e.URL, e.Query
}
// Helper functions for common error checks
// IsNotFoundError checks if an error is a not found error
func IsNotFoundError(err error) bool {
if e, ok := err.(Error); ok {
return e.GetStatusCode() == http.StatusNotFound
}
return false
}
// IsAuthenticationError checks if an error is an authentication error
func IsAuthenticationError(err error) bool {
if e, ok := err.(Error); ok {
return e.GetStatusCode() == http.StatusUnauthorized
}
return false
}
// IsRateLimitError checks if an error is a rate limit error
func IsRateLimitError(err error) bool {
if e, ok := err.(Error); ok {
return e.GetStatusCode() == http.StatusTooManyRequests
}
return false
}

View File

@@ -1,42 +0,0 @@
# Environment variables
.env
.env.local
.env.production
# OS files
.DS_Store
Thumbs.db
# IDE files
.vscode/
.idea/
*.swp
*.swo
# Build artifacts
*.exe
*.exe~
*.dll
*.so
*.dylib
# Test binary, built with `go test -c`
*.test
# Output of the go coverage tool
*.out
# Go workspace file
go.work
go.work.sum
# Local data files
data/
*.db
*.sqlite
*.sqlite3
# Sensitive files
*.key
*.pem
config/secrets.json

View File

@@ -1,113 +0,0 @@
# Garmin Connect Activity Examples
This directory contains examples demonstrating how to use the go-garth library to interact with Garmin Connect activities.
## Prerequisites
1. A Garmin Connect account
2. Your Garmin Connect username and password
3. Go 1.22 or later
## Setup
1. Create a `.env` file in this directory with your credentials:
```
GARMIN_USERNAME=your_username
GARMIN_PASSWORD=your_password
```
2. Install dependencies:
```bash
go mod tidy
```
## Examples
### Basic Activity Listing (`activities_example.go`)
This example demonstrates basic authentication and activity listing functionality:
- Authenticates with Garmin Connect
- Lists recent activities
- Shows basic activity information
Run with:
```bash
go run activities_example.go
```
### Enhanced Activity Listing (`enhanced_example.go`)
This example provides more comprehensive activity querying capabilities:
- Lists recent activities
- Filters activities by date range
- Filters activities by activity type (e.g., running, cycling)
- Searches activities by name
- Retrieves detailed activity information including:
- Elevation data
- Heart rate metrics
- Speed metrics
- Step counts
Run with:
```bash
go run enhanced_example.go
```
## Activity Types
Common activity types you can filter by:
- `running`
- `cycling`
- `walking`
- `swimming`
- `strength_training`
- `hiking`
- `yoga`
## Output Examples
### Basic Listing
```
=== RECENT ACTIVITIES ===
Recent Activities (5 total):
==================================================
1. Morning Run [running]
Date: 2024-01-15 07:30
Distance: 5.20 km
Duration: 28m
Calories: 320
2. Evening Ride [cycling]
Date: 2024-01-14 18:00
Distance: 15.50 km
Duration: 45m
Calories: 280
```
### Detailed Activity Information
```
=== DETAILS FOR ACTIVITY: Morning Run ===
Activity ID: 123456789
Name: Morning Run
Type: running
Description: Easy morning run in the park
Start Time: 2024-01-15 07:30:00
Distance: 5.20 km
Duration: 28m
Calories: 320
Elevation Gain: 45 m
Elevation Loss: 42 m
Max Heart Rate: 165 bpm
Avg Heart Rate: 142 bpm
Max Speed: 12.5 km/h
Avg Speed: 11.1 km/h
Steps: 5200
```
## Security Notes
- Never commit your `.env` file to version control
- The `.env` file is already included in `.gitignore`
- Consider using environment variables directly in production instead of `.env` files

View File

@@ -1,89 +0,0 @@
package main
import (
"context"
"fmt"
"log"
"net/http"
"os"
"time"
"github.com/joho/godotenv"
"github.com/sstent/go-garth"
)
func main() {
// Load environment variables from .env file (robust path handling)
var envPath string
if _, err := os.Stat("../../.env"); err == nil {
envPath = "../../.env"
} else if _, err := os.Stat("../../../.env"); err == nil {
envPath = "../../../.env"
} else {
envPath = ".env"
}
if err := godotenv.Load(envPath); err != nil {
log.Println("Note: Using system environment variables (no .env file found)")
}
// Get credentials from environment
username := os.Getenv("GARMIN_USERNAME")
password := os.Getenv("GARMIN_PASSWORD")
if username == "" || password == "" {
log.Fatal("GARMIN_USERNAME or GARMIN_PASSWORD not set in environment")
}
// Create context with timeout
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
// Create token storage and authenticator
storage := garth.NewMemoryStorage()
auth := garth.NewAuthenticator(garth.ClientOptions{
Storage: storage,
TokenURL: "https://connectapi.garmin.com/oauth-service/oauth/token",
Timeout: 30 * time.Second,
})
// Authenticate
token, err := auth.Login(ctx, username, password, "")
if err != nil {
log.Fatalf("Authentication failed: %v", err)
}
log.Printf("Authenticated successfully! Token expires at: %s", token.Expiry.Format(time.RFC3339))
// Create HTTP client with authentication transport
httpClient := &http.Client{
Transport: garth.NewAuthTransport(auth.(*garth.GarthAuthenticator), storage, nil),
}
// Create API client
apiClient := garth.NewAPIClient("https://connectapi.garmin.com", httpClient)
// Create activity service
activityService := garth.NewActivityService(apiClient)
// List last 20 activities
activities, err := activityService.List(ctx, garth.ActivityListOptions{
Limit: 20,
})
if err != nil {
log.Fatalf("Failed to get activities: %v", err)
}
// Print activities
fmt.Println("\nLast 20 Activities:")
fmt.Println("=======================================")
for i, activity := range activities {
fmt.Printf("%d. %s [%s] - %s\n", i+1,
activity.Name,
activity.Type,
activity.StartTime.Format("2006-01-02 15:04"))
fmt.Printf(" Distance: %.2f km, Duration: %.0f min\n\n",
activity.Distance/1000,
activity.Duration/60)
}
fmt.Println("Example completed successfully!")
}

View File

@@ -1,14 +0,0 @@
Cache-Control: no-cache
Server-Timing: cfCacheStatus;desc="DYNAMIC", cfOrigin;dur=42,cfEdge;dur=13
Date: Wed, 03 Sep 2025 23:07:16 GMT
Report-To: {"endpoints":[{"url":"https:\/\/a.nel.cloudflare.com\/report\/v4?s=7346Ar%2FOury4uGbhytPJFG3PH2b9HD0C9DuZEuS6yOiY1YgnwGU7tSsJ6T53YmLdEbFAhQzAvSpaPqSt87TPqJbw0vXSqmh0kIKwJqfvLbi2tasCY5egiy1mAXM8A%2F9ROC%2Fnrg%3D%3D"}],"group":"cf-nel","max_age":604800}
Set-Cookie: __cfruid=689cb1073d1a26adb7324496f8e3f38449c68ed7-1756940836; path=/; domain=.connect.garmin.com; HttpOnly; Secure; SameSite=None
Content-Encoding: br
Content-Type: text/html; charset=UTF-8
Connection: keep-alive
X-Frame-Options: deny
Server: cloudflare
Cf-Ray: 9798e7037af7dcee-LAX
Last-Modified: Mon, 28 Jul 2025 20:49:34 GMT
Cf-Cache-Status: DYNAMIC
Nel: {"success_fraction":0.01,"report_to":"cf-nel","max_age":604800}

View File

@@ -1,194 +0,0 @@
package main
import (
"context"
"fmt"
"log"
"net/http"
"os"
"strings"
"time"
"github.com/joho/godotenv"
"github.com/sstent/go-garth"
)
func main() {
// Load environment variables from .env file (robust path handling)
var envPath string
if _, err := os.Stat("../../../.env"); err == nil {
envPath = "../../../.env"
} else if _, err := os.Stat("../../.env"); err == nil {
envPath = "../../.env"
} else {
envPath = ".env"
}
if err := godotenv.Load(envPath); err != nil {
log.Println("Note: Using system environment variables (no .env file found)")
}
// Get credentials from environment
username := os.Getenv("GARMIN_USERNAME")
password := os.Getenv("GARMIN_PASSWORD")
if username == "" || password == "" {
log.Fatal("GARMIN_USERNAME or GARMIN_PASSWORD not set in environment")
}
// Create context with timeout
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
// Create token storage and authenticator
storage := garth.NewMemoryStorage()
auth := garth.NewAuthenticator(garth.ClientOptions{
Storage: storage,
TokenURL: "https://connectapi.garmin.com/oauth-service/oauth/token",
Timeout: 30 * time.Second,
})
// Authenticate
token, err := auth.Login(ctx, username, password, "")
if err != nil {
log.Fatalf("Authentication failed: %v", err)
}
log.Printf("Authenticated successfully! Token expires at: %s", token.Expiry.Format(time.RFC3339))
// Create HTTP client with authentication transport
httpClient := &http.Client{
Transport: garth.NewAuthTransport(auth.(*garth.GarthAuthenticator), storage, nil),
}
// Create API client
apiClient := garth.NewAPIClient("https://connectapi.garmin.com", httpClient)
// Create activity service
activityService := garth.NewActivityService(apiClient)
// Example 1: List recent activities
fmt.Println("\n=== RECENT ACTIVITIES ===")
recentActivities, err := activityService.List(ctx, garth.ActivityListOptions{
Limit: 10,
})
if err != nil {
log.Printf("Failed to get recent activities: %v", err)
} else {
printActivities(recentActivities, "Recent Activities")
}
// Example 2: List activities from last 30 days
fmt.Println("\n=== ACTIVITIES FROM LAST 30 DAYS ===")
thirtyDaysAgo := time.Now().AddDate(0, 0, -30)
recentMonthActivities, err := activityService.List(ctx, garth.ActivityListOptions{
StartDate: thirtyDaysAgo,
EndDate: time.Now(),
Limit: 20,
})
if err != nil {
log.Printf("Failed to get activities from last 30 days: %v", err)
} else {
printActivities(recentMonthActivities, "Last 30 Days")
}
// Example 3: List running activities only
fmt.Println("\n=== RUNNING ACTIVITIES ===")
runningActivities, err := activityService.List(ctx, garth.ActivityListOptions{
ActivityType: "running",
Limit: 15,
})
if err != nil {
log.Printf("Failed to get running activities: %v", err)
} else {
printActivities(runningActivities, "Running Activities")
}
// Example 4: Search activities by name
fmt.Println("\n=== ACTIVITIES CONTAINING 'MORNING' ===")
morningActivities, err := activityService.List(ctx, garth.ActivityListOptions{
NameContains: "morning",
Limit: 10,
})
if err != nil {
log.Printf("Failed to search activities: %v", err)
} else {
printActivities(morningActivities, "Activities with 'morning' in name")
}
// Example 5: Get detailed information for the first activity
if len(recentActivities) > 0 {
firstActivity := recentActivities[0]
fmt.Printf("\n=== DETAILS FOR ACTIVITY: %s ===\n", firstActivity.Name)
details, err := activityService.Get(ctx, firstActivity.ActivityID)
if err != nil {
log.Printf("Failed to get activity details: %v", err)
} else {
printActivityDetails(details)
}
}
fmt.Println("\nEnhanced activity listing example completed successfully!")
}
func printActivities(activities []garth.Activity, title string) {
if len(activities) == 0 {
fmt.Printf("No %s found\n", title)
return
}
fmt.Printf("\n%s (%d total):\n", title, len(activities))
fmt.Println(strings.Repeat("=", 50))
for i, activity := range activities {
fmt.Printf("%d. %s [%s]\n", i+1, activity.Name, activity.Type)
fmt.Printf(" Date: %s\n", activity.StartTime.Format("2006-01-02 15:04"))
fmt.Printf(" Distance: %.2f km\n", activity.Distance/1000)
fmt.Printf(" Duration: %s\n", formatDuration(activity.Duration))
fmt.Printf(" Calories: %d\n", activity.Calories)
fmt.Println()
}
}
func printActivityDetails(details *garth.ActivityDetails) {
fmt.Printf("Activity ID: %d\n", details.ActivityID)
fmt.Printf("Name: %s\n", details.Name)
fmt.Printf("Type: %s\n", details.Type)
fmt.Printf("Description: %s\n", details.Description)
fmt.Printf("Start Time: %s\n", details.StartTime.Format("2006-01-02 15:04:05"))
fmt.Printf("Distance: %.2f km\n", details.Distance/1000)
fmt.Printf("Duration: %s\n", formatDuration(details.Duration))
fmt.Printf("Calories: %d\n", details.Calories)
if details.ElevationGain > 0 {
fmt.Printf("Elevation Gain: %.0f m\n", details.ElevationGain)
}
if details.ElevationLoss > 0 {
fmt.Printf("Elevation Loss: %.0f m\n", details.ElevationLoss)
}
if details.MaxHeartRate > 0 {
fmt.Printf("Max Heart Rate: %d bpm\n", details.MaxHeartRate)
}
if details.AvgHeartRate > 0 {
fmt.Printf("Avg Heart Rate: %d bpm\n", details.AvgHeartRate)
}
if details.MaxSpeed > 0 {
fmt.Printf("Max Speed: %.1f km/h\n", details.MaxSpeed*3.6)
}
if details.AvgSpeed > 0 {
fmt.Printf("Avg Speed: %.1f km/h\n", details.AvgSpeed*3.6)
}
if details.Steps > 0 {
fmt.Printf("Steps: %d\n", details.Steps)
}
}
func formatDuration(seconds float64) string {
duration := time.Duration(seconds * float64(time.Second))
hours := int(duration.Hours())
minutes := int(duration.Minutes()) % 60
if hours > 0 {
return fmt.Sprintf("%dh %dm", hours, minutes)
}
return fmt.Sprintf("%dm", minutes)
}

View File

@@ -1,10 +0,0 @@
module github.com/sstent/go-garth/examples/activities
go 1.22
require (
github.com/joho/godotenv v1.5.1
github.com/sstent/go-garth v0.1.0
)
replace github.com/sstent/go-garth => ../../

View File

@@ -1,2 +0,0 @@
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=

View File

@@ -1,57 +0,0 @@
package main
import (
"context"
"fmt"
"log"
"os"
"time"
"github.com/joho/godotenv"
"github.com/sstent/go-garth"
)
func main() {
// Load environment variables
if err := godotenv.Load(); err != nil {
log.Println("Note: Using system environment variables (no .env file found)")
}
// Get credentials
username := os.Getenv("GARMIN_USERNAME")
password := os.Getenv("GARMIN_PASSWORD")
mfaToken := os.Getenv("GARMIN_MFA_TOKEN")
if username == "" || password == "" {
log.Fatal("GARMIN_USERNAME or GARMIN_PASSWORD not set in environment")
}
// Create client
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
client, err := garth.NewGarminClient(ctx, username, password, mfaToken)
if err != nil {
log.Fatalf("Failed to create client: %v", err)
}
// Get profile
profile, err := client.Profile.Get(ctx)
if err != nil {
log.Fatalf("Failed to get profile: %v", err)
}
log.Printf("User Profile: %s %s (%s)", profile.FirstName, profile.LastName, profile.UserID)
// List activities
activities, err := client.Activities.List(ctx, garth.ActivityListOptions{Limit: 5})
if err != nil {
log.Fatalf("Failed to get activities: %v", err)
}
log.Println("Recent Activities:")
for _, activity := range activities {
fmt.Printf("- %s: %s (%s)\n",
activity.StartTime.Format("2006-01-02"),
activity.Name,
activity.Type)
}
}

View File

@@ -1,118 +0,0 @@
package main
import (
"fmt"
"os"
"time"
"github.com/sstent/go-garth"
)
func main() {
// Create a new client (placeholder - actual client creation depends on package structure)
// client := garth.NewClient(nil)
// For demonstration, we'll use a mock server or skip authentication
// In real usage, you would authenticate first:
// err := client.Authenticate("username", "password")
// if err != nil {
// log.Fatalf("Authentication failed: %v", err)
// }
// Example usage of the workout service
fmt.Println("Garmin Connect Workout Service Examples")
fmt.Println("=======================================")
// Create a new workout
newWorkout := garth.Workout{
Name: "Morning Run",
Description: "5K easy run",
Type: "running",
}
_ = newWorkout // Prevent unused variable error
// In a real scenario, you would do:
// createdWorkout, err := client.Workouts.Create(context.Background(), newWorkout)
// if err != nil {
// log.Printf("Failed to create workout: %v", err)
// } else {
// fmt.Printf("Created workout: %+v\n", createdWorkout)
// }
// List workouts with options
opts := garth.WorkoutListOptions{
Limit: 10,
Offset: 0,
StartDate: time.Now().AddDate(0, -1, 0), // Last month
EndDate: time.Now(),
SortBy: "createdDate",
SortOrder: "desc",
}
fmt.Printf("Workout list options: %+v\n", opts)
// List workouts with pagination
paginatedOpts := garth.WorkoutListOptions{
Limit: 5,
Offset: 10,
SortBy: "name",
}
fmt.Printf("Paginated workout options: %+v\n", paginatedOpts)
// List workouts with type and status filters
filteredOpts := garth.WorkoutListOptions{
Type: "running",
Status: "active",
Limit: 20,
}
fmt.Printf("Filtered workout options: %+v\n", filteredOpts)
// Get workout details
workoutID := "12345"
fmt.Printf("Would fetch workout details for ID: %s\n", workoutID)
// workout, err := client.Workouts.Get(context.Background(), workoutID)
// Export workout
fmt.Printf("Would export workout %s in FIT format\n", workoutID)
// reader, err := client.Workouts.Export(context.Background(), workoutID, "fit")
// Search workouts
searchOpts := garth.WorkoutListOptions{
Limit: 5,
}
fmt.Printf("Would search workouts with: %+v\n", searchOpts)
// results, err := client.Workouts.List(context.Background(), searchOpts)
// Get workout templates
fmt.Println("Would fetch workout templates")
// templates, err := client.Workouts.GetWorkoutTemplates(context.Background())
// Copy workout
newName := "Copied Workout"
fmt.Printf("Would copy workout %s as %s\n", workoutID, newName)
// copied, err := client.Workouts.CopyWorkout(context.Background(), workoutID, newName)
// Update workout
update := garth.Workout{
Name: "Updated Morning Run",
Description: "Updated description",
}
fmt.Printf("Would update workout %s with: %+v\n", workoutID, update)
// updated, err := client.Workouts.Update(context.Background(), workoutID, update)
// Delete workout
fmt.Printf("Would delete workout: %s\n", workoutID)
// err = client.Workouts.Delete(context.Background(), workoutID)
fmt.Println("\nExample completed. To use with real data:")
fmt.Println("1. Set GARMIN_USERNAME and GARMIN_PASSWORD environment variables")
fmt.Println("2. Uncomment the authentication and API calls above")
fmt.Println("3. Run: go run examples/workouts_example.go")
}
func init() {
// Check if credentials are provided
if os.Getenv("GARMIN_USERNAME") == "" || os.Getenv("GARMIN_PASSWORD") == "" {
fmt.Println("Note: Set GARMIN_USERNAME and GARMIN_PASSWORD environment variables for real API usage")
}
}

View File

@@ -1,64 +0,0 @@
package garth
import (
"encoding/json"
"os"
"sync"
)
// FileStorage implements TokenStorage using a JSON file
type FileStorage struct {
mu sync.RWMutex
path string
}
// NewFileStorage creates a new file-based token storage
func NewFileStorage(path string) *FileStorage {
return &FileStorage{
path: path,
}
}
// GetToken retrieves token from file
func (s *FileStorage) GetToken() (*Token, error) {
s.mu.RLock()
defer s.mu.RUnlock()
data, err := os.ReadFile(s.path)
if err != nil {
if os.IsNotExist(err) {
return nil, ErrTokenNotFound
}
return nil, err
}
var token Token
if err := json.Unmarshal(data, &token); err != nil {
return nil, err
}
return &token, nil
}
// StoreToken saves token to file
func (s *FileStorage) StoreToken(token *Token) error {
s.mu.Lock()
defer s.mu.Unlock()
data, err := json.MarshalIndent(token, "", " ")
if err != nil {
return err
}
return os.WriteFile(s.path, data, 0600)
}
// ClearToken removes the token file
func (s *FileStorage) ClearToken() error {
s.mu.Lock()
defer s.mu.Unlock()
if err := os.Remove(s.path); err != nil && !os.IsNotExist(err) {
return err
}
return nil
}

62
fix.md
View File

@@ -1,62 +0,0 @@
High Priority (Fix These First):
Fix MFA Response Handling:
gofunc (a *GarthAuthenticator) authenticate(/* params */) (*Token, error) {
// ... existing code ...
if resp.StatusCode == http.StatusPreconditionFailed {
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read MFA challenge: %w", err)
}
return a.handleMFA(ctx, username, password, mfaToken, string(body))
}
// ... rest of method
}
Create Complete Client Factory:
go// Add to garth.go
func NewGarminClient(ctx context.Context, username, password, mfaToken string) (*GarminClient, error) {
storage := NewMemoryStorage()
auth := NewAuthenticator(ClientOptions{
Storage: storage,
TokenURL: "https://connectapi.garmin.com/oauth-service/oauth/token",
Timeout: 30 * time.Second,
})
token, err := auth.Login(ctx, username, password, mfaToken)
if err != nil {
return nil, err
}
transport := NewAuthTransport(auth.(*GarthAuthenticator), storage, nil)
httpClient := &http.Client{Transport: transport}
apiClient := NewAPIClient("https://connectapi.garmin.com", httpClient)
return &GarminClient{
Activities: NewActivityService(apiClient),
Profile: NewProfileService(apiClient),
Workouts: NewWorkoutService(apiClient),
}, nil
}
Add Integration Test:
go//go:build integration
// +build integration
func TestRealGarminFlow(t *testing.T) {
client, err := NewGarminClient(ctx, username, password, "")
require.NoError(t, err)
// Test actual API calls
profile, err := client.Profile.Get(ctx)
require.NoError(t, err)
require.NotEmpty(t, profile.UserID)
activities, err := client.Activities.List(ctx, ActivityListOptions{Limit: 5})
require.NoError(t, err)
}

5
garmin_session.json Normal file
View File

@@ -0,0 +1,5 @@
{
"domain": "garmin.com",
"username": "fbleagh",
"auth_token": "bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImRpLW9hdXRoLXNpZ25lci1wcm9kLTIwMjQtcTEifQ.eyJzY29wZSI6WyJBVFBfUkVBRCIsIkFUUF9XUklURSIsIkNPTU1VTklUWV9DT1VSU0VfUkVBRCIsIkNPTU1VTklUWV9DT1VSU0VfV1JJVEUiLCJDT05ORUNUX01DVF9EQUlMWV9MT0dfUkVBRCIsIkNPTk5FQ1RfUkVBRCIsIkNPTk5FQ1RfV1JJVEUiLCJESVZFX0FQSV9SRUFEIiwiRElfT0FVVEhfMl9BVVRIT1JJWkFUSU9OX0NPREVfQ1JFQVRFIiwiRFRfQ0xJRU5UX0FOQUxZVElDU19XUklURSIsIkdBUk1JTlBBWV9SRUFEIiwiR0FSTUlOUEFZX1dSSVRFIiwiR0NPRkZFUl9SRUFEIiwiR0NPRkZFUl9XUklURSIsIkdIU19TQU1EIiwiR0hTX1VQTE9BRCIsIkdPTEZfQVBJX1JFQUQiLCJHT0xGX0FQSV9XUklURSIsIklOU0lHSFRTX1JFQUQiLCJJTlNJR0hUU19XUklURSIsIk9NVF9DQU1QQUlHTl9SRUFEIiwiT01UX1NVQlNDUklQVElPTl9SRUFEIiwiUFJPRFVDVF9TRUFSQ0hfUkVBRCJdLCJpc3MiOiJodHRwczovL2RpYXV0aC5nYXJtaW4uY29tIiwicmV2b2NhdGlvbl9lbGlnaWJpbGl0eSI6WyJHTE9CQUxfU0lHTk9VVCIsIk1BTkFHRURfU1RBVFVTIl0sImNsaWVudF90eXBlIjoiVU5ERUZJTkVEIiwiZXhwIjoxNzU3MjkxMDE0LCJpYXQiOjE3NTcyMTE0MTUsImdhcm1pbl9ndWlkIjoiNTZlZTk1ZmMtMzY0MC00NGU2LTg1ODAtNDc4NDEwZDQwZGFhIiwianRpIjoiMjJiNzI5ZWUtNjU2OS00OGJkLWI3ZWEtYzk2MDA0N2EzMGUzIiwiY2xpZW50X2lkIjoiR0FSTUlOX0NPTk5FQ1RfTU9CSUxFX0FORFJPSURfREkifQ.X6aPeccjXy1bjmIuwRKf0V1Owo7EaiUbICO99Ae1JAoKDPHczswttd1Oo64wFg0DhGVstRMv9tx5OOZ4UUgA4Asj3NO8npkC17clIUeQQU7SCLM2FtiDT5FuMyLC7Ad2TA1PndWzCCov3cUouhDXXJkfnsve7On4vgDugV-v4nNzrKv3ro9wpVgZ331fzGs6pJ19eZJSdj6r_g30VD3qEjx3spCu9VBZZdgRRyuTnYqwHlbX2OwM8V6NZ0s-1A_YFgOZu8x7bW-Ndvh6u3v4TGi5LSk4Gjtua1f4eGC0R565ZuqtS84tddLxPoItYxqT69Ixw5DEfTisrBZsAdTXIQ"
}

View File

@@ -1,87 +0,0 @@
// Package garth provides a Go client for the Garmin Connect API.
//
// This client supports authentication, user profile management, activity tracking,
// workout management, and other Garmin Connect services.
//
// Features:
// - OAuth 2.0 authentication with MFA support
// - User profile operations (retrieve, update, delete)
// - Activity management (create, read, update, delete)
// - Workout management (CRUD operations, scheduling, templates)
// - Comprehensive error handling
// - Automatic token refresh
//
// Usage:
// 1. Create an Authenticator instance with your credentials
// 2. Obtain an access token
// 3. Create an APIClient using the authenticator
// 4. Use service methods to interact with Garmin Connect API
//
// Example:
//
// opts := garth.NewClientOptionsFromEnv()
// auth := garth.NewBasicAuthenticator(opts)
// token, err := auth.Login(ctx, "username", "password", "")
// client := garth.NewAPIClient(auth)
//
// // Get user profile
// profile, err := client.Profile().Get(ctx)
//
// For more details, see the documentation for each service.
package garth
import (
"context"
"net/http"
"os"
"strconv"
"time"
)
// Authenticator defines the authentication interface
type Authenticator interface {
// Login authenticates with Garmin services using OAuth2 flow
Login(ctx context.Context, username, password, mfaToken string) (*Token, error)
// RefreshToken refreshes an expired OAuth2 access token
RefreshToken(ctx context.Context, refreshToken string) (*Token, error)
// GetClient returns an authenticated HTTP client
GetClient() *http.Client
}
// NewClientOptionsFromEnv creates ClientOptions from environment variables
func NewClientOptionsFromEnv() ClientOptions {
// Default configuration
opts := ClientOptions{
SSOURL: "https://sso.garmin.com/sso",
TokenURL: "https://connectapi.garmin.com/oauth-service",
Domain: "garmin.com",
UserAgent: "GCMv3",
Timeout: 30 * time.Second,
}
// Override from environment variables
if url := os.Getenv("GARTH_SSO_URL"); url != "" {
opts.SSOURL = url
}
if url := os.Getenv("GARTH_TOKEN_URL"); url != "" {
opts.TokenURL = url
}
if domain := os.Getenv("GARTH_DOMAIN"); domain != "" {
opts.Domain = domain
}
if ua := os.Getenv("GARTH_USER_AGENT"); ua != "" {
opts.UserAgent = ua
}
if timeoutStr := os.Getenv("GARTH_TIMEOUT"); timeoutStr != "" {
if timeout, err := strconv.Atoi(timeoutStr); err == nil {
opts.Timeout = time.Duration(timeout) * time.Second
}
}
// Default to memory storage
opts.Storage = NewMemoryStorage()
return opts
}

View File

@@ -1,408 +0,0 @@
# Garth - Garmin SSO Authentication Flows Documentation
## Overview
Garth is a Python library that provides authenticated access to Garmin Connect APIs using the same authentication flow as the official Garmin Connect mobile application. It implements a sophisticated OAuth1/OAuth2 hybrid authentication system that maintains long-term session persistence.
## Architecture Summary
- **Primary Domain**: `connect.garmin.com` (or `connect.garmin.cn` for China)
- **Authentication Method**: Hybrid OAuth1 + OAuth2 with SSO
- **Session Persistence**: OAuth1 tokens valid for ~1 year
- **MFA Support**: Built-in multi-factor authentication handling
- **Storage**: Local credential caching in `~/.garth` by default
## Authentication Flow Components
### 1. Initial Login Flow
#### 1.1 Credential Validation
```
Endpoint: https://sso.garmin.com/sso/signin
Method: POST
Headers:
- Content-Type: application/x-www-form-urlencoded
- User-Agent: GCMv3 (Garmin Connect Mobile v3)
Parameters:
- username: <email_address>
- password: <password>
- embed: true
- lt: <login_ticket> (obtained from initial GET request)
- _eventId: submit
- displayNameRequired: false
```
#### 1.2 Login Ticket Acquisition
```
Endpoint: https://sso.garmin.com/sso/signin
Method: GET
Headers:
- User-Agent: GCMv3
Purpose: Extract login ticket (lt) from hidden form field
Response: HTML form with embedded lt parameter
```
### 2. Multi-Factor Authentication (MFA)
#### 2.1 MFA Detection
After initial credential validation, if MFA is required:
```
Response Pattern:
- HTTP 200 with MFA challenge form
- Contains: mfaCode input field
- Action endpoint: https://sso.garmin.com/sso/verifyMFA
```
#### 2.2 MFA Code Submission
```
Endpoint: https://sso.garmin.com/sso/verifyMFA
Method: POST
Headers:
- Content-Type: application/x-www-form-urlencoded
Parameters:
- mfaCode: <6_digit_code>
- lt: <login_ticket>
- _eventId: submit
```
### 3. OAuth Token Exchange Flow
#### 3.1 OAuth1 Consumer Credentials
```python
OAUTH_CONSUMER = {
'key': 'fc020df2-e33d-4ec5-987a-7fb6de2e3850',
'secret': 'secret_key_from_mobile_app' # Embedded in mobile app
}
```
#### 3.2 OAuth1 Request Token
```
Endpoint: https://connectapi.garmin.com/oauth-service/oauth/request_token
Method: GET
Headers:
- Authorization: OAuth oauth_consumer_key="...", oauth_nonce="...",
oauth_signature_method="HMAC-SHA1", oauth_timestamp="...",
oauth_version="1.0", oauth_signature="..."
Response:
oauth_token=<request_token>&oauth_token_secret=<request_secret>
```
#### 3.3 OAuth1 Authorization
```
Endpoint: https://connect.garmin.com/oauthConfirm
Method: GET
Parameters:
- oauth_token: <request_token>
- oauth_callback: https://connect.garmin.com/modern/
Headers:
- Cookie: <session_cookies_from_login>
```
#### 3.4 OAuth1 Access Token Exchange
```
Endpoint: https://connectapi.garmin.com/oauth-service/oauth/access_token
Method: POST
Headers:
- Authorization: OAuth oauth_consumer_key="...", oauth_nonce="...",
oauth_signature_method="HMAC-SHA1", oauth_timestamp="...",
oauth_token="<request_token>", oauth_verifier="<oauth_verifier>",
oauth_version="1.0", oauth_signature="..."
Response:
oauth_token=<access_token>&oauth_token_secret=<access_secret>
```
### 4. OAuth2 Token Exchange
#### 4.1 OAuth2 Authorization Request
```
Endpoint: https://connectapi.garmin.com/oauth-service/oauth/exchange_token
Method: POST
Headers:
- Authorization: OAuth oauth_consumer_key="...", oauth_nonce="...",
oauth_signature_method="HMAC-SHA1", oauth_timestamp="...",
oauth_token="<oauth1_access_token>", oauth_version="1.0",
oauth_signature="..."
Response Format (JSON):
{
"access_token": "<oauth2_access_token>",
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": "<oauth2_refresh_token>",
"scope": "GHS_ADMIN_READ GHS_ADMIN_WRITE"
}
```
### 5. Token Refresh Flow
#### 5.1 OAuth2 Token Refresh
```
Endpoint: https://connectapi.garmin.com/oauth-service/oauth/token
Method: POST
Headers:
- Content-Type: application/x-www-form-urlencoded
Parameters:
- grant_type: refresh_token
- refresh_token: <oauth2_refresh_token>
Response:
{
"access_token": "<new_oauth2_access_token>",
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": "<new_oauth2_refresh_token>",
"scope": "GHS_ADMIN_READ GHS_ADMIN_WRITE"
}
```
## Session Management
### 6. Credential Storage Structure
#### 6.1 Storage File Location
```
Default Path: ~/.garth
Format: JSON
Permissions: 600 (user read/write only)
```
#### 6.2 Stored Credential Format
```json
{
"domain": "garmin.com",
"oauth1_token": {
"oauth_token": "<oauth1_access_token>",
"oauth_token_secret": "<oauth1_access_secret>"
},
"oauth2_token": {
"access_token": "<oauth2_access_token>",
"token_type": "Bearer",
"expires_in": 3600,
"expires_at": 1698765432,
"refresh_token": "<oauth2_refresh_token>",
"scope": "GHS_ADMIN_READ GHS_ADMIN_WRITE"
},
"user_profile": {
"username": "<username>",
"profile_id": "<profile_id>",
"display_name": "<display_name>"
}
}
```
### 7. API Request Authentication
#### 7.1 Connect API Requests
```
Base URL: https://connectapi.garmin.com
Headers:
- Authorization: Bearer <oauth2_access_token>
- NK: NT (Garmin-specific header)
- User-Agent: GCMv3
Auto-refresh: OAuth2 token refreshed automatically when expired
```
#### 7.2 Upload API Requests
```
Endpoint: https://connectapi.garmin.com/upload-service/upload
Method: POST
Headers:
- Authorization: OAuth oauth_consumer_key="...", oauth_nonce="...",
oauth_signature_method="HMAC-SHA1", oauth_timestamp="...",
oauth_token="<oauth1_access_token>", oauth_version="1.0",
oauth_signature="..."
- Content-Type: multipart/form-data
Body: Multipart form with FIT file data
```
## Key Endpoints Reference
### 8. Authentication Endpoints
| Purpose | Method | Endpoint |
|---------|--------|----------|
| Get Login Form | GET | `https://sso.garmin.com/sso/signin` |
| Submit Credentials | POST | `https://sso.garmin.com/sso/signin` |
| Verify MFA | POST | `https://sso.garmin.com/sso/verifyMFA` |
| OAuth Request Token | GET | `https://connectapi.garmin.com/oauth-service/oauth/request_token` |
| OAuth Authorize | GET | `https://connect.garmin.com/oauthConfirm` |
| OAuth Access Token | POST | `https://connectapi.garmin.com/oauth-service/oauth/access_token` |
| OAuth2 Exchange | POST | `https://connectapi.garmin.com/oauth-service/oauth/exchange_token` |
| OAuth2 Refresh | POST | `https://connectapi.garmin.com/oauth-service/oauth/token` |
### 9. Data API Endpoints
| Data Type | Method | Endpoint Pattern |
|-----------|--------|------------------|
| Sleep Data | GET | `/wellness-service/wellness/dailySleepData/{username}` |
| Stress Data | GET | `/usersummary-service/stats/stress/weekly/{date}/{weeks}` |
| User Profile | GET | `/userprofile-service/userprofile` |
| Activities | GET | `/activitylist-service/activities/search/activities` |
| Upload FIT | POST | `/upload-service/upload` |
| Weight Data | GET | `/weight-service/weight/daterangesnapshot` |
## Configuration Options
### 10. Domain Configuration
#### 10.1 Global Domains
- **Standard**: `garmin.com` (default)
- **China**: `garmin.cn` (use `garth.configure(domain="garmin.cn")`)
#### 10.2 Proxy Configuration
```python
garth.configure(
proxies={"https": "http://localhost:8888"},
ssl_verify=False
)
```
## Error Handling
### 11. Common Error Scenarios
#### 11.1 Session Expiration
```python
from garth.exc import GarthException
try:
garth.client.username
except GarthException:
# Session expired - need to re-authenticate
garth.login(email, password)
```
#### 11.2 MFA Required
```python
# Synchronous MFA handling
result1, result2 = garth.login(email, password, return_on_mfa=True)
if result1 == "needs_mfa":
mfa_code = input("Enter MFA code: ")
oauth1, oauth2 = garth.resume_login(result2, mfa_code)
```
#### 11.3 Token Refresh Failures
- Automatic retry mechanism built-in
- Falls back to OAuth1 if OAuth2 refresh fails
- Full re-authentication required if OAuth1 tokens expire
## Security Considerations
### 12. Security Features
#### 12.1 Token Security
- OAuth1 tokens have 1-year lifetime
- OAuth2 tokens expire every hour (auto-refreshed)
- Stored credentials encrypted at filesystem level
- No plaintext password storage
#### 12.2 Session Security
- CSRF protection via login tickets
- MFA support for enhanced security
- Secure random nonce generation for OAuth signatures
- HMAC-SHA1 signature validation
#### 12.3 Network Security
- All communications over HTTPS
- Certificate validation enabled by default
- User-Agent matching official mobile app
- Request rate limiting handled automatically
## Flow Diagrams
### 13. End-to-End Authentication Flow
```mermaid
graph TB
A[Client Application] --> B[garth.login call]
B --> C[Get Login Ticket]
C --> D{Credentials Valid?}
D -->|No| E[Authentication Error]
D -->|Yes| F{MFA Required?}
F -->|Yes| G[Prompt for MFA Code]
G --> H[Submit MFA Code]
H --> I{MFA Valid?}
I -->|No| E
I -->|Yes| J[Get OAuth1 Request Token]
F -->|No| J
J --> K[Authorize OAuth1 Token]
K --> L[Exchange for OAuth1 Access Token]
L --> M[Exchange OAuth1 for OAuth2 Token]
M --> N[Store Credentials to ~/.garth]
N --> O[Authentication Complete]
O --> P[Make API Request]
P --> Q{OAuth2 Token Valid?}
Q -->|Yes| R[Execute API Request]
Q -->|No| S[Refresh OAuth2 Token]
S --> T{Refresh Successful?}
T -->|Yes| R
T -->|No| U{OAuth1 Token Valid?}
U -->|Yes| M
U -->|No| B
R --> V[Return API Response]
style E fill:#ffcccc
style O fill:#ccffcc
style V fill:#ccffcc
```
### 14. Token Lifecycle Management
```mermaid
graph LR
A[Fresh Login] --> B[OAuth1 Access Token<br/>~1 year lifetime]
B --> C[OAuth2 Access Token<br/>~1 hour lifetime]
C --> D{API Request}
D --> E{OAuth2 Expired?}
E -->|No| F[Use OAuth2 Token]
E -->|Yes| G[Refresh OAuth2 Token]
G --> H{Refresh Success?}
H -->|Yes| C
H -->|No| I{OAuth1 Expired?}
I -->|No| J[Re-exchange OAuth1 for OAuth2]
J --> C
I -->|Yes| K[Full Re-authentication Required]
K --> A
F --> L[API Response]
style K fill:#ffcccc
style L fill:#ccffcc
```
## Implementation Notes
### 15. Key Implementation Details
#### 15.1 User Agent String
- Uses `GCMv3` to mimic Garmin Connect Mobile v3
- Critical for endpoint compatibility
- Some endpoints reject requests without proper User-Agent
#### 15.2 OAuth Signature Generation
- HMAC-SHA1 signatures for OAuth1 requests
- Includes all OAuth parameters in signature base string
- Consumer secret and token secret used as signing key
#### 15.3 Session Cookie Handling
- Automatic cookie jar management
- Session cookies preserved across authentication flow
- Required for OAuth authorization step
#### 15.4 Rate Limiting
- Built-in request throttling
- Exponential backoff for failed requests
- Respects Garmin's API rate limits
This comprehensive documentation covers all aspects of the Garth authentication system, from initial login through ongoing API access, providing developers with the technical details needed to understand and work with the authentication flows.

File diff suppressed because one or more lines are too long

13
go.mod
View File

@@ -1,12 +1,5 @@
module github.com/sstent/go-garth
module garmin-connect
go 1.23.0
go 1.24.2
toolchain go1.24.2
require github.com/joho/godotenv v1.5.1
require (
github.com/andybalholm/brotli v1.2.0 // indirect
golang.org/x/oauth2 v0.30.0 // indirect
)
require github.com/joho/godotenv v1.5.1 // indirect

4
go.sum
View File

@@ -1,6 +1,2 @@
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=

748
main.go Normal file
View File

@@ -0,0 +1,748 @@
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"`
}
// 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 string `json:"activityType"`
EventType string `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 int `json:"averageHR"`
MaxHR int `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()
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)
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()
}
}

View File

@@ -1,45 +0,0 @@
package garth
import (
"sync"
)
// MemoryStorage implements TokenStorage using an in-memory cache
type MemoryStorage struct {
mu sync.RWMutex
token *Token
}
// NewMemoryStorage creates a new in-memory token storage
func NewMemoryStorage() *MemoryStorage {
return &MemoryStorage{}
}
// GetToken retrieves token from memory
func (s *MemoryStorage) GetToken() (*Token, error) {
s.mu.RLock()
defer s.mu.RUnlock()
if s.token == nil {
return nil, ErrTokenNotFound
}
return s.token, nil
}
// SaveToken saves token to memory
func (s *MemoryStorage) StoreToken(token *Token) error {
s.mu.Lock()
defer s.mu.Unlock()
s.token = token
return nil
}
// ClearToken removes the token from memory
func (s *MemoryStorage) ClearToken() error {
s.mu.Lock()
defer s.mu.Unlock()
s.token = nil
return nil
}

View File

@@ -1,153 +0,0 @@
package garth
import (
"bytes"
"context"
"encoding/json"
"io/ioutil"
"net/http"
)
// Profile represents a user's Garmin profile
type Profile struct {
UserID string `json:"userId"`
Username string `json:"username"`
FirstName string `json:"firstName"`
LastName string `json:"lastName"`
EmailAddress string `json:"emailAddress"`
Country string `json:"country"`
City string `json:"city"`
State string `json:"state"`
ProfileImage string `json:"profileImage"`
}
// ProfileService provides access to user profile operations
type ProfileService struct {
client *APIClient
}
// NewProfileService creates a new ProfileService instance
func NewProfileService(client *APIClient) *ProfileService {
return &ProfileService{client: client}
}
// Get fetches the current user's profile
func (s *ProfileService) Get(ctx context.Context) (*Profile, error) {
resp, err := s.client.Get(ctx, "/userprofile-service/userprofile")
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, &APIError{
StatusCode: resp.StatusCode,
Message: "Failed to get user profile",
}
}
body, err := ioutil.ReadAll(resp.Body)
if err != nil {
return nil, &APIError{
StatusCode: http.StatusInternalServerError,
Message: "Failed to read profile response",
Cause: err,
}
}
var profile Profile
if err := json.Unmarshal(body, &profile); err != nil {
return nil, &APIError{
StatusCode: http.StatusInternalServerError,
Message: "Failed to parse profile data",
Cause: err,
}
}
return &profile, nil
}
// UpdateSettings updates the user's profile settings
func (s *ProfileService) UpdateSettings(ctx context.Context, settings map[string]interface{}) error {
// Serialize settings to JSON
jsonData, err := json.Marshal(settings)
if err != nil {
return &APIError{
StatusCode: http.StatusInternalServerError,
Message: "Failed to serialize settings",
Cause: err,
}
}
// Convert JSON data to a Reader
reader := bytes.NewReader(jsonData)
resp, err := s.client.Post(ctx, "/userprofile-service/userprofile/settings", reader)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent {
return &APIError{
StatusCode: resp.StatusCode,
Message: "Failed to update settings",
}
}
return nil
}
// Delete deletes the user's profile
func (s *ProfileService) Delete(ctx context.Context) error {
resp, err := s.client.Delete(ctx, "/userprofile-service/userprofile", nil)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent {
return &APIError{
StatusCode: resp.StatusCode,
Message: "Failed to delete profile",
}
}
return nil
}
// GetPublic retrieves public profile information for a user
func (s *ProfileService) GetPublic(ctx context.Context, userID string) (*Profile, error) {
resp, err := s.client.Get(ctx, "/userprofile-service/userprofile/public/"+userID)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, &APIError{
StatusCode: resp.StatusCode,
Message: "Failed to get public profile",
}
}
body, err := ioutil.ReadAll(resp.Body)
if err != nil {
return nil, &APIError{
StatusCode: http.StatusInternalServerError,
Message: "Failed to read profile response",
Cause: err,
}
}
var profile Profile
if err := json.Unmarshal(body, &profile); err != nil {
return nil, &APIError{
StatusCode: http.StatusInternalServerError,
Message: "Failed to parse profile data",
Cause: err,
}
}
return &profile, nil
}

View File

@@ -1,72 +0,0 @@
package garth
import (
"context"
"net/http"
"net/http/httptest"
"testing"
)
func TestProfileService_Get(t *testing.T) {
// Create test server
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{
"userId": "12345",
"username": "testuser",
"firstName": "Test",
"lastName": "User",
"emailAddress": "test@example.com",
"country": "US",
"city": "Seattle",
"state": "WA",
"profileImage": "https://example.com/avatar.jpg"
}`))
}))
defer ts.Close()
// Create client
apiClient := NewAPIClient(ts.URL, http.DefaultClient)
profileService := NewProfileService(apiClient)
// Test Get method
profile, err := profileService.Get(context.Background())
if err != nil {
t.Fatalf("Unexpected error: %v", err)
}
// Verify profile data
if profile.UserID != "12345" {
t.Errorf("Expected UserID '12345', got '%s'", profile.UserID)
}
if profile.Username != "testuser" {
t.Errorf("Expected Username 'testuser', got '%s'", profile.Username)
}
if profile.EmailAddress != "test@example.com" {
t.Errorf("Expected Email 'test@example.com', got '%s'", profile.EmailAddress)
}
}
func TestProfileService_UpdateSettings(t *testing.T) {
// Create test server
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent)
}))
defer ts.Close()
// Create client
apiClient := NewAPIClient(ts.URL, http.DefaultClient)
profileService := NewProfileService(apiClient)
// Test UpdateSettings method
settings := map[string]interface{}{
"preferences": map[string]string{
"units": "metric",
"theme": "dark",
},
}
err := profileService.UpdateSettings(context.Background(), settings)
if err != nil {
t.Fatalf("Unexpected error: %v", err)
}
}

View File

View File

@@ -1,9 +0,0 @@
- [x] Review task requirements and project structure
- [x] Create workouts.go file with basic structure
- [x] Implement CRUD operations for workouts
- [x] Add workout scheduling functionality
- [x] Implement template management for workouts
- [x] Update package-level documentation in garth.go
- [x] Add method-level comments across all services
- [x] Run Go toolchain commands (mod tidy, fmt, vet, test)
- [x] Verify all tests pass

146
types.go
View File

@@ -1,146 +0,0 @@
package garth
import (
"errors"
"fmt"
"time"
)
// Unified Token Definitions
// Token represents authentication credentials
type Token struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
ExpiresAt int64 `json:"expires_at"`
Domain string `json:"domain"`
UserProfile *UserProfile `json:"user_profile"`
}
// IsExpired checks if the token has expired (with 60 second buffer)
func (t *Token) IsExpired() bool {
return time.Now().Unix() >= (t.ExpiresAt - 60)
}
// NeedsRefresh checks if token needs refresh (within 5 min expiry window)
func (t *Token) NeedsRefresh() bool {
return time.Now().Unix() >= (t.ExpiresAt - 300)
}
// UserProfile represents Garmin user profile information
type UserProfile struct {
Username string `json:"username"`
ProfileID string `json:"profile_id"`
DisplayName string `json:"display_name"`
}
// ClientOptions contains configuration for the authenticator
type ClientOptions struct {
SSOURL string // SSO endpoint
TokenURL string // Token exchange endpoint
Storage TokenStorage // Token storage implementation
Timeout time.Duration // HTTP client timeout
Domain string // Garmin domain (default: garmin.com)
UserAgent string // User-Agent header (default: GCMv3)
}
// TokenStorage defines the interface for token storage
type TokenStorage interface {
StoreToken(token *Token) error
GetToken() (*Token, error)
ClearToken() error
}
// Error interface defines common error behavior for Garth
type Error interface {
error
GetStatusCode() int
GetType() string
GetCause() error
Unwrap() error
}
// ErrTokenNotFound is returned when a token is not available in storage
var ErrTokenNotFound = errors.New("token not found")
// AuthError represents Garmin authentication errors
type AuthError struct {
StatusCode int `json:"status_code"` // HTTP status code
Message string `json:"message"` // Human-readable error message
Type string `json:"type"` // Garmin error type identifier
Cause error `json:"cause"` // Underlying error
CSRF string `json:"csrf"` // CSRF token for MFA flow
}
// GetStatusCode returns the HTTP status code
func (e *AuthError) GetStatusCode() int {
return e.StatusCode
}
// GetType returns the error category
func (e *AuthError) GetType() string {
return e.Type
}
// Error implements the error interface for AuthError
func (e *AuthError) Error() string {
msg := fmt.Sprintf("garmin auth error %d: %s", e.StatusCode, e.Message)
if e.Cause != nil {
msg += " (" + e.Cause.Error() + ")"
}
return msg
}
// Unwrap returns the underlying error
func (e *AuthError) Unwrap() error {
return e.Cause
}
// GetCause returns the underlying error (implements Error interface)
func (e *AuthError) GetCause() error {
return e.Cause
}
// APIError represents errors from API operations
type APIError struct {
StatusCode int // HTTP status code
Message string // Error description
Cause error // Underlying error
ErrorType string // Specific error category
}
// GetStatusCode returns the HTTP status code
func (e *APIError) GetStatusCode() int {
return e.StatusCode
}
// GetType returns the error category
func (e *APIError) GetType() string {
if e.ErrorType == "" {
return "api_error"
}
return e.ErrorType
}
// Error implements the error interface for APIError
func (e *APIError) Error() string {
if e.Cause != nil {
return fmt.Sprintf("API error (%d): %s: %v", e.StatusCode, e.Message, e.Cause)
}
return fmt.Sprintf("API error (%d): %s", e.StatusCode, e.Message)
}
// Unwrap returns the underlying error
func (e *APIError) Unwrap() error {
return e.Cause
}
// GetCause returns the underlying error (implements Error interface)
func (e *APIError) GetCause() error {
return e.Cause
}
// AuthenticatorSetter interface for storage that needs authenticator reference
type AuthenticatorSetter interface {
SetAuthenticator(a Authenticator)
}

View File

@@ -1,157 +0,0 @@
# Workout Service Implementation Specification
## Overview
This document provides the technical specification for implementing the complete WorkoutService functionality in the go-garth library, following the established patterns from the ActivityService.
## Data Structures
### Workout (Summary)
```go
type Workout struct {
WorkoutID int64 `json:"workoutId"`
Name string `json:"workoutName"`
Type string `json:"workoutType"`
Description string `json:"description"`
CreatedDate time.Time `json:"createdDate"`
UpdatedDate time.Time `json:"updatedDate"`
OwnerID int64 `json:"ownerId"`
IsPublic bool `json:"isPublic"`
SportType string `json:"sportType"`
SubSportType string `json:"subSportType"`
}
```
### WorkoutDetails (Full)
```go
type WorkoutDetails struct {
WorkoutID int64 `json:"workoutId"`
Name string `json:"workoutName"`
Description string `json:"description"`
Type string `json:"workoutType"`
CreatedDate time.Time `json:"createdDate"`
UpdatedDate time.Time `json:"updatedDate"`
OwnerID int64 `json:"ownerId"`
IsPublic bool `json:"isPublic"`
SportType string `json:"sportType"`
SubSportType string `json:"subSportType"`
WorkoutSegments []WorkoutSegment `json:"workoutSegments"`
EstimatedDuration int `json:"estimatedDuration"`
EstimatedDistance float64 `json:"estimatedDistance"`
TrainingLoad float64 `json:"trainingLoad"`
Tags []string `json:"tags"`
}
```
### WorkoutSegment
```go
type WorkoutSegment struct {
SegmentID int64 `json:"segmentId"`
Name string `json:"name"`
Description string `json:"description"`
Order int `json:"order"`
Exercises []WorkoutExercise `json:"exercises"`
}
```
### WorkoutExercise
```go
type WorkoutExercise struct {
ExerciseID int64 `json:"exerciseId"`
Name string `json:"name"`
Category string `json:"category"`
Type string `json:"type"`
Duration int `json:"duration,omitempty"`
Distance float64 `json:"distance,omitempty"`
Repetitions int `json:"repetitions,omitempty"`
Weight float64 `json:"weight,omitempty"`
RestInterval int `json:"restInterval,omitempty"`
}
```
### WorkoutListOptions
```go
type WorkoutListOptions struct {
Limit int
Offset int
StartDate time.Time
EndDate time.Time
WorkoutType string
Type string
Status string
SportType string
NameContains string
OwnerID int64
IsPublic *bool
SortBy string
SortOrder string
}
```
### WorkoutUpdate
```go
type WorkoutUpdate struct {
Name string `json:"workoutName,omitempty"`
Description string `json:"description,omitempty"`
Type string `json:"workoutType,omitempty"`
SportType string `json:"sportType,omitempty"`
IsPublic *bool `json:"isPublic,omitempty"`
Tags []string `json:"tags,omitempty"`
}
```
## API Endpoints
### Base URL Pattern
All workout endpoints follow the pattern: `/workout-service/workout`
### Specific Endpoints
- **GET** `/workout-service/workout` - List workouts (with query parameters)
- **GET** `/workout-service/workout/{workoutId}` - Get workout details
- **POST** `/workout-service/workout` - Create new workout
- **PUT** `/workout-service/workout/{workoutId}` - Update existing workout
- **DELETE** `/workout-service/workout/{workoutId}` - Delete workout
- **GET** `/download-service/export/{format}/workout/{workoutId}` - Export workout
## Method Signatures
### WorkoutService Methods
```go
func (s *WorkoutService) List(ctx context.Context, opts WorkoutListOptions) ([]Workout, error)
func (s *WorkoutService) Get(ctx context.Context, workoutID int64) (*WorkoutDetails, error)
func (s *WorkoutService) Create(ctx context.Context, workout WorkoutDetails) (*WorkoutDetails, error)
func (s *WorkoutService) Update(ctx context.Context, workoutID int64, update WorkoutUpdate) (*WorkoutDetails, error)
func (s *WorkoutService) Delete(ctx context.Context, workoutID int64) error
func (s *WorkoutService) Export(ctx context.Context, workoutID int64, format string) (io.ReadCloser, error)
```
## Testing Strategy
### Test Coverage Requirements
- All public methods must have unit tests
- Test both success and error scenarios
- Use httptest for HTTP mocking
- Test JSON marshaling/unmarshaling
- Test query parameter construction
### Test File Structure
- File: `workouts_test.go`
- Test functions:
- `TestWorkoutService_List`
- `TestWorkoutService_Get`
- `TestWorkoutService_Create`
- `TestWorkoutService_Update`
- `TestWorkoutService_Delete`
- `TestWorkoutService_Export`
## Error Handling
- Follow the same pattern as ActivityService
- Use APIError for HTTP-related errors
- Include proper status codes and messages
- Handle JSON parsing errors appropriately
## Implementation Notes
- Use the same HTTP client pattern as ActivityService
- Follow consistent naming conventions
- Ensure proper context handling
- Include appropriate defer statements for resource cleanup
- Use strconv.FormatInt for ID conversion in URLs

View File

@@ -1,475 +0,0 @@
package garth
import (
"bytes"
"context"
"encoding/json"
"io"
"io/ioutil"
"net/http"
"net/url"
"strconv"
"time"
)
// WorkoutService provides methods for interacting with Garmin workout data.
type WorkoutService struct {
client *APIClient
}
// NewWorkoutService creates a new WorkoutService instance.
// client: The authenticated APIClient used to make requests.
func NewWorkoutService(client *APIClient) *WorkoutService {
return &WorkoutService{client: client}
}
// Workout represents a Garmin workout with basic information
type Workout struct {
WorkoutID int64 `json:"workoutId"`
Name string `json:"workoutName"`
Type string `json:"workoutType"`
Description string `json:"description"`
CreatedDate time.Time `json:"createdDate"`
UpdatedDate time.Time `json:"updatedDate"`
OwnerID int64 `json:"ownerId"`
IsPublic bool `json:"isPublic"`
SportType string `json:"sportType"`
SubSportType string `json:"subSportType"`
}
// WorkoutDetails contains detailed information about a workout
type WorkoutDetails struct {
Workout
WorkoutSegments []WorkoutSegment `json:"workoutSegments"`
EstimatedDuration int `json:"estimatedDuration"`
EstimatedDistance float64 `json:"estimatedDistance"`
TrainingLoad float64 `json:"trainingLoad"`
Tags []string `json:"tags"`
}
// WorkoutSegment represents a segment within a workout
type WorkoutSegment struct {
SegmentID int64 `json:"segmentId"`
Name string `json:"name"`
Description string `json:"description"`
Order int `json:"order"`
Exercises []WorkoutExercise `json:"exercises"`
}
// WorkoutExercise represents an exercise within a workout segment
type WorkoutExercise struct {
ExerciseID int64 `json:"exerciseId"`
Name string `json:"name"`
Category string `json:"category"`
Type string `json:"type"`
Duration int `json:"duration,omitempty"`
Distance float64 `json:"distance,omitempty"`
Repetitions int `json:"repetitions,omitempty"`
Weight float64 `json:"weight,omitempty"`
RestInterval int `json:"restInterval,omitempty"`
}
// WorkoutListOptions provides filtering options for listing workouts
type WorkoutListOptions struct {
Limit int
StartDate time.Time
EndDate time.Time
SportType string
NameContains string
OwnerID int64
Offset int
SortBy string
SortOrder string
Type string
Status string
}
// WorkoutUpdate represents fields that can be updated on a workout
type WorkoutUpdate struct {
Name string `json:"name,omitempty"`
Description string `json:"description,omitempty"`
Type string `json:"type,omitempty"`
SportType string `json:"sportType,omitempty"`
SubSportType string `json:"subSportType,omitempty"`
}
// List retrieves a list of workouts for the current user with optional filters
func (s *WorkoutService) List(ctx context.Context, opts WorkoutListOptions) ([]Workout, error) {
params := url.Values{}
if opts.Limit > 0 {
params.Set("limit", strconv.Itoa(opts.Limit))
}
if opts.Offset > 0 {
params.Set("offset", strconv.Itoa(opts.Offset))
}
if !opts.StartDate.IsZero() {
params.Set("startDate", opts.StartDate.Format(time.RFC3339))
}
if !opts.EndDate.IsZero() {
params.Set("endDate", opts.EndDate.Format(time.RFC3339))
}
if opts.SportType != "" {
params.Set("sportType", opts.SportType)
}
if opts.Type != "" {
params.Set("type", opts.Type)
}
if opts.Status != "" {
params.Set("status", opts.Status)
}
if opts.NameContains != "" {
params.Set("nameContains", opts.NameContains)
}
if opts.OwnerID > 0 {
params.Set("ownerId", strconv.FormatInt(opts.OwnerID, 10))
}
if opts.SortBy != "" {
params.Set("sortBy", opts.SortBy)
}
if opts.SortOrder != "" {
params.Set("sortOrder", opts.SortOrder)
}
path := "/workout-service/workouts"
if len(params) > 0 {
path += "?" + params.Encode()
}
resp, err := s.client.Get(ctx, path)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, &APIError{
StatusCode: resp.StatusCode,
Message: "Failed to get workouts list",
}
}
body, err := ioutil.ReadAll(resp.Body)
if err != nil {
return nil, &APIError{
StatusCode: http.StatusInternalServerError,
Message: "Failed to read workouts response",
Cause: err,
}
}
var workouts []Workout
if err := json.Unmarshal(body, &workouts); err != nil {
return nil, &APIError{
StatusCode: http.StatusInternalServerError,
Message: "Failed to parse workouts data",
Cause: err,
}
}
return workouts, nil
}
// Get retrieves detailed information about a specific workout
func (s *WorkoutService) Get(ctx context.Context, id string) (*WorkoutDetails, error) {
path := "/workout-service/workout/" + id
resp, err := s.client.Get(ctx, path)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, &APIError{
StatusCode: resp.StatusCode,
Message: "Failed to get workout details",
}
}
body, err := ioutil.ReadAll(resp.Body)
if err != nil {
return nil, &APIError{
StatusCode: http.StatusInternalServerError,
Message: "Failed to read workout response",
Cause: err,
}
}
var details WorkoutDetails
if err := json.Unmarshal(body, &details); err != nil {
return nil, &APIError{
StatusCode: http.StatusInternalServerError,
Message: "Failed to parse workout data",
Cause: err,
}
}
return &details, nil
}
// Create creates a new workout
func (s *WorkoutService) Create(ctx context.Context, workout Workout) (*Workout, error) {
jsonBody, err := json.Marshal(workout)
if err != nil {
return nil, &APIError{
StatusCode: http.StatusInternalServerError,
Message: "Failed to marshal workout",
Cause: err,
}
}
resp, err := s.client.Post(ctx, "/workout-service/workout", bytes.NewReader(jsonBody))
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusCreated {
return nil, &APIError{
StatusCode: resp.StatusCode,
Message: "Failed to create workout",
}
}
body, err := ioutil.ReadAll(resp.Body)
if err != nil {
return nil, &APIError{
StatusCode: http.StatusInternalServerError,
Message: "Failed to read workout response",
Cause: err,
}
}
var createdWorkout Workout
if err := json.Unmarshal(body, &createdWorkout); err != nil {
return nil, &APIError{
StatusCode: http.StatusInternalServerError,
Message: "Failed to parse workout data",
Cause: err,
}
}
return &createdWorkout, nil
}
// Update updates an existing workout
func (s *WorkoutService) Update(ctx context.Context, id string, update WorkoutUpdate) (*Workout, error) {
jsonBody, err := json.Marshal(update)
if err != nil {
return nil, &APIError{
StatusCode: http.StatusInternalServerError,
Message: "Failed to marshal workout update",
Cause: err,
}
}
path := "/workout-service/workout/" + id
resp, err := s.client.Put(ctx, path, bytes.NewReader(jsonBody))
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, &APIError{
StatusCode: resp.StatusCode,
Message: "Failed to update workout",
}
}
body, err := ioutil.ReadAll(resp.Body)
if err != nil {
return nil, &APIError{
StatusCode: http.StatusInternalServerError,
Message: "Failed to read workout response",
Cause: err,
}
}
var updatedWorkout Workout
if err := json.Unmarshal(body, &updatedWorkout); err != nil {
return nil, &APIError{
StatusCode: http.StatusInternalServerError,
Message: "Failed to parse workout data",
Cause: err,
}
}
return &updatedWorkout, nil
}
// Delete deletes an existing workout
func (s *WorkoutService) Delete(ctx context.Context, id string) error {
path := "/workout-service/workout/" + id
resp, err := s.client.Delete(ctx, path, nil)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusOK {
return &APIError{
StatusCode: resp.StatusCode,
Message: "Failed to delete workout",
}
}
return nil
}
// GetWorkoutTemplates retrieves all workout templates for the current user
func (s *WorkoutService) GetWorkoutTemplates(ctx context.Context) ([]Workout, error) {
path := "/workout-service/templates"
resp, err := s.client.Get(ctx, path)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, &APIError{
StatusCode: resp.StatusCode,
Message: "Failed to get workout templates",
}
}
body, err := ioutil.ReadAll(resp.Body)
if err != nil {
return nil, &APIError{
StatusCode: http.StatusInternalServerError,
Message: "Failed to read workout templates response",
Cause: err,
}
}
var templates []Workout
if err := json.Unmarshal(body, &templates); err != nil {
return nil, &APIError{
StatusCode: http.StatusInternalServerError,
Message: "Failed to parse workout templates data",
Cause: err,
}
}
return templates, nil
}
// SearchWorkouts searches workouts by name or description
func (s *WorkoutService) SearchWorkouts(ctx context.Context, query string, limit int) ([]Workout, error) {
params := url.Values{}
params.Set("q", query)
if limit > 0 {
params.Set("limit", strconv.Itoa(limit))
}
path := "/workout-service/search?" + params.Encode()
resp, err := s.client.Get(ctx, path)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, &APIError{
StatusCode: resp.StatusCode,
Message: "Failed to search workouts",
}
}
body, err := ioutil.ReadAll(resp.Body)
if err != nil {
return nil, &APIError{
StatusCode: http.StatusInternalServerError,
Message: "Failed to read search response",
Cause: err,
}
}
var workouts []Workout
if err := json.Unmarshal(body, &workouts); err != nil {
return nil, &APIError{
StatusCode: http.StatusInternalServerError,
Message: "Failed to parse search results",
Cause: err,
}
}
return workouts, nil
}
// CopyWorkout creates a copy of an existing workout
func (s *WorkoutService) CopyWorkout(ctx context.Context, id string, newName string) (*Workout, error) {
path := "/workout-service/workout/" + id + "/copy"
requestBody := map[string]string{"name": newName}
jsonBody, err := json.Marshal(requestBody)
if err != nil {
return nil, &APIError{
StatusCode: http.StatusInternalServerError,
Message: "Failed to marshal request body",
Cause: err,
}
}
resp, err := s.client.Post(ctx, path, bytes.NewReader(jsonBody))
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusCreated {
return nil, &APIError{
StatusCode: resp.StatusCode,
Message: "Failed to copy workout",
}
}
body, err := ioutil.ReadAll(resp.Body)
if err != nil {
return nil, &APIError{
StatusCode: http.StatusInternalServerError,
Message: "Failed to read copy response",
Cause: err,
}
}
var copiedWorkout Workout
if err := json.Unmarshal(body, &copiedWorkout); err != nil {
return nil, &APIError{
StatusCode: http.StatusInternalServerError,
Message: "Failed to parse copied workout data",
Cause: err,
}
}
return &copiedWorkout, nil
}
// Export exports a workout in the specified format (fit, tcx, json)
func (s *WorkoutService) Export(ctx context.Context, id string, format string) (io.ReadCloser, error) {
if format != "fit" && format != "tcx" && format != "json" {
return nil, &APIError{
StatusCode: http.StatusBadRequest,
Message: "Invalid format. Supported formats: fit, tcx, json",
}
}
path := "/download-service/export/" + format + "/workout/" + id
resp, err := s.client.Get(ctx, path)
if err != nil {
return nil, err
}
if resp.StatusCode != http.StatusOK {
defer resp.Body.Close()
return nil, &APIError{
StatusCode: resp.StatusCode,
Message: "Failed to export workout",
}
}
return resp.Body, nil
}

View File

@@ -1,731 +0,0 @@
package garth
import (
"context"
"encoding/json"
"io/ioutil"
"net/http"
"net/http/httptest"
"testing"
"time"
)
// TestWorkoutService_List tests the List method with various options
func TestWorkoutService_List(t *testing.T) {
tests := []struct {
name string
mockResponse []Workout
mockStatusCode int
opts WorkoutListOptions
wantErr bool
}{
{
name: "successful list with no options",
mockResponse: []Workout{
{WorkoutID: 1, Name: "Morning Run", Type: "running"},
{WorkoutID: 2, Name: "Evening Ride", Type: "cycling"},
},
mockStatusCode: http.StatusOK,
opts: WorkoutListOptions{},
wantErr: false,
},
{
name: "successful list with limit",
mockResponse: []Workout{
{WorkoutID: 1, Name: "Morning Run", Type: "running"},
},
mockStatusCode: http.StatusOK,
opts: WorkoutListOptions{Limit: 1},
wantErr: false,
},
{
name: "successful list with date range",
mockResponse: []Workout{
{WorkoutID: 1, Name: "Morning Run", Type: "running"},
},
mockStatusCode: http.StatusOK,
opts: WorkoutListOptions{
StartDate: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC),
EndDate: time.Date(2024, 1, 31, 0, 0, 0, 0, time.UTC),
},
wantErr: false,
},
{
name: "successful list with pagination",
mockResponse: []Workout{
{WorkoutID: 2, Name: "Evening Ride", Type: "cycling"},
},
mockStatusCode: http.StatusOK,
opts: WorkoutListOptions{
Limit: 1,
Offset: 1,
},
wantErr: false,
},
{
name: "successful list with sorting",
mockResponse: []Workout{
{WorkoutID: 1, Name: "Morning Run", Type: "running"},
{WorkoutID: 2, Name: "Evening Ride", Type: "cycling"},
},
mockStatusCode: http.StatusOK,
opts: WorkoutListOptions{
SortBy: "createdDate",
SortOrder: "desc",
},
wantErr: false,
},
{
name: "successful list with type filter",
mockResponse: []Workout{
{WorkoutID: 1, Name: "Morning Run", Type: "running"},
},
mockStatusCode: http.StatusOK,
opts: WorkoutListOptions{
Type: "running",
},
wantErr: false,
},
{
name: "successful list with status filter",
mockResponse: []Workout{
{WorkoutID: 1, Name: "Morning Run", Type: "running"},
},
mockStatusCode: http.StatusOK,
opts: WorkoutListOptions{
Status: "active",
},
wantErr: false,
},
{
name: "server error",
mockResponse: nil,
mockStatusCode: http.StatusInternalServerError,
opts: WorkoutListOptions{},
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/workout-service/workouts" {
t.Errorf("expected path /workout-service/workouts, got %s", r.URL.Path)
}
w.WriteHeader(tt.mockStatusCode)
if tt.mockResponse != nil {
json.NewEncoder(w).Encode(tt.mockResponse)
}
}))
defer server.Close()
client := &APIClient{baseURL: server.URL, httpClient: server.Client()}
service := NewWorkoutService(client)
workouts, err := service.List(context.Background(), tt.opts)
if (err != nil) != tt.wantErr {
t.Errorf("WorkoutService.List() error = %v, wantErr %v", err, tt.wantErr)
return
}
if !tt.wantErr && len(workouts) != len(tt.mockResponse) {
t.Errorf("WorkoutService.List() got %d workouts, want %d", len(workouts), len(tt.mockResponse))
}
})
}
}
// TestWorkoutService_Get tests the Get method
func TestWorkoutService_Get(t *testing.T) {
tests := []struct {
name string
workoutID string
mockResponse *WorkoutDetails
mockStatusCode int
wantErr bool
}{
{
name: "successful get",
workoutID: "123",
mockResponse: &WorkoutDetails{
Workout: Workout{
WorkoutID: 123,
Name: "Test Workout",
Type: "running",
},
EstimatedDuration: 3600,
TrainingLoad: 50.5,
},
mockStatusCode: http.StatusOK,
wantErr: false,
},
{
name: "workout not found",
workoutID: "999",
mockResponse: nil,
mockStatusCode: http.StatusNotFound,
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
expectedPath := "/workout-service/workout/" + tt.workoutID
if r.URL.Path != expectedPath {
t.Errorf("expected path %s, got %s", expectedPath, r.URL.Path)
}
w.WriteHeader(tt.mockStatusCode)
if tt.mockResponse != nil {
json.NewEncoder(w).Encode(tt.mockResponse)
}
}))
defer server.Close()
client := &APIClient{baseURL: server.URL, httpClient: server.Client()}
service := NewWorkoutService(client)
workout, err := service.Get(context.Background(), tt.workoutID)
if (err != nil) != tt.wantErr {
t.Errorf("WorkoutService.Get() error = %v, wantErr %v", err, tt.wantErr)
return
}
if !tt.wantErr && workout.WorkoutID != 123 {
t.Errorf("WorkoutService.Get() got ID %d, want 123", workout.WorkoutID)
}
})
}
}
// TestWorkoutService_Create tests the Create method
func TestWorkoutService_Create(t *testing.T) {
tests := []struct {
name string
workout Workout
mockResponse *Workout
mockStatusCode int
wantErr bool
}{
{
name: "successful create",
workout: Workout{
Name: "New Workout",
Description: "Test workout",
Type: "cycling",
},
mockResponse: &Workout{
WorkoutID: 456,
Name: "New Workout",
Description: "Test workout",
Type: "cycling",
},
mockStatusCode: http.StatusCreated,
wantErr: false,
},
{
name: "invalid workout data",
workout: Workout{
Name: "",
},
mockResponse: nil,
mockStatusCode: http.StatusBadRequest,
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/workout-service/workout" {
t.Errorf("expected path /workout-service/workout, got %s", r.URL.Path)
}
body, _ := ioutil.ReadAll(r.Body)
var receivedWorkout Workout
json.Unmarshal(body, &receivedWorkout)
w.WriteHeader(tt.mockStatusCode)
if tt.mockResponse != nil {
json.NewEncoder(w).Encode(tt.mockResponse)
}
}))
defer server.Close()
client := &APIClient{baseURL: server.URL, httpClient: server.Client()}
service := NewWorkoutService(client)
createdWorkout, err := service.Create(context.Background(), tt.workout)
if (err != nil) != tt.wantErr {
t.Errorf("WorkoutService.Create() error = %v, wantErr %v", err, tt.wantErr)
return
}
if !tt.wantErr && createdWorkout.Name != tt.workout.Name {
t.Errorf("WorkoutService.Create() got name %s, want %s", createdWorkout.Name, tt.workout.Name)
}
})
}
}
// TestWorkoutService_Update tests the Update method
func TestWorkoutService_Update(t *testing.T) {
tests := []struct {
name string
workoutID string
update WorkoutUpdate
mockResponse *Workout
mockStatusCode int
wantErr bool
}{
{
name: "successful update",
workoutID: "123",
update: WorkoutUpdate{
Name: "Updated Workout",
Description: "Updated description",
},
mockResponse: &Workout{
WorkoutID: 123,
Name: "Updated Workout",
Description: "Updated description",
},
mockStatusCode: http.StatusOK,
wantErr: false,
},
{
name: "workout not found",
workoutID: "999",
update: WorkoutUpdate{
Name: "Updated Workout",
},
mockResponse: nil,
mockStatusCode: http.StatusNotFound,
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
expectedPath := "/workout-service/workout/" + tt.workoutID
if r.URL.Path != expectedPath {
t.Errorf("expected path %s, got %s", expectedPath, r.URL.Path)
}
body, _ := ioutil.ReadAll(r.Body)
var receivedUpdate WorkoutUpdate
json.Unmarshal(body, &receivedUpdate)
w.WriteHeader(tt.mockStatusCode)
if tt.mockResponse != nil {
json.NewEncoder(w).Encode(tt.mockResponse)
}
}))
defer server.Close()
client := &APIClient{baseURL: server.URL, httpClient: server.Client()}
service := NewWorkoutService(client)
updatedWorkout, err := service.Update(context.Background(), tt.workoutID, tt.update)
if (err != nil) != tt.wantErr {
t.Errorf("WorkoutService.Update() error = %v, wantErr %v", err, tt.wantErr)
return
}
if !tt.wantErr && updatedWorkout.Name != tt.update.Name {
t.Errorf("WorkoutService.Update() got name %s, want %s", updatedWorkout.Name, tt.update.Name)
}
})
}
}
// TestWorkoutService_Delete tests the Delete method
func TestWorkoutService_Delete(t *testing.T) {
tests := []struct {
name string
workoutID string
mockStatusCode int
wantErr bool
}{
{
name: "successful delete",
workoutID: "123",
mockStatusCode: http.StatusNoContent,
wantErr: false,
},
{
name: "workout not found",
workoutID: "999",
mockStatusCode: http.StatusNotFound,
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
expectedPath := "/workout-service/workout/" + tt.workoutID
if r.URL.Path != expectedPath {
t.Errorf("expected path %s, got %s", expectedPath, r.URL.Path)
}
w.WriteHeader(tt.mockStatusCode)
}))
defer server.Close()
client := &APIClient{baseURL: server.URL, httpClient: server.Client()}
service := NewWorkoutService(client)
err := service.Delete(context.Background(), tt.workoutID)
if (err != nil) != tt.wantErr {
t.Errorf("WorkoutService.Delete() error = %v, wantErr %v", err, tt.wantErr)
}
})
}
}
// TestWorkoutService_SearchWorkouts tests the SearchWorkouts method
func TestWorkoutService_SearchWorkouts(t *testing.T) {
tests := []struct {
name string
query string
limit int
mockResponse []Workout
mockStatusCode int
wantErr bool
}{
{
name: "successful search",
query: "running",
limit: 10,
mockResponse: []Workout{
{WorkoutID: 1, Name: "Morning Run", Type: "running"},
{WorkoutID: 2, Name: "Evening Run", Type: "running"},
},
mockStatusCode: http.StatusOK,
wantErr: false,
},
{
name: "empty search results",
query: "nonexistent",
limit: 10,
mockResponse: []Workout{},
mockStatusCode: http.StatusOK,
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/workout-service/search" {
t.Errorf("expected path /workout-service/search, got %s", r.URL.Path)
}
w.WriteHeader(tt.mockStatusCode)
if tt.mockResponse != nil {
json.NewEncoder(w).Encode(tt.mockResponse)
}
}))
defer server.Close()
client := &APIClient{baseURL: server.URL, httpClient: server.Client()}
service := NewWorkoutService(client)
workouts, err := service.SearchWorkouts(context.Background(), tt.query, tt.limit)
if (err != nil) != tt.wantErr {
t.Errorf("WorkoutService.SearchWorkouts() error = %v, wantErr %v", err, tt.wantErr)
return
}
if !tt.wantErr && len(workouts) != len(tt.mockResponse) {
t.Errorf("WorkoutService.SearchWorkouts() got %d workouts, want %d", len(workouts), len(tt.mockResponse))
}
})
}
}
// TestWorkoutService_GetWorkoutTemplates tests the GetWorkoutTemplates method
func TestWorkoutService_GetWorkoutTemplates(t *testing.T) {
tests := []struct {
name string
mockResponse []Workout
mockStatusCode int
wantErr bool
}{
{
name: "successful get templates",
mockResponse: []Workout{
{WorkoutID: 1, Name: "Template 1", Type: "running"},
{WorkoutID: 2, Name: "Template 2", Type: "cycling"},
},
mockStatusCode: http.StatusOK,
wantErr: false,
},
{
name: "no templates",
mockResponse: []Workout{},
mockStatusCode: http.StatusOK,
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/workout-service/templates" {
t.Errorf("expected path /workout-service/templates, got %s", r.URL.Path)
}
w.WriteHeader(tt.mockStatusCode)
if tt.mockResponse != nil {
json.NewEncoder(w).Encode(tt.mockResponse)
}
}))
defer server.Close()
client := &APIClient{baseURL: server.URL, httpClient: server.Client()}
service := NewWorkoutService(client)
templates, err := service.GetWorkoutTemplates(context.Background())
if (err != nil) != tt.wantErr {
t.Errorf("WorkoutService.GetWorkoutTemplates() error = %v, wantErr %v", err, tt.wantErr)
return
}
if !tt.wantErr && len(templates) != len(tt.mockResponse) {
t.Errorf("WorkoutService.GetWorkoutTemplates() got %d templates, want %d", len(templates), len(tt.mockResponse))
}
})
}
}
// TestWorkoutService_CopyWorkout tests the CopyWorkout method
func TestWorkoutService_CopyWorkout(t *testing.T) {
tests := []struct {
name string
workoutID string
newName string
mockResponse *Workout
mockStatusCode int
wantErr bool
}{
{
name: "successful copy",
workoutID: "123",
newName: "Copied Workout",
mockResponse: &Workout{
WorkoutID: 456,
Name: "Copied Workout",
},
mockStatusCode: http.StatusCreated,
wantErr: false,
},
{
name: "workout not found",
workoutID: "999",
newName: "Copied Workout",
mockResponse: nil,
mockStatusCode: http.StatusNotFound,
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
expectedPath := "/workout-service/workout/" + tt.workoutID + "/copy"
if r.URL.Path != expectedPath {
t.Errorf("expected path %s, got %s", expectedPath, r.URL.Path)
}
body, _ := ioutil.ReadAll(r.Body)
var requestBody map[string]string
json.Unmarshal(body, &requestBody)
if requestBody["name"] != tt.newName {
t.Errorf("expected name %s, got %s", tt.newName, requestBody["name"])
}
w.WriteHeader(tt.mockStatusCode)
if tt.mockResponse != nil {
json.NewEncoder(w).Encode(tt.mockResponse)
}
}))
defer server.Close()
client := &APIClient{baseURL: server.URL, httpClient: server.Client()}
service := NewWorkoutService(client)
copiedWorkout, err := service.CopyWorkout(context.Background(), tt.workoutID, tt.newName)
if (err != nil) != tt.wantErr {
t.Errorf("WorkoutService.CopyWorkout() error = %v, wantErr %v", err, tt.wantErr)
return
}
if !tt.wantErr && copiedWorkout.Name != tt.newName {
t.Errorf("WorkoutService.CopyWorkout() got name %s, want %s", copiedWorkout.Name, tt.newName)
}
})
}
}
// TestWorkoutService_Export tests the Export method
func TestWorkoutService_Export(t *testing.T) {
tests := []struct {
name string
workoutID string
format string
mockStatusCode int
wantErr bool
}{
{
name: "successful export fit",
workoutID: "123",
format: "fit",
mockStatusCode: http.StatusOK,
wantErr: false,
},
{
name: "successful export tcx",
workoutID: "123",
format: "tcx",
mockStatusCode: http.StatusOK,
wantErr: false,
},
{
name: "invalid format",
workoutID: "123",
format: "invalid",
mockStatusCode: http.StatusBadRequest,
wantErr: true,
},
{
name: "workout not found",
workoutID: "999",
format: "fit",
mockStatusCode: http.StatusNotFound,
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
expectedPath := "/download-service/export/" + tt.format + "/workout/" + tt.workoutID
if r.URL.Path != expectedPath {
t.Errorf("expected path %s, got %s", expectedPath, r.URL.Path)
}
w.WriteHeader(tt.mockStatusCode)
if !tt.wantErr {
w.Write([]byte("test export data"))
}
}))
defer server.Close()
client := &APIClient{baseURL: server.URL, httpClient: server.Client()}
service := NewWorkoutService(client)
reader, err := service.Export(context.Background(), tt.workoutID, tt.format)
if (err != nil) != tt.wantErr {
t.Errorf("WorkoutService.Export() error = %v, wantErr %v", err, tt.wantErr)
return
}
if !tt.wantErr {
defer reader.Close()
data, _ := ioutil.ReadAll(reader)
if string(data) != "test export data" {
t.Errorf("WorkoutService.Export() got unexpected export data")
}
}
})
}
}
// TestWorkoutService_ContextMethods tests the new context-aware methods
func TestWorkoutService_ContextMethods(t *testing.T) {
t.Run("Create with context", func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(&Workout{WorkoutID: 123, Name: "Test Workout"})
}))
defer server.Close()
client := &APIClient{baseURL: server.URL, httpClient: server.Client()}
service := NewWorkoutService(client)
workout, err := service.Create(context.Background(), Workout{Name: "Test Workout"})
if err != nil {
t.Errorf("Create() error = %v", err)
return
}
if workout.WorkoutID != 123 {
t.Errorf("Create() got ID %d, want 123", workout.WorkoutID)
}
})
t.Run("Get with context", func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(&WorkoutDetails{
Workout: Workout{WorkoutID: 123, Name: "Test Workout"},
})
}))
defer server.Close()
client := &APIClient{baseURL: server.URL, httpClient: server.Client()}
service := NewWorkoutService(client)
workout, err := service.Get(context.Background(), "123")
if err != nil {
t.Errorf("Get() error = %v", err)
return
}
if workout.WorkoutID != 123 {
t.Errorf("Get() got ID %d, want 123", workout.WorkoutID)
}
})
t.Run("Update with context", func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(&Workout{WorkoutID: 123, Name: "Updated Workout"})
}))
defer server.Close()
client := &APIClient{baseURL: server.URL, httpClient: server.Client()}
service := NewWorkoutService(client)
updatedWorkout, err := service.Update(context.Background(), "123", WorkoutUpdate{Name: "Updated Workout"})
if err != nil {
t.Errorf("Update() error = %v", err)
return
}
if updatedWorkout.Name != "Updated Workout" {
t.Errorf("Update() got name %s, want 'Updated Workout'", updatedWorkout.Name)
}
})
t.Run("Delete with context", func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent)
}))
defer server.Close()
client := &APIClient{baseURL: server.URL, httpClient: server.Client()}
service := NewWorkoutService(client)
err := service.Delete(context.Background(), "123")
if err != nil {
t.Errorf("Delete() error = %v", err)
}
})
}