Files
at-container-registry/pkg/auth/cache.go
2025-12-29 17:02:07 -06:00

143 lines
4.0 KiB
Go

// Package token provides service token caching and management for AppView.
// Service tokens are JWTs issued by a user's PDS to authorize AppView to
// act on their behalf when communicating with hold services. Tokens are
// cached with automatic expiry parsing and 10-second safety margins.
package auth
import (
"log/slog"
"sync"
"time"
)
// serviceTokenEntry represents a cached service token
type serviceTokenEntry struct {
token string
expiresAt time.Time
err error
once sync.Once
}
// Global cache for service tokens (DID:HoldDID -> token)
// Service tokens are JWTs issued by a user's PDS to authorize AppView to act on their behalf
// when communicating with hold services. These tokens are scoped to specific holds and have
// limited lifetime (typically 60s, can request up to 5min).
var (
globalServiceTokens = make(map[string]*serviceTokenEntry)
globalServiceTokensMu sync.RWMutex
)
// GetServiceToken retrieves a cached service token for the given DID and hold DID
// Returns empty string if no valid cached token exists
func GetServiceToken(did, holdDID string) (token string, expiresAt time.Time) {
cacheKey := did + ":" + holdDID
globalServiceTokensMu.RLock()
entry, exists := globalServiceTokens[cacheKey]
globalServiceTokensMu.RUnlock()
if !exists {
return "", time.Time{}
}
// Check if token is still valid
if time.Now().After(entry.expiresAt) {
// Token expired, remove from cache
globalServiceTokensMu.Lock()
delete(globalServiceTokens, cacheKey)
globalServiceTokensMu.Unlock()
return "", time.Time{}
}
return entry.token, entry.expiresAt
}
// SetServiceToken stores a service token in the cache
// Automatically parses the JWT to extract the expiry time
// Applies a 10-second safety margin (cache expires 10s before actual JWT expiry)
func SetServiceToken(did, holdDID, token string) error {
cacheKey := did + ":" + holdDID
// Parse JWT to extract expiry (don't verify signature - we trust the PDS)
expiry, err := ParseJWTExpiry(token)
if err != nil {
// If parsing fails, use default 50s TTL (conservative fallback)
slog.Warn("Failed to parse JWT expiry, using default 50s", "error", err, "cacheKey", cacheKey)
expiry = time.Now().Add(50 * time.Second)
} else {
// Apply 10s safety margin to avoid using nearly-expired tokens
expiry = expiry.Add(-10 * time.Second)
}
globalServiceTokensMu.Lock()
globalServiceTokens[cacheKey] = &serviceTokenEntry{
token: token,
expiresAt: expiry,
}
globalServiceTokensMu.Unlock()
slog.Debug("Cached service token",
"cacheKey", cacheKey,
"expiresIn", time.Until(expiry).Round(time.Second))
return nil
}
// InvalidateServiceToken removes a service token from the cache
// Used when we detect that a token is invalid or the user's session has expired
func InvalidateServiceToken(did, holdDID string) {
cacheKey := did + ":" + holdDID
globalServiceTokensMu.Lock()
delete(globalServiceTokens, cacheKey)
globalServiceTokensMu.Unlock()
slog.Debug("Invalidated service token", "cacheKey", cacheKey)
}
// GetCacheStats returns statistics about the service token cache for debugging
func GetCacheStats() map[string]any {
globalServiceTokensMu.RLock()
defer globalServiceTokensMu.RUnlock()
validCount := 0
expiredCount := 0
now := time.Now()
for _, entry := range globalServiceTokens {
if now.Before(entry.expiresAt) {
validCount++
} else {
expiredCount++
}
}
return map[string]any{
"total_entries": len(globalServiceTokens),
"valid_tokens": validCount,
"expired_tokens": expiredCount,
}
}
// CleanExpiredTokens removes expired tokens from the cache
// Can be called periodically to prevent unbounded growth (though expired tokens
// are also removed lazily on access)
func CleanExpiredTokens() {
globalServiceTokensMu.Lock()
defer globalServiceTokensMu.Unlock()
now := time.Now()
removed := 0
for key, entry := range globalServiceTokens {
if now.After(entry.expiresAt) {
delete(globalServiceTokens, key)
removed++
}
}
if removed > 0 {
slog.Debug("Cleaned expired service tokens", "count", removed)
}
}