From 673c8025ee738e4ec2f72a1f3f10796efc50e434 Mon Sep 17 00:00:00 2001 From: cottongin Date: Sun, 2 Nov 2025 16:49:12 -0500 Subject: [PATCH] nailed it 2.0 --- TOKEN_PERSISTENCE.md | 181 +++++++++++++++++++++++ bridge/kosmi/token_cache.go | 197 +++++++++++++++++++++++++ cmd/get-kosmi-token/go.mod | 16 +++ cmd/get-kosmi-token/go.sum | 23 +++ cmd/get-kosmi-token/main.go | 276 ++++++++++++++++++++++++++++++++++++ 5 files changed, 693 insertions(+) create mode 100644 TOKEN_PERSISTENCE.md create mode 100644 bridge/kosmi/token_cache.go create mode 100644 cmd/get-kosmi-token/go.mod create mode 100644 cmd/get-kosmi-token/go.sum create mode 100644 cmd/get-kosmi-token/main.go diff --git a/TOKEN_PERSISTENCE.md b/TOKEN_PERSISTENCE.md new file mode 100644 index 0000000..e179161 --- /dev/null +++ b/TOKEN_PERSISTENCE.md @@ -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 diff --git a/bridge/kosmi/token_cache.go b/bridge/kosmi/token_cache.go new file mode 100644 index 0000000..026dd65 --- /dev/null +++ b/bridge/kosmi/token_cache.go @@ -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 +} + diff --git a/cmd/get-kosmi-token/go.mod b/cmd/get-kosmi-token/go.mod new file mode 100644 index 0000000..7f2fe55 --- /dev/null +++ b/cmd/get-kosmi-token/go.mod @@ -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 +) diff --git a/cmd/get-kosmi-token/go.sum b/cmd/get-kosmi-token/go.sum new file mode 100644 index 0000000..3045b3e --- /dev/null +++ b/cmd/get-kosmi-token/go.sum @@ -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= diff --git a/cmd/get-kosmi-token/main.go b/cmd/get-kosmi-token/main.go new file mode 100644 index 0000000..8f2e23e --- /dev/null +++ b/cmd/get-kosmi-token/main.go @@ -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 +} +