nailed it 2.0
This commit is contained in:
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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user