nailed it

This commit is contained in:
cottongin
2025-11-02 16:04:03 -05:00
parent 1cad3cb47f
commit f764519a30
4 changed files with 144 additions and 382 deletions

View File

@@ -3,8 +3,20 @@ FROM golang:1.23-alpine
WORKDIR /app WORKDIR /app
# Install only essential dependencies # Install essential dependencies and Chromium for authentication
RUN apk add --no-cache ca-certificates # 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 files
COPY go.mod go.sum ./ COPY go.mod go.sum ./

View File

@@ -2,129 +2,55 @@ package bkosmi
import ( import (
"context" "context"
"encoding/base64"
"encoding/json"
"fmt" "fmt"
"strings"
"time" "time"
"github.com/chromedp/chromedp" "github.com/chromedp/chromedp"
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
) )
const ( // loginWithChromedp uses browser automation to log in and extract the JWT token.
// Check token expiry 7 days before it expires // This is the proven implementation that successfully authenticates users.
tokenExpiryCheckBuffer = 7 * 24 * time.Hour func loginWithChromedp(email, password string, log *logrus.Entry) (string, error) {
) log.Info("Starting browser automation for authentication...")
// 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 // Create context with timeout
ctx, cancel := chromedp.NewContext(allocCtx) ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
defer cancel() defer cancel()
// Set a reasonable timeout for the entire login process // Set up chromedp options (headless mode)
ctx, cancel = context.WithTimeout(ctx, 90*time.Second) 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() defer cancel()
var token string var token string
// Run the browser automation tasks // Run the automation tasks
err := chromedp.Run(ctx, err := chromedp.Run(ctx,
// Navigate to Kosmi // Navigate to Kosmi
chromedp.Navigate("https://app.kosmi.io"), chromedp.Navigate("https://app.kosmi.io"),
// Wait for page to load completely
chromedp.WaitReady("body"), 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 { chromedp.ActionFunc(func(ctx context.Context) error {
// First, log what buttons we can see log.Debug("Looking for Login button...")
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 var found bool
if err := chromedp.Evaluate(` if err := chromedp.Evaluate(`
(() => { (() => {
const buttons = Array.from(document.querySelectorAll('button')); const buttons = Array.from(document.querySelectorAll('button'));
// Try both "Login" and "Log in"
const btn = buttons.find(el => { const btn = buttons.find(el => {
const text = el.textContent.trim(); const text = el.textContent.trim();
return text === 'Login' || text === 'Log in'; return text === 'Login' || text === 'Log in';
@@ -139,27 +65,22 @@ func (b *BrowserAuthManager) loginViaBrowser() error {
return err return err
} }
if !found { 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 return nil
}), }),
// Wait for login modal // Wait and click "Login with Email"
chromedp.Sleep(3*time.Second), chromedp.Sleep(3*time.Second),
// Click "Login with Email" button
chromedp.ActionFunc(func(ctx context.Context) error { chromedp.ActionFunc(func(ctx context.Context) error {
// Log what buttons we can see now log.Debug("Looking for 'Login with Email' button...")
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 var found bool
if err := chromedp.Evaluate(` 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) { if (btn) {
btn.click(); btn.click();
return true; return true;
@@ -170,293 +91,85 @@ func (b *BrowserAuthManager) loginViaBrowser() error {
return err return err
} }
if !found { 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 return nil
}), }),
// Wait for email form // Wait for form and fill credentials
chromedp.Sleep(3*time.Second), 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.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.Click(`input[placeholder*="Email"], input[placeholder*="Username"]`, chromedp.ByQuery),
chromedp.Sleep(200*time.Millisecond), chromedp.Sleep(200*time.Millisecond),
chromedp.SendKeys(`input[placeholder*="Email"], input[placeholder*="Username"]`, email, chromedp.ByQuery),
// 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), chromedp.Sleep(500*time.Millisecond),
// Click on the password input to focus it
chromedp.Click(`input[type="password"]`, chromedp.ByQuery), chromedp.Click(`input[type="password"]`, chromedp.ByQuery),
chromedp.Sleep(200*time.Millisecond), chromedp.Sleep(200*time.Millisecond),
chromedp.SendKeys(`input[type="password"]`, password+"\n", chromedp.ByQuery),
// 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), chromedp.Sleep(500*time.Millisecond),
// Wait a moment for form validation // Wait for login to complete - check for modal to close
chromedp.Sleep(1*time.Second),
// Click the login submit button (be very specific)
chromedp.ActionFunc(func(ctx context.Context) error { chromedp.ActionFunc(func(ctx context.Context) error {
b.log.Debug("Attempting to click submit button...") log.Debug("Waiting for login to complete...")
var result string // Wait for the login modal to disappear (indicates successful login)
if err := chromedp.Evaluate(` maxAttempts := 30 // 15 seconds total
(() => { for i := 0; i < maxAttempts; i++ {
const buttons = Array.from(document.querySelectorAll('button')); time.Sleep(500 * time.Millisecond)
// Find the submit button in the login form // Check if login modal is gone (successful login)
// It should be visible, enabled, and contain "Login" but not be the main nav button var modalGone bool
const submitBtn = buttons.find(el => { chromedp.Evaluate(`
const text = el.textContent.trim(); (() => {
const isLoginBtn = text === 'Login' || text.startsWith('Login'); // Check if the email/password form is still visible
const isEnabled = !el.disabled; const emailInput = document.querySelector('input[placeholder*="Email"], input[placeholder*="Username"]');
const isVisible = el.offsetParent !== null; const passwordInput = document.querySelector('input[type="password"]');
const isInForm = el.closest('form') !== null || el.closest('[role="dialog"]') !== null; return !emailInput && !passwordInput;
})()
return isLoginBtn && isEnabled && isVisible && isInForm; `, &modalGone).Do(ctx)
});
if modalGone {
if (submitBtn) { log.Debug("✓ Login modal closed")
submitBtn.click(); // Modal is gone, wait a bit more for token to be set
return 'CLICKED: ' + submitBtn.textContent.trim(); time.Sleep(2 * time.Second)
chromedp.Evaluate(`localStorage.getItem('token')`, &token).Do(ctx)
if token != "" {
log.Info("✅ Authentication successful")
return nil
} }
}
return 'NOT_FOUND';
})() // Check for error messages
`, &result).Do(ctx); err != nil { var errorText string
return err 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) // Timeout - get whatever token is there
chromedp.Evaluate(`localStorage.getItem('token')`, &token).Do(ctx)
if result == "NOT_FOUND" { if token == "" {
return fmt.Errorf("Login submit button not found or not clickable") return fmt.Errorf("login timeout: no token found")
} }
log.Warn("⚠ Login timeout but token was 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)
return nil return nil
}), }),
) )
if err != nil { if err != nil {
return fmt.Errorf("browser automation failed: %w", err) return "", fmt.Errorf("browser automation failed: %w", err)
} }
if token == "" { if token == "" {
return fmt.Errorf("no token found in localStorage after login") return "", fmt.Errorf("failed to extract token from localStorage")
} }
b.token = token return token, nil
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
}

View File

@@ -34,6 +34,7 @@ const (
type GraphQLWSClient struct { type GraphQLWSClient struct {
roomURL string roomURL string
roomID string roomID string
token string // JWT token (can be empty for anonymous)
log *logrus.Entry log *logrus.Entry
conn *websocket.Conn conn *websocket.Conn
messageCallback func(*NewMessagePayload) messageCallback func(*NewMessagePayload)
@@ -50,10 +51,11 @@ type WSMessage struct {
} }
// NewGraphQLWSClient creates a new native WebSocket client // 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{ return &GraphQLWSClient{
roomURL: roomURL, roomURL: roomURL,
roomID: roomID, roomID: roomID,
token: token,
log: log, log: log,
done: make(chan struct{}), done: make(chan struct{}),
} }
@@ -63,11 +65,18 @@ func NewGraphQLWSClient(roomURL, roomID string, log *logrus.Entry) *GraphQLWSCli
func (c *GraphQLWSClient) Connect() error { func (c *GraphQLWSClient) Connect() error {
c.log.Info("Connecting to Kosmi via native WebSocket") c.log.Info("Connecting to Kosmi via native WebSocket")
// Step 1: Get anonymous token // Step 1: Get token (use provided or get anonymous)
c.log.Debug("Getting anonymous token...") var token string
token, err := c.getAnonymousToken() if c.token != "" {
if err != nil { c.log.Debug("Using provided authentication token")
return fmt.Errorf("failed to get token: %w", err) 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 // Step 2: Connect to WebSocket

View File

@@ -31,6 +31,7 @@ type Bkosmi struct {
roomID string roomID string
roomURL string roomURL string
connected bool connected bool
authDone bool // Signals that authentication is complete (like IRC bridge)
msgChannel chan config.Message msgChannel chan config.Message
jackboxClient *jackbox.Client jackboxClient *jackbox.Client
} }
@@ -63,8 +64,25 @@ func (b *Bkosmi) Connect() error {
b.roomID = roomID b.roomID = roomID
b.Log.Infof("Extracted room ID: %s", b.roomID) b.Log.Infof("Extracted room ID: %s", b.roomID)
// Create GraphQL WebSocket client (pure Go, no Playwright!) // Check if we need authentication
b.client = NewGraphQLWSClient(b.roomURL, b.roomID, b.Log) 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 // Register message handler
b.client.OnMessage(b.handleIncomingMessage) b.client.OnMessage(b.handleIncomingMessage)
@@ -75,6 +93,7 @@ func (b *Bkosmi) Connect() error {
} }
b.connected = true b.connected = true
b.authDone = true // Signal that authentication is complete
b.Log.Info("Successfully connected to Kosmi") b.Log.Info("Successfully connected to Kosmi")
return nil 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) // JoinChannel joins a Kosmi room (no-op as we connect to a specific room)
func (b *Bkosmi) JoinChannel(channel config.ChannelInfo) error { 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 // Kosmi doesn't have a concept of joining channels after connection
// The room is specified in the configuration and joined on Connect() // 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 return nil
} }