mirror of
https://github.com/sstent/go-garth.git
synced 2026-01-25 16:42:28 +00:00
sync 2
This commit is contained in:
105
auth.go
105
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 {
|
||||
|
||||
63
auth_test.go
63
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")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
|
||||
10
garth.go
10
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
|
||||
|
||||
36
types.go
36
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
|
||||
|
||||
Reference in New Issue
Block a user