A container registry that uses the AT Protocol for manifest storage and S3 for blob storage.
atcr.io
docker
container
atproto
go
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 "log/slog"
9 "sync"
10 "time"
11)
12
13// serviceTokenEntry represents a cached service token
14type serviceTokenEntry struct {
15 token string
16 expiresAt time.Time
17 err error
18 once sync.Once
19}
20
21// Global cache for service tokens (DID:HoldDID -> token)
22// Service tokens are JWTs issued by a user's PDS to authorize AppView to act on their behalf
23// when communicating with hold services. These tokens are scoped to specific holds and have
24// limited lifetime (typically 60s, can request up to 5min).
25var (
26 globalServiceTokens = make(map[string]*serviceTokenEntry)
27 globalServiceTokensMu sync.RWMutex
28)
29
30// GetServiceToken retrieves a cached service token for the given DID and hold DID
31// Returns empty string if no valid cached token exists
32func GetServiceToken(did, holdDID string) (token string, expiresAt time.Time) {
33 cacheKey := did + ":" + holdDID
34
35 globalServiceTokensMu.RLock()
36 entry, exists := globalServiceTokens[cacheKey]
37 globalServiceTokensMu.RUnlock()
38
39 if !exists {
40 return "", time.Time{}
41 }
42
43 // Check if token is still valid
44 if time.Now().After(entry.expiresAt) {
45 // Token expired, remove from cache
46 globalServiceTokensMu.Lock()
47 delete(globalServiceTokens, cacheKey)
48 globalServiceTokensMu.Unlock()
49 return "", time.Time{}
50 }
51
52 return entry.token, entry.expiresAt
53}
54
55// SetServiceToken stores a service token in the cache
56// Automatically parses the JWT to extract the expiry time
57// Applies a 10-second safety margin (cache expires 10s before actual JWT expiry)
58func SetServiceToken(did, holdDID, token string) error {
59 cacheKey := did + ":" + holdDID
60
61 // Parse JWT to extract expiry (don't verify signature - we trust the PDS)
62 expiry, err := ParseJWTExpiry(token)
63 if err != nil {
64 // If parsing fails, use default 50s TTL (conservative fallback)
65 slog.Warn("Failed to parse JWT expiry, using default 50s", "error", err, "cacheKey", cacheKey)
66 expiry = time.Now().Add(50 * time.Second)
67 } else {
68 // Apply 10s safety margin to avoid using nearly-expired tokens
69 expiry = expiry.Add(-10 * time.Second)
70 }
71
72 globalServiceTokensMu.Lock()
73 globalServiceTokens[cacheKey] = &serviceTokenEntry{
74 token: token,
75 expiresAt: expiry,
76 }
77 globalServiceTokensMu.Unlock()
78
79 slog.Debug("Cached service token",
80 "cacheKey", cacheKey,
81 "expiresIn", time.Until(expiry).Round(time.Second))
82
83 return nil
84}
85
86// InvalidateServiceToken removes a service token from the cache
87// Used when we detect that a token is invalid or the user's session has expired
88func InvalidateServiceToken(did, holdDID string) {
89 cacheKey := did + ":" + holdDID
90
91 globalServiceTokensMu.Lock()
92 delete(globalServiceTokens, cacheKey)
93 globalServiceTokensMu.Unlock()
94
95 slog.Debug("Invalidated service token", "cacheKey", cacheKey)
96}
97
98// GetCacheStats returns statistics about the service token cache for debugging
99func GetCacheStats() map[string]any {
100 globalServiceTokensMu.RLock()
101 defer globalServiceTokensMu.RUnlock()
102
103 validCount := 0
104 expiredCount := 0
105 now := time.Now()
106
107 for _, entry := range globalServiceTokens {
108 if now.Before(entry.expiresAt) {
109 validCount++
110 } else {
111 expiredCount++
112 }
113 }
114
115 return map[string]any{
116 "total_entries": len(globalServiceTokens),
117 "valid_tokens": validCount,
118 "expired_tokens": expiredCount,
119 }
120}
121
122// CleanExpiredTokens removes expired tokens from the cache
123// Can be called periodically to prevent unbounded growth (though expired tokens
124// are also removed lazily on access)
125func CleanExpiredTokens() {
126 globalServiceTokensMu.Lock()
127 defer globalServiceTokensMu.Unlock()
128
129 now := time.Now()
130 removed := 0
131
132 for key, entry := range globalServiceTokens {
133 if now.After(entry.expiresAt) {
134 delete(globalServiceTokens, key)
135 removed++
136 }
137 }
138
139 if removed > 0 {
140 slog.Debug("Cleaned expired service tokens", "count", removed)
141 }
142}