Files
IRC-kosmi-relay/bridge/kosmi/browser_auth.go

463 lines
12 KiB
Go
Raw Normal View History

2025-11-01 21:00:16 -04:00
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
}