This commit is contained in:
2025-09-05 06:38:19 -07:00
parent 6d1c572538
commit b8e95bfddc
35 changed files with 1109 additions and 545 deletions

791
auth.go
View File

@@ -2,58 +2,69 @@ package garth
import (
"context"
"crypto/hmac"
"crypto/sha1"
"crypto/tls"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"math/rand"
"net/http"
"net/url"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"time"
"net/http/cookiejar" // Add cookiejar import
"net/http/cookiejar"
)
// GarthAuthenticator implements the Authenticator interface
type GarthAuthenticator struct {
client *http.Client
tokenURL string
storage TokenStorage
userAgent string
csrfToken string
oauth1Token string // Add OAuth1 token storage
oauth1Token string
domain string
}
// NewAuthenticator creates a new Garth authentication client
func NewAuthenticator(opts ClientOptions) Authenticator {
// Create HTTP client with browser-like settings
transport := &http.Transport{
// Set default domain if not provided
if opts.Domain == "" {
opts.Domain = "garmin.com"
}
baseTransport := &http.Transport{
TLSClientConfig: &tls.Config{
MinVersion: tls.VersionTLS12,
},
Proxy: http.ProxyFromEnvironment,
}
// Create cookie jar for session persistence
jar, err := cookiejar.New(nil)
if err != nil {
// Fallback to no cookie jar if creation fails
jar = nil
}
client := &http.Client{
Timeout: opts.Timeout,
Transport: transport,
Jar: jar, // Add cookie jar
Transport: baseTransport,
Jar: jar,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
// Allow up to 10 redirects
if len(via) >= 10 {
return errors.New("stopped after 10 redirects")
}
if jar != nil {
for _, v := range via {
if v.Response != nil {
if cookies := v.Response.Cookies(); len(cookies) > 0 {
jar.SetCookies(req.URL, cookies)
}
}
}
}
return nil
},
}
@@ -63,135 +74,264 @@ func NewAuthenticator(opts ClientOptions) Authenticator {
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",
domain: opts.Domain,
}
// Set authenticator reference in storage if needed
if setter, ok := opts.Storage.(AuthenticatorSetter); ok {
if setter, ok := opts.Storage.(interface{ SetAuthenticator(a Authenticator) }); ok {
setter.SetAuthenticator(auth)
}
return auth
}
// Login authenticates with Garmin services
func (a *GarthAuthenticator) Login(ctx context.Context, username, password, mfaToken string) (*Token, error) {
// Step 1: Get OAuth1 token FIRST
oauth1Token, err := a.fetchOAuth1Token(ctx)
// Step 1: Get OAuth1 request token
oauth1RequestToken, err := a.fetchOAuth1RequestToken(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)
return nil, fmt.Errorf("failed to get OAuth1 request token: %w", err)
}
a.csrfToken = authToken // Store for session
// Step 2: Authorize OAuth1 request token
authToken, tokenType, 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 all tokens
token, err := a.authenticate(ctx, username, password, mfaToken, authToken, tokenType)
// 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
}
// Save token to storage
if err := a.storage.SaveToken(token); err != nil {
// Step 4: 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
token, err := a.exchangeOAuth1ForOAuth2Token(ctx, oauth1AccessToken)
if err != nil {
return nil, fmt.Errorf("failed to exchange OAuth1 for OAuth2 token: %w", err)
}
if err := a.storage.StoreToken(token); err != nil {
return nil, fmt.Errorf("failed to save token: %w", err)
}
return token, nil
}
// RefreshToken refreshes an expired access token
func (a *GarthAuthenticator) RefreshToken(ctx context.Context, refreshToken string) (*Token, error) {
if refreshToken == "" {
return nil, &AuthError{
StatusCode: http.StatusBadRequest,
Message: "Refresh token is required",
Type: "invalid_request",
}
}
data := url.Values{}
data.Set("grant_type", "refresh_token")
data.Set("refresh_token", refreshToken)
req, err := http.NewRequestWithContext(ctx, "POST", a.tokenURL, strings.NewReader(data.Encode()))
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)
if err != nil {
return nil, &AuthError{
StatusCode: http.StatusInternalServerError,
Message: "Failed to create refresh request",
Cause: err,
}
return nil, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("User-Agent", a.userAgent)
req.SetBasicAuth("garmin-connect", "garmin-connect-secret")
// 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, &AuthError{
StatusCode: http.StatusBadGateway,
Message: "Refresh request failed",
Cause: err,
}
return nil, fmt.Errorf("request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return nil, &AuthError{
StatusCode: resp.StatusCode,
Message: fmt.Sprintf("Token refresh failed: %s", body),
Type: "token_refresh_failure",
}
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) (string, string, 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)
}
req.Header = a.getEnhancedBrowserHeaders(authURL)
resp, err := a.client.Do(req)
if err != nil {
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)
}
return getCSRFToken(string(body))
}
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)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("access token request 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)
}
values, err := url.ParseQuery(string(body))
if err != nil {
return nil, fmt.Errorf("failed to parse access token response: %w", err)
}
return &OAuth1Token{
Token: values.Get("oauth_token"),
Secret: values.Get("oauth_token_secret"),
}, 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, &AuthError{
StatusCode: http.StatusInternalServerError,
Message: "Failed to parse token response",
Cause: err,
}
return nil, fmt.Errorf("failed to parse token response: %w", err)
}
token.Expiry = time.Now().Add(time.Duration(token.ExpiresIn) * time.Second)
// Persist the refreshed token to storage
if err := a.storage.SaveToken(&token); err != nil {
return nil, fmt.Errorf("failed to save refreshed token: %w", err)
if token.OAuth2 != nil {
token.OAuth2.Expiry = time.Now().Add(time.Duration(token.OAuth2.ExpiresIn) * time.Second)
}
token.OAuth1 = oauth1Token
return &token, nil
}
// GetClient returns an authenticated HTTP client
func (a *GarthAuthenticator) GetClient() *http.Client {
// This would be a client with middleware that automatically
// adds authentication headers and handles token refresh
return a.client
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))
}
// 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("gauthHost", fmt.Sprintf("https://sso.%s/sso", a.domain))
params.Set("service", fmt.Sprintf("https://connect.%s/oauthConfirm", a.domain))
params.Set("source", fmt.Sprintf("https://sso.%s/sso", a.domain))
params.Set("redirectAfterAccountLoginUrl", fmt.Sprintf("https://connect.%s/oauthConfirm", a.domain))
params.Set("redirectAfterAccountCreationUrl", fmt.Sprintf("https://connect.%s/oauthConfirm", a.domain))
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)
}
@@ -203,13 +343,11 @@ func (a *GarthAuthenticator) fetchLoginParamsWithOAuth1(ctx context.Context) (to
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")
req.Header.Set("Referer", fmt.Sprintf("https://sso.%s/sso/signin?id=gauth-widget&embedWidget=true&gauthHost=https://sso.%s/sso", a.domain, a.domain))
req.Header.Set("Origin", fmt.Sprintf("https://sso.%s", a.domain))
resp, err := a.client.Do(req)
if err != nil {
@@ -222,118 +360,16 @@ func (a *GarthAuthenticator) fetchLoginParamsWithOAuth1(ctx context.Context) (to
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
return getCSRFToken(string(body))
}
// buildLoginURL constructs the complete login URL with parameters
func (a *GarthAuthenticator) buildLoginURL() string {
// Match Python implementation exactly (order and values)
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")
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")
return "https://sso.garmin.com/sso/signin?" + params.Encode()
}
// 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)
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 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
func (a *GarthAuthenticator) authenticate(ctx context.Context, username, password, mfaToken, authToken, tokenType string) (*Token, error) {
func (a *GarthAuthenticator) authenticate(ctx context.Context, username, password, mfaToken, authToken, tokenType string) (string, 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 {
@@ -343,18 +379,17 @@ func (a *GarthAuthenticator) authenticate(ctx context.Context, username, passwor
data.Set("_eventId", "submit")
data.Set("geolocation", "")
data.Set("clientId", "GarminConnect")
data.Set("service", "https://connect.garmin.com/oauthConfirm") // Updated service URL
data.Set("webhost", "https://connect.garmin.com")
data.Set("service", fmt.Sprintf("https://connect.%s/oauthConfirm", a.domain))
data.Set("webhost", fmt.Sprintf("https://connect.%s", a.domain))
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")
data.Set("redirectAfterAccountLoginUrl", fmt.Sprintf("https://connect.%s/oauthConfirm", a.domain))
data.Set("redirectAfterAccountCreationUrl", fmt.Sprintf("https://connect.%s/oauthConfirm", a.domain))
loginURL := "https://sso.garmin.com/sso/signin"
req, err := http.NewRequestWithContext(ctx, "POST", loginURL, strings.NewReader(data.Encode()))
req, err := http.NewRequestWithContext(ctx, "POST", fmt.Sprintf("https://sso.%s/sso/signin", a.domain), strings.NewReader(data.Encode()))
if err != nil {
return nil, fmt.Errorf("failed to create SSO request: %w", err)
return "", fmt.Errorf("failed to create SSO request: %w", err)
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
@@ -363,7 +398,7 @@ func (a *GarthAuthenticator) authenticate(ctx context.Context, username, passwor
resp, err := a.client.Do(req)
if err != nil {
return nil, fmt.Errorf("SSO request failed: %w", err)
return "", fmt.Errorf("SSO request failed: %w", err)
}
defer resp.Body.Close()
@@ -374,164 +409,51 @@ func (a *GarthAuthenticator) authenticate(ctx context.Context, username, passwor
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("authentication failed with status: %d, response: %s", resp.StatusCode, body)
return "", 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)
return "", fmt.Errorf("failed to parse SSO response: %w", err)
}
if authResponse.Ticket == "" {
return nil, errors.New("empty ticket in SSO response")
return "", errors.New("empty ticket in SSO response")
}
return a.exchangeTicketForToken(ctx, authResponse.Ticket)
return authResponse.Ticket, nil
}
// exchangeTicketForToken exchanges an SSO ticket for an access token
func (a *GarthAuthenticator) exchangeTicketForToken(ctx context.Context, ticket string) (*Token, error) {
data := url.Values{}
data.Set("grant_type", "authorization_code")
data.Set("code", ticket)
data.Set("redirect_uri", "https://connect.garmin.com")
req, err := http.NewRequestWithContext(ctx, "POST", a.tokenURL, strings.NewReader(data.Encode()))
if err != nil {
return nil, fmt.Errorf("failed to create token request: %w", err)
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("User-Agent", a.userAgent)
req.SetBasicAuth("garmin-connect", "garmin-connect-secret")
resp, err := a.client.Do(req)
if err != nil {
return nil, fmt.Errorf("token exchange failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("token exchange failed: %d %s", resp.StatusCode, body)
}
var token Token
if err := json.NewDecoder(resp.Body).Decode(&token); err != nil {
return nil, fmt.Errorf("failed to parse token response: %w", err)
}
token.Expiry = time.Now().Add(time.Duration(token.ExpiresIn) * time.Second)
return &token, nil
func (a *GarthAuthenticator) ExchangeToken(ctx context.Context, token *OAuth1Token) (*Token, error) {
return a.exchangeOAuth1ForOAuth2Token(ctx, token)
}
// handleMFA processes multi-factor authentication
func (a *GarthAuthenticator) handleMFA(ctx context.Context, username, password, mfaToken, responseBody string) (*Token, error) {
// Extract CSRF token from response body
csrfToken, err := extractParam(`name="_csrf"\s+value="([^"]+)"`, responseBody)
if err != nil {
return nil, &AuthError{
StatusCode: http.StatusPreconditionFailed,
Message: "MFA CSRF token not found",
Cause: err,
}
// Removed exchangeTicketForToken method - no longer needed
func (a *GarthAuthenticator) getEnhancedBrowserHeaders(referrer string) http.Header {
u, _ := url.Parse(referrer)
origin := fmt.Sprintf("%s://%s", u.Scheme, u.Host)
return http.Header{
"User-Agent": {a.userAgent},
"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"},
"Cache-Control": {"max-age=0"},
"Origin": {origin},
"Referer": {referrer},
"Sec-Fetch-Site": {"same-origin"},
"Sec-Fetch-Mode": {"navigate"},
"Sec-Fetch-User": {"?1"},
"Sec-Fetch-Dest": {"document"},
"DNT": {"1"},
"Upgrade-Insecure-Requests": {"1"},
}
// Prepare MFA request
data := url.Values{}
data.Set("username", username)
data.Set("password", password)
data.Set("mfaToken", mfaToken)
data.Set("embed", "true")
data.Set("rememberme", "on")
data.Set("_csrf", csrfToken)
data.Set("_eventId", "submit")
data.Set("geolocation", "")
data.Set("clientId", "GarminConnect")
data.Set("service", "https://connect.garmin.com")
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")
req, err := http.NewRequestWithContext(ctx, "POST", "https://sso.garmin.com/sso/signin", strings.NewReader(data.Encode()))
if err != nil {
return nil, &AuthError{
StatusCode: http.StatusInternalServerError,
Message: "Failed to create MFA request",
Cause: err,
}
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Accept", "application/json")
req.Header.Set("User-Agent", a.userAgent)
resp, err := a.client.Do(req)
if err != nil {
return nil, &AuthError{
StatusCode: http.StatusBadGateway,
Message: "MFA request failed",
Cause: err,
}
}
defer resp.Body.Close()
// Handle MFA response
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return nil, &AuthError{
StatusCode: resp.StatusCode,
Message: fmt.Sprintf("MFA failed: %s", body),
Type: "mfa_failure",
}
}
// Parse MFA response
var mfaResponse struct {
Ticket string `json:"ticket"`
}
if err := json.NewDecoder(resp.Body).Decode(&mfaResponse); err != nil {
return nil, &AuthError{
StatusCode: http.StatusInternalServerError,
Message: "Failed to parse MFA response",
Cause: err,
}
}
if mfaResponse.Ticket == "" {
return nil, &AuthError{
StatusCode: http.StatusUnauthorized,
Message: "Invalid MFA response - ticket missing",
Type: "invalid_mfa_response",
}
}
return a.exchangeTicketForToken(ctx, mfaResponse.Ticket)
}
// extractParam helper to extract regex pattern
func extractParam(pattern, body string) (string, error) {
re := regexp.MustCompile(pattern)
matches := re.FindStringSubmatch(body)
if len(matches) < 2 {
return "", fmt.Errorf("pattern not found: %s", pattern)
}
return matches[1], nil
}
// getCSRFToken extracts the CSRF token from HTML using multiple patterns
func getCSRFToken(html string) (string, error) {
token, _, err := getCSRFTokenWithType(html)
return token, err
}
// getCSRFTokenWithType returns token and its type (lt or _csrf)
func getCSRFTokenWithType(html string) (string, string, error) {
// Check lt patterns first
func getCSRFToken(html string) (string, string, error) {
ltPatterns := []string{
`name="lt"\s+value="([^"]+)"`,
`name="lt"\s+type="hidden"\s+value="([^"]+)"`,
@@ -546,7 +468,6 @@ func getCSRFTokenWithType(html string) (string, string, error) {
}
}
// Check CSRF patterns
csrfPatterns := []string{
`name="_csrf"\s+value="([^"]+)"`,
`"csrfToken":"([^"]+)"`,
@@ -563,49 +484,141 @@ func getCSRFTokenWithType(html string) (string, string, error) {
return "", "", errors.New("no authentication token found")
}
// extractFromJSON tries to find the CSRF token in a JSON structure
func extractFromJSON(html string) (string, error) {
// Pattern to find the JSON config in script tags
re := regexp.MustCompile(`window\.__INITIAL_CONFIG__ = (\{.*?\});`)
matches := re.FindStringSubmatch(html)
// RefreshToken implements token refresh functionality
func (a *GarthAuthenticator) RefreshToken(ctx context.Context, refreshToken string) (*Token, error) {
data := url.Values{}
data.Set("grant_type", "refresh_token")
data.Set("refresh_token", refreshToken)
req, err := http.NewRequestWithContext(ctx, "POST", a.tokenURL, strings.NewReader(data.Encode()))
if err != nil {
return nil, fmt.Errorf("failed to create refresh request: %w", err)
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("User-Agent", a.userAgent)
req.SetBasicAuth("garmin-connect", "garmin-connect-secret")
resp, err := a.client.Do(req)
if err != nil {
return nil, fmt.Errorf("refresh request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("refresh failed: %d %s", resp.StatusCode, body)
}
var token Token
if err := json.NewDecoder(resp.Body).Decode(&token); err != nil {
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)
}
return &token, nil
}
// GetClient returns the HTTP client used for authentication
func (a *GarthAuthenticator) GetClient() *http.Client {
return a.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,
}
}
data := url.Values{}
data.Set("username", username)
data.Set("password", password)
data.Set("mfaToken", mfaToken)
data.Set("embed", "true")
data.Set("rememberme", "on")
data.Set("_csrf", csrfToken)
data.Set("_eventId", "submit")
data.Set("geolocation", "")
data.Set("clientId", "GarminConnect")
data.Set("service", fmt.Sprintf("https://connect.%s", a.domain))
data.Set("webhost", fmt.Sprintf("https://connect.%s", a.domain))
data.Set("fromPage", "oauth")
data.Set("locale", "en_US")
data.Set("id", "gauth-widget")
data.Set("redirectAfterAccountLoginUrl", fmt.Sprintf("https://connect.%s/oauthConfirm", a.domain))
data.Set("redirectAfterAccountCreationUrl", fmt.Sprintf("https://connect.%s/oauthConfirm", a.domain))
req, err := http.NewRequestWithContext(ctx, "POST", fmt.Sprintf("https://sso.%s/sso/signin", a.domain), strings.NewReader(data.Encode()))
if err != nil {
return "", &AuthError{
StatusCode: http.StatusInternalServerError,
Message: "Failed to create MFA request",
Cause: err,
}
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Accept", "application/json")
req.Header.Set("User-Agent", a.userAgent)
resp, err := a.client.Do(req)
if err != nil {
return "", &AuthError{
StatusCode: http.StatusBadGateway,
Message: "MFA request failed",
Cause: err,
}
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return "", &AuthError{
StatusCode: resp.StatusCode,
Message: fmt.Sprintf("MFA failed: %s", body),
Type: "mfa_failure",
}
}
var mfaResponse struct {
Ticket string `json:"ticket"`
}
if err := json.NewDecoder(resp.Body).Decode(&mfaResponse); err != nil {
return "", &AuthError{
StatusCode: http.StatusInternalServerError,
Message: "Failed to parse MFA response",
Cause: err,
}
}
if mfaResponse.Ticket == "" {
return "", &AuthError{
StatusCode: http.StatusUnauthorized,
Message: "Invalid MFA response - ticket missing",
Type: "invalid_mfa_response",
}
}
return mfaResponse.Ticket, nil
}
// Configure updates authenticator settings
func (a *GarthAuthenticator) Configure(domain string) {
a.domain = domain
}
// extractParam helper to extract regex pattern
func extractParam(pattern, body string) (string, error) {
re := regexp.MustCompile(pattern)
matches := re.FindStringSubmatch(body)
if len(matches) < 2 {
return "", errors.New("JSON config not found")
}
// Parse the JSON
var config struct {
CSRFToken string `json:"csrfToken"`
}
if err := json.Unmarshal([]byte(matches[1]), &config); err != nil {
return "", fmt.Errorf("failed to parse JSON config: %w", err)
}
if config.CSRFToken == "" {
return "", errors.New("csrfToken not found in JSON config")
}
return config.CSRFToken, nil
}
// getEnhancedBrowserHeaders returns browser-like headers including Referer and Origin
func (a *GarthAuthenticator) getEnhancedBrowserHeaders(referrer string) http.Header {
u, _ := url.Parse(referrer)
origin := fmt.Sprintf("%s://%s", u.Scheme, u.Host)
return http.Header{
"User-Agent": {a.userAgent},
"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"},
"Accept-Encoding": {"gzip, deflate, br"},
"Connection": {"keep-alive"},
"Cache-Control": {"max-age=0"},
"Origin": {origin},
"Referer": {referrer},
"Sec-Fetch-Site": {"same-origin"},
"Sec-Fetch-Mode": {"navigate"},
"Sec-Fetch-User": {"?1"},
"Sec-Fetch-Dest": {"document"},
"DNT": {"1"},
"Upgrade-Insecure-Requests": {"1"},
return "", fmt.Errorf("pattern not found: %s", pattern)
}
return matches[1], nil
}

View File

@@ -44,16 +44,16 @@ func TestRealAuthentication(t *testing.T) {
}
log.Printf("Authentication successful! Token details:")
log.Printf("Access Token: %s", token.AccessToken)
log.Printf("Expires: %s", token.Expiry.Format(time.RFC3339))
log.Printf("Refresh Token: %s", token.RefreshToken)
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)
// Verify token storage
storedToken, err := storage.GetToken()
if err != nil {
t.Fatalf("Token storage verification failed: %v", err)
}
if storedToken.AccessToken != token.AccessToken {
if storedToken.OAuth2.AccessToken != token.OAuth2.AccessToken {
t.Fatal("Stored token doesn't match authenticated token")
}

View File

@@ -16,12 +16,16 @@ type AuthTransport struct {
mutex sync.Mutex // Protects refreshing token
}
// NewAuthTransport creates a new authenticated transport
// NewAuthTransport creates a new authenticated transport with specified storage
func NewAuthTransport(auth *GarthAuthenticator, storage TokenStorage, base http.RoundTripper) *AuthTransport {
if base == nil {
base = http.DefaultTransport
}
if storage == nil {
storage = NewFileStorage("garmin_session.json")
}
return &AuthTransport{
base: base,
auth: auth,
@@ -55,7 +59,7 @@ func (t *AuthTransport) RoundTrip(req *http.Request) (*http.Response, error) {
}
// Add Authorization header
req.Header.Set("Authorization", "Bearer "+token.AccessToken)
req.Header.Set("Authorization", "Bearer "+token.OAuth2.AccessToken)
req.Header.Set("User-Agent", t.userAgent)
// Execute request with retry logic
@@ -80,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.AccessToken)
req.Header.Set("Authorization", "Bearer "+token.OAuth2.AccessToken)
continue
}
@@ -118,19 +122,29 @@ func (t *AuthTransport) refreshToken(ctx context.Context, token *Token) (*Token,
}
// Perform refresh
newToken, err := t.auth.RefreshToken(ctx, token.RefreshToken)
newToken, err := t.auth.RefreshToken(ctx, token.OAuth2.RefreshToken)
if err != nil {
return nil, err
}
// Save new token
if err := t.storage.SaveToken(newToken); err != nil {
if err := t.storage.StoreToken(newToken); err != nil {
return nil, err
}
return newToken, nil
}
// NewDefaultAuthTransport creates a transport with persistent storage
func NewDefaultAuthTransport(auth *GarthAuthenticator) *AuthTransport {
return NewAuthTransport(auth, NewFileStorage("garmin_session.json"), nil)
}
// NewMemoryAuthTransport creates a transport with in-memory storage (for testing)
func NewMemoryAuthTransport(auth *GarthAuthenticator) *AuthTransport {
return NewAuthTransport(auth, NewMemoryStorage(), nil)
}
// cloneRequest returns a clone of the provided HTTP request
func cloneRequest(r *http.Request) *http.Request {
// Shallow copy of the struct

View File

@@ -40,16 +40,16 @@ func main() {
}
fmt.Println("\nAuthentication successful! Token details:")
fmt.Printf("Access Token: %s\n", token.AccessToken)
fmt.Printf("Expires: %s\n", token.Expiry.Format("2006-01-02 15:04:05"))
fmt.Printf("Refresh Token: %s\n", token.RefreshToken)
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)
// Verify token storage
storedToken, err := storage.GetToken()
if err != nil {
log.Fatalf("Token storage verification failed: %v", err)
}
if storedToken.AccessToken != token.AccessToken {
if storedToken.OAuth2.AccessToken != token.OAuth2.AccessToken {
log.Fatal("Stored token doesn't match authenticated token")
}

Binary file not shown.

View File

@@ -0,0 +1,13 @@
Report-To: {"endpoints":[{"url":"https:\/\/a.nel.cloudflare.com\/report\/v4?s=LB4nTuybIbmdfmkjfbhGa%2FMJZapsS682xCWKJOOWqrhVV7CnzARk2sMV8ZeZUijqC9AcFQhhxv4W8Egdel5Asb9wivzQo0hCUo8%2B2oe2%2FHqZKCfXs5p5Pk623hXacIo8Vnf8cg%3D%3D"}],"group":"cf-nel","max_age":604800}
Nel: {"success_fraction":0.01,"report_to":"cf-nel","max_age":604800}
Server: cloudflare
Connection: keep-alive
Last-Modified: Mon, 28 Jul 2025 20:49:30 GMT
Cache-Control: no-cache
Cf-Cache-Status: DYNAMIC
Content-Type: text/html; charset=UTF-8
Server-Timing: cfCacheStatus;desc="DYNAMIC", cfOrigin;dur=13,cfEdge;dur=70
Set-Cookie: __cfruid=690cd828ea6910e26e5c8a6646ea53e1167f4fbd-1756949664; path=/; domain=.connect.garmin.com; HttpOnly; Secure; SameSite=None
Date: Thu, 04 Sep 2025 01:34:24 GMT
Cf-Ray: 9799be879b8c08d3-LAX
X-Frame-Options: deny

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,13 @@
Cf-Ray: 97a5db741ab22efb-LAX
Date: Fri, 05 Sep 2025 12:51:17 GMT
Last-Modified: Mon, 28 Jul 2025 20:49:42 GMT
Cf-Cache-Status: DYNAMIC
Connection: keep-alive
Cache-Control: no-cache
Set-Cookie: __cfruid=2107ee6871a3cf02bd004445e95d2e77bd292984-1757076677; path=/; domain=.connect.garmin.com; HttpOnly; Secure; SameSite=None
Content-Type: text/html; charset=UTF-8
X-Frame-Options: deny
Server-Timing: cfCacheStatus;desc="DYNAMIC", cfOrigin;dur=3,cfEdge;dur=75
Nel: {"success_fraction":0.01,"report_to":"cf-nel","max_age":604800}
Server: cloudflare
Report-To: {"endpoints":[{"url":"https:\/\/a.nel.cloudflare.com\/report\/v4?s=Hv7AO1s60DI9JCEW%2F%2Bz16fzKmavV%2BZg8D7Ouwbop8oHlOuJd3ogudd%2Fo5U7ZKPYavncF6AOa%2Fk72KCsUS8l2qjRk%2B%2F5kV0HMgJurjZS53jTWuqxpehMyNPvqdnE7EOlocrh5NQ%3D%3D"}],"group":"cf-nel","max_age":604800}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,13 @@
Cf-Ray: 97a5dd2fbe1cc982-LAX
Report-To: {"endpoints":[{"url":"https:\/\/a.nel.cloudflare.com\/report\/v4?s=0CCGeDUIydmkb7dMMeIz85bxJdJITAb2XMoWWDEgTh7zhr6HkfCEdXWgYLJSFvpzmAp1F3I5wbJ2jocrjrymEMg5TF9GgWtPcTl7PMY2IZ14xFpMe1jUWVi0kcRfSRLtIOyEwA%3D%3D"}],"group":"cf-nel","max_age":604800}
Content-Type: text/html; charset=UTF-8
Cache-Control: no-cache
Cf-Cache-Status: DYNAMIC
Server-Timing: cfCacheStatus;desc="DYNAMIC", cfOrigin;dur=5,cfEdge;dur=51
Set-Cookie: __cfruid=0091461aff8318be73baddcab194a5eb97ce5bbd-1757076748; path=/; domain=.connect.garmin.com; HttpOnly; Secure; SameSite=None
Connection: keep-alive
Last-Modified: Mon, 28 Jul 2025 20:49:50 GMT
Nel: {"success_fraction":0.01,"report_to":"cf-nel","max_age":604800}
Server: cloudflare
Date: Fri, 05 Sep 2025 12:52:28 GMT
X-Frame-Options: deny

File diff suppressed because one or more lines are too long

View File

View File

@@ -0,0 +1,14 @@
Cache-Control: no-cache
Server-Timing: cfCacheStatus;desc="DYNAMIC", cfOrigin;dur=42,cfEdge;dur=13
Date: Wed, 03 Sep 2025 23:07:16 GMT
Report-To: {"endpoints":[{"url":"https:\/\/a.nel.cloudflare.com\/report\/v4?s=7346Ar%2FOury4uGbhytPJFG3PH2b9HD0C9DuZEuS6yOiY1YgnwGU7tSsJ6T53YmLdEbFAhQzAvSpaPqSt87TPqJbw0vXSqmh0kIKwJqfvLbi2tasCY5egiy1mAXM8A%2F9ROC%2Fnrg%3D%3D"}],"group":"cf-nel","max_age":604800}
Set-Cookie: __cfruid=689cb1073d1a26adb7324496f8e3f38449c68ed7-1756940836; path=/; domain=.connect.garmin.com; HttpOnly; Secure; SameSite=None
Content-Encoding: br
Content-Type: text/html; charset=UTF-8
Connection: keep-alive
X-Frame-Options: deny
Server: cloudflare
Cf-Ray: 9798e7037af7dcee-LAX
Last-Modified: Mon, 28 Jul 2025 20:49:34 GMT
Cf-Cache-Status: DYNAMIC
Nel: {"success_fraction":0.01,"report_to":"cf-nel","max_age":604800}

View File

@@ -1,53 +1,34 @@
package garth
import (
"context"
"encoding/json"
"os"
"path/filepath"
"sync"
)
// fileStorage implements TokenStorage using a file
type fileStorage struct {
// FileStorage implements TokenStorage using a JSON file
type FileStorage struct {
mu sync.RWMutex
path string
auth Authenticator // Reference to authenticator for token refreshes
}
// NewFileStorage creates a new file-based token storage
func NewFileStorage(path string) TokenStorage {
return &fileStorage{path: path}
}
// SetAuthenticator sets the authenticator for token refreshes
func (s *fileStorage) SetAuthenticator(a Authenticator) {
s.auth = a
}
// GetToken retrieves token from file, refreshing if expired
func (s *fileStorage) GetToken() (*Token, error) {
token, err := s.loadToken()
if err != nil {
return nil, err
func NewFileStorage(path string) *FileStorage {
return &FileStorage{
path: path,
}
// Refresh token if expired
if token.IsExpired() {
refreshed, err := s.auth.RefreshToken(context.Background(), token.RefreshToken)
if err != nil {
return nil, err
}
if err := s.SaveToken(refreshed); err != nil {
return nil, err
}
return refreshed, nil
}
return token, nil
}
// loadToken loads token from file without refreshing
func (s *fileStorage) loadToken() (*Token, error) {
// GetToken retrieves token from file
func (s *FileStorage) GetToken() (*Token, error) {
s.mu.RLock()
defer s.mu.RUnlock()
data, err := os.ReadFile(s.path)
if err != nil {
if os.IsNotExist(err) {
return nil, ErrTokenNotFound
}
return nil, err
}
@@ -55,21 +36,13 @@ func (s *fileStorage) loadToken() (*Token, error) {
if err := json.Unmarshal(data, &token); err != nil {
return nil, err
}
if token.AccessToken == "" || token.RefreshToken == "" {
return nil, os.ErrNotExist
}
return &token, nil
}
// SaveToken saves token to file
func (s *fileStorage) SaveToken(token *Token) error {
// Create directory if needed
dir := filepath.Dir(s.path)
if err := os.MkdirAll(dir, 0700); err != nil {
return err
}
// StoreToken saves token to file
func (s *FileStorage) StoreToken(token *Token) error {
s.mu.Lock()
defer s.mu.Unlock()
data, err := json.MarshalIndent(token, "", " ")
if err != nil {
@@ -78,3 +51,14 @@ func (s *fileStorage) SaveToken(token *Token) error {
return os.WriteFile(s.path, data, 0600)
}
// ClearToken removes the token file
func (s *FileStorage) ClearToken() error {
s.mu.Lock()
defer s.mu.Unlock()
if err := os.Remove(s.path); err != nil && !os.IsNotExist(err) {
return err
}
return nil
}

View File

@@ -32,6 +32,7 @@ package garth
import (
"context"
"errors"
"net/http"
"os"
"strconv"
@@ -40,36 +41,53 @@ import (
// Authenticator defines the authentication interface
type Authenticator interface {
// Login authenticates with Garmin services
// Login authenticates with Garmin services using OAuth1/OAuth2 hybrid flow
Login(ctx context.Context, username, password, mfaToken string) (*Token, error)
// RefreshToken refreshes an expired access token
// RefreshToken refreshes an expired OAuth2 access token
RefreshToken(ctx context.Context, refreshToken string) (*Token, error)
// ExchangeToken exchanges OAuth1 token for OAuth2 token
ExchangeToken(ctx context.Context, oauth1Token *OAuth1Token) (*Token, error)
// GetClient returns an authenticated HTTP client
GetClient() *http.Client
}
// ClientOptions configures the Authenticator
type ClientOptions struct {
TokenURL string // Token exchange endpoint
Storage TokenStorage // Token storage implementation
Timeout time.Duration // HTTP client timeout
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
opts := ClientOptions{
TokenURL: "https://connectapi.garmin.com/oauth-service/oauth/token",
Timeout: 30 * time.Second,
SSOURL: "https://sso.garmin.com/sso",
TokenURL: "https://connectapi.garmin.com/oauth-service",
Domain: "garmin.com",
UserAgent: "GCMv3",
Timeout: 30 * time.Second,
}
// Override from environment variables
if url := os.Getenv("GARTH_SSO_URL"); url != "" {
opts.SSOURL = url
}
if url := os.Getenv("GARTH_TOKEN_URL"); url != "" {
opts.TokenURL = url
}
if domain := os.Getenv("GARTH_DOMAIN"); domain != "" {
opts.Domain = domain
}
if ua := os.Getenv("GARTH_USER_AGENT"); ua != "" {
opts.UserAgent = ua
}
if timeoutStr := os.Getenv("GARTH_TIMEOUT"); timeoutStr != "" {
if timeout, err := strconv.Atoi(timeoutStr); err == nil {
opts.Timeout = time.Duration(timeout) * time.Second
@@ -81,3 +99,16 @@ func NewClientOptionsFromEnv() ClientOptions {
return opts
}
// getRequestToken retrieves OAuth1 request token
// getRequestToken retrieves OAuth1 request token
func getRequestToken(a Authenticator, ctx context.Context) (*OAuth1Token, error) {
// Implementation will be added in next step
return nil, errors.New("not implemented")
}
// authorizeRequestToken authorizes OAuth1 token through SSO
func authorizeRequestToken(a Authenticator, ctx context.Context, token *OAuth1Token) error {
// Implementation will be added in next step
return errors.New("not implemented")
}

408
garth_auth_documentation.md Executable file
View File

@@ -0,0 +1,408 @@
# Garth - Garmin SSO Authentication Flows Documentation
## Overview
Garth is a Python library that provides authenticated access to Garmin Connect APIs using the same authentication flow as the official Garmin Connect mobile application. It implements a sophisticated OAuth1/OAuth2 hybrid authentication system that maintains long-term session persistence.
## Architecture Summary
- **Primary Domain**: `connect.garmin.com` (or `connect.garmin.cn` for China)
- **Authentication Method**: Hybrid OAuth1 + OAuth2 with SSO
- **Session Persistence**: OAuth1 tokens valid for ~1 year
- **MFA Support**: Built-in multi-factor authentication handling
- **Storage**: Local credential caching in `~/.garth` by default
## Authentication Flow Components
### 1. Initial Login Flow
#### 1.1 Credential Validation
```
Endpoint: https://sso.garmin.com/sso/signin
Method: POST
Headers:
- Content-Type: application/x-www-form-urlencoded
- User-Agent: GCMv3 (Garmin Connect Mobile v3)
Parameters:
- username: <email_address>
- password: <password>
- embed: true
- lt: <login_ticket> (obtained from initial GET request)
- _eventId: submit
- displayNameRequired: false
```
#### 1.2 Login Ticket Acquisition
```
Endpoint: https://sso.garmin.com/sso/signin
Method: GET
Headers:
- User-Agent: GCMv3
Purpose: Extract login ticket (lt) from hidden form field
Response: HTML form with embedded lt parameter
```
### 2. Multi-Factor Authentication (MFA)
#### 2.1 MFA Detection
After initial credential validation, if MFA is required:
```
Response Pattern:
- HTTP 200 with MFA challenge form
- Contains: mfaCode input field
- Action endpoint: https://sso.garmin.com/sso/verifyMFA
```
#### 2.2 MFA Code Submission
```
Endpoint: https://sso.garmin.com/sso/verifyMFA
Method: POST
Headers:
- Content-Type: application/x-www-form-urlencoded
Parameters:
- mfaCode: <6_digit_code>
- lt: <login_ticket>
- _eventId: submit
```
### 3. OAuth Token Exchange Flow
#### 3.1 OAuth1 Consumer Credentials
```python
OAUTH_CONSUMER = {
'key': 'fc020df2-e33d-4ec5-987a-7fb6de2e3850',
'secret': 'secret_key_from_mobile_app' # Embedded in mobile app
}
```
#### 3.2 OAuth1 Request Token
```
Endpoint: https://connectapi.garmin.com/oauth-service/oauth/request_token
Method: GET
Headers:
- Authorization: OAuth oauth_consumer_key="...", oauth_nonce="...",
oauth_signature_method="HMAC-SHA1", oauth_timestamp="...",
oauth_version="1.0", oauth_signature="..."
Response:
oauth_token=<request_token>&oauth_token_secret=<request_secret>
```
#### 3.3 OAuth1 Authorization
```
Endpoint: https://connect.garmin.com/oauthConfirm
Method: GET
Parameters:
- oauth_token: <request_token>
- oauth_callback: https://connect.garmin.com/modern/
Headers:
- Cookie: <session_cookies_from_login>
```
#### 3.4 OAuth1 Access Token Exchange
```
Endpoint: https://connectapi.garmin.com/oauth-service/oauth/access_token
Method: POST
Headers:
- Authorization: OAuth oauth_consumer_key="...", oauth_nonce="...",
oauth_signature_method="HMAC-SHA1", oauth_timestamp="...",
oauth_token="<request_token>", oauth_verifier="<oauth_verifier>",
oauth_version="1.0", oauth_signature="..."
Response:
oauth_token=<access_token>&oauth_token_secret=<access_secret>
```
### 4. OAuth2 Token Exchange
#### 4.1 OAuth2 Authorization Request
```
Endpoint: https://connectapi.garmin.com/oauth-service/oauth/exchange_token
Method: POST
Headers:
- Authorization: OAuth oauth_consumer_key="...", oauth_nonce="...",
oauth_signature_method="HMAC-SHA1", oauth_timestamp="...",
oauth_token="<oauth1_access_token>", oauth_version="1.0",
oauth_signature="..."
Response Format (JSON):
{
"access_token": "<oauth2_access_token>",
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": "<oauth2_refresh_token>",
"scope": "GHS_ADMIN_READ GHS_ADMIN_WRITE"
}
```
### 5. Token Refresh Flow
#### 5.1 OAuth2 Token Refresh
```
Endpoint: https://connectapi.garmin.com/oauth-service/oauth/token
Method: POST
Headers:
- Content-Type: application/x-www-form-urlencoded
Parameters:
- grant_type: refresh_token
- refresh_token: <oauth2_refresh_token>
Response:
{
"access_token": "<new_oauth2_access_token>",
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": "<new_oauth2_refresh_token>",
"scope": "GHS_ADMIN_READ GHS_ADMIN_WRITE"
}
```
## Session Management
### 6. Credential Storage Structure
#### 6.1 Storage File Location
```
Default Path: ~/.garth
Format: JSON
Permissions: 600 (user read/write only)
```
#### 6.2 Stored Credential Format
```json
{
"domain": "garmin.com",
"oauth1_token": {
"oauth_token": "<oauth1_access_token>",
"oauth_token_secret": "<oauth1_access_secret>"
},
"oauth2_token": {
"access_token": "<oauth2_access_token>",
"token_type": "Bearer",
"expires_in": 3600,
"expires_at": 1698765432,
"refresh_token": "<oauth2_refresh_token>",
"scope": "GHS_ADMIN_READ GHS_ADMIN_WRITE"
},
"user_profile": {
"username": "<username>",
"profile_id": "<profile_id>",
"display_name": "<display_name>"
}
}
```
### 7. API Request Authentication
#### 7.1 Connect API Requests
```
Base URL: https://connectapi.garmin.com
Headers:
- Authorization: Bearer <oauth2_access_token>
- NK: NT (Garmin-specific header)
- User-Agent: GCMv3
Auto-refresh: OAuth2 token refreshed automatically when expired
```
#### 7.2 Upload API Requests
```
Endpoint: https://connectapi.garmin.com/upload-service/upload
Method: POST
Headers:
- Authorization: OAuth oauth_consumer_key="...", oauth_nonce="...",
oauth_signature_method="HMAC-SHA1", oauth_timestamp="...",
oauth_token="<oauth1_access_token>", oauth_version="1.0",
oauth_signature="..."
- Content-Type: multipart/form-data
Body: Multipart form with FIT file data
```
## Key Endpoints Reference
### 8. Authentication Endpoints
| Purpose | Method | Endpoint |
|---------|--------|----------|
| Get Login Form | GET | `https://sso.garmin.com/sso/signin` |
| Submit Credentials | POST | `https://sso.garmin.com/sso/signin` |
| Verify MFA | POST | `https://sso.garmin.com/sso/verifyMFA` |
| OAuth Request Token | GET | `https://connectapi.garmin.com/oauth-service/oauth/request_token` |
| OAuth Authorize | GET | `https://connect.garmin.com/oauthConfirm` |
| OAuth Access Token | POST | `https://connectapi.garmin.com/oauth-service/oauth/access_token` |
| OAuth2 Exchange | POST | `https://connectapi.garmin.com/oauth-service/oauth/exchange_token` |
| OAuth2 Refresh | POST | `https://connectapi.garmin.com/oauth-service/oauth/token` |
### 9. Data API Endpoints
| Data Type | Method | Endpoint Pattern |
|-----------|--------|------------------|
| Sleep Data | GET | `/wellness-service/wellness/dailySleepData/{username}` |
| Stress Data | GET | `/usersummary-service/stats/stress/weekly/{date}/{weeks}` |
| User Profile | GET | `/userprofile-service/userprofile` |
| Activities | GET | `/activitylist-service/activities/search/activities` |
| Upload FIT | POST | `/upload-service/upload` |
| Weight Data | GET | `/weight-service/weight/daterangesnapshot` |
## Configuration Options
### 10. Domain Configuration
#### 10.1 Global Domains
- **Standard**: `garmin.com` (default)
- **China**: `garmin.cn` (use `garth.configure(domain="garmin.cn")`)
#### 10.2 Proxy Configuration
```python
garth.configure(
proxies={"https": "http://localhost:8888"},
ssl_verify=False
)
```
## Error Handling
### 11. Common Error Scenarios
#### 11.1 Session Expiration
```python
from garth.exc import GarthException
try:
garth.client.username
except GarthException:
# Session expired - need to re-authenticate
garth.login(email, password)
```
#### 11.2 MFA Required
```python
# Synchronous MFA handling
result1, result2 = garth.login(email, password, return_on_mfa=True)
if result1 == "needs_mfa":
mfa_code = input("Enter MFA code: ")
oauth1, oauth2 = garth.resume_login(result2, mfa_code)
```
#### 11.3 Token Refresh Failures
- Automatic retry mechanism built-in
- Falls back to OAuth1 if OAuth2 refresh fails
- Full re-authentication required if OAuth1 tokens expire
## Security Considerations
### 12. Security Features
#### 12.1 Token Security
- OAuth1 tokens have 1-year lifetime
- OAuth2 tokens expire every hour (auto-refreshed)
- Stored credentials encrypted at filesystem level
- No plaintext password storage
#### 12.2 Session Security
- CSRF protection via login tickets
- MFA support for enhanced security
- Secure random nonce generation for OAuth signatures
- HMAC-SHA1 signature validation
#### 12.3 Network Security
- All communications over HTTPS
- Certificate validation enabled by default
- User-Agent matching official mobile app
- Request rate limiting handled automatically
## Flow Diagrams
### 13. End-to-End Authentication Flow
```mermaid
graph TB
A[Client Application] --> B[garth.login call]
B --> C[Get Login Ticket]
C --> D{Credentials Valid?}
D -->|No| E[Authentication Error]
D -->|Yes| F{MFA Required?}
F -->|Yes| G[Prompt for MFA Code]
G --> H[Submit MFA Code]
H --> I{MFA Valid?}
I -->|No| E
I -->|Yes| J[Get OAuth1 Request Token]
F -->|No| J
J --> K[Authorize OAuth1 Token]
K --> L[Exchange for OAuth1 Access Token]
L --> M[Exchange OAuth1 for OAuth2 Token]
M --> N[Store Credentials to ~/.garth]
N --> O[Authentication Complete]
O --> P[Make API Request]
P --> Q{OAuth2 Token Valid?}
Q -->|Yes| R[Execute API Request]
Q -->|No| S[Refresh OAuth2 Token]
S --> T{Refresh Successful?}
T -->|Yes| R
T -->|No| U{OAuth1 Token Valid?}
U -->|Yes| M
U -->|No| B
R --> V[Return API Response]
style E fill:#ffcccc
style O fill:#ccffcc
style V fill:#ccffcc
```
### 14. Token Lifecycle Management
```mermaid
graph LR
A[Fresh Login] --> B[OAuth1 Access Token<br/>~1 year lifetime]
B --> C[OAuth2 Access Token<br/>~1 hour lifetime]
C --> D{API Request}
D --> E{OAuth2 Expired?}
E -->|No| F[Use OAuth2 Token]
E -->|Yes| G[Refresh OAuth2 Token]
G --> H{Refresh Success?}
H -->|Yes| C
H -->|No| I{OAuth1 Expired?}
I -->|No| J[Re-exchange OAuth1 for OAuth2]
J --> C
I -->|Yes| K[Full Re-authentication Required]
K --> A
F --> L[API Response]
style K fill:#ffcccc
style L fill:#ccffcc
```
## Implementation Notes
### 15. Key Implementation Details
#### 15.1 User Agent String
- Uses `GCMv3` to mimic Garmin Connect Mobile v3
- Critical for endpoint compatibility
- Some endpoints reject requests without proper User-Agent
#### 15.2 OAuth Signature Generation
- HMAC-SHA1 signatures for OAuth1 requests
- Includes all OAuth parameters in signature base string
- Consumer secret and token secret used as signing key
#### 15.3 Session Cookie Handling
- Automatic cookie jar management
- Session cookies preserved across authentication flow
- Required for OAuth authorization step
#### 15.4 Rate Limiting
- Built-in request throttling
- Exponential backoff for failed requests
- Respects Garmin's API rate limits
This comprehensive documentation covers all aspects of the Garth authentication system, from initial login through ongoing API access, providing developers with the technical details needed to understand and work with the authentication flows.

File diff suppressed because one or more lines are too long

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

9
go.mod
View File

@@ -1,5 +1,12 @@
module github.com/sstent/go-garth
go 1.22
go 1.23.0
toolchain go1.24.2
require github.com/joho/godotenv v1.5.1
require (
github.com/andybalholm/brotli v1.2.0 // indirect
golang.org/x/oauth2 v0.30.0 // indirect
)

4
go.sum
View File

@@ -1,2 +1,6 @@
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=

View File

@@ -27,10 +27,19 @@ func (s *MemoryStorage) GetToken() (*Token, error) {
}
// SaveToken saves token to memory
func (s *MemoryStorage) SaveToken(token *Token) error {
func (s *MemoryStorage) StoreToken(token *Token) error {
s.mu.Lock()
defer s.mu.Unlock()
s.token = token
return nil
}
// ClearToken removes the token from memory
func (s *MemoryStorage) ClearToken() error {
s.mu.Lock()
defer s.mu.Unlock()
s.token = nil
return nil
}

0
teferenceflow.md Normal file
View File

View File

@@ -6,13 +6,72 @@ import (
"time"
)
// TokenStorage defines the interface for token persistence
type TokenStorage interface {
// GetToken retrieves the stored token
GetToken() (*Token, error)
// Unified Token Definitions
// SaveToken stores a new token
SaveToken(token *Token) error
// OAuth1Token represents OAuth1 credentials
type OAuth1Token struct {
Token string
Secret string
}
// 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:"-"`
}
// IsExpired checks if the token has expired
func (t *OAuth2Token) IsExpired() bool {
return time.Now().After(t.Expiry)
}
// Token represents unified authentication credentials
type Token struct {
OAuth1 *OAuth1Token
OAuth2 *OAuth2Token
UserProfile *UserProfile
Domain string
}
// IsExpired checks if the OAuth2 token has expired
func (t *Token) IsExpired() bool {
if t.OAuth2 == nil {
return true
}
return t.OAuth2.IsExpired()
}
// NeedsRefresh checks if token needs refresh (within 5 min expiry window)
func (t *Token) NeedsRefresh() bool {
if t.OAuth2 == nil {
return true
}
return time.Now().Add(5 * time.Minute).After(t.OAuth2.Expiry)
}
// UserProfile represents Garmin user profile information
type UserProfile struct {
Username string
ProfileID string
DisplayName string
}
// ClientOptions contains configuration for the authenticator
type ClientOptions struct {
Storage TokenStorage
TokenURL string
Domain string // garmin.com or garmin.cn
Timeout time.Duration
}
// TokenStorage defines the interface for token storage
type TokenStorage interface {
StoreToken(token *Token) error
GetToken() (*Token, error)
ClearToken() error
}
// Error interface defines common error behavior for Garth
@@ -27,20 +86,6 @@ type Error interface {
// ErrTokenNotFound is returned when a token is not available in storage
var ErrTokenNotFound = errors.New("token not found")
// Token represents OAuth 2.0 tokens
type Token struct {
AccessToken string `json:"access_token"`
TokenType string `json:"token_type,omitempty"`
RefreshToken string `json:"refresh_token"`
ExpiresIn int `json:"expires_in,omitempty"` // Duration in seconds
Expiry time.Time `json:"expiry"` // Absolute time of expiration
}
// IsExpired checks if the token has expired
func (t *Token) IsExpired() bool {
return time.Now().After(t.Expiry)
}
// AuthError represents Garmin authentication errors
type AuthError struct {
StatusCode int `json:"status_code"` // HTTP status code

View File

@@ -25,56 +25,48 @@ func NewWorkoutService(client *APIClient) *WorkoutService {
// Workout represents a Garmin workout with basic information
type Workout struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Type string `json:"type"`
SportType string `json:"sportType"`
SubSportType string `json:"subSportType"`
CreatedDate time.Time `json:"createdDate"`
UpdatedDate time.Time `json:"updatedDate"`
OwnerID int64 `json:"ownerId"`
WorkoutSegments []WorkoutSegment `json:"workoutSegments,omitempty"`
WorkoutID int64 `json:"workoutId"`
Name string `json:"workoutName"`
Type string `json:"workoutType"`
Description string `json:"description"`
CreatedDate time.Time `json:"createdDate"`
UpdatedDate time.Time `json:"updatedDate"`
OwnerID int64 `json:"ownerId"`
IsPublic bool `json:"isPublic"`
SportType string `json:"sportType"`
SubSportType string `json:"subSportType"`
}
// WorkoutDetails contains detailed information about a workout
type WorkoutDetails struct {
Workout
EstimatedDuration int64 `json:"estimatedDuration"`
EstimatedDistance float64 `json:"estimatedDistance"`
TrainingStressScore float64 `json:"trainingStressScore"`
IntensityFactor float64 `json:"intensityFactor"`
WorkoutProvider string `json:"workoutProvider"`
WorkoutSource string `json:"workoutSource"`
WorkoutMetrics json.RawMessage `json:"workoutMetrics"`
WorkoutGoals json.RawMessage `json:"workoutGoals"`
WorkoutTags []string `json:"workoutTags"`
WorkoutSegments []WorkoutSegment `json:"workoutSegments"`
WorkoutSegments []WorkoutSegment `json:"workoutSegments"`
EstimatedDuration int `json:"estimatedDuration"`
EstimatedDistance float64 `json:"estimatedDistance"`
TrainingLoad float64 `json:"trainingLoad"`
Tags []string `json:"tags"`
}
// WorkoutSegment represents a segment within a workout
type WorkoutSegment struct {
ID string `json:"id"`
SegmentID int64 `json:"segmentId"`
Name string `json:"name"`
Description string `json:"description"`
Order int `json:"order"`
Duration int64 `json:"duration"`
Distance float64 `json:"distance"`
Exercises []WorkoutExercise `json:"exercises"`
}
// WorkoutExercise represents an exercise within a workout segment
type WorkoutExercise struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Order int `json:"order"`
Duration int64 `json:"duration"`
Distance float64 `json:"distance"`
Repetitions int `json:"repetitions"`
Weight float64 `json:"weight"`
Intensity string `json:"intensity"`
ExerciseMetrics json.RawMessage `json:"exerciseMetrics"`
ExerciseID int64 `json:"exerciseId"`
Name string `json:"name"`
Category string `json:"category"`
Type string `json:"type"`
Duration int `json:"duration,omitempty"`
Distance float64 `json:"distance,omitempty"`
Repetitions int `json:"repetitions,omitempty"`
Weight float64 `json:"weight,omitempty"`
RestInterval int `json:"restInterval,omitempty"`
}
// WorkoutListOptions provides filtering options for listing workouts
@@ -464,7 +456,7 @@ func (s *WorkoutService) Export(ctx context.Context, id string, format string) (
}
}
path := "/workout-service/workout/" + id + "/export/" + format
path := "/download-service/export/" + format + "/workout/" + id
resp, err := s.client.Get(ctx, path)
if err != nil {

View File

@@ -22,8 +22,8 @@ func TestWorkoutService_List(t *testing.T) {
{
name: "successful list with no options",
mockResponse: []Workout{
{ID: "1", Name: "Morning Run", Type: "running"},
{ID: "2", Name: "Evening Ride", Type: "cycling"},
{WorkoutID: 1, Name: "Morning Run", Type: "running"},
{WorkoutID: 2, Name: "Evening Ride", Type: "cycling"},
},
mockStatusCode: http.StatusOK,
opts: WorkoutListOptions{},
@@ -32,7 +32,7 @@ func TestWorkoutService_List(t *testing.T) {
{
name: "successful list with limit",
mockResponse: []Workout{
{ID: "1", Name: "Morning Run", Type: "running"},
{WorkoutID: 1, Name: "Morning Run", Type: "running"},
},
mockStatusCode: http.StatusOK,
opts: WorkoutListOptions{Limit: 1},
@@ -41,7 +41,7 @@ func TestWorkoutService_List(t *testing.T) {
{
name: "successful list with date range",
mockResponse: []Workout{
{ID: "1", Name: "Morning Run", Type: "running"},
{WorkoutID: 1, Name: "Morning Run", Type: "running"},
},
mockStatusCode: http.StatusOK,
opts: WorkoutListOptions{
@@ -53,7 +53,7 @@ func TestWorkoutService_List(t *testing.T) {
{
name: "successful list with pagination",
mockResponse: []Workout{
{ID: "2", Name: "Evening Ride", Type: "cycling"},
{WorkoutID: 2, Name: "Evening Ride", Type: "cycling"},
},
mockStatusCode: http.StatusOK,
opts: WorkoutListOptions{
@@ -65,8 +65,8 @@ func TestWorkoutService_List(t *testing.T) {
{
name: "successful list with sorting",
mockResponse: []Workout{
{ID: "1", Name: "Morning Run", Type: "running"},
{ID: "2", Name: "Evening Ride", Type: "cycling"},
{WorkoutID: 1, Name: "Morning Run", Type: "running"},
{WorkoutID: 2, Name: "Evening Ride", Type: "cycling"},
},
mockStatusCode: http.StatusOK,
opts: WorkoutListOptions{
@@ -78,7 +78,7 @@ func TestWorkoutService_List(t *testing.T) {
{
name: "successful list with type filter",
mockResponse: []Workout{
{ID: "1", Name: "Morning Run", Type: "running"},
{WorkoutID: 1, Name: "Morning Run", Type: "running"},
},
mockStatusCode: http.StatusOK,
opts: WorkoutListOptions{
@@ -89,7 +89,7 @@ func TestWorkoutService_List(t *testing.T) {
{
name: "successful list with status filter",
mockResponse: []Workout{
{ID: "1", Name: "Morning Run", Type: "running"},
{WorkoutID: 1, Name: "Morning Run", Type: "running"},
},
mockStatusCode: http.StatusOK,
opts: WorkoutListOptions{
@@ -150,12 +150,12 @@ func TestWorkoutService_Get(t *testing.T) {
workoutID: "123",
mockResponse: &WorkoutDetails{
Workout: Workout{
ID: "123",
Name: "Test Workout",
Type: "running",
WorkoutID: 123,
Name: "Test Workout",
Type: "running",
},
EstimatedDuration: 3600,
TrainingStressScore: 50.5,
EstimatedDuration: 3600,
TrainingLoad: 50.5,
},
mockStatusCode: http.StatusOK,
wantErr: false,
@@ -193,8 +193,8 @@ func TestWorkoutService_Get(t *testing.T) {
return
}
if !tt.wantErr && workout.ID != tt.workoutID {
t.Errorf("WorkoutService.Get() got ID %s, want %s", workout.ID, tt.workoutID)
if !tt.wantErr && workout.WorkoutID != 123 {
t.Errorf("WorkoutService.Get() got ID %d, want 123", workout.WorkoutID)
}
})
}
@@ -217,7 +217,7 @@ func TestWorkoutService_Create(t *testing.T) {
Type: "cycling",
},
mockResponse: &Workout{
ID: "456",
WorkoutID: 456,
Name: "New Workout",
Description: "Test workout",
Type: "cycling",
@@ -288,7 +288,7 @@ func TestWorkoutService_Update(t *testing.T) {
Description: "Updated description",
},
mockResponse: &Workout{
ID: "123",
WorkoutID: 123,
Name: "Updated Workout",
Description: "Updated description",
},
@@ -402,8 +402,8 @@ func TestWorkoutService_SearchWorkouts(t *testing.T) {
query: "running",
limit: 10,
mockResponse: []Workout{
{ID: "1", Name: "Morning Run", Type: "running"},
{ID: "2", Name: "Evening Run", Type: "running"},
{WorkoutID: 1, Name: "Morning Run", Type: "running"},
{WorkoutID: 2, Name: "Evening Run", Type: "running"},
},
mockStatusCode: http.StatusOK,
wantErr: false,
@@ -459,8 +459,8 @@ func TestWorkoutService_GetWorkoutTemplates(t *testing.T) {
{
name: "successful get templates",
mockResponse: []Workout{
{ID: "1", Name: "Template 1", Type: "running"},
{ID: "2", Name: "Template 2", Type: "cycling"},
{WorkoutID: 1, Name: "Template 1", Type: "running"},
{WorkoutID: 2, Name: "Template 2", Type: "cycling"},
},
mockStatusCode: http.StatusOK,
wantErr: false,
@@ -518,8 +518,8 @@ func TestWorkoutService_CopyWorkout(t *testing.T) {
workoutID: "123",
newName: "Copied Workout",
mockResponse: &Workout{
ID: "456",
Name: "Copied Workout",
WorkoutID: 456,
Name: "Copied Workout",
},
mockStatusCode: http.StatusCreated,
wantErr: false,
@@ -652,7 +652,7 @@ func TestWorkoutService_ContextMethods(t *testing.T) {
t.Run("Create with context", func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(&Workout{ID: "123", Name: "Test Workout"})
json.NewEncoder(w).Encode(&Workout{WorkoutID: 123, Name: "Test Workout"})
}))
defer server.Close()
@@ -665,8 +665,8 @@ func TestWorkoutService_ContextMethods(t *testing.T) {
return
}
if workout.ID != "123" {
t.Errorf("Create() got ID %s, want 123", workout.ID)
if workout.WorkoutID != 123 {
t.Errorf("Create() got ID %d, want 123", workout.WorkoutID)
}
})
@@ -674,7 +674,7 @@ func TestWorkoutService_ContextMethods(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(&WorkoutDetails{
Workout: Workout{ID: "123", Name: "Test Workout"},
Workout: Workout{WorkoutID: 123, Name: "Test Workout"},
})
}))
defer server.Close()
@@ -688,15 +688,15 @@ func TestWorkoutService_ContextMethods(t *testing.T) {
return
}
if workout.ID != "123" {
t.Errorf("Get() got ID %s, want 123", workout.ID)
if workout.WorkoutID != 123 {
t.Errorf("Get() got ID %d, want 123", workout.WorkoutID)
}
})
t.Run("Update with context", func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(&Workout{ID: "123", Name: "Updated Workout"})
json.NewEncoder(w).Encode(&Workout{WorkoutID: 123, Name: "Updated Workout"})
}))
defer server.Close()