nailed it 2.0
This commit is contained in:
181
TOKEN_PERSISTENCE.md
Normal file
181
TOKEN_PERSISTENCE.md
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
# Token Persistence Guide
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The Kosmi bridge now caches JWT authentication tokens to avoid repeated browser automation on every startup. The token is stored in a local directory that persists across Docker container rebuilds and restarts.
|
||||||
|
|
||||||
|
## How It Works
|
||||||
|
|
||||||
|
### Token Cache Location
|
||||||
|
|
||||||
|
The token cache is stored in a file called `kosmi_token_cache.json` in the following locations:
|
||||||
|
|
||||||
|
- **Docker (Production)**: `./data/kosmi_token_cache.json` (mounted from your host machine)
|
||||||
|
- **Local Development**: `~/.matterbridge/kosmi_token_cache.json`
|
||||||
|
|
||||||
|
### Token Cache Structure
|
||||||
|
|
||||||
|
The cache file contains:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
|
||||||
|
"email": "your-email@example.com",
|
||||||
|
"expires_at": "2026-11-02T15:56:23Z",
|
||||||
|
"saved_at": "2025-11-02T15:56:23Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Token Lifecycle
|
||||||
|
|
||||||
|
1. **On Startup**: The bridge checks for a cached token
|
||||||
|
- If found and valid, it uses the cached token (no browser automation needed)
|
||||||
|
- If expired or expiring within 7 days, it performs fresh authentication
|
||||||
|
- If not found, it performs fresh authentication
|
||||||
|
|
||||||
|
2. **Token Expiry**: Kosmi JWT tokens expire after 1 year
|
||||||
|
- The bridge automatically refreshes tokens that expire within 7 days
|
||||||
|
- You'll see a log message indicating how long until the token expires
|
||||||
|
|
||||||
|
3. **Token Storage**: After successful authentication, the token is saved to the cache file
|
||||||
|
- File permissions are set to `0600` (read/write for owner only)
|
||||||
|
- The cache directory is created automatically if it doesn't exist
|
||||||
|
|
||||||
|
## Docker Configuration
|
||||||
|
|
||||||
|
### Volume Mount
|
||||||
|
|
||||||
|
The `docker-compose.yml` includes a volume mount for persistent storage:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
volumes:
|
||||||
|
- ./data:/app/data:z
|
||||||
|
```
|
||||||
|
|
||||||
|
This mounts the `./data` directory from your host machine into the container at `/app/data`.
|
||||||
|
|
||||||
|
### Environment Variable
|
||||||
|
|
||||||
|
The container sets the `MATTERBRIDGE_DATA_DIR` environment variable:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
environment:
|
||||||
|
- MATTERBRIDGE_DATA_DIR=/app/data
|
||||||
|
```
|
||||||
|
|
||||||
|
This tells the bridge where to store persistent data like the token cache.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
### First Run
|
||||||
|
|
||||||
|
On the first run with email/password configured:
|
||||||
|
|
||||||
|
1. The bridge will launch a headless browser
|
||||||
|
2. Authenticate with Kosmi using your credentials
|
||||||
|
3. Extract and cache the JWT token
|
||||||
|
4. Save it to `./data/kosmi_token_cache.json`
|
||||||
|
|
||||||
|
You'll see logs like:
|
||||||
|
|
||||||
|
```
|
||||||
|
level=info msg="No cached token found, performing authentication..."
|
||||||
|
level=info msg="Starting browser automation for authentication..."
|
||||||
|
level=info msg="💾 Token cached (expires in 8760h)"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Subsequent Runs
|
||||||
|
|
||||||
|
On subsequent runs (container restarts, rebuilds, etc.):
|
||||||
|
|
||||||
|
1. The bridge checks the cached token
|
||||||
|
2. If valid, uses it immediately (no browser needed)
|
||||||
|
3. Connects to Kosmi in seconds
|
||||||
|
|
||||||
|
You'll see logs like:
|
||||||
|
|
||||||
|
```
|
||||||
|
level=info msg="✅ Using cached token (expires in 8736h)"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Token Refresh
|
||||||
|
|
||||||
|
When the token is close to expiring (within 7 days):
|
||||||
|
|
||||||
|
1. The bridge automatically performs fresh authentication
|
||||||
|
2. Updates the cached token
|
||||||
|
3. Continues normal operation
|
||||||
|
|
||||||
|
You'll see logs like:
|
||||||
|
|
||||||
|
```
|
||||||
|
level=info msg="Cached token expires soon (2025-11-09T15:56:23Z), will refresh"
|
||||||
|
level=info msg="Starting browser automation for authentication..."
|
||||||
|
level=info msg="💾 Token cached (expires in 8760h)"
|
||||||
|
```
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
After running with authentication, your directory structure will look like:
|
||||||
|
|
||||||
|
```
|
||||||
|
irc-kosmi-relay/
|
||||||
|
├── data/ # Persistent data directory
|
||||||
|
│ └── kosmi_token_cache.json # Cached JWT token
|
||||||
|
├── docker-compose.yml
|
||||||
|
├── matterbridge.toml
|
||||||
|
└── ...
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Token Cache Not Persisting
|
||||||
|
|
||||||
|
If the token cache doesn't persist across container restarts:
|
||||||
|
|
||||||
|
1. Check that the `./data` directory exists and is writable
|
||||||
|
2. Verify the volume mount in `docker-compose.yml` is correct
|
||||||
|
3. Check container logs for permission errors
|
||||||
|
|
||||||
|
### Force Token Refresh
|
||||||
|
|
||||||
|
To force a fresh authentication (e.g., if credentials changed):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Stop the container
|
||||||
|
docker-compose down
|
||||||
|
|
||||||
|
# Remove the cached token
|
||||||
|
rm ./data/kosmi_token_cache.json
|
||||||
|
|
||||||
|
# Start the container
|
||||||
|
docker-compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
### Check Token Status
|
||||||
|
|
||||||
|
To view the current cached token:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cat ./data/kosmi_token_cache.json | jq .
|
||||||
|
```
|
||||||
|
|
||||||
|
This will show you:
|
||||||
|
- When the token was saved
|
||||||
|
- When it expires
|
||||||
|
- Which email it's associated with
|
||||||
|
|
||||||
|
## Security Notes
|
||||||
|
|
||||||
|
- The token cache file has restricted permissions (`0600`) for security
|
||||||
|
- The token is a JWT that expires after 1 year
|
||||||
|
- The cache file is stored locally and never transmitted
|
||||||
|
- If you commit your code to version control, add `data/` to `.gitignore`
|
||||||
|
|
||||||
|
## Benefits
|
||||||
|
|
||||||
|
1. **Faster Startup**: No browser automation on every restart (saves 10-15 seconds)
|
||||||
|
2. **Reduced Resource Usage**: No need to launch Chromium on every startup
|
||||||
|
3. **Persistence**: Token survives container rebuilds, restarts, and host reboots
|
||||||
|
4. **Automatic Refresh**: Token is automatically refreshed before expiry
|
||||||
|
5. **Local Storage**: Token is stored on your host machine, not in the container
|
||||||
197
bridge/kosmi/token_cache.go
Normal file
197
bridge/kosmi/token_cache.go
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
package bkosmi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/sirupsen/logrus"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TokenCache represents a cached JWT token with metadata
|
||||||
|
type TokenCache struct {
|
||||||
|
Token string `json:"token"`
|
||||||
|
Email string `json:"email"` // To verify it's for the right account
|
||||||
|
ExpiresAt time.Time `json:"expires_at"` // When the token expires
|
||||||
|
SavedAt time.Time `json:"saved_at"` // When we cached it
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
tokenCacheFile = "kosmi_token_cache.json"
|
||||||
|
// Refresh token if it expires within this window
|
||||||
|
tokenRefreshBuffer = 7 * 24 * time.Hour // 1 week before expiry
|
||||||
|
)
|
||||||
|
|
||||||
|
// getTokenCachePath returns the path to the token cache file
|
||||||
|
// Prioritizes MATTERBRIDGE_DATA_DIR env var, falls back to ~/.matterbridge
|
||||||
|
func getTokenCachePath() (string, error) {
|
||||||
|
var cacheDir string
|
||||||
|
|
||||||
|
// Check for environment variable (for Docker volumes)
|
||||||
|
if dataDir := os.Getenv("MATTERBRIDGE_DATA_DIR"); dataDir != "" {
|
||||||
|
cacheDir = dataDir
|
||||||
|
} else {
|
||||||
|
// Fall back to home directory for local development
|
||||||
|
homeDir, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to get home directory: %w", err)
|
||||||
|
}
|
||||||
|
cacheDir = filepath.Join(homeDir, ".matterbridge")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create cache directory if it doesn't exist
|
||||||
|
if err := os.MkdirAll(cacheDir, 0700); err != nil {
|
||||||
|
return "", fmt.Errorf("failed to create cache directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return filepath.Join(cacheDir, tokenCacheFile), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseJWTExpiry extracts the expiry time from a JWT token
|
||||||
|
func parseJWTExpiry(token string) (time.Time, error) {
|
||||||
|
// JWT format: header.payload.signature
|
||||||
|
parts := strings.Split(token, ".")
|
||||||
|
if len(parts) != 3 {
|
||||||
|
return time.Time{}, fmt.Errorf("invalid JWT format")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decode the payload (second part)
|
||||||
|
payload := parts[1]
|
||||||
|
|
||||||
|
// JWT uses base64url encoding, which may not have padding
|
||||||
|
// Add padding if needed
|
||||||
|
if len(payload)%4 != 0 {
|
||||||
|
payload += strings.Repeat("=", 4-len(payload)%4)
|
||||||
|
}
|
||||||
|
|
||||||
|
decoded, err := base64.URLEncoding.DecodeString(payload)
|
||||||
|
if err != nil {
|
||||||
|
// Try standard base64 if URL encoding fails
|
||||||
|
decoded, err = base64.StdEncoding.DecodeString(payload)
|
||||||
|
if err != nil {
|
||||||
|
return time.Time{}, fmt.Errorf("failed to decode JWT payload: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the JSON payload
|
||||||
|
var claims struct {
|
||||||
|
Exp int64 `json:"exp"` // Expiry time as Unix timestamp
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.Unmarshal(decoded, &claims); err != nil {
|
||||||
|
return time.Time{}, fmt.Errorf("failed to parse JWT claims: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if claims.Exp == 0 {
|
||||||
|
return time.Time{}, fmt.Errorf("no expiry time in JWT")
|
||||||
|
}
|
||||||
|
|
||||||
|
return time.Unix(claims.Exp, 0), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadTokenCache loads a cached token from disk
|
||||||
|
func loadTokenCache(email string, log *logrus.Entry) (*TokenCache, error) {
|
||||||
|
cachePath, err := getTokenCachePath()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get cache path: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if cache file exists
|
||||||
|
if _, err := os.Stat(cachePath); os.IsNotExist(err) {
|
||||||
|
return nil, nil // No cache file, not an error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read cache file
|
||||||
|
data, err := os.ReadFile(cachePath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read cache file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse cache
|
||||||
|
var cache TokenCache
|
||||||
|
if err := json.Unmarshal(data, &cache); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse cache file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify it's for the correct email
|
||||||
|
if cache.Email != email {
|
||||||
|
log.Debugf("Cached token is for different email (%s vs %s), ignoring", cache.Email, email)
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if token is expired or close to expiring
|
||||||
|
now := time.Now()
|
||||||
|
if now.After(cache.ExpiresAt) {
|
||||||
|
log.Info("Cached token has expired")
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if now.After(cache.ExpiresAt.Add(-tokenRefreshBuffer)) {
|
||||||
|
log.Infof("Cached token expires soon (%s), will refresh", cache.ExpiresAt.Format(time.RFC3339))
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
timeUntilExpiry := cache.ExpiresAt.Sub(now)
|
||||||
|
log.Infof("✅ Using cached token (expires in %s)", timeUntilExpiry.Round(24*time.Hour))
|
||||||
|
|
||||||
|
return &cache, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// saveTokenCache saves a token to disk
|
||||||
|
func saveTokenCache(token, email string, log *logrus.Entry) error {
|
||||||
|
cachePath, err := getTokenCachePath()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to get cache path: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse token expiry
|
||||||
|
expiresAt, err := parseJWTExpiry(token)
|
||||||
|
if err != nil {
|
||||||
|
log.Warnf("Failed to parse token expiry, will not cache: %v", err)
|
||||||
|
return nil // Don't fail the connection, just skip caching
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create cache structure
|
||||||
|
cache := TokenCache{
|
||||||
|
Token: token,
|
||||||
|
Email: email,
|
||||||
|
ExpiresAt: expiresAt,
|
||||||
|
SavedAt: time.Now(),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Marshal to JSON
|
||||||
|
data, err := json.MarshalIndent(cache, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to marshal cache: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write to file with restricted permissions
|
||||||
|
if err := os.WriteFile(cachePath, data, 0600); err != nil {
|
||||||
|
return fmt.Errorf("failed to write cache file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
timeUntilExpiry := expiresAt.Sub(time.Now())
|
||||||
|
log.Infof("💾 Token cached (expires in %s)", timeUntilExpiry.Round(24*time.Hour))
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// clearTokenCache removes the cached token
|
||||||
|
func clearTokenCache(log *logrus.Entry) error {
|
||||||
|
cachePath, err := getTokenCachePath()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to get cache path: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.Remove(cachePath); err != nil && !os.IsNotExist(err) {
|
||||||
|
return fmt.Errorf("failed to remove cache file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Debug("Token cache cleared")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
16
cmd/get-kosmi-token/go.mod
Normal file
16
cmd/get-kosmi-token/go.mod
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
module github.com/erikfredericks/get-kosmi-token
|
||||||
|
|
||||||
|
go 1.21
|
||||||
|
|
||||||
|
require github.com/chromedp/chromedp v0.9.2
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/chromedp/cdproto v0.0.0-20230802225258-3cf4e6d46a89 // indirect
|
||||||
|
github.com/chromedp/sysutil v1.0.0 // indirect
|
||||||
|
github.com/gobwas/httphead v0.1.0 // indirect
|
||||||
|
github.com/gobwas/pool v0.2.1 // indirect
|
||||||
|
github.com/gobwas/ws v1.2.1 // indirect
|
||||||
|
github.com/josharian/intern v1.0.0 // indirect
|
||||||
|
github.com/mailru/easyjson v0.7.7 // indirect
|
||||||
|
golang.org/x/sys v0.11.0 // indirect
|
||||||
|
)
|
||||||
23
cmd/get-kosmi-token/go.sum
Normal file
23
cmd/get-kosmi-token/go.sum
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
github.com/chromedp/cdproto v0.0.0-20230802225258-3cf4e6d46a89 h1:aPflPkRFkVwbW6dmcVqfgwp1i+UWGFH6VgR1Jim5Ygc=
|
||||||
|
github.com/chromedp/cdproto v0.0.0-20230802225258-3cf4e6d46a89/go.mod h1:GKljq0VrfU4D5yc+2qA6OVr8pmO/MBbPEWqWQ/oqGEs=
|
||||||
|
github.com/chromedp/chromedp v0.9.2 h1:dKtNz4kApb06KuSXoTQIyUC2TrA0fhGDwNZf3bcgfKw=
|
||||||
|
github.com/chromedp/chromedp v0.9.2/go.mod h1:LkSXJKONWTCHAfQasKFUZI+mxqS4tZqhmtGzzhLsnLs=
|
||||||
|
github.com/chromedp/sysutil v1.0.0 h1:+ZxhTpfpZlmchB58ih/LBHX52ky7w2VhQVKQMucy3Ic=
|
||||||
|
github.com/chromedp/sysutil v1.0.0/go.mod h1:kgWmDdq8fTzXYcKIBqIYvRRTnYb9aNS9moAV0xufSww=
|
||||||
|
github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU=
|
||||||
|
github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM=
|
||||||
|
github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og=
|
||||||
|
github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
|
||||||
|
github.com/gobwas/ws v1.2.1 h1:F2aeBZrm2NDsc7vbovKrWSogd4wvfAxg0FQ89/iqOTk=
|
||||||
|
github.com/gobwas/ws v1.2.1/go.mod h1:hRKAFb8wOxFROYNsT1bqfWnhX+b5MFeJM9r2ZSwg/KY=
|
||||||
|
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
|
||||||
|
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
||||||
|
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80 h1:6Yzfa6GP0rIo/kULo2bwGEkFvCePZ3qHDDTC3/J9Swo=
|
||||||
|
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs=
|
||||||
|
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
|
||||||
|
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
|
||||||
|
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde h1:x0TT0RDC7UhAVbbWWBzr41ElhJx5tXPWkIHA2HWPRuw=
|
||||||
|
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0=
|
||||||
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.11.0 h1:eG7RXZHdqOJ1i+0lgLgCpSXAp6M3LYlAo6osgSi0xOM=
|
||||||
|
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
276
cmd/get-kosmi-token/main.go
Normal file
276
cmd/get-kosmi-token/main.go
Normal file
@@ -0,0 +1,276 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/chromedp/chromedp"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Standalone script to extract JWT token from Kosmi using browser automation
|
||||||
|
// Usage: go run get-token.go --email "email@email.com" --password "password"
|
||||||
|
|
||||||
|
var (
|
||||||
|
emailFlag = flag.String("email", "", "Email for Kosmi login")
|
||||||
|
passwordFlag = flag.String("password", "", "Password for Kosmi login")
|
||||||
|
headlessFlag = flag.Bool("headless", true, "Run browser in headless mode")
|
||||||
|
verboseFlag = flag.Bool("verbose", false, "Enable verbose logging")
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
if *emailFlag == "" || *passwordFlag == "" {
|
||||||
|
log.Fatal("Error: --email and --password are required")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set up logging
|
||||||
|
if !*verboseFlag {
|
||||||
|
log.SetOutput(os.Stderr)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Println("Starting browser automation to extract JWT token...")
|
||||||
|
log.Printf("Email: %s", *emailFlag)
|
||||||
|
log.Printf("Headless mode: %v", *headlessFlag)
|
||||||
|
|
||||||
|
token, err := extractToken(*emailFlag, *passwordFlag, *headlessFlag)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Failed to extract token: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Output token to stdout (so it can be captured)
|
||||||
|
fmt.Println(token)
|
||||||
|
|
||||||
|
if *verboseFlag {
|
||||||
|
log.Printf("✓ Successfully extracted token (length: %d)", len(token))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractToken(email, password string, headless bool) (string, error) {
|
||||||
|
// Create context with timeout
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
// Set up chromedp options
|
||||||
|
opts := []chromedp.ExecAllocatorOption{
|
||||||
|
chromedp.NoFirstRun,
|
||||||
|
chromedp.NoDefaultBrowserCheck,
|
||||||
|
chromedp.DisableGPU,
|
||||||
|
chromedp.NoSandbox,
|
||||||
|
}
|
||||||
|
|
||||||
|
if headless {
|
||||||
|
opts = append(opts, chromedp.Headless)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create allocator context
|
||||||
|
allocCtx, cancel := chromedp.NewExecAllocator(ctx, opts...)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
// Create browser context - suppress cookie errors unless verbose
|
||||||
|
logFunc := func(string, ...interface{}) {}
|
||||||
|
if *verboseFlag {
|
||||||
|
logFunc = log.Printf
|
||||||
|
}
|
||||||
|
ctx, cancel = chromedp.NewContext(allocCtx, chromedp.WithLogf(logFunc))
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
var token string
|
||||||
|
|
||||||
|
// Run the automation tasks
|
||||||
|
err := chromedp.Run(ctx,
|
||||||
|
// Step 1: Navigate to Kosmi
|
||||||
|
chromedp.ActionFunc(func(ctx context.Context) error {
|
||||||
|
log.Println("→ Navigating to https://app.kosmi.io")
|
||||||
|
return chromedp.Navigate("https://app.kosmi.io").Do(ctx)
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Step 2: Wait for page to load
|
||||||
|
chromedp.WaitReady("body"),
|
||||||
|
chromedp.Sleep(3*time.Second),
|
||||||
|
|
||||||
|
// Step 3: Find and click Login button
|
||||||
|
chromedp.ActionFunc(func(ctx context.Context) error {
|
||||||
|
log.Println("→ Looking for Login button...")
|
||||||
|
|
||||||
|
// Log all buttons we can see
|
||||||
|
if *verboseFlag {
|
||||||
|
var buttonTexts []string
|
||||||
|
chromedp.Evaluate(`
|
||||||
|
Array.from(document.querySelectorAll('button')).map(el => el.textContent.trim())
|
||||||
|
`, &buttonTexts).Do(ctx)
|
||||||
|
log.Printf(" Found buttons: %v", buttonTexts)
|
||||||
|
}
|
||||||
|
|
||||||
|
var found bool
|
||||||
|
if err := chromedp.Evaluate(`
|
||||||
|
(() => {
|
||||||
|
const buttons = Array.from(document.querySelectorAll('button'));
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Println("✓ Clicked Login button")
|
||||||
|
return nil
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Step 4: Wait for login modal
|
||||||
|
chromedp.Sleep(3*time.Second),
|
||||||
|
|
||||||
|
// Step 5: Click "Login with Email" button
|
||||||
|
chromedp.ActionFunc(func(ctx context.Context) error {
|
||||||
|
log.Println("→ Looking for 'Login with Email' button...")
|
||||||
|
|
||||||
|
if *verboseFlag {
|
||||||
|
var buttonTexts []string
|
||||||
|
chromedp.Evaluate(`
|
||||||
|
Array.from(document.querySelectorAll('button')).map(el => el.textContent.trim())
|
||||||
|
`, &buttonTexts).Do(ctx)
|
||||||
|
log.Printf(" 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")
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Println("✓ Clicked 'Login with Email' button")
|
||||||
|
return nil
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Step 6: Wait for email form
|
||||||
|
chromedp.Sleep(3*time.Second),
|
||||||
|
chromedp.WaitVisible(`input[type="password"]`, chromedp.ByQuery),
|
||||||
|
|
||||||
|
// Step 7: Fill in email
|
||||||
|
chromedp.ActionFunc(func(ctx context.Context) error {
|
||||||
|
log.Println("→ Filling email field...")
|
||||||
|
return nil
|
||||||
|
}),
|
||||||
|
chromedp.Click(`input[placeholder*="Email"], input[placeholder*="Username"]`, chromedp.ByQuery),
|
||||||
|
chromedp.Sleep(200*time.Millisecond),
|
||||||
|
chromedp.SendKeys(`input[placeholder*="Email"], input[placeholder*="Username"]`, email, chromedp.ByQuery),
|
||||||
|
chromedp.ActionFunc(func(ctx context.Context) error {
|
||||||
|
log.Println("✓ Email entered")
|
||||||
|
return nil
|
||||||
|
}),
|
||||||
|
|
||||||
|
chromedp.Sleep(500*time.Millisecond),
|
||||||
|
|
||||||
|
// Step 8: Fill in password
|
||||||
|
chromedp.ActionFunc(func(ctx context.Context) error {
|
||||||
|
log.Println("→ Filling password field...")
|
||||||
|
return nil
|
||||||
|
}),
|
||||||
|
chromedp.Click(`input[type="password"]`, chromedp.ByQuery),
|
||||||
|
chromedp.Sleep(200*time.Millisecond),
|
||||||
|
chromedp.SendKeys(`input[type="password"]`, password+"\n", chromedp.ByQuery),
|
||||||
|
chromedp.ActionFunc(func(ctx context.Context) error {
|
||||||
|
log.Println("✓ Password entered and form submitted")
|
||||||
|
return nil
|
||||||
|
}),
|
||||||
|
|
||||||
|
chromedp.Sleep(500*time.Millisecond),
|
||||||
|
|
||||||
|
// Step 10: Wait for login to complete - check for modal to close
|
||||||
|
chromedp.ActionFunc(func(ctx context.Context) error {
|
||||||
|
log.Println("→ 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.Println("✓ Login modal closed - login successful")
|
||||||
|
// 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.Println("✓ Authenticated token received")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Timeout - get whatever token is there
|
||||||
|
log.Println("⚠ Login timeout - extracting current token")
|
||||||
|
chromedp.Evaluate(`localStorage.getItem('token')`, &token).Do(ctx)
|
||||||
|
|
||||||
|
if token == "" {
|
||||||
|
return fmt.Errorf("login timeout: no token found")
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Println("✓ Token extracted (may be anonymous)")
|
||||||
|
return nil
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("browser automation failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if token == "" {
|
||||||
|
return "", fmt.Errorf("failed to extract token from localStorage")
|
||||||
|
}
|
||||||
|
|
||||||
|
return token, nil
|
||||||
|
}
|
||||||
|
|
||||||
Reference in New Issue
Block a user