mirror of
https://github.com/sstent/go-garth-cli.git
synced 2026-01-25 16:42:48 +00:00
sync
This commit is contained in:
37
internal/auth/credentials/credentials.go
Normal file
37
internal/auth/credentials/credentials.go
Normal file
@@ -0,0 +1,37 @@
|
||||
package credentials
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
)
|
||||
|
||||
// LoadEnvCredentials loads credentials from .env file
|
||||
func LoadEnvCredentials() (email, password, domain string, err error) {
|
||||
// Determine project root (assuming .env is in the project root)
|
||||
projectRoot := "/home/sstent/Projects/go-garth"
|
||||
envPath := filepath.Join(projectRoot, ".env")
|
||||
|
||||
// Load .env file
|
||||
if err := godotenv.Load(envPath); err != nil {
|
||||
return "", "", "", fmt.Errorf("error loading .env file from %s: %w", envPath, err)
|
||||
}
|
||||
|
||||
email = os.Getenv("GARMIN_EMAIL")
|
||||
password = os.Getenv("GARMIN_PASSWORD")
|
||||
domain = os.Getenv("GARMIN_DOMAIN")
|
||||
|
||||
if email == "" {
|
||||
return "", "", "", fmt.Errorf("GARMIN_EMAIL not found in .env file")
|
||||
}
|
||||
if password == "" {
|
||||
return "", "", "", fmt.Errorf("GARMIN_PASSWORD not found in .env file")
|
||||
}
|
||||
if domain == "" {
|
||||
domain = "garmin.com" // default value
|
||||
}
|
||||
|
||||
return email, password, domain, nil
|
||||
}
|
||||
162
internal/auth/oauth/oauth.go
Normal file
162
internal/auth/oauth/oauth.go
Normal file
@@ -0,0 +1,162 @@
|
||||
package oauth
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go-garth/internal/models/types"
|
||||
"go-garth/internal/utils"
|
||||
)
|
||||
|
||||
// GetOAuth1Token retrieves an OAuth1 token using the provided ticket
|
||||
func GetOAuth1Token(domain, ticket string) (*types.OAuth1Token, error) {
|
||||
scheme := "https"
|
||||
if strings.HasPrefix(domain, "127.0.0.1") {
|
||||
scheme = "http"
|
||||
}
|
||||
consumer, err := utils.LoadOAuthConsumer()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to load OAuth consumer: %w", err)
|
||||
}
|
||||
|
||||
baseURL := fmt.Sprintf("%s://connectapi.%s/oauth-service/oauth/", scheme, domain)
|
||||
loginURL := fmt.Sprintf("%s://sso.%s/sso/embed", scheme, domain)
|
||||
tokenURL := fmt.Sprintf("%spreauthorized?ticket=%s&login-url=%s&accepts-mfa-tokens=true",
|
||||
baseURL, ticket, url.QueryEscape(loginURL))
|
||||
|
||||
// Parse URL to extract query parameters for signing
|
||||
parsedURL, err := url.Parse(tokenURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Extract query parameters
|
||||
queryParams := make(map[string]string)
|
||||
for key, values := range parsedURL.Query() {
|
||||
if len(values) > 0 {
|
||||
queryParams[key] = values[0]
|
||||
}
|
||||
}
|
||||
|
||||
// Create OAuth1 signed request
|
||||
baseURLForSigning := parsedURL.Scheme + "://" + parsedURL.Host + parsedURL.Path
|
||||
authHeader := utils.CreateOAuth1AuthorizationHeader("GET", baseURLForSigning, queryParams,
|
||||
consumer.ConsumerKey, consumer.ConsumerSecret, "", "")
|
||||
|
||||
req, err := http.NewRequest("GET", tokenURL, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Authorization", authHeader)
|
||||
req.Header.Set("User-Agent", "com.garmin.android.apps.connectmobile")
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
bodyStr := string(body)
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("OAuth1 request failed with status %d: %s", resp.StatusCode, bodyStr)
|
||||
}
|
||||
|
||||
// Parse query string response - handle both & and ; separators
|
||||
bodyStr = strings.ReplaceAll(bodyStr, ";", "&")
|
||||
values, err := url.ParseQuery(bodyStr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse OAuth1 response: %w", err)
|
||||
}
|
||||
|
||||
oauthToken := values.Get("oauth_token")
|
||||
oauthTokenSecret := values.Get("oauth_token_secret")
|
||||
|
||||
if oauthToken == "" || oauthTokenSecret == "" {
|
||||
return nil, fmt.Errorf("missing oauth_token or oauth_token_secret in response")
|
||||
}
|
||||
|
||||
return &types.OAuth1Token{
|
||||
OAuthToken: oauthToken,
|
||||
OAuthTokenSecret: oauthTokenSecret,
|
||||
MFAToken: values.Get("mfa_token"),
|
||||
Domain: domain,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ExchangeToken exchanges an OAuth1 token for an OAuth2 token
|
||||
func ExchangeToken(oauth1Token *types.OAuth1Token) (*types.OAuth2Token, error) {
|
||||
scheme := "https"
|
||||
if strings.HasPrefix(oauth1Token.Domain, "127.0.0.1") {
|
||||
scheme = "http"
|
||||
}
|
||||
consumer, err := utils.LoadOAuthConsumer()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to load OAuth consumer: %w", err)
|
||||
}
|
||||
|
||||
exchangeURL := fmt.Sprintf("%s://connectapi.%s/oauth-service/oauth/exchange/user/2.0", scheme, oauth1Token.Domain)
|
||||
|
||||
// Prepare form data
|
||||
formData := url.Values{}
|
||||
if oauth1Token.MFAToken != "" {
|
||||
formData.Set("mfa_token", oauth1Token.MFAToken)
|
||||
}
|
||||
|
||||
// Convert form data to map for OAuth signing
|
||||
formParams := make(map[string]string)
|
||||
for key, values := range formData {
|
||||
if len(values) > 0 {
|
||||
formParams[key] = values[0]
|
||||
}
|
||||
}
|
||||
|
||||
// Create OAuth1 signed request
|
||||
authHeader := utils.CreateOAuth1AuthorizationHeader("POST", exchangeURL, formParams,
|
||||
consumer.ConsumerKey, consumer.ConsumerSecret, oauth1Token.OAuthToken, oauth1Token.OAuthTokenSecret)
|
||||
|
||||
req, err := http.NewRequest("POST", exchangeURL, strings.NewReader(formData.Encode()))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req.Header.Set("Authorization", authHeader)
|
||||
req.Header.Set("User-Agent", "com.garmin.android.apps.connectmobile")
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("OAuth2 exchange failed with status %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var oauth2Token types.OAuth2Token
|
||||
if err := json.Unmarshal(body, &oauth2Token); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode OAuth2 token: %w", err)
|
||||
}
|
||||
|
||||
// Set expiration time
|
||||
if oauth2Token.ExpiresIn > 0 {
|
||||
oauth2Token.ExpiresAt = time.Now().Add(time.Duration(oauth2Token.ExpiresIn) * time.Second)
|
||||
}
|
||||
|
||||
return &oauth2Token, nil
|
||||
}
|
||||
265
internal/auth/sso/sso.go
Normal file
265
internal/auth/sso/sso.go
Normal file
@@ -0,0 +1,265 @@
|
||||
package sso
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go-garth/internal/auth/oauth"
|
||||
types "go-garth/internal/models/types"
|
||||
)
|
||||
|
||||
var (
|
||||
csrfRegex = regexp.MustCompile(`name="_csrf"\s+value="(.+?)"`)
|
||||
titleRegex = regexp.MustCompile(`<title>(.+?)</title>`)
|
||||
ticketRegex = regexp.MustCompile(`embed\?ticket=([^"]+)"`)
|
||||
)
|
||||
|
||||
// MFAContext preserves state for resuming MFA login
|
||||
type MFAContext struct {
|
||||
SigninURL string
|
||||
CSRFToken string
|
||||
Ticket string
|
||||
}
|
||||
|
||||
// Client represents an SSO client
|
||||
type Client struct {
|
||||
Domain string
|
||||
HTTPClient *http.Client
|
||||
}
|
||||
|
||||
// NewClient creates a new SSO client
|
||||
func NewClient(domain string) *Client {
|
||||
return &Client{
|
||||
Domain: domain,
|
||||
HTTPClient: &http.Client{Timeout: 30 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
// Login performs the SSO authentication flow
|
||||
func (c *Client) Login(email, password string) (*types.OAuth2Token, *MFAContext, error) {
|
||||
fmt.Printf("Logging in to Garmin Connect (%s) using SSO flow...\n", c.Domain)
|
||||
|
||||
scheme := "https"
|
||||
if strings.HasPrefix(c.Domain, "127.0.0.1") {
|
||||
scheme = "http"
|
||||
}
|
||||
|
||||
// Step 1: Set up SSO parameters
|
||||
ssoURL := fmt.Sprintf("https://sso.%s/sso", c.Domain)
|
||||
ssoEmbedURL := fmt.Sprintf("%s/embed", ssoURL)
|
||||
|
||||
ssoEmbedParams := url.Values{
|
||||
"id": {"gauth-widget"},
|
||||
"embedWidget": {"true"},
|
||||
"gauthHost": {ssoURL},
|
||||
}
|
||||
|
||||
signinParams := url.Values{
|
||||
"id": {"gauth-widget"},
|
||||
"embedWidget": {"true"},
|
||||
"gauthHost": {ssoEmbedURL},
|
||||
"service": {ssoEmbedURL},
|
||||
"source": {ssoEmbedURL},
|
||||
"redirectAfterAccountLoginUrl": {ssoEmbedURL},
|
||||
"redirectAfterAccountCreationUrl": {ssoEmbedURL},
|
||||
}
|
||||
|
||||
// Step 2: Initialize SSO session
|
||||
fmt.Println("Initializing SSO session...")
|
||||
embedURL := fmt.Sprintf("https://sso.%s/sso/embed?%s", c.Domain, ssoEmbedParams.Encode())
|
||||
req, err := http.NewRequest("GET", embedURL, nil)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to create embed request: %w", err)
|
||||
}
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36")
|
||||
|
||||
resp, err := c.HTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to initialize SSO: %w", err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
// Step 3: Get signin page and CSRF token
|
||||
fmt.Println("Getting signin page...")
|
||||
signinURL := fmt.Sprintf("%s://sso.%s/sso/signin?%s", scheme, c.Domain, signinParams.Encode())
|
||||
req, err = http.NewRequest("GET", signinURL, nil)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to create signin request: %w", err)
|
||||
}
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36")
|
||||
req.Header.Set("Referer", embedURL)
|
||||
|
||||
resp, err = c.HTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to get signin page: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to read signin response: %w", err)
|
||||
}
|
||||
|
||||
// Extract CSRF token
|
||||
csrfToken := extractCSRFToken(string(body))
|
||||
if csrfToken == "" {
|
||||
return nil, nil, fmt.Errorf("failed to find CSRF token")
|
||||
}
|
||||
fmt.Printf("Found CSRF token: %s\n", csrfToken[:10]+"...")
|
||||
|
||||
// Step 4: Submit login form
|
||||
fmt.Println("Submitting login credentials...")
|
||||
formData := url.Values{
|
||||
"username": {email},
|
||||
"password": {password},
|
||||
"embed": {"true"},
|
||||
"_csrf": {csrfToken},
|
||||
}
|
||||
|
||||
req, err = http.NewRequest("POST", signinURL, strings.NewReader(formData.Encode()))
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to create login request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36")
|
||||
req.Header.Set("Referer", signinURL)
|
||||
|
||||
resp, err = c.HTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to submit login: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err = io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to read login response: %w", err)
|
||||
}
|
||||
|
||||
// Check login result
|
||||
title := extractTitle(string(body))
|
||||
fmt.Printf("Login response title: %s\n", title)
|
||||
|
||||
// Handle MFA requirement
|
||||
if strings.Contains(title, "MFA") {
|
||||
fmt.Println("MFA required - returning context for ResumeLogin")
|
||||
ticket := extractTicket(string(body))
|
||||
return nil, &MFAContext{
|
||||
SigninURL: signinURL,
|
||||
CSRFToken: csrfToken,
|
||||
Ticket: ticket,
|
||||
}, nil
|
||||
}
|
||||
|
||||
if title != "Success" {
|
||||
return nil, nil, fmt.Errorf("login failed, unexpected title: %s", title)
|
||||
}
|
||||
|
||||
// Step 5: Extract ticket for OAuth flow
|
||||
fmt.Println("Extracting OAuth ticket...")
|
||||
ticket := extractTicket(string(body))
|
||||
if ticket == "" {
|
||||
return nil, nil, fmt.Errorf("failed to find OAuth ticket")
|
||||
}
|
||||
fmt.Printf("Found ticket: %s\n", ticket[:10]+"...")
|
||||
|
||||
// Step 6: Get OAuth1 token
|
||||
oauth1Token, err := oauth.GetOAuth1Token(c.Domain, ticket)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to get OAuth1 token: %w", err)
|
||||
}
|
||||
fmt.Println("Got OAuth1 token")
|
||||
|
||||
// Step 7: Exchange for OAuth2 token
|
||||
oauth2Token, err := oauth.ExchangeToken(oauth1Token)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to exchange for OAuth2 token: %w", err)
|
||||
}
|
||||
fmt.Printf("Got OAuth2 token: %s\n", oauth2Token.TokenType)
|
||||
|
||||
return oauth2Token, nil, nil
|
||||
}
|
||||
|
||||
// ResumeLogin completes authentication after MFA challenge
|
||||
func (c *Client) ResumeLogin(mfaCode string, ctx *MFAContext) (*types.OAuth2Token, error) {
|
||||
fmt.Println("Resuming login with MFA code...")
|
||||
|
||||
// Submit MFA form
|
||||
formData := url.Values{
|
||||
"mfa-code": {mfaCode},
|
||||
"embed": {"true"},
|
||||
"_csrf": {ctx.CSRFToken},
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("POST", ctx.SigninURL, strings.NewReader(formData.Encode()))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create MFA request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36")
|
||||
req.Header.Set("Referer", ctx.SigninURL)
|
||||
|
||||
resp, err := c.HTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to submit MFA: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read MFA response: %w", err)
|
||||
}
|
||||
|
||||
// Verify MFA success
|
||||
title := extractTitle(string(body))
|
||||
if title != "Success" {
|
||||
return nil, fmt.Errorf("MFA failed, unexpected title: %s", title)
|
||||
}
|
||||
|
||||
// Continue with ticket flow
|
||||
fmt.Println("Extracting OAuth ticket after MFA...")
|
||||
ticket := extractTicket(string(body))
|
||||
if ticket == "" {
|
||||
return nil, fmt.Errorf("failed to find OAuth ticket after MFA")
|
||||
}
|
||||
|
||||
// Get OAuth1 token
|
||||
oauth1Token, err := oauth.GetOAuth1Token(c.Domain, ticket)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get OAuth1 token: %w", err)
|
||||
}
|
||||
|
||||
// Exchange for OAuth2 token
|
||||
return oauth.ExchangeToken(oauth1Token)
|
||||
}
|
||||
|
||||
// extractCSRFToken extracts CSRF token from HTML
|
||||
func extractCSRFToken(html string) string {
|
||||
matches := csrfRegex.FindStringSubmatch(html)
|
||||
if len(matches) > 1 {
|
||||
return matches[1]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// extractTitle extracts page title from HTML
|
||||
func extractTitle(html string) string {
|
||||
matches := titleRegex.FindStringSubmatch(html)
|
||||
if len(matches) > 1 {
|
||||
return matches[1]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// extractTicket extracts OAuth ticket from HTML
|
||||
func extractTicket(html string) string {
|
||||
matches := ticketRegex.FindStringSubmatch(html)
|
||||
if len(matches) > 1 {
|
||||
return matches[1]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
Reference in New Issue
Block a user