mirror of
https://github.com/sstent/go-garminconnect.git
synced 2025-12-06 08:02:02 +00:00
first try at auth before swtiching
This commit is contained in:
28
Dockerfile
Normal file
28
Dockerfile
Normal file
@@ -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"]
|
||||||
@@ -6,18 +6,26 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/joho/godotenv"
|
||||||
"github.com/sstent/go-garminconnect/internal/api"
|
"github.com/sstent/go-garminconnect/internal/api"
|
||||||
"github.com/sstent/go-garminconnect/internal/auth"
|
"github.com/sstent/go-garminconnect/internal/auth"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
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") == "" {
|
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)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set up authentication client
|
// Set up authentication client with headless mode enabled
|
||||||
client := auth.NewAuthClient()
|
client := auth.NewAuthClient()
|
||||||
token, err := client.Authenticate(
|
token, err := client.Authenticate(
|
||||||
context.Background(),
|
context.Background(),
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ func main() {
|
|||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create authentication client
|
// Create authentication client with headless mode enabled
|
||||||
authClient := auth.NewAuthClient()
|
authClient := auth.NewAuthClient()
|
||||||
|
|
||||||
// Authenticate with credentials
|
// Authenticate with credentials
|
||||||
|
|||||||
404
garth.md
Normal file
404
garth.md
Normal file
@@ -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.
|
||||||
1
go.mod
1
go.mod
@@ -10,6 +10,7 @@ require (
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
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
|
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
2
go.sum
2
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/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 h1:EkEM/zMDMp3zOsX2DC/ZQ2vnEX3ELK0/l9kb+vs4ptE=
|
||||||
github.com/dghubble/oauth1 v0.7.3/go.mod h1:oxTe+az9NSMIucDPDCCtzJGsPhciJV33xocHfcR2sVY=
|
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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
|
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
|
||||||
|
|||||||
@@ -8,109 +8,294 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"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
|
// Authenticate handles Garmin Connect authentication with MFA support
|
||||||
func (c *AuthClient) Authenticate(ctx context.Context, username, password, mfaToken string) (*Token, error) {
|
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 := url.Values{}
|
||||||
data.Set("username", username)
|
data.Set("username", username)
|
||||||
data.Set("password", password)
|
data.Set("password", password)
|
||||||
data.Set("embed", "false")
|
data.Set("embed", "false")
|
||||||
data.Set("rememberme", "on")
|
data.Set("rememberme", "on")
|
||||||
|
data.Set("_eventId", "submit")
|
||||||
|
data.Set("mfaCode", mfaToken)
|
||||||
|
|
||||||
// Create login request
|
// Add all parameters from the initial response
|
||||||
loginURL := fmt.Sprintf("%s%s", c.BaseURL, c.LoginPath)
|
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()))
|
req, err := http.NewRequestWithContext(ctx, "POST", loginURL, strings.NewReader(data.Encode()))
|
||||||
if err != nil {
|
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("Content-Type", "application/x-www-form-urlencoded")
|
||||||
req.Header.Set("User-Agent", "Mozilla/5.0 (compatible; go-garminconnect/1.0)")
|
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)
|
resp, err := c.Client.Do(req)
|
||||||
if err != nil {
|
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()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
// Check if MFA is required
|
// Read response body
|
||||||
if resp.StatusCode == http.StatusUnauthorized {
|
body, err := io.ReadAll(resp.Body)
|
||||||
// Parse MFA response
|
if err != nil {
|
||||||
var mfaResponse struct {
|
return nil, fmt.Errorf("failed to read MFA response: %w", err)
|
||||||
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()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle non-200 responses
|
// Extract ticket from MFA response
|
||||||
if resp.StatusCode != http.StatusOK {
|
ticket, err := extractSSOTicket(string(body))
|
||||||
body, _ := io.ReadAll(resp.Body)
|
if err != nil {
|
||||||
return nil, fmt.Errorf("authentication failed: %d\n%s", resp.StatusCode, body)
|
return nil, fmt.Errorf("ticket not found in MFA response: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse response cookies to get tokens
|
// Exchange ticket for tokens
|
||||||
var token Token
|
return c.exchangeTicketForTokens(ctx, ticket)
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// RefreshToken exchanges a refresh token for a new access token
|
// extractSessionCookie extracts session cookie from headers
|
||||||
func (c *AuthClient) RefreshToken(ctx context.Context, token *Token) (*Token, error) {
|
func extractSessionCookie(cookieHeader string) string {
|
||||||
// Create token refresh data
|
sessionPattern := `SESSION=([^;]+)`
|
||||||
data := url.Values{}
|
re := regexp.MustCompile(sessionPattern)
|
||||||
data.Set("grant_type", "refresh_token")
|
matches := re.FindStringSubmatch(cookieHeader)
|
||||||
data.Set("refresh_token", token.RefreshToken)
|
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()))
|
req, err := http.NewRequestWithContext(ctx, "POST", c.TokenURL, strings.NewReader(data.Encode()))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to create token request: %w", err)
|
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("Content-Type", "application/x-www-form-urlencoded")
|
||||||
req.Header.Set("User-Agent", "Mozilla/5.0 (compatible; go-garminconnect/1.0)")
|
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)
|
resp, err := c.Client.Do(req)
|
||||||
if err != nil {
|
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()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
// Handle non-200 responses
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
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 token Token
|
||||||
var newToken Token
|
if err := json.NewDecoder(resp.Body).Decode(&token); err != nil {
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&newToken); err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to parse token response: %w", err)
|
return nil, fmt.Errorf("failed to parse token response: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate token
|
token.Expiry = time.Now().Add(time.Duration(token.ExpiresIn) * time.Second)
|
||||||
if newToken.AccessToken == "" || newToken.RefreshToken == "" {
|
return &token, nil
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,25 +2,25 @@ package auth
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/http/cookiejar"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// AuthClient handles authentication with Garmin Connect
|
// AuthClient struct handles authentication
|
||||||
type AuthClient struct {
|
type AuthClient struct {
|
||||||
BaseURL string
|
Client *http.Client
|
||||||
LoginPath string
|
TokenURL string
|
||||||
TokenURL string
|
|
||||||
Client *http.Client
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewAuthClient creates a new authentication client
|
// NewAuthClient creates a new authentication client with cookie persistence
|
||||||
func NewAuthClient() *AuthClient {
|
func NewAuthClient() *AuthClient {
|
||||||
|
jar, _ := cookiejar.New(nil)
|
||||||
|
client := &http.Client{
|
||||||
|
Jar: jar,
|
||||||
|
Timeout: 30 * time.Second,
|
||||||
|
}
|
||||||
return &AuthClient{
|
return &AuthClient{
|
||||||
BaseURL: "https://connect.garmin.com",
|
Client: client,
|
||||||
LoginPath: "/signin",
|
TokenURL: "https://connectapi.garmin.com/oauth-service/oauth/exchange/user/2.0",
|
||||||
TokenURL: "https://connect.garmin.com/oauth/token",
|
|
||||||
Client: &http.Client{
|
|
||||||
Timeout: 30 * time.Second,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user