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 }