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(`(.+?)`) 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 "" }