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