diff --git a/auth.go b/auth.go index e05fbd8..1464485 100644 --- a/auth.go +++ b/auth.go @@ -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 } diff --git a/auth_test.go b/auth_test.go index 2698cee..12e4ec0 100644 --- a/auth_test.go +++ b/auth_test.go @@ -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") } diff --git a/client.go b/client.go index 8880fb0..74f0501 100644 --- a/client.go +++ b/client.go @@ -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 diff --git a/cmd/debug_auth/main.go b/cmd/debug_auth/main.go index 2e5e678..1fe306f 100644 --- a/cmd/debug_auth/main.go +++ b/cmd/debug_auth/main.go @@ -40,16 +40,16 @@ func main() { } fmt.Println("\nAuthentication successful! Token details:") - fmt.Printf("Access Token: %s\n", token.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") } diff --git a/debug/oauth1_response_1756933046.html b/debug/oauth1_response_1756933046.html new file mode 100644 index 0000000..471c524 Binary files /dev/null and b/debug/oauth1_response_1756933046.html differ diff --git a/debug/oauth1_response_1756949663.headers b/debug/oauth1_response_1756949663.headers new file mode 100644 index 0000000..f30ba9c --- /dev/null +++ b/debug/oauth1_response_1756949663.headers @@ -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 diff --git a/go-garth/debug/oauth1_response_1756930976.html b/debug/oauth1_response_1756949663.html similarity index 99% rename from go-garth/debug/oauth1_response_1756930976.html rename to debug/oauth1_response_1756949663.html index 7d1dada..cc3d1c5 100644 --- a/go-garth/debug/oauth1_response_1756930976.html +++ b/debug/oauth1_response_1756949663.html @@ -1,2 +1,2 @@ -
+
\ No newline at end of file diff --git a/debug/oauth1_response_1757076677.headers b/debug/oauth1_response_1757076677.headers new file mode 100644 index 0000000..f218562 --- /dev/null +++ b/debug/oauth1_response_1757076677.headers @@ -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} diff --git a/go-garth/debug/oauth1_response_1756930988.html b/debug/oauth1_response_1757076677.html similarity index 99% rename from go-garth/debug/oauth1_response_1756930988.html rename to debug/oauth1_response_1757076677.html index e2dbea4..9ee1c18 100644 --- a/go-garth/debug/oauth1_response_1756930988.html +++ b/debug/oauth1_response_1757076677.html @@ -1,2 +1,2 @@ -
+
\ No newline at end of file diff --git a/debug/oauth1_response_1757076748.headers b/debug/oauth1_response_1757076748.headers new file mode 100644 index 0000000..0d7330d --- /dev/null +++ b/debug/oauth1_response_1757076748.headers @@ -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 diff --git a/debug/oauth1_response_1757076748.html b/debug/oauth1_response_1757076748.html new file mode 100644 index 0000000..8e35006 --- /dev/null +++ b/debug/oauth1_response_1757076748.html @@ -0,0 +1,2 @@ +
+ \ No newline at end of file diff --git a/debug/oauth1_response_decoded.html b/debug/oauth1_response_decoded.html new file mode 100644 index 0000000..e69de29 diff --git a/examples/activities/enhanced/debug/oauth1_response_1756940835.headers b/examples/activities/enhanced/debug/oauth1_response_1756940835.headers new file mode 100644 index 0000000..aff6f69 --- /dev/null +++ b/examples/activities/enhanced/debug/oauth1_response_1756940835.headers @@ -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} diff --git a/examples/activities/enhanced/debug/oauth1_response_1756940835.html b/examples/activities/enhanced/debug/oauth1_response_1756940835.html new file mode 100644 index 0000000..d7cef66 Binary files /dev/null and b/examples/activities/enhanced/debug/oauth1_response_1756940835.html differ diff --git a/filestorage.go b/filestorage.go index 617821d..aba9c8c 100644 --- a/filestorage.go +++ b/filestorage.go @@ -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 +} diff --git a/garth.go b/garth.go index 5871554..cdeba0a 100644 --- a/garth.go +++ b/garth.go @@ -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") +} diff --git a/garth_auth_documentation.md b/garth_auth_documentation.md new file mode 100755 index 0000000..5f7626a --- /dev/null +++ b/garth_auth_documentation.md @@ -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: + - password: + - embed: true + - lt: (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: + - _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=&oauth_token_secret= +``` + +#### 3.3 OAuth1 Authorization +``` +Endpoint: https://connect.garmin.com/oauthConfirm +Method: GET +Parameters: + - oauth_token: + - oauth_callback: https://connect.garmin.com/modern/ + +Headers: + - Cookie: +``` + +#### 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="", oauth_verifier="", + oauth_version="1.0", oauth_signature="..." + +Response: + oauth_token=&oauth_token_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="", oauth_version="1.0", + oauth_signature="..." + +Response Format (JSON): +{ + "access_token": "", + "token_type": "Bearer", + "expires_in": 3600, + "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: + +Response: +{ + "access_token": "", + "token_type": "Bearer", + "expires_in": 3600, + "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": "", + "oauth_token_secret": "" + }, + "oauth2_token": { + "access_token": "", + "token_type": "Bearer", + "expires_in": 3600, + "expires_at": 1698765432, + "refresh_token": "", + "scope": "GHS_ADMIN_READ GHS_ADMIN_WRITE" + }, + "user_profile": { + "username": "", + "profile_id": "", + "display_name": "" + } +} +``` + +### 7. API Request Authentication + +#### 7.1 Connect API Requests +``` +Base URL: https://connectapi.garmin.com +Headers: + - Authorization: Bearer + - 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="", 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
~1 year lifetime] + B --> C[OAuth2 Access Token
~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. diff --git a/go-garth/debug/oauth1_response_1756931165.html b/go-garth/debug/oauth1_response_1756931165.html new file mode 100644 index 0000000..8476956 --- /dev/null +++ b/go-garth/debug/oauth1_response_1756931165.html @@ -0,0 +1,2 @@ +
+ \ No newline at end of file diff --git a/go-garth/debug/oauth1_response_1756931594.html b/go-garth/debug/oauth1_response_1756931594.html new file mode 100644 index 0000000..4335d7c Binary files /dev/null and b/go-garth/debug/oauth1_response_1756931594.html differ diff --git a/go-garth/debug/oauth1_response_1756931793.html b/go-garth/debug/oauth1_response_1756931793.html new file mode 100644 index 0000000..e008b03 Binary files /dev/null and b/go-garth/debug/oauth1_response_1756931793.html differ diff --git a/go-garth/debug/oauth1_response_1756931970.html b/go-garth/debug/oauth1_response_1756931970.html new file mode 100644 index 0000000..831d609 Binary files /dev/null and b/go-garth/debug/oauth1_response_1756931970.html differ diff --git a/go-garth/debug/oauth1_response_1756932002.html b/go-garth/debug/oauth1_response_1756932002.html new file mode 100644 index 0000000..55444cb Binary files /dev/null and b/go-garth/debug/oauth1_response_1756932002.html differ diff --git a/go-garth/debug/oauth1_response_1756932038.html b/go-garth/debug/oauth1_response_1756932038.html new file mode 100644 index 0000000..95cac4f Binary files /dev/null and b/go-garth/debug/oauth1_response_1756932038.html differ diff --git a/go-garth/debug/oauth1_response_1756932473.html b/go-garth/debug/oauth1_response_1756932473.html new file mode 100644 index 0000000..2ef2651 Binary files /dev/null and b/go-garth/debug/oauth1_response_1756932473.html differ diff --git a/go-garth/debug/oauth1_response_1756932518.html b/go-garth/debug/oauth1_response_1756932518.html new file mode 100644 index 0000000..f934934 Binary files /dev/null and b/go-garth/debug/oauth1_response_1756932518.html differ diff --git a/go-garth/debug/oauth1_response_1756932679.html b/go-garth/debug/oauth1_response_1756932679.html new file mode 100644 index 0000000..fd41fe2 Binary files /dev/null and b/go-garth/debug/oauth1_response_1756932679.html differ diff --git a/go-garth/debug/oauth1_response_1756932689.html b/go-garth/debug/oauth1_response_1756932689.html new file mode 100644 index 0000000..045a1aa Binary files /dev/null and b/go-garth/debug/oauth1_response_1756932689.html differ diff --git a/go-garth/debug/oauth1_response_1756932845.html b/go-garth/debug/oauth1_response_1756932845.html new file mode 100644 index 0000000..a04dab2 Binary files /dev/null and b/go-garth/debug/oauth1_response_1756932845.html differ diff --git a/go.mod b/go.mod index 1fc068b..6050f07 100644 --- a/go.mod +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum index d61b19e..230371d 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/memorystorage.go b/memorystorage.go index e755554..a2ab113 100644 --- a/memorystorage.go +++ b/memorystorage.go @@ -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 +} diff --git a/teferenceflow.md b/teferenceflow.md new file mode 100644 index 0000000..e69de29 diff --git a/types.go b/types.go index 0ff9b24..2f0f769 100644 --- a/types.go +++ b/types.go @@ -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 diff --git a/workouts.go b/workouts.go index 338535f..dd6994a 100644 --- a/workouts.go +++ b/workouts.go @@ -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 { diff --git a/workouts_test.go b/workouts_test.go index 90f9dc9..15c3bff 100644 --- a/workouts_test.go +++ b/workouts_test.go @@ -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()