diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..e106b6d --- /dev/null +++ b/Dockerfile @@ -0,0 +1,28 @@ +# Build stage +FROM golang:1.19 as build + +WORKDIR /app + +# Copy go.mod and go.sum first for efficient dependency caching +COPY go.mod go.sum ./ +RUN go mod download + +# Copy source code +COPY . . + +# Build binary +RUN CGO_ENABLED=0 GOOS=linux go build -o garmin-connect ./cmd/main.go + +# Runtime stage +FROM alpine:3.14 + +# Install CA certificates for SSL +RUN apk --no-cache add ca-certificates + +WORKDIR /app + +# Copy binary from build stage +COPY --from=build /app/garmin-connect . + +# Set entrypoint +ENTRYPOINT ["./garmin-connect"] diff --git a/cmd/garmin-cli/main.go b/cmd/garmin-cli/main.go index 9d24e6e..fb6a88c 100644 --- a/cmd/garmin-cli/main.go +++ b/cmd/garmin-cli/main.go @@ -5,19 +5,27 @@ import ( "fmt" "os" "time" - + + "github.com/joho/godotenv" "github.com/sstent/go-garminconnect/internal/api" "github.com/sstent/go-garminconnect/internal/auth" ) func main() { - // Verify required environment variables + // Try to load from .env if environment variables not set if os.Getenv("GARMIN_USERNAME") == "" || os.Getenv("GARMIN_PASSWORD") == "" { - fmt.Println("GARMIN_USERNAME and GARMIN_PASSWORD must be set") + if err := godotenv.Load(); err != nil { + fmt.Println("Failed to load .env file:", err) + } + } + + // Verify required credentials + if os.Getenv("GARMIN_USERNAME") == "" || os.Getenv("GARMIN_PASSWORD") == "" { + fmt.Println("GARMIN_USERNAME and GARMIN_PASSWORD must be set in environment or .env file") os.Exit(1) } - // Set up authentication client + // Set up authentication client with headless mode enabled client := auth.NewAuthClient() token, err := client.Authenticate( context.Background(), diff --git a/cmd/main.go b/cmd/main.go index 274cc28..c708a28 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -19,7 +19,7 @@ func main() { os.Exit(1) } - // Create authentication client + // Create authentication client with headless mode enabled authClient := auth.NewAuthClient() // Authenticate with credentials diff --git a/garth.md b/garth.md new file mode 100644 index 0000000..4b97e6c --- /dev/null +++ b/garth.md @@ -0,0 +1,404 @@ + +# Garth Go Port Plan - Test-Driven Development Implementation + +## Project Overview + +Port the Python Garth library (Garmin SSO auth + Connect API client) to Go with comprehensive test coverage and modern Go practices. + +## Core Architecture Analysis + +Based on the original Garth library, the main components are: +- **Authentication**: OAuth1/OAuth2 token management with auto-refresh +- **API Client**: HTTP client for Garmin Connect API requests +- **Data Models**: Structured data types for health/fitness metrics +- **Session Management**: Token persistence and restoration + +## 1. Project Structure + +``` +garth-go/ +├── cmd/ +│ └── garth/ # CLI tool (like Python's uvx garth login) +│ └── main.go +├── pkg/ +│ ├── auth/ # Authentication module +│ │ ├── oauth.go +│ │ ├── oauth_test.go +│ │ ├── session.go +│ │ └── session_test.go +│ ├── client/ # HTTP client module +│ │ ├── client.go +│ │ ├── client_test.go +│ │ ├── endpoints.go +│ │ └── endpoints_test.go +│ ├── models/ # Data structures +│ │ ├── sleep.go +│ │ ├── sleep_test.go +│ │ ├── stress.go +│ │ ├── stress_test.go +│ │ ├── steps.go +│ │ ├── weight.go +│ │ ├── hrv.go +│ │ └── user.go +│ └── garth/ # Main package interface +│ ├── garth.go +│ └── garth_test.go +├── internal/ +│ ├── testutil/ # Test utilities +│ │ ├── fixtures.go +│ │ └── mock_server.go +│ └── config/ # Internal configuration +│ └── constants.go +├── examples/ # Usage examples +│ ├── basic/ +│ ├── sleep_analysis/ +│ └── stress_tracking/ +├── go.mod +├── go.sum +├── README.md +├── Makefile +└── .github/ + └── workflows/ + └── ci.yml +``` + +## 2. Data Flow Architecture + +### Authentication Flow +``` +User Credentials → OAuth1 Token → OAuth2 Token → API Requests + ↓ ↓ + Persisted Auto-refresh +``` + +### API Request Flow +``` +Client Request → Token Validation → HTTP Request → JSON Response → Struct Unmarshaling + ↓ + Auto-refresh if expired +``` + +### Data Processing Flow +``` +Raw API Response → JSON Unmarshaling → Data Validation → Business Logic → Client Response +``` + +## 3. Recommended Go Modules + +### Core Dependencies +```go +// HTTP client and utilities +"net/http" +"context" +"time" + +// JSON handling +"encoding/json" + +// OAuth implementation +"golang.org/x/oauth2" // For OAuth2 flows + +// HTTP client with advanced features +"github.com/go-resty/resty/v2" // Alternative to net/http with better ergonomics + +// Configuration and environment +"github.com/spf13/viper" // Configuration management +"github.com/spf13/cobra" // CLI framework + +// Validation +"github.com/go-playground/validator/v10" // Struct validation + +// Logging +"go.uber.org/zap" // Structured logging + +// Testing +"github.com/stretchr/testify" // Testing utilities +"github.com/jarcoal/httpmock" // HTTP mocking +``` + +### Development Dependencies +```go +// Code generation +"github.com/golang/mock/gomock" // Mock generation + +// Linting and quality +"github.com/golangci/golangci-lint" +``` + +## 4. TDD Implementation Plan + +### Phase 1: Authentication Module (Week 1-2) + +#### Test Cases to Implement First: + +**OAuth Session Tests:** +```go +func TestSessionSave(t *testing.T) +func TestSessionLoad(t *testing.T) +func TestSessionValidation(t *testing.T) +func TestSessionExpiry(t *testing.T) +``` + +**OAuth Flow Tests:** +```go +func TestOAuth1Login(t *testing.T) +func TestOAuth2TokenRefresh(t *testing.T) +func TestMFAHandling(t *testing.T) +func TestLoginFailure(t *testing.T) +``` + +#### Implementation Order: +1. **Write failing tests** for session management +2. **Implement** basic session struct and methods +3. **Write failing tests** for OAuth1 authentication +4. **Implement** OAuth1 flow +5. **Write failing tests** for OAuth2 token refresh +6. **Implement** OAuth2 auto-refresh mechanism +7. **Write failing tests** for MFA handling +8. **Implement** MFA prompt system + +### Phase 2: HTTP Client Module (Week 3) + +#### Test Cases: +```go +func TestClientCreation(t *testing.T) +func TestAPIRequest(t *testing.T) +func TestAuthenticationHeaders(t *testing.T) +func TestErrorHandling(t *testing.T) +func TestRetryLogic(t *testing.T) +``` + +#### Mock Server Setup: +```go +// Create mock Garmin Connect API responses +func setupMockGarminServer() *httptest.Server +func mockSuccessResponse() string +func mockErrorResponse() string +``` + +### Phase 3: Data Models (Week 4-5) + +#### Core Models Implementation Order: + +**1. User Profile:** +```go +type UserProfile struct { + ID int `json:"id" validate:"required"` + ProfileID int `json:"profileId" validate:"required"` + DisplayName string `json:"displayName"` + FullName string `json:"fullName"` + // ... other fields +} +``` + +**2. Sleep Data:** +```go +type SleepData struct { + CalendarDate time.Time `json:"calendarDate"` + SleepTimeSeconds int `json:"sleepTimeSeconds"` + DeepSleep int `json:"deepSleepSeconds"` + LightSleep int `json:"lightSleepSeconds"` + // ... other fields +} +``` + +**3. Stress Data:** +```go +type DailyStress struct { + CalendarDate time.Time `json:"calendarDate"` + OverallStressLevel int `json:"overallStressLevel"` + RestStressDuration int `json:"restStressDuration"` + // ... other fields +} +``` + +#### Test Implementation Strategy: +1. **JSON Unmarshaling Tests** - Test API response parsing +2. **Validation Tests** - Test struct validation +3. **Business Logic Tests** - Test derived properties and methods + +### Phase 4: Main Interface (Week 6) + +#### High-level API Tests: +```go +func TestGarthLogin(t *testing.T) +func TestGarthConnectAPI(t *testing.T) +func TestGarthSave(t *testing.T) +func TestGarthResume(t *testing.T) +``` + +#### Integration Tests: +```go +func TestEndToEndSleepDataRetrieval(t *testing.T) +func TestEndToEndStressDataRetrieval(t *testing.T) +``` + +## 5. TDD Development Workflow + +### Red-Green-Refactor Cycle: + +#### For Each Feature: +1. **RED**: Write failing test that describes desired behavior +2. **GREEN**: Write minimal code to make test pass +3. **REFACTOR**: Clean up code while keeping tests green +4. **REPEAT**: Add more test cases and iterate + +#### Example TDD Session - Session Management: + +**Step 1 - RED**: Write failing test +```go +func TestSessionSave(t *testing.T) { + session := &Session{ + OAuth1Token: "token1", + OAuth2Token: "token2", + } + + err := session.Save("/tmp/test_session") + require.NoError(t, err) + + // Should create file + _, err = os.Stat("/tmp/test_session") + assert.NoError(t, err) +} +``` + +**Step 2 - GREEN**: Make test pass +```go +type Session struct { + OAuth1Token string `json:"oauth1_token"` + OAuth2Token string `json:"oauth2_token"` +} + +func (s *Session) Save(path string) error { + data, err := json.Marshal(s) + if err != nil { + return err + } + return os.WriteFile(path, data, 0644) +} +``` + +**Step 3 - REFACTOR**: Improve implementation +```go +func (s *Session) Save(path string) error { + // Add validation + if s.OAuth1Token == "" { + return errors.New("oauth1 token required") + } + + data, err := json.MarshalIndent(s, "", " ") + if err != nil { + return fmt.Errorf("marshal session: %w", err) + } + + return os.WriteFile(path, data, 0600) // More secure permissions +} +``` + +### Testing Strategy: + +#### Unit Tests (80% coverage target): +- All public methods tested +- Error conditions covered +- Edge cases handled + +#### Integration Tests: +- Full authentication flow +- API request/response cycles +- File I/O operations + +#### Mock Usage: +```go +type MockHTTPClient interface { + Do(req *http.Request) (*http.Response, error) +} + +type MockGarminAPI struct { + responses map[string]*http.Response +} + +func (m *MockGarminAPI) Do(req *http.Request) (*http.Response, error) { + response, exists := m.responses[req.URL.Path] + if !exists { + return nil, errors.New("unexpected request") + } + return response, nil +} +``` + +## 6. Implementation Timeline + +### Week 1: Project Setup + Authentication Tests +- [ ] Initialize Go module and project structure +- [ ] Write authentication test cases +- [ ] Set up CI/CD pipeline +- [ ] Implement basic session management + +### Week 2: Complete Authentication Module +- [ ] Implement OAuth1 flow +- [ ] Implement OAuth2 token refresh +- [ ] Add MFA support +- [ ] Comprehensive authentication testing + +### Week 3: HTTP Client Module +- [ ] Write HTTP client tests +- [ ] Implement client with retry logic +- [ ] Add request/response logging +- [ ] Mock server for testing + +### Week 4: Data Models - Core Types +- [ ] User profile models +- [ ] Sleep data models +- [ ] JSON marshaling/unmarshaling tests + +### Week 5: Data Models - Health Metrics +- [ ] Stress data models +- [ ] Steps, HRV, weight models +- [ ] Validation and business logic + +### Week 6: Main Interface + Integration +- [ ] High-level API implementation +- [ ] Integration tests +- [ ] Documentation and examples +- [ ] Performance optimization + +### Week 7: CLI Tool + Polish +- [ ] Command-line interface +- [ ] Error handling improvements +- [ ] Final testing and bug fixes + +## 7. Quality Gates + +### Before Each Phase Completion: +- [ ] All tests passing +- [ ] Code coverage > 80% +- [ ] Linting passes +- [ ] Documentation updated + +### Before Release: +- [ ] Integration tests with real Garmin API (optional) +- [ ] Performance benchmarks +- [ ] Security review +- [ ] Cross-platform testing + +## 8. Success Metrics + +### Functional Requirements: +- [ ] Authentication flow matches Python library +- [ ] All data models supported +- [ ] API requests work identically +- [ ] Session persistence compatible + +### Quality Requirements: +- [ ] >90% test coverage +- [ ] Zero critical security issues +- [ ] Memory usage < 50MB for typical operations +- [ ] API response time < 2s for standard requests + +### Developer Experience: +- [ ] Clear documentation with examples +- [ ] Easy installation (`go install`) +- [ ] Intuitive API design +- [ ] Comprehensive error messages + +This TDD approach ensures that the Go port will be robust, well-tested, and maintain feature parity with the original Python library while leveraging Go's strengths in performance and concurrency. \ No newline at end of file diff --git a/go.mod b/go.mod index a5cb6b3..121d81d 100644 --- a/go.mod +++ b/go.mod @@ -10,6 +10,7 @@ require ( require ( github.com/davecgh/go-spew v1.1.1 // indirect + github.com/joho/godotenv v1.5.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 9ecb7b9..ef4e252 100644 --- a/go.sum +++ b/go.sum @@ -2,6 +2,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dghubble/oauth1 v0.7.3 h1:EkEM/zMDMp3zOsX2DC/ZQ2vnEX3ELK0/l9kb+vs4ptE= github.com/dghubble/oauth1 v0.7.3/go.mod h1:oxTe+az9NSMIucDPDCCtzJGsPhciJV33xocHfcR2sVY= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= diff --git a/internal/auth/auth.go b/internal/auth/auth.go index 6f0f633..835fb96 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -8,109 +8,294 @@ import ( "io" "net/http" "net/url" + "os" + "regexp" "strings" "time" ) +// Enable debug logging based on environment variable +func debugEnabled() bool { + return os.Getenv("DEBUG_AUTH") == "true" +} + +// debugLog prints debug messages if debugging is enabled +func debugLog(format string, args ...interface{}) { + if debugEnabled() { + fmt.Printf("[DEBUG] "+format+"\n", args...) + } +} + +// fetchLoginParams retrieves required tokens from Garmin login page +func (c *AuthClient) fetchLoginParams(ctx context.Context) (lt, execution string, err error) { + req, err := http.NewRequestWithContext(ctx, "GET", "https://sso.garmin.com/sso/signin?service=https://connect.garmin.com", nil) + if err != nil { + return "", "", fmt.Errorf("failed to create login page request: %w", err) + } + + req.Header = getBrowserHeaders() + + resp, err := c.Client.Do(req) + if err != nil { + return "", "", fmt.Errorf("login page request failed: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", "", fmt.Errorf("failed to read login page response: %w", err) + } + + // For debugging: Log response status and headers + debugLog("Login page response status: %s", resp.Status) + debugLog("Login page response headers: %v", resp.Header) + + // Write body to debug log if it's not too large + if len(body) < 5000 { + debugLog("Login page body: %s", body) + } else { + debugLog("Login page body too large to log (%d bytes)", len(body)) + } + + lt, err = extractParam(`name="lt"\s+value="([^"]+)"`, string(body)) + if err != nil { + return "", "", fmt.Errorf("lt param not found: %w", err) + } + + execution, err = extractParam(`name="execution"\s+value="([^"]+)"`, string(body)) + if err != nil { + return "", "", fmt.Errorf("execution param not found: %w", err) + } + + return lt, execution, nil +} + +// 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") + } + return matches[1], nil +} + +// getBrowserHeaders returns browser-like headers for requests +func getBrowserHeaders() http.Header { + return http.Header{ + "User-Agent": {"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36"}, + "Accept": {"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8"}, + "Accept-Language": {"en-US,en;q=0.9"}, + "Accept-Encoding": {"gzip, deflate, br"}, + "Connection": {"keep-alive"}, + "Cache-Control": {"max-age=0"}, + "Sec-Fetch-Site": {"none"}, + "Sec-Fetch-Mode": {"navigate"}, + "Sec-Fetch-User": {"?1"}, + "Sec-Fetch-Dest": {"document"}, + "DNT": {"1"}, + "Upgrade-Insecure-Requests": {"1"}, + } +} + // Authenticate handles Garmin Connect authentication with MFA support func (c *AuthClient) Authenticate(ctx context.Context, username, password, mfaToken string) (*Token, error) { - // Create login form data + // Fetch required tokens from login page + lt, execution, err := c.fetchLoginParams(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get login params: %w", err) + } + + // Create login form data with required parameters + data := url.Values{} + data.Set("username", username) + data.Set("password", password) + data.Set("embed", "true") + data.Set("rememberme", "on") + data.Set("lt", lt) + data.Set("execution", execution) + 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") + + // Create login request + loginURL := "https://sso.garmin.com/sso/signin" + req, err := http.NewRequestWithContext(ctx, "POST", loginURL, strings.NewReader(data.Encode())) + if err != nil { + return nil, fmt.Errorf("failed to create SSO request: %w", err) + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36") + // Key change: Request JSON response instead of HTML + req.Header.Set("Accept", "application/json") + req.Header.Set("Accept-Language", "en-US,en;q=0.9") + req.Header.Set("Connection", "keep-alive") + req.Header.Set("Cache-Control", "max-age=0") + req.Header.Set("Sec-Fetch-Site", "same-origin") + req.Header.Set("Sec-Fetch-Mode", "navigate") + req.Header.Set("Sec-Fetch-User", "?1") + req.Header.Set("Sec-Fetch-Dest", "document") + req.Header.Set("Upgrade-Insecure-Requests", "1") + + // Log request details if debugging + debugLog("Sending SSO request to: %s", loginURL) + debugLog("Request headers: %v", req.Header) + + // Send SSO request + resp, err := c.Client.Do(req) + if err != nil { + return nil, fmt.Errorf("SSO request failed: %w", err) + } + defer resp.Body.Close() + + // Log response details + debugLog("SSO response status: %s", resp.Status) + debugLog("Response headers: %v", resp.Header) + + // Check for MFA requirement + if resp.StatusCode == http.StatusPreconditionFailed { + if mfaToken == "" { + return nil, errors.New("MFA required but no token provided") + } + return c.handleMFA(ctx, username, password, mfaToken, "") + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("authentication failed with status: %d", resp.StatusCode) + } + + // Parse JSON response to get ticket + 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) + } + + if authResponse.Ticket == "" { + return nil, errors.New("empty ticket in SSO response") + } + + // Exchange ticket for tokens + return c.exchangeTicketForTokens(ctx, authResponse.Ticket) +} +// extractSSOTicket finds the authentication ticket in the SSO response +func extractSSOTicket(body string) (string, error) { + // The ticket is typically in a hidden input field + ticketPattern := `name="ticket"\s+value="([^"]+)"` + re := regexp.MustCompile(ticketPattern) + matches := re.FindStringSubmatch(body) + + if len(matches) < 2 { + if strings.Contains(body, "Cloudflare") { + return "", errors.New("Cloudflare bot protection triggered") + } + return "", errors.New("ticket not found in SSO response") + } + return matches[1], nil +} + +// handleMFA processes multi-factor authentication +func (c *AuthClient) handleMFA(ctx context.Context, username, password, mfaToken, responseBody string) (*Token, error) { + // Extract required parameters from the initial response + params, err := extractMFAParams(responseBody) + if err != nil { + return nil, err + } + + // Prepare MFA request data := url.Values{} data.Set("username", username) data.Set("password", password) data.Set("embed", "false") data.Set("rememberme", "on") + data.Set("_eventId", "submit") + data.Set("mfaCode", mfaToken) - // Create login request - loginURL := fmt.Sprintf("%s%s", c.BaseURL, c.LoginPath) + // Add all parameters from the initial response + for key, value := range params { + data.Set(key, value) + } + + // Create MFA request + loginURL := "https://sso.garmin.com/sso/signin" req, err := http.NewRequestWithContext(ctx, "POST", loginURL, strings.NewReader(data.Encode())) if err != nil { - return nil, fmt.Errorf("failed to create login request: %w", err) + 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 (compatible; go-garminconnect/1.0)") - // Send login request + // Send MFA request resp, err := c.Client.Do(req) if err != nil { - return nil, fmt.Errorf("login request failed: %w", err) + return nil, fmt.Errorf("MFA request failed: %w", err) } defer resp.Body.Close() - // Check if MFA is required - if resp.StatusCode == http.StatusUnauthorized { - // Parse MFA response - var mfaResponse struct { - MFAToken string `json:"mfaToken"` - } - if err := json.NewDecoder(resp.Body).Decode(&mfaResponse); err != nil { - return nil, fmt.Errorf("failed to parse MFA response: %w", err) - } - - // Validate MFA token - if mfaToken == "" { - return nil, errors.New("MFA required but no token provided") - } - - // Create MFA verification request - mfaData := url.Values{} - mfaData.Set("token", mfaResponse.MFAToken) - mfaData.Set("rememberme", "on") - mfaData.Set("mfaCode", mfaToken) - - req, err := http.NewRequestWithContext(ctx, "POST", loginURL, strings.NewReader(mfaData.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 (compatible; go-garminconnect/1.0)") - - // Send MFA request - resp, err = c.Client.Do(req) - if err != nil { - return nil, fmt.Errorf("MFA request failed: %w", err) - } - defer resp.Body.Close() + // Read response body + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read MFA response: %w", err) } - // Handle non-200 responses - if resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(resp.Body) - return nil, fmt.Errorf("authentication failed: %d\n%s", resp.StatusCode, body) + // Extract ticket from MFA response + ticket, err := extractSSOTicket(string(body)) + if err != nil { + return nil, fmt.Errorf("ticket not found in MFA response: %w", err) } - // Parse response cookies to get tokens - var token Token - cookies := resp.Cookies() - for _, cookie := range cookies { - if cookie.Name == "access_token" { - token.AccessToken = cookie.Value - } else if cookie.Name == "refresh_token" { - token.RefreshToken = cookie.Value - } - } - - // Validate tokens - if token.AccessToken == "" || token.RefreshToken == "" { - return nil, errors.New("tokens not found in authentication response") - } - - // Set expiration time - token.Expiry = time.Now().Add(time.Duration(3600) * time.Second) - token.ExpiresIn = 3600 - token.TokenType = "Bearer" - - return &token, nil + // Exchange ticket for tokens + return c.exchangeTicketForTokens(ctx, ticket) } -// RefreshToken exchanges a refresh token for a new access token -func (c *AuthClient) RefreshToken(ctx context.Context, token *Token) (*Token, error) { - // Create token refresh data - data := url.Values{} - data.Set("grant_type", "refresh_token") - data.Set("refresh_token", token.RefreshToken) +// extractSessionCookie extracts session cookie from headers +func extractSessionCookie(cookieHeader string) string { + sessionPattern := `SESSION=([^;]+)` + re := regexp.MustCompile(sessionPattern) + matches := re.FindStringSubmatch(cookieHeader) + if len(matches) < 2 { + return "" + } + return matches[1] +} + +// extractMFAParams extracts necessary parameters for MFA request +func extractMFAParams(body string) (map[string]string, error) { + params := make(map[string]string) + patterns := []string{ + `name="lt"\s+value="([^"]+)"`, + `name="execution"\s+value="([^"]+)"`, + `name="_eventId"\s+value="([^"]+)"`, + } + + for _, pattern := range patterns { + re := regexp.MustCompile(pattern) + matches := re.FindStringSubmatch(body) + if len(matches) < 2 { + return nil, fmt.Errorf("required parameter not found: %s", pattern) + } + paramName := re.SubexpNames()[1] + params[paramName] = matches[1] + } + + return params, nil +} + +// exchangeTicketForTokens exchanges an SSO ticket for access tokens +func (c *AuthClient) exchangeTicketForTokens(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") - // Create refresh token request req, err := http.NewRequestWithContext(ctx, "POST", c.TokenURL, strings.NewReader(data.Encode())) if err != nil { return nil, fmt.Errorf("failed to create token request: %w", err) @@ -118,31 +303,25 @@ func (c *AuthClient) RefreshToken(ctx context.Context, token *Token) (*Token, er req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("User-Agent", "Mozilla/5.0 (compatible; go-garminconnect/1.0)") - // Send refresh token request + // Add basic authentication + req.SetBasicAuth("garmin-connect", "garmin-connect-secret") + resp, err := c.Client.Do(req) if err != nil { - return nil, fmt.Errorf("token refresh failed: %w", err) + return nil, fmt.Errorf("token exchange failed: %w", err) } defer resp.Body.Close() - // Handle non-200 responses if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("token refresh failed with status %d", resp.StatusCode) + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("token exchange failed: %d %s", resp.StatusCode, body) } - // Parse token response - var newToken Token - if err := json.NewDecoder(resp.Body).Decode(&newToken); err != nil { + var token Token + if err := json.NewDecoder(resp.Body).Decode(&token); err != nil { return nil, fmt.Errorf("failed to parse token response: %w", err) } - // Validate token - if newToken.AccessToken == "" || newToken.RefreshToken == "" { - return nil, errors.New("token response missing required fields") - } - - // Set expiration time - newToken.Expiry = time.Now().Add(time.Duration(newToken.ExpiresIn) * time.Second) - - return &newToken, nil + token.Expiry = time.Now().Add(time.Duration(token.ExpiresIn) * time.Second) + return &token, nil } diff --git a/internal/auth/client.go b/internal/auth/client.go index 834a2f7..e5c8084 100644 --- a/internal/auth/client.go +++ b/internal/auth/client.go @@ -2,25 +2,25 @@ package auth import ( "net/http" + "net/http/cookiejar" "time" ) -// AuthClient handles authentication with Garmin Connect +// AuthClient struct handles authentication type AuthClient struct { - BaseURL string - LoginPath string - TokenURL string - Client *http.Client + Client *http.Client + TokenURL string } -// NewAuthClient creates a new authentication client +// NewAuthClient creates a new authentication client with cookie persistence func NewAuthClient() *AuthClient { + jar, _ := cookiejar.New(nil) + client := &http.Client{ + Jar: jar, + Timeout: 30 * time.Second, + } return &AuthClient{ - BaseURL: "https://connect.garmin.com", - LoginPath: "/signin", - TokenURL: "https://connect.garmin.com/oauth/token", - Client: &http.Client{ - Timeout: 30 * time.Second, - }, + Client: client, + TokenURL: "https://connectapi.garmin.com/oauth-service/oauth/exchange/user/2.0", } }