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 @@ + + + + + +
+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.
+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.
+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.
+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.
+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.
+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.
+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.
+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.
+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.
+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.
+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.
+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.
+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.
+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.
+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.
+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.
+