This commit is contained in:
2025-08-27 11:58:01 -07:00
parent f24d21033a
commit f4b9f350ae
25 changed files with 2184 additions and 485 deletions

View File

@@ -1,92 +1,148 @@
package auth
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"github.com/dghubble/oauth1"
"net/url"
"strings"
"time"
)
// OAuthConfig holds OAuth1 configuration for Garmin Connect
type OAuthConfig struct {
ConsumerKey string
ConsumerSecret string
// 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
data := url.Values{}
data.Set("username", username)
data.Set("password", password)
data.Set("embed", "false")
data.Set("rememberme", "on")
// Create login request
loginURL := fmt.Sprintf("%s%s", c.BaseURL, c.LoginPath)
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)
}
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
resp, err := c.Client.Do(req)
if err != nil {
return nil, fmt.Errorf("login 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()
}
// 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)
}
// 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
}
// TokenStorage defines the interface for storing and retrieving OAuth tokens
type TokenStorage interface {
GetToken() (*oauth1.Token, error)
SaveToken(*oauth1.Token) error
}
// Authenticate initiates the OAuth1 authentication flow
func Authenticate(w http.ResponseWriter, r *http.Request, config *OAuthConfig, storage TokenStorage) {
// Create OAuth1 config
oauthConfig := oauth1.Config{
ConsumerKey: config.ConsumerKey,
ConsumerSecret: config.ConsumerSecret,
CallbackURL: "http://localhost:8080/callback",
Endpoint: oauth1.Endpoint{
RequestTokenURL: "https://connect.garmin.com/oauth-service/oauth/request_token",
AuthorizeURL: "https://connect.garmin.com/oauth-service/oauth/authorize",
AccessTokenURL: "https://connect.garmin.com/oauth-service/oauth/access_token",
},
}
// Get request token
requestToken, _, err := oauthConfig.RequestToken()
if err != nil {
http.Error(w, "Failed to get request token", http.StatusInternalServerError)
return
}
// Save request token secret temporarily (for callback)
// In a real application, you'd store this in a session
// Redirect to authorization URL
authURL, err := oauthConfig.AuthorizationURL(requestToken)
if err != nil {
http.Error(w, "Failed to get authorization URL", http.StatusInternalServerError)
return
}
http.Redirect(w, r, authURL.String(), http.StatusTemporaryRedirect)
}
// Callback handles OAuth1 callback
func Callback(w http.ResponseWriter, r *http.Request, config *OAuthConfig, storage TokenStorage, requestSecret string) {
// Get request token and verifier from query params
requestToken := r.URL.Query().Get("oauth_token")
verifier := r.URL.Query().Get("oauth_verifier")
// Create OAuth1 config
oauthConfig := oauth1.Config{
ConsumerKey: config.ConsumerKey,
ConsumerSecret: config.ConsumerSecret,
Endpoint: oauth1.Endpoint{
RequestTokenURL: "https://connect.garmin.com/oauth-service/oauth/request_token",
AccessTokenURL: "https://connect.garmin.com/oauth-service/oauth/access_token",
},
}
// Get access token
accessToken, accessSecret, err := oauthConfig.AccessToken(requestToken, requestSecret, verifier)
if err != nil {
http.Error(w, "Failed to get access token", http.StatusInternalServerError)
return
}
// Create token and save
token := &oauth1.Token{
Token: accessToken,
TokenSecret: accessSecret,
}
err = storage.SaveToken(token)
if err != nil {
http.Error(w, "Failed to save token", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
w.Write([]byte("Authentication successful!"))
// 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)
// 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)
}
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
resp, err := c.Client.Do(req)
if err != nil {
return nil, fmt.Errorf("token refresh 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)
}
// Parse token response
var newToken Token
if err := json.NewDecoder(resp.Body).Decode(&newToken); 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
}

309
internal/auth/auth_test.go Normal file
View File

@@ -0,0 +1,309 @@
package auth
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/stretchr/testify/assert"
)
// TestTokenRefresh tests the token refresh functionality
func TestTokenRefresh(t *testing.T) {
tests := []struct {
name string
mockResponse interface{}
mockStatus int
expectedToken *Token
expectedError string
}{
{
name: "successful token refresh",
mockResponse: map[string]interface{}{
"access_token": "new-access-token",
"refresh_token": "new-refresh-token",
"expires_in": 3600,
"token_type": "Bearer",
},
mockStatus: http.StatusOK,
expectedToken: &Token{
AccessToken: "new-access-token",
RefreshToken: "new-refresh-token",
ExpiresIn: 3600,
TokenType: "Bearer",
Expiry: time.Now().Add(3600 * time.Second),
},
},
{
name: "expired refresh token",
mockResponse: map[string]interface{}{
"error": "invalid_grant",
"error_description": "Refresh token expired",
},
mockStatus: http.StatusBadRequest,
expectedError: "token refresh failed with status 400",
},
{
name: "invalid token response",
mockResponse: map[string]interface{}{
"invalid": "data",
},
mockStatus: http.StatusOK,
expectedError: "token response missing required fields",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Create test server
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(tt.mockStatus)
json.NewEncoder(w).Encode(tt.mockResponse)
}))
defer server.Close()
// Create auth client
client := &AuthClient{
Client: &http.Client{},
TokenURL: server.URL,
}
// Create token to refresh
token := &Token{
RefreshToken: "old-refresh-token",
}
// Execute test
newToken, err := client.RefreshToken(context.Background(), token)
// Assert results
if tt.expectedError != "" {
assert.Error(t, err)
assert.Contains(t, err.Error(), tt.expectedError)
assert.Nil(t, newToken)
} else {
assert.NoError(t, err)
assert.NotNil(t, newToken)
assert.Equal(t, tt.expectedToken.AccessToken, newToken.AccessToken)
assert.Equal(t, tt.expectedToken.RefreshToken, newToken.RefreshToken)
assert.Equal(t, tt.expectedToken.ExpiresIn, newToken.ExpiresIn)
assert.WithinDuration(t, tt.expectedToken.Expiry, newToken.Expiry, 5*time.Second)
}
})
}
}
// TestMFAAuthentication tests MFA authentication flow
func TestMFAAuthentication(t *testing.T) {
tests := []struct {
name string
username string
password string
mfaToken string
mockResponses []mockResponse // Multiple responses for MFA flow
expectedToken *Token
expectedError string
}{
{
name: "successful MFA authentication",
username: "user@example.com",
password: "password123",
mfaToken: "123456",
mockResponses: []mockResponse{
{
status: http.StatusUnauthorized,
body: map[string]interface{}{
"mfaToken": "mfa-challenge-token",
},
},
{
status: http.StatusOK,
body: map[string]interface{}{},
cookies: map[string]string{
"access_token": "access-token",
"refresh_token": "refresh-token",
},
},
},
expectedToken: &Token{
AccessToken: "access-token",
RefreshToken: "refresh-token",
ExpiresIn: 3600,
TokenType: "Bearer",
Expiry: time.Now().Add(3600 * time.Second),
},
},
{
name: "invalid MFA code",
username: "user@example.com",
password: "password123",
mfaToken: "wrong-code",
mockResponses: []mockResponse{
{
status: http.StatusUnauthorized,
body: map[string]interface{}{
"mfaToken": "mfa-challenge-token",
},
},
{
status: http.StatusUnauthorized,
body: map[string]interface{}{
"error": "Invalid MFA token",
},
},
},
expectedError: "authentication failed: 401",
},
{
name: "MFA required but not provided",
username: "user@example.com",
password: "password123",
mfaToken: "",
mockResponses: []mockResponse{
{
status: http.StatusUnauthorized,
body: map[string]interface{}{
"mfaToken": "mfa-challenge-token",
},
},
},
expectedError: "MFA required but no token provided",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Create test server with state
currentResponse := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if currentResponse < len(tt.mockResponses) {
response := tt.mockResponses[currentResponse]
w.Header().Set("Content-Type", "application/json")
// Set additional headers if specified
for key, value := range response.headers {
w.Header().Set(key, value)
}
// Set cookies if specified
for name, value := range response.cookies {
http.SetCookie(w, &http.Cookie{
Name: name,
Value: value,
})
}
w.WriteHeader(response.status)
json.NewEncoder(w).Encode(response.body)
currentResponse++
} else {
w.WriteHeader(http.StatusInternalServerError)
}
}))
defer server.Close()
// Create auth client
client := &AuthClient{
Client: &http.Client{},
BaseURL: server.URL,
TokenURL: fmt.Sprintf("%s/oauth/token", server.URL),
LoginPath: "/sso/login",
}
// Execute test
token, err := client.Authenticate(context.Background(), tt.username, tt.password, tt.mfaToken)
// Assert results
if tt.expectedError != "" {
assert.Error(t, err)
assert.Contains(t, err.Error(), tt.expectedError)
assert.Nil(t, token)
} else {
assert.NoError(t, err)
assert.NotNil(t, token)
assert.Equal(t, tt.expectedToken.AccessToken, token.AccessToken)
assert.Equal(t, tt.expectedToken.RefreshToken, token.RefreshToken)
assert.Equal(t, tt.expectedToken.ExpiresIn, token.ExpiresIn)
assert.WithinDuration(t, tt.expectedToken.Expiry, token.Expiry, 5*time.Second)
}
})
}
}
// BenchmarkTokenRefresh measures the performance of token refresh
func BenchmarkTokenRefresh(b *testing.B) {
// Create test server
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"access_token": "benchmark-access-token",
"refresh_token": "benchmark-refresh-token",
"expires_in": 3600,
"token_type": "Bearer",
})
}))
defer server.Close()
// Create auth client
client := &AuthClient{
Client: &http.Client{},
TokenURL: server.URL,
}
// Create token to refresh
token := &Token{
RefreshToken: "benchmark-refresh-token",
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
_, _ = client.RefreshToken(context.Background(), token)
}
}
// BenchmarkMFAAuthentication measures the performance of MFA authentication
func BenchmarkMFAAuthentication(b *testing.B) {
// Create test server
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if r.URL.Path == "/sso/login" {
// First request returns MFA challenge
w.WriteHeader(http.StatusUnauthorized)
json.NewEncoder(w).Encode(map[string]interface{}{
"mfaToken": "mfa-challenge-token",
})
} else if r.URL.Path == "/oauth/token" {
// Second request returns tokens
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]interface{}{
"access_token": "benchmark-access-token",
"refresh_token": "benchmark-refresh-token",
"expires_in": 3600,
"token_type": "Bearer",
})
}
}))
defer server.Close()
// Create auth client
client := &AuthClient{
Client: &http.Client{},
BaseURL: server.URL,
TokenURL: fmt.Sprintf("%s/oauth/token", server.URL),
LoginPath: "/sso/login",
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
_, _ = client.Authenticate(context.Background(), "benchmark@example.com", "benchmark-password", "123456")
}
}
type mockResponse struct {
status int
body interface{}
headers map[string]string
cookies map[string]string
}

26
internal/auth/client.go Normal file
View File

@@ -0,0 +1,26 @@
package auth
import (
"net/http"
"time"
)
// AuthClient handles authentication with Garmin Connect
type AuthClient struct {
BaseURL string
LoginPath string
TokenURL string
Client *http.Client
}
// NewAuthClient creates a new authentication client
func NewAuthClient() *AuthClient {
return &AuthClient{
BaseURL: "https://connect.garmin.com",
LoginPath: "/signin",
TokenURL: "https://connect.garmin.com/oauth/token",
Client: &http.Client{
Timeout: 30 * time.Second,
},
}
}

12
internal/auth/types.go Normal file
View File

@@ -0,0 +1,12 @@
package auth
import "time"
// Token represents OAuth2 tokens
type Token struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
ExpiresIn int `json:"expires_in"`
TokenType string `json:"token_type"`
Expiry time.Time `json:"expiry"`
}