nailed it
This commit is contained in:
16
Dockerfile
16
Dockerfile
@@ -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 ./
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user