Files
IRC-kosmi-relay/bridge/kosmi/token_cache.go

198 lines
5.3 KiB
Go
Raw Normal View History

2025-11-02 16:49:12 -05:00
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
}