A container registry that uses the AT Protocol for manifest storage and S3 for blob storage.
at codeberg-source 175 lines 5.2 kB view raw
1// Package token provides service token caching and management for AppView. 2// Service tokens are JWTs issued by a user's PDS to authorize AppView to 3// act on their behalf when communicating with hold services. Tokens are 4// cached with automatic expiry parsing and 10-second safety margins. 5package auth 6 7import ( 8 "encoding/base64" 9 "encoding/json" 10 "fmt" 11 "log/slog" 12 "strings" 13 "sync" 14 "time" 15) 16 17// serviceTokenEntry represents a cached service token 18type serviceTokenEntry struct { 19 token string 20 expiresAt time.Time 21} 22 23// Global cache for service tokens (DID:HoldDID -> token) 24// Service tokens are JWTs issued by a user's PDS to authorize AppView to act on their behalf 25// when communicating with hold services. These tokens are scoped to specific holds and have 26// limited lifetime (typically 60s, can request up to 5min). 27var ( 28 globalServiceTokens = make(map[string]*serviceTokenEntry) 29 globalServiceTokensMu sync.RWMutex 30) 31 32// GetServiceToken retrieves a cached service token for the given DID and hold DID 33// Returns empty string if no valid cached token exists 34func GetServiceToken(did, holdDID string) (token string, expiresAt time.Time) { 35 cacheKey := did + ":" + holdDID 36 37 globalServiceTokensMu.RLock() 38 entry, exists := globalServiceTokens[cacheKey] 39 globalServiceTokensMu.RUnlock() 40 41 if !exists { 42 return "", time.Time{} 43 } 44 45 // Check if token is still valid 46 if time.Now().After(entry.expiresAt) { 47 // Token expired, remove from cache 48 globalServiceTokensMu.Lock() 49 delete(globalServiceTokens, cacheKey) 50 globalServiceTokensMu.Unlock() 51 return "", time.Time{} 52 } 53 54 return entry.token, entry.expiresAt 55} 56 57// SetServiceToken stores a service token in the cache 58// Automatically parses the JWT to extract the expiry time 59// Applies a 10-second safety margin (cache expires 10s before actual JWT expiry) 60func SetServiceToken(did, holdDID, token string) error { 61 cacheKey := did + ":" + holdDID 62 63 // Parse JWT to extract expiry (don't verify signature - we trust the PDS) 64 expiry, err := parseJWTExpiry(token) 65 if err != nil { 66 // If parsing fails, use default 50s TTL (conservative fallback) 67 slog.Warn("Failed to parse JWT expiry, using default 50s", "error", err, "cacheKey", cacheKey) 68 expiry = time.Now().Add(50 * time.Second) 69 } else { 70 // Apply 10s safety margin to avoid using nearly-expired tokens 71 expiry = expiry.Add(-10 * time.Second) 72 } 73 74 globalServiceTokensMu.Lock() 75 globalServiceTokens[cacheKey] = &serviceTokenEntry{ 76 token: token, 77 expiresAt: expiry, 78 } 79 globalServiceTokensMu.Unlock() 80 81 slog.Debug("Cached service token", 82 "cacheKey", cacheKey, 83 "expiresIn", time.Until(expiry).Round(time.Second)) 84 85 return nil 86} 87 88// parseJWTExpiry extracts the expiry time from a JWT without verifying the signature 89// We trust tokens from the user's PDS, so signature verification isn't needed here 90// Manually decodes the JWT payload to avoid algorithm compatibility issues 91func parseJWTExpiry(tokenString string) (time.Time, error) { 92 // JWT format: header.payload.signature 93 parts := strings.Split(tokenString, ".") 94 if len(parts) != 3 { 95 return time.Time{}, fmt.Errorf("invalid JWT format: expected 3 parts, got %d", len(parts)) 96 } 97 98 // Decode the payload (second part) 99 payload, err := base64.RawURLEncoding.DecodeString(parts[1]) 100 if err != nil { 101 return time.Time{}, fmt.Errorf("failed to decode JWT payload: %w", err) 102 } 103 104 // Parse the JSON payload 105 var claims struct { 106 Exp int64 `json:"exp"` 107 } 108 if err := json.Unmarshal(payload, &claims); err != nil { 109 return time.Time{}, fmt.Errorf("failed to parse JWT claims: %w", err) 110 } 111 112 if claims.Exp == 0 { 113 return time.Time{}, fmt.Errorf("JWT missing exp claim") 114 } 115 116 return time.Unix(claims.Exp, 0), nil 117} 118 119// InvalidateServiceToken removes a service token from the cache 120// Used when we detect that a token is invalid or the user's session has expired 121func InvalidateServiceToken(did, holdDID string) { 122 cacheKey := did + ":" + holdDID 123 124 globalServiceTokensMu.Lock() 125 delete(globalServiceTokens, cacheKey) 126 globalServiceTokensMu.Unlock() 127 128 slog.Debug("Invalidated service token", "cacheKey", cacheKey) 129} 130 131// GetCacheStats returns statistics about the service token cache for debugging 132func GetCacheStats() map[string]any { 133 globalServiceTokensMu.RLock() 134 defer globalServiceTokensMu.RUnlock() 135 136 validCount := 0 137 expiredCount := 0 138 now := time.Now() 139 140 for _, entry := range globalServiceTokens { 141 if now.Before(entry.expiresAt) { 142 validCount++ 143 } else { 144 expiredCount++ 145 } 146 } 147 148 return map[string]any{ 149 "total_entries": len(globalServiceTokens), 150 "valid_tokens": validCount, 151 "expired_tokens": expiredCount, 152 } 153} 154 155// CleanExpiredTokens removes expired tokens from the cache 156// Can be called periodically to prevent unbounded growth (though expired tokens 157// are also removed lazily on access) 158func CleanExpiredTokens() { 159 globalServiceTokensMu.Lock() 160 defer globalServiceTokensMu.Unlock() 161 162 now := time.Now() 163 removed := 0 164 165 for key, entry := range globalServiceTokens { 166 if now.After(entry.expiresAt) { 167 delete(globalServiceTokens, key) 168 removed++ 169 } 170 } 171 172 if removed > 0 { 173 slog.Debug("Cleaned expired service tokens", "count", removed) 174 } 175}