package bkosmi import ( "context" "encoding/base64" "encoding/json" "fmt" "strings" "time" "github.com/chromedp/chromedp" "github.com/sirupsen/logrus" ) const ( // Check token expiry 7 days before it expires tokenExpiryCheckBuffer = 7 * 24 * time.Hour ) // BrowserAuthManager handles automated browser-based authentication type BrowserAuthManager struct { email string password string token string tokenExpiry time.Time log *logrus.Entry lastCheckTime time.Time checkInterval time.Duration } // NewBrowserAuthManager creates a new browser-based authentication manager func NewBrowserAuthManager(email, password string, log *logrus.Entry) *BrowserAuthManager { return &BrowserAuthManager{ email: email, password: password, log: log, checkInterval: 24 * time.Hour, // Check daily for token expiry } } // GetToken returns a valid token, obtaining a new one via browser if needed func (b *BrowserAuthManager) GetToken() (string, error) { // Check if we need to obtain or refresh the token if b.token == "" || b.shouldRefreshToken() { b.log.Info("Obtaining authentication token via browser automation...") if err := b.loginViaBrowser(); err != nil { return "", &AuthError{ Op: "browser_login", Reason: "failed to obtain token via browser", Err: err, } } } return b.token, nil } // shouldRefreshToken checks if the token needs to be refreshed func (b *BrowserAuthManager) shouldRefreshToken() bool { // No token yet if b.token == "" { return true } // Token expired or about to expire if time.Now().After(b.tokenExpiry.Add(-tokenExpiryCheckBuffer)) { b.log.Info("Token expired or expiring soon, will refresh") return true } // Periodic check (daily) to verify token is still valid if time.Since(b.lastCheckTime) > b.checkInterval { b.log.Debug("Performing periodic token validity check") b.lastCheckTime = time.Now() // For now, we trust the expiry time. Could add a validation check here. } return false } // loginViaBrowser uses chromedp to automate login and extract token func (b *BrowserAuthManager) loginViaBrowser() error { // Set up Chrome options opts := append(chromedp.DefaultExecAllocatorOptions[:], chromedp.Flag("headless", true), chromedp.Flag("disable-gpu", true), chromedp.Flag("no-sandbox", true), chromedp.Flag("disable-dev-shm-usage", true), ) // Create allocator context allocCtx, cancel := chromedp.NewExecAllocator(context.Background(), opts...) defer cancel() // Create context with timeout ctx, cancel := chromedp.NewContext(allocCtx) defer cancel() // Set a reasonable timeout for the entire login process ctx, cancel = context.WithTimeout(ctx, 90*time.Second) defer cancel() var token string // Run the browser automation tasks err := chromedp.Run(ctx, // Navigate to Kosmi chromedp.Navigate("https://app.kosmi.io"), // Wait for page to load completely chromedp.WaitReady("body"), chromedp.Sleep(2*time.Second), // Click Login button (find by text content using JS with error handling) chromedp.ActionFunc(func(ctx context.Context) error { // First, log what buttons we can see var buttonTexts []string chromedp.Evaluate(` Array.from(document.querySelectorAll('button')).map(el => el.textContent.trim()) `, &buttonTexts).Do(ctx) b.log.Debugf("Found buttons: %v", buttonTexts) var found bool if err := chromedp.Evaluate(` (() => { const buttons = Array.from(document.querySelectorAll('button')); // Try both "Login" and "Log in" const btn = buttons.find(el => { const text = el.textContent.trim(); return text === 'Login' || text === 'Log in'; }); if (btn) { btn.click(); return true; } return false; })() `, &found).Do(ctx); err != nil { return err } if !found { return fmt.Errorf("Login button not found (found buttons: %v)", buttonTexts) } return nil }), // Wait for login modal chromedp.Sleep(3*time.Second), // Click "Login with Email" button chromedp.ActionFunc(func(ctx context.Context) error { // Log what buttons we can see now var buttonTexts []string chromedp.Evaluate(` Array.from(document.querySelectorAll('button')).map(el => el.textContent.trim()) `, &buttonTexts).Do(ctx) b.log.Debugf("After clicking Log in, found buttons: %v", buttonTexts) var found bool if err := chromedp.Evaluate(` (() => { const btn = Array.from(document.querySelectorAll('button')).find(el => el.textContent.includes('Email')); if (btn) { btn.click(); return true; } return false; })() `, &found).Do(ctx); err != nil { return err } if !found { return fmt.Errorf("Login with Email button not found (found buttons: %v)", buttonTexts) } return nil }), // Wait for email form chromedp.Sleep(3*time.Second), chromedp.ActionFunc(func(ctx context.Context) error { b.log.Debug("Waiting for password input...") return nil }), chromedp.WaitVisible(`input[type="password"]`, chromedp.ByQuery), chromedp.ActionFunc(func(ctx context.Context) error { b.log.Debug("Password input found, preparing to fill form...") return nil }), // Click on the email input to focus it chromedp.Click(`input[placeholder*="Email"], input[placeholder*="Username"]`, chromedp.ByQuery), chromedp.Sleep(200*time.Millisecond), // Type email character by character chromedp.ActionFunc(func(ctx context.Context) error { b.log.Debugf("Typing email: %s", b.email) return chromedp.SendKeys(`input[placeholder*="Email"], input[placeholder*="Username"]`, b.email, chromedp.ByQuery).Do(ctx) }), chromedp.Sleep(500*time.Millisecond), // Click on the password input to focus it chromedp.Click(`input[type="password"]`, chromedp.ByQuery), chromedp.Sleep(200*time.Millisecond), // Type password character by character chromedp.ActionFunc(func(ctx context.Context) error { b.log.Debugf("Typing password (length: %d)", len(b.password)) return chromedp.SendKeys(`input[type="password"]`, b.password, chromedp.ByQuery).Do(ctx) }), // Verify password was filled correctly chromedp.ActionFunc(func(ctx context.Context) error { var actualLength int chromedp.Evaluate(` (() => { const passwordInput = document.querySelector('input[type="password"]'); return passwordInput ? passwordInput.value.length : 0; })() `, &actualLength).Do(ctx) b.log.Debugf("Password filled (actual length: %d, expected: %d)", actualLength, len(b.password)) if actualLength != len(b.password) { return fmt.Errorf("password length mismatch: got %d, expected %d", actualLength, len(b.password)) } return nil }), chromedp.Sleep(500*time.Millisecond), // Wait a moment for form validation chromedp.Sleep(1*time.Second), // Click the login submit button (be very specific) chromedp.ActionFunc(func(ctx context.Context) error { b.log.Debug("Attempting to click submit button...") var result string if err := chromedp.Evaluate(` (() => { const buttons = Array.from(document.querySelectorAll('button')); // Find the submit button in the login form // It should be visible, enabled, and contain "Login" but not be the main nav button const submitBtn = buttons.find(el => { const text = el.textContent.trim(); const isLoginBtn = text === 'Login' || text.startsWith('Login'); const isEnabled = !el.disabled; const isVisible = el.offsetParent !== null; const isInForm = el.closest('form') !== null || el.closest('[role="dialog"]') !== null; return isLoginBtn && isEnabled && isVisible && isInForm; }); if (submitBtn) { submitBtn.click(); return 'CLICKED: ' + submitBtn.textContent.trim(); } return 'NOT_FOUND'; })() `, &result).Do(ctx); err != nil { return err } b.log.Debugf("Submit button result: %s", result) if result == "NOT_FOUND" { return fmt.Errorf("Login submit button not found or not clickable") } b.log.Debug("Submit button clicked") return nil }), // Wait for login to complete (page will reload/redirect) chromedp.Sleep(5*time.Second), // Check if login succeeded by looking for error messages chromedp.ActionFunc(func(ctx context.Context) error { var errorText string chromedp.Evaluate(` (() => { const errorEl = document.querySelector('[role="alert"], .error, .alert-error'); return errorEl ? errorEl.textContent : ''; })() `, &errorText).Do(ctx) if errorText != "" { return fmt.Errorf("login failed with error: %s", errorText) } b.log.Debug("No error messages found, checking token...") return nil }), // Extract token from localStorage chromedp.Evaluate(`localStorage.getItem('token')`, &token), // Verify the token is not anonymous by checking if user info exists chromedp.ActionFunc(func(ctx context.Context) error { var userInfo string chromedp.Evaluate(` (() => { try { const token = localStorage.getItem('token'); if (!token) return 'NO_TOKEN'; // Decode JWT payload (middle part) const parts = token.split('.'); if (parts.length !== 3) return 'INVALID_TOKEN'; const payload = JSON.parse(atob(parts[1])); return JSON.stringify({ sub: payload.sub, typ: payload.typ, isAnon: payload.sub ? false : true }); } catch (e) { return 'ERROR: ' + e.message; } })() `, &userInfo).Do(ctx) b.log.Debugf("Token info from browser: %s", userInfo) return nil }), ) if err != nil { return fmt.Errorf("browser automation failed: %w", err) } if token == "" { return fmt.Errorf("no token found in localStorage after login") } b.token = token b.log.Infof("✅ Successfully obtained token via browser automation") b.log.Infof(" Email used: %s", b.email) b.log.Infof(" Token (first 50 chars): %s...", token[:min(50, len(token))]) b.log.Infof(" Token (last 50 chars): ...%s", token[max(0, len(token)-50):]) // Parse token to get expiry if err := b.parseTokenExpiry(); err != nil { b.log.Warnf("Failed to parse token expiry: %v", err) // Default to 1 year if we can't parse b.tokenExpiry = time.Now().Add(365 * 24 * time.Hour) } b.lastCheckTime = time.Now() expiresIn := time.Until(b.tokenExpiry) b.log.Infof("Token expires in: %v", expiresIn.Round(24*time.Hour)) return nil } // parseTokenExpiry extracts the expiry time from the JWT token func (b *BrowserAuthManager) parseTokenExpiry() error { // JWT format: header.payload.signature parts := strings.Split(b.token, ".") if len(parts) != 3 { return fmt.Errorf("invalid JWT format") } // Decode the payload (base64url without padding) payload := parts[1] // Add padding if needed switch len(payload) % 4 { case 2: payload += "==" case 3: payload += "=" } // Replace URL-safe characters payload = strings.ReplaceAll(payload, "-", "+") payload = strings.ReplaceAll(payload, "_", "/") decoded, err := base64.StdEncoding.DecodeString(payload) if err != nil { return fmt.Errorf("failed to decode JWT payload: %w", err) } // Parse JSON var claims struct { Exp int64 `json:"exp"` Sub string `json:"sub"` Typ string `json:"typ"` } if err := json.Unmarshal(decoded, &claims); err != nil { return fmt.Errorf("failed to parse JWT claims: %w", err) } if claims.Exp == 0 { return fmt.Errorf("no expiry in token") } b.tokenExpiry = time.Unix(claims.Exp, 0) b.log.Infof(" Token user ID (sub): %s", claims.Sub) b.log.Infof(" Token type (typ): %s", claims.Typ) return nil } func min(a, b int) int { if a < b { return a } return b } func max(a, b int) int { if a > b { return a } return b } // IsAuthenticated checks if we have a valid token func (b *BrowserAuthManager) IsAuthenticated() bool { return b.token != "" && time.Now().Before(b.tokenExpiry) } // GetUserID returns the user ID from the token (if available) func (b *BrowserAuthManager) GetUserID() string { if b.token == "" { return "" } parts := strings.Split(b.token, ".") if len(parts) != 3 { return "" } payload := parts[1] switch len(payload) % 4 { case 2: payload += "==" case 3: payload += "=" } payload = strings.ReplaceAll(payload, "-", "+") payload = strings.ReplaceAll(payload, "_", "/") decoded, err := base64.StdEncoding.DecodeString(payload) if err != nil { return "" } var claims struct { Sub string `json:"sub"` } if err := json.Unmarshal(decoded, &claims); err != nil { return "" } return claims.Sub }