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
}