This commit is contained in:
2025-09-05 08:53:48 -07:00
parent b8e95bfddc
commit 9d60abfcf7
6 changed files with 138 additions and 90 deletions

105
auth.go
View File

@@ -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 {

View File

@@ -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")
})
}
}

View File

@@ -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
}

View File

@@ -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")
}

View File

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

View File

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