diff --git a/auth.go b/auth.go index 3be2c6b..0574dac 100644 --- a/auth.go +++ b/auth.go @@ -2,10 +2,7 @@ package garth import ( "context" - "crypto/hmac" - "crypto/sha1" "crypto/tls" - "encoding/base64" "encoding/json" "errors" "fmt" @@ -16,7 +13,7 @@ import ( "net/url" "os" "regexp" - "sort" + "strconv" "strings" "time" @@ -164,28 +161,10 @@ func (a *GarthAuthenticator) Login(ctx context.Context, username, password, mfaT } } - // Step 3: Get OAuth1 request token - oauth1RequestToken, err := a.fetchOAuth1RequestToken(ctx) + // Step 3: Exchange service ticket for access token + token, err := a.exchangeServiceTicketForToken(ctx, serviceTicket) if err != nil { - return nil, fmt.Errorf("failed to get OAuth1 request token: %w", err) - } - - // Step 4: Authorize OAuth1 request token (using the session from authentication) - err = a.authorizeOAuth1Token(ctx, oauth1RequestToken) - if err != nil { - return nil, fmt.Errorf("failed to authorize OAuth1 token: %w", err) - } - - // Step 5: Exchange service ticket for OAuth1 access token - oauth1AccessToken, err := a.exchangeTicketForOAuth1Token(ctx, serviceTicket) - if err != nil { - return nil, fmt.Errorf("failed to exchange ticket for OAuth1 access token: %w", err) - } - - // Step 6: Exchange OAuth1 access token for OAuth2 token - token, err := a.exchangeOAuth1ForOAuth2Token(ctx, oauth1AccessToken) - if err != nil { - return nil, fmt.Errorf("failed to exchange OAuth1 for OAuth2 token: %w", err) + return nil, fmt.Errorf("failed to exchange service ticket for token: %w", err) } if err := a.storage.StoreToken(token); err != nil { @@ -195,203 +174,61 @@ func (a *GarthAuthenticator) Login(ctx context.Context, username, password, mfaT return token, nil } -func (a *GarthAuthenticator) fetchOAuth1RequestToken(ctx context.Context) (*OAuth1Token, error) { - req, err := http.NewRequestWithContext(ctx, "GET", fmt.Sprintf("https://connectapi.%s/oauth-service/oauth/request_token", a.domain), 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 request: %w", err) - } - - // Sign request with OAuth1 consumer credentials - req.Header.Set("Authorization", a.buildOAuth1Header(req, nil, "")) - - resp, err := a.client.Do(req) - if err != nil { - return nil, fmt.Errorf("request failed: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("request failed with status: %d", resp.StatusCode) - } - - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, fmt.Errorf("failed to read response: %w", err) - } - - values, err := url.ParseQuery(string(body)) - if err != nil { - return nil, fmt.Errorf("failed to parse response: %w", err) - } - - return &OAuth1Token{ - Token: values.Get("oauth_token"), - Secret: values.Get("oauth_token_secret"), - }, nil -} - -func (a *GarthAuthenticator) authorizeOAuth1Token(ctx context.Context, token *OAuth1Token) error { - params := url.Values{} - params.Set("oauth_token", token.Token) - authURL := fmt.Sprintf("https://connect.%s/oauthConfirm?%s", a.domain, params.Encode()) - - req, err := http.NewRequestWithContext(ctx, "GET", authURL, nil) - if err != nil { - return fmt.Errorf("failed to create authorization request: %w", err) + return nil, fmt.Errorf("failed to create callback request: %w", err) } // Use realistic browser headers - req.Header = a.getRealisticBrowserHeaders("https://connect.garmin.com") + req.Header = a.getRealisticBrowserHeaders("https://sso.garmin.com") resp, err := a.client.Do(req) if err != nil { - return fmt.Errorf("authorization request failed: %w", err) - } - defer resp.Body.Close() - - // We don't need the CSRF token anymore, so just check for success - if resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(resp.Body) - return fmt.Errorf("authorization failed with status: %d, response: %s", resp.StatusCode, body) - } - - return nil -} - -func (a *GarthAuthenticator) exchangeTicketForOAuth1Token(ctx context.Context, ticket string) (*OAuth1Token, error) { - data := url.Values{} - data.Set("oauth_verifier", ticket) - - req, err := http.NewRequestWithContext(ctx, "POST", fmt.Sprintf("https://connectapi.%s/oauth-service/oauth/access_token", a.domain), strings.NewReader(data.Encode())) - if err != nil { - return nil, fmt.Errorf("failed to create access token request: %w", err) - } - - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - req.Header.Set("Authorization", a.buildOAuth1Header(req, nil, "")) - - resp, err := a.client.Do(req) - if err != nil { - return nil, fmt.Errorf("access token request failed: %w", err) + return nil, fmt.Errorf("callback request failed: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("access token request failed with status: %d", resp.StatusCode) + 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 access token response: %w", err) + return nil, fmt.Errorf("failed to read callback response: %w", err) } - values, err := url.ParseQuery(string(body)) + // Extract tokens from embedded JavaScript + accessToken, err := extractParam(`"accessToken":"([^"]+)"`, string(body)) if err != nil { - return nil, fmt.Errorf("failed to parse access token response: %w", err) + return nil, fmt.Errorf("failed to extract access token: %w", err) } - return &OAuth1Token{ - Token: values.Get("oauth_token"), - Secret: values.Get("oauth_token_secret"), + 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) exchangeOAuth1ForOAuth2Token(ctx context.Context, oauth1Token *OAuth1Token) (*Token, error) { - req, err := http.NewRequestWithContext(ctx, "POST", fmt.Sprintf("https://connectapi.%s/oauth-service/oauth/exchange_token", a.domain), nil) - if err != nil { - return nil, fmt.Errorf("failed to create token exchange request: %w", err) - } - - req.Header.Set("Authorization", a.buildOAuth1Header(req, oauth1Token, "")) - - resp, err := a.client.Do(req) - if err != nil { - return nil, fmt.Errorf("token exchange request failed: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("token exchange failed with status: %d", resp.StatusCode) - } - - var token Token - if err := json.NewDecoder(resp.Body).Decode(&token); err != nil { - return nil, fmt.Errorf("failed to parse token response: %w", err) - } - - if token.OAuth2Token != nil { - token.OAuth2Token.ExpiresAt = time.Now().Add(time.Duration(token.OAuth2Token.ExpiresIn) * time.Second).Unix() - } - token.OAuth1Token = oauth1Token - return &token, nil -} - -func (a *GarthAuthenticator) buildOAuth1Header(req *http.Request, token *OAuth1Token, callback string) string { - oauthParams := url.Values{} - oauthParams.Set("oauth_consumer_key", "fc020df2-e33d-4ec5-987a-7fb6de2e3850") - oauthParams.Set("oauth_signature_method", "HMAC-SHA1") - oauthParams.Set("oauth_timestamp", fmt.Sprintf("%d", time.Now().Unix())) - oauthParams.Set("oauth_nonce", fmt.Sprintf("%d", rand.Int63())) - oauthParams.Set("oauth_version", "1.0") - - if token != nil { - oauthParams.Set("oauth_token", token.Token) - } - if callback != "" { - oauthParams.Set("oauth_callback", callback) - } - - // Generate signature - baseString := a.buildSignatureBaseString(req, oauthParams) - signingKey := url.QueryEscape("secret_key_from_mobile_app") + "&" - if token != nil { - signingKey += url.QueryEscape(token.Secret) - } - - mac := hmac.New(sha1.New, []byte(signingKey)) - mac.Write([]byte(baseString)) - signature := base64.StdEncoding.EncodeToString(mac.Sum(nil)) - oauthParams.Set("oauth_signature", signature) - - // Build header - params := make([]string, 0, len(oauthParams)) - for k, v := range oauthParams { - params = append(params, fmt.Sprintf(`%s="%s"`, k, url.QueryEscape(v[0]))) - } - sort.Strings(params) - - return "OAuth " + strings.Join(params, ", ") -} - -func (a *GarthAuthenticator) buildSignatureBaseString(req *http.Request, oauthParams url.Values) string { - method := strings.ToUpper(req.Method) - baseURL := req.URL.Scheme + "://" + req.URL.Host + req.URL.Path - - // Collect all parameters - params := url.Values{} - for k, v := range req.URL.Query() { - params[k] = v - } - for k, v := range oauthParams { - params[k] = v - } - - // Sort parameters - paramKeys := make([]string, 0, len(params)) - for k := range params { - paramKeys = append(paramKeys, k) - } - sort.Strings(paramKeys) - - paramPairs := make([]string, 0, len(paramKeys)) - for _, k := range paramKeys { - paramPairs = append(paramPairs, fmt.Sprintf("%s=%s", k, url.QueryEscape(params[k][0]))) - } - - queryString := strings.Join(paramPairs, "&") - return fmt.Sprintf("%s&%s&%s", method, url.QueryEscape(baseURL), url.QueryEscape(queryString)) -} - func (a *GarthAuthenticator) getLoginTicket(ctx context.Context) (string, string, error) { params := url.Values{} params.Set("id", "gauth-widget") @@ -527,10 +364,6 @@ func (a *GarthAuthenticator) authenticate(ctx context.Context, username, passwor return authResponse.Ticket, nil } -func (a *GarthAuthenticator) ExchangeToken(ctx context.Context, token *OAuth1Token) (*Token, error) { - return a.exchangeOAuth1ForOAuth2Token(ctx, token) -} - func (a *GarthAuthenticator) getEnhancedBrowserHeaders(referrer string) http.Header { u, _ := url.Parse(referrer) origin := fmt.Sprintf("%s://%s", u.Scheme, u.Host) @@ -553,25 +386,27 @@ func (a *GarthAuthenticator) getEnhancedBrowserHeaders(referrer string) http.Hea } func getCSRFToken(html string) (string, string, error) { - // Extract login ticket (lt) from hidden input field - re := regexp.MustCompile(``) - matches := re.FindStringSubmatch(html) - if len(matches) > 1 { - return matches[1], "lt", nil + // More robust regex patterns to handle variations in HTML structure + patterns := []struct { + regex string + tokenType string + }{ + // Pattern for login ticket (lt) + {`]*name="lt"[^>]*value="([^"]+)"`, "lt"}, + // Pattern for CSRF token in hidden input + {`]*name="_csrf"[^>]*value="([^"]+)"`, "_csrf"}, + // Pattern for CSRF token in meta tag + {`]*name="_csrf"[^>]*content="([^"]+)"`, "_csrf"}, + // Pattern for CSRF token in JSON payload + {`"csrfToken"\s*:\s*"([^"]+)"`, "_csrf"}, } - // Extract CSRF token as fallback - re = regexp.MustCompile(``) - matches = re.FindStringSubmatch(html) - if len(matches) > 1 { - return matches[1], "_csrf", nil - } - - // Try alternative CSRF token pattern - re = regexp.MustCompile(`"csrfToken":"([^"]+)"`) - matches = re.FindStringSubmatch(html) - if len(matches) > 1 { - return matches[1], "_csrf", nil + 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 @@ -609,15 +444,22 @@ func (a *GarthAuthenticator) RefreshToken(ctx context.Context, refreshToken stri return nil, fmt.Errorf("refresh failed: %d %s", resp.StatusCode, body) } - var token Token - if err := json.NewDecoder(resp.Body).Decode(&token); err != nil { + 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) } - if token.OAuth2Token != nil { - token.OAuth2Token.ExpiresAt = time.Now().Add(time.Duration(token.OAuth2Token.ExpiresIn) * time.Second).Unix() - } - return &token, nil + 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 diff --git a/auth_test.go b/auth_test.go index ef5e094..8202966 100644 --- a/auth_test.go +++ b/auth_test.go @@ -59,27 +59,27 @@ func TestRealAuthentication(t *testing.T) { } log.Printf("Authentication successful! Token details:") - log.Printf("Access Token: %s", token.OAuth2Token.AccessToken) - log.Printf("Expires At: %d", token.OAuth2Token.ExpiresAt) - log.Printf("Refresh Token: %s", token.OAuth2Token.RefreshToken) + 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.OAuth2Token.AccessToken != token.OAuth2Token.AccessToken { + 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.OAuth2Token.RefreshToken) + newToken, err := auth.RefreshToken(ctx, token.RefreshToken) if err != nil { t.Fatalf("Token refresh failed: %v", err) } - if newToken.OAuth2Token.AccessToken == token.OAuth2Token.AccessToken { + if newToken.AccessToken == token.AccessToken { t.Fatal("Refreshed token should be different from original") } log.Println("Token refresh successful") diff --git a/client.go b/client.go index 7cea2e0..9616b91 100644 --- a/client.go +++ b/client.go @@ -59,7 +59,7 @@ func (t *AuthTransport) RoundTrip(req *http.Request) (*http.Response, error) { } // Add Authorization header - req.Header.Set("Authorization", "Bearer "+token.OAuth2Token.AccessToken) + req.Header.Set("Authorization", "Bearer "+token.AccessToken) req.Header.Set("User-Agent", t.userAgent) req.Header.Set("Referer", "https://sso.garmin.com/sso/signin") @@ -85,7 +85,7 @@ func (t *AuthTransport) RoundTrip(req *http.Request) (*http.Response, error) { if err != nil { return nil, err } - req.Header.Set("Authorization", "Bearer "+token.OAuth2Token.AccessToken) + req.Header.Set("Authorization", "Bearer "+token.AccessToken) continue } @@ -123,7 +123,7 @@ func (t *AuthTransport) refreshToken(ctx context.Context, token *Token) (*Token, } // Perform refresh - newToken, err := t.auth.RefreshToken(ctx, token.OAuth2Token.RefreshToken) + newToken, err := t.auth.RefreshToken(ctx, token.RefreshToken) if err != nil { return nil, err } diff --git a/cmd/debug_auth/main.go b/cmd/debug_auth/main.go index 0d44cb6..c7cffc8 100644 --- a/cmd/debug_auth/main.go +++ b/cmd/debug_auth/main.go @@ -40,16 +40,16 @@ func main() { } fmt.Println("\nAuthentication successful! Token details:") - fmt.Printf("Access Token: %s\n", token.OAuth2Token.AccessToken) - fmt.Printf("Expires At: %d\n", token.OAuth2Token.ExpiresAt) - fmt.Printf("Refresh Token: %s\n", token.OAuth2Token.RefreshToken) + 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.OAuth2Token.AccessToken != token.OAuth2Token.AccessToken { + if storedToken.AccessToken != token.AccessToken { log.Fatal("Stored token doesn't match authenticated token") } diff --git a/debug/oauth1_response_1757098946234920274.html b/debug/oauth1_response_1757098946234920274.html new file mode 100644 index 0000000..cfbb071 Binary files /dev/null and b/debug/oauth1_response_1757098946234920274.html differ diff --git a/debug/oauth1_response_1757098948026901630.html b/debug/oauth1_response_1757098948026901630.html new file mode 100644 index 0000000..408931e Binary files /dev/null and b/debug/oauth1_response_1757098948026901630.html differ diff --git a/debug/oauth1_response_1757129132587570285.html b/debug/oauth1_response_1757129132587570285.html new file mode 100644 index 0000000..b9517d8 Binary files /dev/null and b/debug/oauth1_response_1757129132587570285.html differ diff --git a/debug/oauth1_response_1757129134048278764.html b/debug/oauth1_response_1757129134048278764.html new file mode 100644 index 0000000..4899296 Binary files /dev/null and b/debug/oauth1_response_1757129134048278764.html differ diff --git a/debug/oauth1_response_1757129231414677442.html b/debug/oauth1_response_1757129231414677442.html new file mode 100644 index 0000000..37755bc Binary files /dev/null and b/debug/oauth1_response_1757129231414677442.html differ diff --git a/debug/oauth1_response_1757129232952257721.html b/debug/oauth1_response_1757129232952257721.html new file mode 100644 index 0000000..2e3c8e8 Binary files /dev/null and b/debug/oauth1_response_1757129232952257721.html differ diff --git a/debug_auth_response_1757103692.html b/debug_auth_response_1757103692.html new file mode 100644 index 0000000..12bb07f --- /dev/null +++ b/debug_auth_response_1757103692.html @@ -0,0 +1,93 @@ + + + + + + +Attention Required! | Cloudflare + + + + + + + + + + + + + + + + + +
+ +
+
+

Sorry, you have been blocked

+

You are unable to access sso.garmin.com

+
+ +
+
+
+ + + +
+
+
+ +
+
+
+

Why have I been blocked?

+ +

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.

+
+ +
+

What can I do to resolve this?

+ +

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.

+
+
+
+ + + + +
+
+ + + + + diff --git a/debug_auth_response_1757103857.html b/debug_auth_response_1757103857.html new file mode 100644 index 0000000..8b9ee61 --- /dev/null +++ b/debug_auth_response_1757103857.html @@ -0,0 +1,93 @@ + + + + + + +Attention Required! | Cloudflare + + + + + + + + + + + + + + + + + +
+ +
+
+

Sorry, you have been blocked

+

You are unable to access sso.garmin.com

+
+ +
+
+
+ + + +
+
+
+ +
+
+
+

Why have I been blocked?

+ +

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.

+
+ +
+

What can I do to resolve this?

+ +

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.

+
+
+
+ + + + +
+
+ + + + + diff --git a/debug_auth_response_1757104480.html b/debug_auth_response_1757104480.html new file mode 100644 index 0000000..521e6da --- /dev/null +++ b/debug_auth_response_1757104480.html @@ -0,0 +1,93 @@ + + + + + + +Attention Required! | Cloudflare + + + + + + + + + + + + + + + + + +
+ +
+
+

Sorry, you have been blocked

+

You are unable to access sso.garmin.com

+
+ +
+
+
+ + + +
+
+
+ +
+
+
+

Why have I been blocked?

+ +

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.

+
+ +
+

What can I do to resolve this?

+ +

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.

+
+
+
+ + + + +
+
+ + + + + diff --git a/debug_auth_response_1757116993.html b/debug_auth_response_1757116993.html new file mode 100644 index 0000000..fb5c2eb --- /dev/null +++ b/debug_auth_response_1757116993.html @@ -0,0 +1,93 @@ + + + + + + +Attention Required! | Cloudflare + + + + + + + + + + + + + + + + + +
+ +
+
+

Sorry, you have been blocked

+

You are unable to access sso.garmin.com

+
+ +
+
+
+ + + +
+
+
+ +
+
+
+

Why have I been blocked?

+ +

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.

+
+ +
+

What can I do to resolve this?

+ +

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.

+
+
+
+ + + + +
+
+ + + + + diff --git a/debug_auth_response_1757117105.html b/debug_auth_response_1757117105.html new file mode 100644 index 0000000..0f24a32 --- /dev/null +++ b/debug_auth_response_1757117105.html @@ -0,0 +1,93 @@ + + + + + + +Attention Required! | Cloudflare + + + + + + + + + + + + + + + + + +
+ +
+
+

Sorry, you have been blocked

+

You are unable to access sso.garmin.com

+
+ +
+
+
+ + + +
+
+
+ +
+
+
+

Why have I been blocked?

+ +

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.

+
+ +
+

What can I do to resolve this?

+ +

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.

+
+
+
+ + + + +
+
+ + + + + diff --git a/debug_auth_response_1757117228.html b/debug_auth_response_1757117228.html new file mode 100644 index 0000000..c35f2dc --- /dev/null +++ b/debug_auth_response_1757117228.html @@ -0,0 +1,93 @@ + + + + + + +Attention Required! | Cloudflare + + + + + + + + + + + + + + + + + +
+ +
+
+

Sorry, you have been blocked

+

You are unable to access sso.garmin.com

+
+ +
+
+
+ + + +
+
+
+ +
+
+
+

Why have I been blocked?

+ +

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.

+
+ +
+

What can I do to resolve this?

+ +

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.

+
+
+
+ + + + +
+
+ + + + + diff --git a/debug_auth_response_1757117813.html b/debug_auth_response_1757117813.html new file mode 100644 index 0000000..07708f4 --- /dev/null +++ b/debug_auth_response_1757117813.html @@ -0,0 +1,93 @@ + + + + + + +Attention Required! | Cloudflare + + + + + + + + + + + + + + + + + +
+ +
+
+

Sorry, you have been blocked

+

You are unable to access sso.garmin.com

+
+ +
+
+
+ + + +
+
+
+ +
+
+
+

Why have I been blocked?

+ +

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.

+
+ +
+

What can I do to resolve this?

+ +

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.

+
+
+
+ + + + +
+
+ + + + + diff --git a/debug_auth_response_1757119493.html b/debug_auth_response_1757119493.html new file mode 100644 index 0000000..52c2454 --- /dev/null +++ b/debug_auth_response_1757119493.html @@ -0,0 +1,93 @@ + + + + + + +Attention Required! | Cloudflare + + + + + + + + + + + + + + + + + +
+ +
+
+

Sorry, you have been blocked

+

You are unable to access sso.garmin.com

+
+ +
+
+
+ + + +
+
+
+ +
+
+
+

Why have I been blocked?

+ +

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.

+
+ +
+

What can I do to resolve this?

+ +

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.

+
+
+
+ + + + +
+
+ + + + + diff --git a/debug_response_1757099867.html b/debug_response_1757099867.html new file mode 100644 index 0000000..904f177 Binary files /dev/null and b/debug_response_1757099867.html differ diff --git a/debug_response_fixed_1757099969.html b/debug_response_fixed_1757099969.html new file mode 100644 index 0000000..5e5f0d3 --- /dev/null +++ b/debug_response_fixed_1757099969.html @@ -0,0 +1,206 @@ + + + + + + + GARMIN Authentication Application + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + +
+

Sign In

+ +
+ +
+ + + + + + + + + + +
+
+ + + + + + +
+ +
+ + + + +
+ + + + + + + + + +
+
+ + + +
+ +
+ + + +
+ + + +
+ +
+
+ + +
+ + + + + diff --git a/garth.go b/garth.go index 4c3b7cf..cc38e14 100644 --- a/garth.go +++ b/garth.go @@ -32,7 +32,6 @@ package garth import ( "context" - "errors" "net/http" "os" "strconv" @@ -41,15 +40,12 @@ import ( // Authenticator defines the authentication interface type Authenticator interface { - // Login authenticates with Garmin services using OAuth1/OAuth2 hybrid flow + // 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) - // ExchangeToken exchanges OAuth1 token for OAuth2 token - ExchangeToken(ctx context.Context, oauth1Token *OAuth1Token) (*Token, error) - // GetClient returns an authenticated HTTP client GetClient() *http.Client } @@ -89,16 +85,3 @@ func NewClientOptionsFromEnv() ClientOptions { return opts } - -// getRequestToken retrieves OAuth1 request token -// getRequestToken retrieves OAuth1 request token -func getRequestToken(a Authenticator, ctx context.Context) (*OAuth1Token, error) { - // Implementation will be added in next step - return nil, errors.New("not implemented") -} - -// authorizeRequestToken authorizes OAuth1 token through SSO -func authorizeRequestToken(a Authenticator, ctx context.Context, token *OAuth1Token) error { - // Implementation will be added in next step - return errors.New("not implemented") -} diff --git a/types.go b/types.go index 13afd4b..b6e219d 100644 --- a/types.go +++ b/types.go @@ -8,49 +8,23 @@ import ( // Unified Token Definitions -// OAuth1Token represents OAuth1 credentials -type OAuth1Token struct { - Token string - Secret string -} - -// OAuth2Token represents OAuth2 credentials -type OAuth2Token struct { - AccessToken string `json:"access_token"` - TokenType string `json:"token_type"` - ExpiresIn int `json:"expires_in"` - ExpiresAt int64 `json:"expires_at"` - RefreshToken string `json:"refresh_token"` - Scope string `json:"scope"` -} - -// IsExpired checks if the token has expired -func (t *OAuth2Token) IsExpired() bool { - return time.Now().After(time.Unix(t.ExpiresAt, 0)) -} - -// Token represents unified authentication credentials +// Token represents authentication credentials type Token struct { - Domain string `json:"domain"` - OAuth1Token *OAuth1Token `json:"oauth1_token"` - OAuth2Token *OAuth2Token `json:"oauth2_token"` - UserProfile *UserProfile `json:"user_profile"` + 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 OAuth2 token has expired +// IsExpired checks if the token has expired (with 60 second buffer) func (t *Token) IsExpired() bool { - if t.OAuth2Token == nil { - return true - } - return t.OAuth2Token.IsExpired() + return time.Now().Unix() >= (t.ExpiresAt - 60) } // NeedsRefresh checks if token needs refresh (within 5 min expiry window) func (t *Token) NeedsRefresh() bool { - if t.OAuth2Token == nil { - return true - } - return time.Now().Add(5 * time.Minute).After(time.Unix(t.OAuth2Token.ExpiresAt, 0)) + return time.Now().Unix() >= (t.ExpiresAt - 300) } // UserProfile represents Garmin user profile information