mirror of
https://github.com/sstent/go-garth-cli.git
synced 2025-12-05 23:52:02 +00:00
266 lines
7.4 KiB
Go
266 lines
7.4 KiB
Go
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 ""
|
|
}
|