From 6d1c5725386fa7a43392c3acc7b67a58973876b5 Mon Sep 17 00:00:00 2001 From: sstent Date: Wed, 3 Sep 2025 13:23:48 -0700 Subject: [PATCH] sync --- auth.go | 126 ++++++---- claude.md | 224 ++++++++++++++++++ .../debug/oauth1_response_1756930976.html | 2 + .../debug/oauth1_response_1756930988.html | 2 + 4 files changed, 312 insertions(+), 42 deletions(-) create mode 100644 claude.md create mode 100644 go-garth/debug/oauth1_response_1756930976.html create mode 100644 go-garth/debug/oauth1_response_1756930988.html diff --git a/auth.go b/auth.go index 2263688..e05fbd8 100644 --- a/auth.go +++ b/auth.go @@ -10,6 +10,7 @@ import ( "net/http" "net/url" "os" + "path/filepath" "regexp" "strings" "time" @@ -74,22 +75,22 @@ func NewAuthenticator(opts ClientOptions) Authenticator { // Login authenticates with Garmin services func (a *GarthAuthenticator) Login(ctx context.Context, username, password, mfaToken string) (*Token, error) { - // Fetch and store OAuth1 token to initialize session + // 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 - // Get login parameters including CSRF token - authToken, tokenType, err := a.fetchLoginParams(ctx) + // 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 // Store for session - // Call authenticate with the extracted token and its type + // Step 3: Authenticate with all tokens token, err := a.authenticate(ctx, username, password, mfaToken, authToken, tokenType) if err != nil { return nil, err @@ -174,34 +175,41 @@ func (a *GarthAuthenticator) GetClient() *http.Client { return a.client } -// fetchLoginParams retrieves required tokens from Garmin login page and returns token + type -func (a *GarthAuthenticator) fetchLoginParams(ctx context.Context) (token string, tokenType string, err error) { - // Step 1: Set cookies by accessing the embed endpoint - embedURL := "https://sso.garmin.com/sso/embed?" + url.Values{ - "id": []string{"gauth-widget"}, - "embedWidget": []string{"true"}, - "gauthHost": []string{"https://sso.garmin.com/sso"}, - }.Encode() +// fetchLoginParamsWithOAuth1 retrieves login parameters 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") - embedReq, err := http.NewRequestWithContext(ctx, "GET", embedURL, nil) - if err != nil { - return "", "", fmt.Errorf("failed to create embed request: %w", err) - } - embedReq.Header = a.getEnhancedBrowserHeaders(embedURL) - - _, err = a.client.Do(embedReq) - if err != nil { - return "", "", fmt.Errorf("embed request failed: %w", err) + // Add OAuth1 token if we have it + if a.oauth1Token != "" { + params.Set("oauth_token", a.oauth1Token) } - // Step 2: Get login parameters including CSRF token - loginURL := a.buildLoginURL() + 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) } - req.Header = a.getEnhancedBrowserHeaders(loginURL) + // 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 { @@ -216,14 +224,15 @@ func (a *GarthAuthenticator) fetchLoginParams(ctx context.Context) (token string bodyStr := string(body) - // Use our robust CSRF token extractor with multiple patterns + // Extract CSRF/lt token token, tokenType, err = getCSRFTokenWithType(bodyStr) if err != nil { - filename := fmt.Sprintf("login_page_%d.html", time.Now().Unix()) + // 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: %w (HTML saved to %s)", err, filename) + return "", "", fmt.Errorf("authentication token not found with OAuth1 context: %w (HTML saved to %s)", err, filename) } - return "", "", fmt.Errorf("authentication token not found: %w (failed to save HTML for debugging)", err) + return "", "", fmt.Errorf("authentication token not found with OAuth1 context: %w", err) } return token, tokenType, nil @@ -250,6 +259,7 @@ func (a *GarthAuthenticator) buildLoginURL() string { // fetchOAuth1Token retrieves initial OAuth1 token for session 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) @@ -257,7 +267,9 @@ func (a *GarthAuthenticator) fetchOAuth1Token(ctx context.Context) (string, erro 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 { @@ -265,24 +277,52 @@ func (a *GarthAuthenticator) fetchOAuth1Token(ctx context.Context) (string, erro } defer resp.Body.Close() - // Extract oauth_token from Location header or response body - if location := resp.Header.Get("Location"); location != "" { - if u, err := url.Parse(location); err == nil { - if token := u.Query().Get("oauth_token"); token != "" { - return token, nil + // 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 + } } } } - // Or extract from HTML response - body, _ := io.ReadAll(resp.Body) - tokenPattern := regexp.MustCompile(`oauth_token=([^&\s"]+)`) - matches := tokenPattern.FindStringSubmatch(string(body)) - if len(matches) > 1 { - return matches[1], 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) } - return "", fmt.Errorf("OAuth1 token not found") + // 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 to project debug directory + debugDir := "go-garth/debug" + if err := os.MkdirAll(debugDir, 0755); err != nil { + return "", fmt.Errorf("failed to create debug directory: %w", err) + } + + filename := filepath.Join(debugDir, fmt.Sprintf("oauth1_response_%d.html", time.Now().Unix())) + absPath, _ := filepath.Abs(filename) + if writeErr := os.WriteFile(filename, body, 0644); writeErr == nil { + return "", fmt.Errorf("OAuth1 token not found (response saved to %s)", absPath) + } + + return "", fmt.Errorf("OAuth1 token not found in response (failed to save debug file)") } // authenticate performs the authentication flow @@ -292,16 +332,18 @@ func (a *GarthAuthenticator) authenticate(ctx context.Context, username, passwor data.Set("password", password) data.Set("embed", "true") data.Set("rememberme", "on") - // Use correct token field based on token type + + // 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") + data.Set("service", "https://connect.garmin.com/oauthConfirm") // Updated service URL data.Set("webhost", "https://connect.garmin.com") data.Set("fromPage", "oauth") data.Set("locale", "en_US") diff --git a/claude.md b/claude.md new file mode 100644 index 0000000..167d48c --- /dev/null +++ b/claude.md @@ -0,0 +1,224 @@ +// 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) +} \ No newline at end of file diff --git a/go-garth/debug/oauth1_response_1756930976.html b/go-garth/debug/oauth1_response_1756930976.html new file mode 100644 index 0000000..7d1dada --- /dev/null +++ b/go-garth/debug/oauth1_response_1756930976.html @@ -0,0 +1,2 @@ +
+ \ No newline at end of file diff --git a/go-garth/debug/oauth1_response_1756930988.html b/go-garth/debug/oauth1_response_1756930988.html new file mode 100644 index 0000000..e2dbea4 --- /dev/null +++ b/go-garth/debug/oauth1_response_1756930988.html @@ -0,0 +1,2 @@ +
+ \ No newline at end of file