diff --git a/auth.go b/auth.go
index fbb3199..3be2c6b 100644
--- a/auth.go
+++ b/auth.go
@@ -14,6 +14,7 @@ import (
"math/rand"
"net/http"
"net/url"
+ "os"
"regexp"
"sort"
"strings"
@@ -37,11 +38,23 @@ func NewAuthenticator(opts ClientOptions) Authenticator {
if opts.Domain == "" {
opts.Domain = "garmin.com"
}
+
+ // Enhanced transport with better TLS settings and compression
baseTransport := &http.Transport{
TLSClientConfig: &tls.Config{
- MinVersion: tls.VersionTLS12,
+ 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,
+ Proxy: http.ProxyFromEnvironment,
+ DisableCompression: false, // Enable compression
+ MaxIdleConns: 10,
+ IdleConnTimeout: 30 * time.Second,
+ TLSHandshakeTimeout: 10 * time.Second,
}
jar, err := cookiejar.New(nil)
@@ -57,6 +70,15 @@ func NewAuthenticator(opts ClientOptions) Authenticator {
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 {
@@ -74,7 +96,7 @@ func NewAuthenticator(opts ClientOptions) Authenticator {
client: client,
tokenURL: opts.TokenURL,
storage: opts.Storage,
- userAgent: "GCMv3",
+ 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,
}
@@ -85,7 +107,37 @@ func NewAuthenticator(opts ClientOptions) Authenticator {
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 {
@@ -188,7 +240,8 @@ func (a *GarthAuthenticator) authorizeOAuth1Token(ctx context.Context, token *OA
return fmt.Errorf("failed to create authorization request: %w", err)
}
- req.Header = a.getEnhancedBrowserHeaders(authURL)
+ // Use realistic browser headers
+ req.Header = a.getRealisticBrowserHeaders("https://connect.garmin.com")
resp, err := a.client.Do(req)
if err != nil {
@@ -364,11 +417,13 @@ func (a *GarthAuthenticator) getLoginTicket(ctx context.Context) (string, string
return "", "", fmt.Errorf("failed to create login page request: %w", err)
}
- 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("Referer", fmt.Sprintf("https://sso.%s/sso/signin?id=gauth-widget&embedWidget=true&gauthHost=https://sso.%s/sso", a.domain, a.domain))
- req.Header.Set("Origin", fmt.Sprintf("https://sso.%s", a.domain))
+ // 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 {
@@ -376,6 +431,11 @@ func (a *GarthAuthenticator) getLoginTicket(ctx context.Context) (string, string
}
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)
@@ -416,6 +476,11 @@ func (a *GarthAuthenticator) authenticate(ctx context.Context, username, passwor
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 {
@@ -466,14 +531,12 @@ func (a *GarthAuthenticator) ExchangeToken(ctx context.Context, token *OAuth1Tok
return a.exchangeOAuth1ForOAuth2Token(ctx, token)
}
-// Removed exchangeTicketForToken method - no longer needed
-
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": {"GCMv3"},
+ "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"},
@@ -490,34 +553,35 @@ func (a *GarthAuthenticator) getEnhancedBrowserHeaders(referrer string) http.Hea
}
func getCSRFToken(html string) (string, string, error) {
- ltPatterns := []string{
- `name="lt"\s+value="([^"]+)"`,
- `name="lt"\s+type="hidden"\s+value="([^"]+)"`,
- `]*name="lt"[^>]*value="([^"]+)"[^>]*>`,
+ // Extract login ticket (lt) from hidden input field
+ re := regexp.MustCompile(``)
+ matches := re.FindStringSubmatch(html)
+ if len(matches) > 1 {
+ return matches[1], "lt", nil
}
- for _, pattern := range ltPatterns {
- re := regexp.MustCompile(pattern)
- matches := re.FindStringSubmatch(html)
- if len(matches) > 1 {
- return matches[1], "lt", nil
- }
+ // Extract CSRF token as fallback
+ re = regexp.MustCompile(``)
+ matches = re.FindStringSubmatch(html)
+ if len(matches) > 1 {
+ return matches[1], "_csrf", nil
}
- csrfPatterns := []string{
- `name="_csrf"\s+value="([^"]+)"`,
- `"csrfToken":"([^"]+)"`,
+ // Try alternative CSRF token pattern
+ re = regexp.MustCompile(`"csrfToken":"([^"]+)"`)
+ matches = re.FindStringSubmatch(html)
+ if len(matches) > 1 {
+ return matches[1], "_csrf", nil
}
- for _, pattern := range csrfPatterns {
- re := regexp.MustCompile(pattern)
- matches := re.FindStringSubmatch(html)
- if len(matches) > 1 {
- return matches[1], "_csrf", 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 "", "", errors.New("no authentication token found")
+ return "", "", fmt.Errorf("no authentication token found in HTML response; response written to %s", debugFilename)
}
// RefreshToken implements token refresh functionality
@@ -592,7 +656,7 @@ func (a *GarthAuthenticator) handleMFA(ctx context.Context, username, password,
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Accept", "application/json")
- req.Header.Set("User-Agent", "GCMv3")
+ req.Header.Set("User-Agent", a.userAgent)
resp, err := a.client.Do(req)
if err != nil {
diff --git a/cloudflare.go b/cloudflare.go
new file mode 100644
index 0000000..7a3d755
--- /dev/null
+++ b/cloudflare.go
@@ -0,0 +1,157 @@
+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
+}
diff --git a/debug/oauth1_response_1757093254543542353.html b/debug/oauth1_response_1757093254543542353.html
new file mode 100644
index 0000000..5154463
Binary files /dev/null and b/debug/oauth1_response_1757093254543542353.html differ
diff --git a/debug/oauth1_response_1757093255905112264.html b/debug/oauth1_response_1757093255905112264.html
new file mode 100644
index 0000000..ee53a4a
Binary files /dev/null and b/debug/oauth1_response_1757093255905112264.html differ
diff --git a/debug/oauth1_response_1757093301799755076.html b/debug/oauth1_response_1757093301799755076.html
new file mode 100644
index 0000000..561e3d9
Binary files /dev/null and b/debug/oauth1_response_1757093301799755076.html differ