From 9d60abfcf77e62c13ac79c86f22ac288d4d176f4 Mon Sep 17 00:00:00 2001 From: sstent Date: Fri, 5 Sep 2025 08:53:48 -0700 Subject: [PATCH] sync 2 --- auth.go | 105 ++++++++++++++++++++++++++--------------- auth_test.go | 63 ++++++++++++++++++------- client.go | 6 +-- cmd/debug_auth/main.go | 8 ++-- garth.go | 10 ---- types.go | 36 +++++++------- 6 files changed, 138 insertions(+), 90 deletions(-) diff --git a/auth.go b/auth.go index 1464485..fbb3199 100644 --- a/auth.go +++ b/auth.go @@ -10,6 +10,7 @@ import ( "errors" "fmt" "io" + "log" "math/rand" "net/http" "net/url" @@ -73,7 +74,7 @@ func NewAuthenticator(opts ClientOptions) Authenticator { client: client, tokenURL: opts.TokenURL, storage: opts.Storage, - userAgent: "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", + userAgent: "GCMv3", domain: opts.Domain, } @@ -85,32 +86,51 @@ func NewAuthenticator(opts ClientOptions) Authenticator { } func (a *GarthAuthenticator) Login(ctx context.Context, username, password, mfaToken string) (*Token, error) { - // Step 1: Get OAuth1 request token + // 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: Get OAuth1 request token oauth1RequestToken, err := a.fetchOAuth1RequestToken(ctx) if err != nil { return nil, fmt.Errorf("failed to get OAuth1 request token: %w", err) } - // Step 2: Authorize OAuth1 request token - authToken, tokenType, err := a.authorizeOAuth1Token(ctx, oauth1RequestToken) + // 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) } - a.csrfToken = authToken - // Step 3: Authenticate with credentials to get service ticket - serviceTicket, err := a.authenticate(ctx, username, password, mfaToken, authToken, tokenType) - if err != nil { - return nil, err - } - - // Step 4: Exchange service ticket for OAuth1 access token + // 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 5: Exchange OAuth1 access token for OAuth2 token + // 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) @@ -158,30 +178,31 @@ func (a *GarthAuthenticator) fetchOAuth1RequestToken(ctx context.Context) (*OAut }, nil } -func (a *GarthAuthenticator) authorizeOAuth1Token(ctx context.Context, token *OAuth1Token) (string, string, error) { +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 fmt.Errorf("failed to create authorization request: %w", err) } req.Header = a.getEnhancedBrowserHeaders(authURL) resp, err := a.client.Do(req) if err != nil { - return "", "", fmt.Errorf("authorization request failed: %w", err) + return fmt.Errorf("authorization request failed: %w", err) } defer resp.Body.Close() - body, err := io.ReadAll(resp.Body) - if err != nil { - return "", "", fmt.Errorf("failed to read authorization response: %w", err) + // 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 getCSRFToken(string(body)) + return nil } func (a *GarthAuthenticator) exchangeTicketForOAuth1Token(ctx context.Context, ticket string) (*OAuth1Token, error) { @@ -245,10 +266,10 @@ func (a *GarthAuthenticator) exchangeOAuth1ForOAuth2Token(ctx context.Context, o return nil, fmt.Errorf("failed to parse token response: %w", err) } - if token.OAuth2 != nil { - token.OAuth2.Expiry = time.Now().Add(time.Duration(token.OAuth2.ExpiresIn) * time.Second) + if token.OAuth2Token != nil { + token.OAuth2Token.ExpiresAt = time.Now().Add(time.Duration(token.OAuth2Token.ExpiresIn) * time.Second).Unix() } - token.OAuth1 = oauth1Token + token.OAuth1Token = oauth1Token return &token, nil } @@ -318,7 +339,7 @@ func (a *GarthAuthenticator) buildSignatureBaseString(req *http.Request, oauthPa return fmt.Sprintf("%s&%s&%s", method, url.QueryEscape(baseURL), url.QueryEscape(queryString)) } -func (a *GarthAuthenticator) fetchLoginParamsWithOAuth1(ctx context.Context) (token string, tokenType string, err error) { +func (a *GarthAuthenticator) getLoginTicket(ctx context.Context) (string, string, error) { params := url.Values{} params.Set("id", "gauth-widget") params.Set("embedWidget", "true") @@ -404,7 +425,22 @@ func (a *GarthAuthenticator) authenticate(ctx context.Context, username, passwor if resp.StatusCode == http.StatusPreconditionFailed { body, _ := io.ReadAll(resp.Body) - return a.handleMFA(ctx, username, password, mfaToken, string(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 { @@ -437,7 +473,7 @@ func (a *GarthAuthenticator) getEnhancedBrowserHeaders(referrer string) http.Hea origin := fmt.Sprintf("%s://%s", u.Scheme, u.Host) return http.Header{ - "User-Agent": {a.userAgent}, + "User-Agent": {"GCMv3"}, "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"}, @@ -514,8 +550,8 @@ func (a *GarthAuthenticator) RefreshToken(ctx context.Context, refreshToken stri return nil, fmt.Errorf("failed to parse refresh response: %w", err) } - if token.OAuth2 != nil { - token.OAuth2.Expiry = time.Now().Add(time.Duration(token.OAuth2.ExpiresIn) * time.Second) + if token.OAuth2Token != nil { + token.OAuth2Token.ExpiresAt = time.Now().Add(time.Duration(token.OAuth2Token.ExpiresIn) * time.Second).Unix() } return &token, nil } @@ -526,16 +562,7 @@ func (a *GarthAuthenticator) GetClient() *http.Client { } // handleMFA processes multi-factor authentication -func (a *GarthAuthenticator) handleMFA(ctx context.Context, username, password, mfaToken, responseBody string) (string, error) { - csrfToken, err := extractParam(`name="_csrf"\s+value="([^"]+)"`, responseBody) - if err != nil { - return "", &AuthError{ - StatusCode: http.StatusPreconditionFailed, - Message: "MFA CSRF token not found", - Cause: err, - } - } - +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) @@ -565,7 +592,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", a.userAgent) + req.Header.Set("User-Agent", "GCMv3") resp, err := a.client.Do(req) if err != nil { diff --git a/auth_test.go b/auth_test.go index 12e4ec0..ef5e094 100644 --- a/auth_test.go +++ b/auth_test.go @@ -19,12 +19,13 @@ func TestRealAuthentication(t *testing.T) { // 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(), 30*time.Second) + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) defer cancel() // Create token storage (using memory storage for this test) @@ -37,25 +38,51 @@ func TestRealAuthentication(t *testing.T) { Timeout: 30 * time.Second, }) - // Perform authentication with timeout context - token, err := auth.Login(ctx, username, password, "") - if err != nil { - t.Fatalf("Authentication failed: %v", err) + // Test authentication with and without MFA + testCases := []struct { + name string + mfaToken string + }{ + {"Without MFA", ""}, + {"With MFA", mfaToken}, } - log.Printf("Authentication successful! Token details:") - log.Printf("Access Token: %s", token.OAuth2.AccessToken) - log.Printf("Expires: %s", token.OAuth2.Expiry.Format(time.RFC3339)) - log.Printf("Refresh Token: %s", token.OAuth2.RefreshToken) + 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) + } - // Verify token storage - storedToken, err := storage.GetToken() - if err != nil { - t.Fatalf("Token storage verification failed: %v", err) - } - if storedToken.OAuth2.AccessToken != token.OAuth2.AccessToken { - t.Fatal("Stored token doesn't match authenticated token") - } + 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.Println("Token storage verification successful") + // 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 { + 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) + if err != nil { + t.Fatalf("Token refresh failed: %v", err) + } + if newToken.OAuth2Token.AccessToken == token.OAuth2Token.AccessToken { + t.Fatal("Refreshed token should be different from original") + } + log.Println("Token refresh successful") + }) + } } diff --git a/client.go b/client.go index 74f0501..0b68efa 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.OAuth2.AccessToken) + req.Header.Set("Authorization", "Bearer "+token.OAuth2Token.AccessToken) req.Header.Set("User-Agent", t.userAgent) // Execute request with retry logic @@ -84,7 +84,7 @@ func (t *AuthTransport) RoundTrip(req *http.Request) (*http.Response, error) { if err != nil { return nil, err } - req.Header.Set("Authorization", "Bearer "+token.OAuth2.AccessToken) + req.Header.Set("Authorization", "Bearer "+token.OAuth2Token.AccessToken) continue } @@ -122,7 +122,7 @@ func (t *AuthTransport) refreshToken(ctx context.Context, token *Token) (*Token, } // Perform refresh - newToken, err := t.auth.RefreshToken(ctx, token.OAuth2.RefreshToken) + newToken, err := t.auth.RefreshToken(ctx, token.OAuth2Token.RefreshToken) if err != nil { return nil, err } diff --git a/cmd/debug_auth/main.go b/cmd/debug_auth/main.go index 1fe306f..0d44cb6 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.OAuth2.AccessToken) - fmt.Printf("Expires: %s\n", token.OAuth2.Expiry.Format("2006-01-02 15:04:05")) - fmt.Printf("Refresh Token: %s\n", token.OAuth2.RefreshToken) + 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) // Verify token storage storedToken, err := storage.GetToken() if err != nil { log.Fatalf("Token storage verification failed: %v", err) } - if storedToken.OAuth2.AccessToken != token.OAuth2.AccessToken { + if storedToken.OAuth2Token.AccessToken != token.OAuth2Token.AccessToken { log.Fatal("Stored token doesn't match authenticated token") } diff --git a/garth.go b/garth.go index cdeba0a..4c3b7cf 100644 --- a/garth.go +++ b/garth.go @@ -54,16 +54,6 @@ type Authenticator interface { GetClient() *http.Client } -// ClientOptions configures 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) -} - // NewClientOptionsFromEnv creates ClientOptions from environment variables func NewClientOptionsFromEnv() ClientOptions { // Default configuration diff --git a/types.go b/types.go index 2f0f769..6feeb66 100644 --- a/types.go +++ b/types.go @@ -16,11 +16,12 @@ type OAuth1Token struct { // OAuth2Token represents OAuth2 credentials type OAuth2Token struct { - AccessToken string `json:"access_token"` - TokenType string `json:"token_type"` - RefreshToken string `json:"refresh_token"` - ExpiresIn int `json:"expires_in"` - Expiry time.Time `json:"-"` + 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 @@ -30,10 +31,10 @@ func (t *OAuth2Token) IsExpired() bool { // Token represents unified authentication credentials type Token struct { - OAuth1 *OAuth1Token - OAuth2 *OAuth2Token - UserProfile *UserProfile - Domain string + Domain string `json:"domain"` + OAuth1Token *OAuth1Token `json:"oauth1_token"` + OAuth2Token *OAuth2Token `json:"oauth2_token"` + UserProfile *UserProfile `json:"user_profile"` } // IsExpired checks if the OAuth2 token has expired @@ -54,17 +55,19 @@ func (t *Token) NeedsRefresh() bool { // UserProfile represents Garmin user profile information type UserProfile struct { - Username string - ProfileID string - DisplayName string + Username string `json:"username"` + ProfileID string `json:"profile_id"` + DisplayName string `json:"display_name"` } // ClientOptions contains configuration for the authenticator type ClientOptions struct { - Storage TokenStorage - TokenURL string - Domain string // garmin.com or garmin.cn - Timeout time.Duration + 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 @@ -92,6 +95,7 @@ type AuthError struct { 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