diff --git a/Dockerfile b/Dockerfile index 113a92f..d380356 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,8 +3,20 @@ FROM golang:1.23-alpine WORKDIR /app -# Install only essential dependencies -RUN apk add --no-cache ca-certificates +# Install essential dependencies and Chromium for authentication +# Chromium is needed for email/password authentication via browser automation +RUN apk add --no-cache \ + ca-certificates \ + chromium \ + chromium-chromedriver \ + nss \ + freetype \ + harfbuzz \ + ttf-freefont + +# Set environment variables for Chromium +ENV CHROME_BIN=/usr/bin/chromium-browser \ + CHROME_PATH=/usr/lib/chromium/ # Copy go mod files COPY go.mod go.sum ./ diff --git a/bridge/kosmi/browser_auth.go b/bridge/kosmi/browser_auth.go index 52e5c11..ecbb382 100644 --- a/bridge/kosmi/browser_auth.go +++ b/bridge/kosmi/browser_auth.go @@ -2,129 +2,55 @@ 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() +// loginWithChromedp uses browser automation to log in and extract the JWT token. +// This is the proven implementation that successfully authenticates users. +func loginWithChromedp(email, password string, log *logrus.Entry) (string, error) { + log.Info("Starting browser automation for authentication...") // Create context with timeout - ctx, cancel := chromedp.NewContext(allocCtx) + ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) defer cancel() - // Set a reasonable timeout for the entire login process - ctx, cancel = context.WithTimeout(ctx, 90*time.Second) + // Set up chromedp options (headless mode) + opts := []chromedp.ExecAllocatorOption{ + chromedp.NoFirstRun, + chromedp.NoDefaultBrowserCheck, + chromedp.DisableGPU, + chromedp.NoSandbox, + chromedp.Headless, + } + + // Create allocator context + allocCtx, cancel := chromedp.NewExecAllocator(ctx, opts...) + defer cancel() + + // Create browser context with no logging to suppress cookie errors + ctx, cancel = chromedp.NewContext(allocCtx, chromedp.WithLogf(func(string, ...interface{}) {})) defer cancel() var token string - // Run the browser automation tasks + // Run the 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), + chromedp.Sleep(3*time.Second), - // Click Login button (find by text content using JS with error handling) + // Find and click Login button 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) - + log.Debug("Looking for Login button...") 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'; @@ -139,27 +65,22 @@ func (b *BrowserAuthManager) loginViaBrowser() error { return err } if !found { - return fmt.Errorf("Login button not found (found buttons: %v)", buttonTexts) + return fmt.Errorf("Login button not found") } + log.Debug("✓ Clicked Login button") return nil }), - // Wait for login modal + // Wait and click "Login with Email" 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) - + log.Debug("Looking for 'Login with Email' button...") var found bool if err := chromedp.Evaluate(` (() => { - const btn = Array.from(document.querySelectorAll('button')).find(el => el.textContent.includes('Email')); + const btn = Array.from(document.querySelectorAll('button')).find(el => + el.textContent.includes('Email') + ); if (btn) { btn.click(); return true; @@ -170,293 +91,85 @@ func (b *BrowserAuthManager) loginViaBrowser() error { return err } if !found { - return fmt.Errorf("Login with Email button not found (found buttons: %v)", buttonTexts) + return fmt.Errorf("'Login with Email' button not found") } + log.Debug("✓ Clicked 'Login with Email' button") return nil }), - // Wait for email form + // Wait for form and fill credentials 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.SendKeys(`input[placeholder*="Email"], input[placeholder*="Username"]`, email, chromedp.ByQuery), 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.SendKeys(`input[type="password"]`, password+"\n", chromedp.ByQuery), chromedp.Sleep(500*time.Millisecond), - // Wait a moment for form validation - chromedp.Sleep(1*time.Second), - - // Click the login submit button (be very specific) + // Wait for login to complete - check for modal to close 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(); + log.Debug("Waiting for login to complete...") + // Wait for the login modal to disappear (indicates successful login) + maxAttempts := 30 // 15 seconds total + for i := 0; i < maxAttempts; i++ { + time.Sleep(500 * time.Millisecond) + + // Check if login modal is gone (successful login) + var modalGone bool + chromedp.Evaluate(` + (() => { + // Check if the email/password form is still visible + const emailInput = document.querySelector('input[placeholder*="Email"], input[placeholder*="Username"]'); + const passwordInput = document.querySelector('input[type="password"]'); + return !emailInput && !passwordInput; + })() + `, &modalGone).Do(ctx) + + if modalGone { + log.Debug("✓ Login modal closed") + // Modal is gone, wait a bit more for token to be set + time.Sleep(2 * time.Second) + chromedp.Evaluate(`localStorage.getItem('token')`, &token).Do(ctx) + if token != "" { + log.Info("✅ Authentication successful") + return nil } - - return 'NOT_FOUND'; - })() - `, &result).Do(ctx); err != nil { - return err + } + + // Check for error messages + var errorText string + chromedp.Evaluate(` + (() => { + const errorEl = document.querySelector('[role="alert"], .error, .error-message'); + return errorEl ? errorEl.textContent.trim() : ''; + })() + `, &errorText).Do(ctx) + + if errorText != "" && errorText != "null" { + return fmt.Errorf("login failed: %s", errorText) + } } - b.log.Debugf("Submit button result: %s", result) - - if result == "NOT_FOUND" { - return fmt.Errorf("Login submit button not found or not clickable") + // Timeout - get whatever token is there + chromedp.Evaluate(`localStorage.getItem('token')`, &token).Do(ctx) + if token == "" { + return fmt.Errorf("login timeout: no token found") } - - 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) + log.Warn("⚠ Login timeout but token was found") return nil }), ) if err != nil { - return fmt.Errorf("browser automation failed: %w", err) + return "", fmt.Errorf("browser automation failed: %w", err) } if token == "" { - return fmt.Errorf("no token found in localStorage after login") + return "", fmt.Errorf("failed to extract token from localStorage") } - 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 + return token, 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 -} - diff --git a/bridge/kosmi/graphql_ws_client.go b/bridge/kosmi/graphql_ws_client.go index a1642cc..e038103 100644 --- a/bridge/kosmi/graphql_ws_client.go +++ b/bridge/kosmi/graphql_ws_client.go @@ -34,6 +34,7 @@ const ( type GraphQLWSClient struct { roomURL string roomID string + token string // JWT token (can be empty for anonymous) log *logrus.Entry conn *websocket.Conn messageCallback func(*NewMessagePayload) @@ -50,10 +51,11 @@ type WSMessage struct { } // NewGraphQLWSClient creates a new native WebSocket client -func NewGraphQLWSClient(roomURL, roomID string, log *logrus.Entry) *GraphQLWSClient { +func NewGraphQLWSClient(roomURL, roomID, token string, log *logrus.Entry) *GraphQLWSClient { return &GraphQLWSClient{ roomURL: roomURL, roomID: roomID, + token: token, log: log, done: make(chan struct{}), } @@ -63,11 +65,18 @@ func NewGraphQLWSClient(roomURL, roomID string, log *logrus.Entry) *GraphQLWSCli func (c *GraphQLWSClient) Connect() error { c.log.Info("Connecting to Kosmi via native WebSocket") - // Step 1: Get anonymous token - c.log.Debug("Getting anonymous token...") - token, err := c.getAnonymousToken() - if err != nil { - return fmt.Errorf("failed to get token: %w", err) + // Step 1: Get token (use provided or get anonymous) + var token string + if c.token != "" { + c.log.Debug("Using provided authentication token") + token = c.token + } else { + c.log.Debug("Getting anonymous token...") + var err error + token, err = c.getAnonymousToken() + if err != nil { + return fmt.Errorf("failed to get token: %w", err) + } } // Step 2: Connect to WebSocket diff --git a/bridge/kosmi/kosmi.go b/bridge/kosmi/kosmi.go index 39d58b2..f3508f9 100644 --- a/bridge/kosmi/kosmi.go +++ b/bridge/kosmi/kosmi.go @@ -31,6 +31,7 @@ type Bkosmi struct { roomID string roomURL string connected bool + authDone bool // Signals that authentication is complete (like IRC bridge) msgChannel chan config.Message jackboxClient *jackbox.Client } @@ -63,8 +64,25 @@ func (b *Bkosmi) Connect() error { b.roomID = roomID b.Log.Infof("Extracted room ID: %s", b.roomID) - // Create GraphQL WebSocket client (pure Go, no Playwright!) - b.client = NewGraphQLWSClient(b.roomURL, b.roomID, b.Log) + // Check if we need authentication + email := b.GetString("Email") + password := b.GetString("Password") + + var token string + if email != "" && password != "" { + b.Log.Info("Authenticating with email/password...") + token, err = loginWithChromedp(email, password, b.Log) + if err != nil { + return fmt.Errorf("authentication failed: %w", err) + } + b.Log.Info("✅ Authentication successful") + } else { + b.Log.Info("No credentials provided, using anonymous access") + // token will be empty, client will get anonymous token + } + + // Create GraphQL WebSocket client with token + b.client = NewGraphQLWSClient(b.roomURL, b.roomID, token, b.Log) // Register message handler b.client.OnMessage(b.handleIncomingMessage) @@ -75,6 +93,7 @@ func (b *Bkosmi) Connect() error { } b.connected = true + b.authDone = true // Signal that authentication is complete b.Log.Info("Successfully connected to Kosmi") return nil @@ -98,9 +117,18 @@ func (b *Bkosmi) Disconnect() error { // JoinChannel joins a Kosmi room (no-op as we connect to a specific room) func (b *Bkosmi) JoinChannel(channel config.ChannelInfo) error { + // Wait for authentication to complete before proceeding + // This ensures the WebSocket connection is fully established (like IRC bridge) + for { + if b.authDone { + break + } + time.Sleep(time.Second) + } + // Kosmi doesn't have a concept of joining channels after connection // The room is specified in the configuration and joined on Connect() - b.Log.Infof("Channel %s is already connected via room URL", channel.Name) + b.Log.Debugf("Channel ready: %s (connected via room URL)", channel.Name) return nil }