Files
at-container-registry/pkg/auth/cache.go
T

221 lines
5.9 KiB
Go

// Package auth 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 (
"encoding/base64"
"encoding/json"
"fmt"
"log/slog"
"strings"
"sync"
"time"
)
// serviceTokenEntry represents a cached service token.
type serviceTokenEntry struct {
token string
expiresAt time.Time
}
// Cache stores per-(DID, hold DID) service tokens with automatic expiry.
// The zero value is not usable; construct via NewCache. A default package
// instance backs the GetServiceToken/SetServiceToken/... wrappers; tests
// that need isolation can construct their own.
type Cache struct {
mu sync.RWMutex
tokens map[string]*serviceTokenEntry
}
// NewCache returns an empty Cache.
func NewCache() *Cache {
return &Cache{tokens: make(map[string]*serviceTokenEntry)}
}
// defaultCache backs the package-level wrappers (GetServiceToken, etc.).
// Existing callers reach it transparently via those wrappers; tests that
// need isolation should construct a fresh Cache instead.
var defaultCache = NewCache()
// Get returns the cached token for (did, holdDID) and its expiry. If the
// entry is expired it is removed and (zero values, zero time) is returned.
func (c *Cache) Get(did, holdDID string) (string, time.Time) {
cacheKey := did + ":" + holdDID
c.mu.RLock()
entry, exists := c.tokens[cacheKey]
c.mu.RUnlock()
if !exists {
return "", time.Time{}
}
if time.Now().After(entry.expiresAt) {
c.mu.Lock()
delete(c.tokens, cacheKey)
c.mu.Unlock()
return "", time.Time{}
}
return entry.token, entry.expiresAt
}
// Set stores token for (did, holdDID), parsing its JWT exp claim and
// applying a 10s safety margin so the cache expires before the real
// token does. Falls back to a 50s TTL if the JWT can't be parsed.
func (c *Cache) Set(did, holdDID, token string) error {
cacheKey := did + ":" + holdDID
expiry, err := parseJWTExpiry(token)
if err != nil {
slog.Warn("Failed to parse JWT expiry, using default 50s", "error", err, "cacheKey", cacheKey)
expiry = time.Now().Add(50 * time.Second)
} else {
expiry = expiry.Add(-10 * time.Second)
}
c.mu.Lock()
c.tokens[cacheKey] = &serviceTokenEntry{
token: token,
expiresAt: expiry,
}
c.mu.Unlock()
slog.Debug("Cached service token",
"cacheKey", cacheKey,
"expiresIn", time.Until(expiry).Round(time.Second))
return nil
}
// Invalidate removes the cached entry for (did, holdDID). No-op if absent.
func (c *Cache) Invalidate(did, holdDID string) {
cacheKey := did + ":" + holdDID
c.mu.Lock()
delete(c.tokens, cacheKey)
c.mu.Unlock()
slog.Debug("Invalidated service token", "cacheKey", cacheKey)
}
// Stats returns total/valid/expired counts for debugging.
func (c *Cache) Stats() map[string]any {
c.mu.RLock()
defer c.mu.RUnlock()
validCount := 0
expiredCount := 0
now := time.Now()
for _, entry := range c.tokens {
if now.Before(entry.expiresAt) {
validCount++
} else {
expiredCount++
}
}
return map[string]any{
"total_entries": len(c.tokens),
"valid_tokens": validCount,
"expired_tokens": expiredCount,
}
}
// CleanExpired removes all expired entries.
func (c *Cache) CleanExpired() {
c.mu.Lock()
defer c.mu.Unlock()
now := time.Now()
removed := 0
for key, entry := range c.tokens {
if now.After(entry.expiresAt) {
delete(c.tokens, key)
removed++
}
}
if removed > 0 {
slog.Debug("Cleaned expired service tokens", "count", removed)
}
}
// Clear removes every entry. Intended for tests that need isolation
// between subtests, not for production code.
func (c *Cache) Clear() {
c.mu.Lock()
for k := range c.tokens {
delete(c.tokens, k)
}
c.mu.Unlock()
}
// GetServiceToken returns the cached service token for (did, holdDID).
// Returns ("", zero time) if absent or expired. Delegates to the default
// package cache; tests that need isolation should construct a Cache.
func GetServiceToken(did, holdDID string) (token string, expiresAt time.Time) {
return defaultCache.Get(did, holdDID)
}
// SetServiceToken stores token under (did, holdDID) in the default cache,
// applying the standard 10s safety margin against the JWT's exp claim.
func SetServiceToken(did, holdDID, token string) error {
return defaultCache.Set(did, holdDID, token)
}
// InvalidateServiceToken removes (did, holdDID) from the default cache.
func InvalidateServiceToken(did, holdDID string) {
defaultCache.Invalidate(did, holdDID)
}
// GetCacheStats returns default-cache statistics for debugging.
func GetCacheStats() map[string]any {
return defaultCache.Stats()
}
// CleanExpiredTokens prunes expired entries from the default cache.
func CleanExpiredTokens() {
defaultCache.CleanExpired()
}
// DefaultCache returns the package-level cache that backs the
// GetServiceToken/SetServiceToken/... wrappers. Callers (notably
// ServiceAuthFetcher) use this when they want to read back a value
// that GetOrFetchServiceToken* wrote.
func DefaultCache() *Cache {
return defaultCache
}
// parseJWTExpiry extracts the exp claim from a JWT without verifying its
// signature. We trust tokens from the user's PDS, so signature
// verification isn't needed here.
func parseJWTExpiry(tokenString string) (time.Time, error) {
parts := strings.Split(tokenString, ".")
if len(parts) != 3 {
return time.Time{}, fmt.Errorf("invalid JWT format: expected 3 parts, got %d", len(parts))
}
payload, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil {
return time.Time{}, fmt.Errorf("failed to decode JWT payload: %w", err)
}
var claims struct {
Exp int64 `json:"exp"`
}
if err := json.Unmarshal(payload, &claims); err != nil {
return time.Time{}, fmt.Errorf("failed to parse JWT claims: %w", err)
}
if claims.Exp == 0 {
return time.Time{}, fmt.Errorf("JWT missing exp claim")
}
return time.Unix(claims.Exp, 0), nil
}