forked from
evan.jarrett.net/at-container-registry
A container registry that uses the AT Protocol for manifest storage and S3 for blob storage.
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}