detached 6b5b414

fix(auth): add token cache and fix scope validation

- Add in-memory cache to PassStore to prevent GPG agent overload
  when handling concurrent requests (e.g., docker buildx push)
- Cache tokens and revocation list after first lookup
- Add backwards compatibility for simple scopes (push/pull) in
  auth middleware for existing tokens
- Convert simple scopes to Docker v2 format in CLI token generation
  (repository:*:push,pull)

Fixes registry push failures with 403 Forbidden due to:
1. GPG agent being killed under concurrent pass show calls
2. Simple scopes not matching Docker v2 scope format
commit
6b5b41459beaa265e575c776a57ecce9e84f25e4
author
brice · · 2026-01-18 09:23 UTC
parent
c6f9c5d
signature
unsigned
4 files · +364 -11
M internal/adapters/in/cli/auth.go +34 -4
@@ -367,10 +367,40 @@ func runTokenGenerate(subject, scopesStr, expiryStr, configPath string) error {
367367 }
368368 }
369369
370- // Parse scopes
371- scopes := strings.Split(scopesStr, ",")
372- for i := range scopes {
373- scopes[i] = strings.TrimSpace(scopes[i])
370+ // Parse scopes and convert simple scopes to Docker v2 format
371+ // Simple scopes like "push,pull" become "repository:*:push,pull"
372+ rawScopes := strings.Split(scopesStr, ",")
373+ for i := range rawScopes {
374+ rawScopes[i] = strings.TrimSpace(rawScopes[i])
375+ }
376+
377+ // Check if scopes are already in v2 format (contain colons)
378+ // or if they're simple scopes that need conversion
379+ var scopes []string
380+ hasSimpleScope := false
381+ for _, s := range rawScopes {
382+ if !strings.Contains(s, ":") && (s == "push" || s == "pull" || s == "*") {
383+ hasSimpleScope = true
384+ break
385+ }
386+ }
387+
388+ if hasSimpleScope {
389+ // Convert simple scopes to v2 format: repository:*:push,pull
390+ var actions []string
391+ for _, s := range rawScopes {
392+ if s == "push" || s == "pull" || s == "*" {
393+ actions = append(actions, s)
394+ } else {
395+ // Keep non-simple scopes as-is (e.g., admin scopes)
396+ scopes = append(scopes, s)
397+ }
398+ }
399+ if len(actions) > 0 {
400+ scopes = append(scopes, "repository:*:"+strings.Join(actions, ","))
401+ }
402+ } else {
403+ scopes = rawScopes
374404 }
375405
376406 // Create auth service
@@ -367,10 +367,40 @@ func runTokenGenerate(subject, scopesStr, expiryStr, configPath string) error {
367 }367 }
368 }368 }
369 369
370- // Parse scopes370+ // Parse scopes and convert simple scopes to Docker v2 format
371- scopes := strings.Split(scopesStr, ",")371+ // Simple scopes like "push,pull" become "repository:*:push,pull"
372- for i := range scopes {372+ rawScopes := strings.Split(scopesStr, ",")
373- scopes[i] = strings.TrimSpace(scopes[i])373+ for i := range rawScopes {
374+ rawScopes[i] = strings.TrimSpace(rawScopes[i])
375+ }
376+
377+ // Check if scopes are already in v2 format (contain colons)
378+ // or if they're simple scopes that need conversion
379+ var scopes []string
380+ hasSimpleScope := false
381+ for _, s := range rawScopes {
382+ if !strings.Contains(s, ":") && (s == "push" || s == "pull" || s == "*") {
383+ hasSimpleScope = true
384+ break
385+ }
386+ }
387+
388+ if hasSimpleScope {
389+ // Convert simple scopes to v2 format: repository:*:push,pull
390+ var actions []string
391+ for _, s := range rawScopes {
392+ if s == "push" || s == "pull" || s == "*" {
393+ actions = append(actions, s)
394+ } else {
395+ // Keep non-simple scopes as-is (e.g., admin scopes)
396+ scopes = append(scopes, s)
397+ }
398+ }
399+ if len(actions) > 0 {
400+ scopes = append(scopes, "repository:*:"+strings.Join(actions, ","))
401+ }
402+ } else {
403+ scopes = rawScopes
374 }404 }
375 405
376 // Create auth service406 // Create auth service
M internal/adapters/in/http/middleware/auth.go +13 -1
@@ -358,9 +358,21 @@ func checkScopeAccess(r *http.Request, claims *domain.TokenClaims, log zerowrap.
358358
359359 // Check if any token scope grants access
360360 for _, scopeStr := range claims.Scopes {
361+ // Handle simple scopes (e.g., "push", "pull") for backwards compatibility
362+ // with tokens generated before v2 scope format was enforced
363+ if scopeStr == action || scopeStr == "*" {
364+ log.Debug().
365+ Str("repo", repoName).
366+ Str("action", action).
367+ Str("scope", scopeStr).
368+ Msg("simple scope access granted")
369+ return true
370+ }
371+
372+ // Handle Docker v2 format scopes (e.g., "repository:myrepo:push,pull")
361373 scope, err := domain.ParseScope(scopeStr)
362374 if err != nil {
363- log.Debug().Err(err).Str("scope", scopeStr).Msg("failed to parse scope")
375+ log.Debug().Err(err).Str("scope", scopeStr).Msg("scope not in v2 format, skipping")
364376 continue
365377 }
366378
@@ -358,9 +358,21 @@ func checkScopeAccess(r *http.Request, claims *domain.TokenClaims, log zerowrap.
358 358
359 // Check if any token scope grants access359 // Check if any token scope grants access
360 for _, scopeStr := range claims.Scopes {360 for _, scopeStr := range claims.Scopes {
361+ // Handle simple scopes (e.g., "push", "pull") for backwards compatibility
362+ // with tokens generated before v2 scope format was enforced
363+ if scopeStr == action || scopeStr == "*" {
364+ log.Debug().
365+ Str("repo", repoName).
366+ Str("action", action).
367+ Str("scope", scopeStr).
368+ Msg("simple scope access granted")
369+ return true
370+ }
371+
372+ // Handle Docker v2 format scopes (e.g., "repository:myrepo:push,pull")
361 scope, err := domain.ParseScope(scopeStr)373 scope, err := domain.ParseScope(scopeStr)
362 if err != nil {374 if err != nil {
363- log.Debug().Err(err).Str("scope", scopeStr).Msg("failed to parse scope")375+ log.Debug().Err(err).Str("scope", scopeStr).Msg("scope not in v2 format, skipping")
364 continue376 continue
365 }377 }
366 378
M internal/adapters/out/tokenstore/pass.go +63 -6
@@ -8,6 +8,7 @@ import (
88 "os/exec"
99 "regexp"
1010 "strings"
11+ "sync"
1112 "time"
1213
1314 "github.com/bnema/zerowrap"
@@ -25,17 +26,31 @@ const (
2526 passRevokedPath = "gordon/registry/revoked"
2627 )
2728
29+// cachedToken holds a token and its JWT in memory.
30+type cachedToken struct {
31+ jwt string
32+ token *domain.Token
33+}
34+
2835 // PassStore implements TokenStore using the pass password manager.
2936 type PassStore struct {
3037 timeout time.Duration
3138 log zerowrap.Logger
39+
40+ // In-memory cache to avoid repeated pass calls
41+ cacheMu sync.RWMutex
42+ tokenCache map[string]*cachedToken // keyed by subject
43+ revokedList []string // cached revocation list
44+ revokedSet map[string]struct{} // for O(1) lookup
3245 }
3346
3447 // NewPassStore creates a new pass-based token store.
3548 func NewPassStore(log zerowrap.Logger) *PassStore {
3649 return &PassStore{
37- timeout: 10 * time.Second,
38- log: log,
50+ timeout: 10 * time.Second,
51+ log: log,
52+ tokenCache: make(map[string]*cachedToken),
53+ revokedSet: make(map[string]struct{}),
3954 }
4055 }
4156
@@ -79,6 +94,11 @@ func (s *PassStore) SaveToken(ctx context.Context, token *domain.Token, jwt stri
7994 return fmt.Errorf("failed to store token metadata: %w", err)
8095 }
8196
97+ // Update cache
98+ s.cacheMu.Lock()
99+ s.tokenCache[token.Subject] = &cachedToken{jwt: jwt, token: token}
100+ s.cacheMu.Unlock()
101+
82102 s.log.Debug().
83103 Str(zerowrap.FieldLayer, "adapter").
84104 Str(zerowrap.FieldAdapter, "tokenstore").
@@ -91,6 +111,14 @@ func (s *PassStore) SaveToken(ctx context.Context, token *domain.Token, jwt stri
91111
92112 // GetToken retrieves token JWT by subject from pass.
93113 func (s *PassStore) GetToken(ctx context.Context, subject string) (string, *domain.Token, error) {
114+ // Check cache first
115+ s.cacheMu.RLock()
116+ if cached, ok := s.tokenCache[subject]; ok {
117+ s.cacheMu.RUnlock()
118+ return cached.jwt, cached.token, nil
119+ }
120+ s.cacheMu.RUnlock()
121+
94122 ctx, cancel := context.WithTimeout(ctx, s.timeout)
95123 defer cancel()
96124
@@ -122,6 +150,11 @@ func (s *PassStore) GetToken(ctx context.Context, subject string) (string, *doma
122150 Revoked: meta.Revoked,
123151 }
124152
153+ // Cache the token
154+ s.cacheMu.Lock()
155+ s.tokenCache[subject] = &cachedToken{jwt: jwt, token: token}
156+ s.cacheMu.Unlock()
157+
125158 return jwt, token, nil
126159 }
127160
@@ -206,6 +239,12 @@ func (s *PassStore) Revoke(ctx context.Context, tokenID string) error {
206239 return fmt.Errorf("failed to store revocation list: %w", err)
207240 }
208241
242+ // Update cache
243+ s.cacheMu.Lock()
244+ s.revokedList = revokedList
245+ s.revokedSet[tokenID] = struct{}{}
246+ s.cacheMu.Unlock()
247+
209248 s.log.Info().
210249 Str(zerowrap.FieldLayer, "adapter").
211250 Str(zerowrap.FieldAdapter, "tokenstore").
@@ -217,6 +256,15 @@ func (s *PassStore) Revoke(ctx context.Context, tokenID string) error {
217256
218257 // IsRevoked checks if token ID is in revocation list.
219258 func (s *PassStore) IsRevoked(ctx context.Context, tokenID string) (bool, error) {
259+ // Check cache first
260+ s.cacheMu.RLock()
261+ if s.revokedList != nil {
262+ _, revoked := s.revokedSet[tokenID]
263+ s.cacheMu.RUnlock()
264+ return revoked, nil
265+ }
266+ s.cacheMu.RUnlock()
267+
220268 ctx, cancel := context.WithTimeout(ctx, s.timeout)
221269 defer cancel()
222270
@@ -225,13 +273,17 @@ func (s *PassStore) IsRevoked(ctx context.Context, tokenID string) (bool, error)
225273 return false, err
226274 }
227275
276+ // Cache the revocation list
277+ s.cacheMu.Lock()
278+ s.revokedList = revokedList
279+ s.revokedSet = make(map[string]struct{}, len(revokedList))
228280 for _, id := range revokedList {
229- if id == tokenID {
230- return true, nil
231- }
281+ s.revokedSet[id] = struct{}{}
232282 }
283+ s.cacheMu.Unlock()
233284
234- return false, nil
285+ _, revoked := s.revokedSet[tokenID]
286+ return revoked, nil
235287 }
236288
237289 // DeleteToken removes token from pass.
@@ -256,6 +308,11 @@ func (s *PassStore) DeleteToken(ctx context.Context, subject string) error {
256308 s.log.Warn().Err(err).Msg("failed to delete token metadata")
257309 }
258310
311+ // Clear from cache
312+ s.cacheMu.Lock()
313+ delete(s.tokenCache, subject)
314+ s.cacheMu.Unlock()
315+
259316 s.log.Debug().
260317 Str(zerowrap.FieldLayer, "adapter").
261318 Str(zerowrap.FieldAdapter, "tokenstore").
@@ -8,6 +8,7 @@ import (
8 "os/exec"8 "os/exec"
9 "regexp"9 "regexp"
10 "strings"10 "strings"
11+ "sync"
11 "time"12 "time"
12 13
13 "github.com/bnema/zerowrap"14 "github.com/bnema/zerowrap"
@@ -25,17 +26,31 @@ const (
25 passRevokedPath = "gordon/registry/revoked"26 passRevokedPath = "gordon/registry/revoked"
26 )27 )
27 28
29+// cachedToken holds a token and its JWT in memory.
30+type cachedToken struct {
31+ jwt string
32+ token *domain.Token
33+}
34+
28 // PassStore implements TokenStore using the pass password manager.35 // PassStore implements TokenStore using the pass password manager.
29 type PassStore struct {36 type PassStore struct {
30 timeout time.Duration37 timeout time.Duration
31 log zerowrap.Logger38 log zerowrap.Logger
39+
40+ // In-memory cache to avoid repeated pass calls
41+ cacheMu sync.RWMutex
42+ tokenCache map[string]*cachedToken // keyed by subject
43+ revokedList []string // cached revocation list
44+ revokedSet map[string]struct{} // for O(1) lookup
32 }45 }
33 46
34 // NewPassStore creates a new pass-based token store.47 // NewPassStore creates a new pass-based token store.
35 func NewPassStore(log zerowrap.Logger) *PassStore {48 func NewPassStore(log zerowrap.Logger) *PassStore {
36 return &PassStore{49 return &PassStore{
37- timeout: 10 * time.Second,50+ timeout: 10 * time.Second,
38- log: log,51+ log: log,
52+ tokenCache: make(map[string]*cachedToken),
53+ revokedSet: make(map[string]struct{}),
39 }54 }
40 }55 }
41 56
@@ -79,6 +94,11 @@ func (s *PassStore) SaveToken(ctx context.Context, token *domain.Token, jwt stri
79 return fmt.Errorf("failed to store token metadata: %w", err)94 return fmt.Errorf("failed to store token metadata: %w", err)
80 }95 }
81 96
97+ // Update cache
98+ s.cacheMu.Lock()
99+ s.tokenCache[token.Subject] = &cachedToken{jwt: jwt, token: token}
100+ s.cacheMu.Unlock()
101+
82 s.log.Debug().102 s.log.Debug().
83 Str(zerowrap.FieldLayer, "adapter").103 Str(zerowrap.FieldLayer, "adapter").
84 Str(zerowrap.FieldAdapter, "tokenstore").104 Str(zerowrap.FieldAdapter, "tokenstore").
@@ -91,6 +111,14 @@ func (s *PassStore) SaveToken(ctx context.Context, token *domain.Token, jwt stri
91 111
92 // GetToken retrieves token JWT by subject from pass.112 // GetToken retrieves token JWT by subject from pass.
93 func (s *PassStore) GetToken(ctx context.Context, subject string) (string, *domain.Token, error) {113 func (s *PassStore) GetToken(ctx context.Context, subject string) (string, *domain.Token, error) {
114+ // Check cache first
115+ s.cacheMu.RLock()
116+ if cached, ok := s.tokenCache[subject]; ok {
117+ s.cacheMu.RUnlock()
118+ return cached.jwt, cached.token, nil
119+ }
120+ s.cacheMu.RUnlock()
121+
94 ctx, cancel := context.WithTimeout(ctx, s.timeout)122 ctx, cancel := context.WithTimeout(ctx, s.timeout)
95 defer cancel()123 defer cancel()
96 124
@@ -122,6 +150,11 @@ func (s *PassStore) GetToken(ctx context.Context, subject string) (string, *doma
122 Revoked: meta.Revoked,150 Revoked: meta.Revoked,
123 }151 }
124 152
153+ // Cache the token
154+ s.cacheMu.Lock()
155+ s.tokenCache[subject] = &cachedToken{jwt: jwt, token: token}
156+ s.cacheMu.Unlock()
157+
125 return jwt, token, nil158 return jwt, token, nil
126 }159 }
127 160
@@ -206,6 +239,12 @@ func (s *PassStore) Revoke(ctx context.Context, tokenID string) error {
206 return fmt.Errorf("failed to store revocation list: %w", err)239 return fmt.Errorf("failed to store revocation list: %w", err)
207 }240 }
208 241
242+ // Update cache
243+ s.cacheMu.Lock()
244+ s.revokedList = revokedList
245+ s.revokedSet[tokenID] = struct{}{}
246+ s.cacheMu.Unlock()
247+
209 s.log.Info().248 s.log.Info().
210 Str(zerowrap.FieldLayer, "adapter").249 Str(zerowrap.FieldLayer, "adapter").
211 Str(zerowrap.FieldAdapter, "tokenstore").250 Str(zerowrap.FieldAdapter, "tokenstore").
@@ -217,6 +256,15 @@ func (s *PassStore) Revoke(ctx context.Context, tokenID string) error {
217 256
218 // IsRevoked checks if token ID is in revocation list.257 // IsRevoked checks if token ID is in revocation list.
219 func (s *PassStore) IsRevoked(ctx context.Context, tokenID string) (bool, error) {258 func (s *PassStore) IsRevoked(ctx context.Context, tokenID string) (bool, error) {
259+ // Check cache first
260+ s.cacheMu.RLock()
261+ if s.revokedList != nil {
262+ _, revoked := s.revokedSet[tokenID]
263+ s.cacheMu.RUnlock()
264+ return revoked, nil
265+ }
266+ s.cacheMu.RUnlock()
267+
220 ctx, cancel := context.WithTimeout(ctx, s.timeout)268 ctx, cancel := context.WithTimeout(ctx, s.timeout)
221 defer cancel()269 defer cancel()
222 270
@@ -225,13 +273,17 @@ func (s *PassStore) IsRevoked(ctx context.Context, tokenID string) (bool, error)
225 return false, err273 return false, err
226 }274 }
227 275
276+ // Cache the revocation list
277+ s.cacheMu.Lock()
278+ s.revokedList = revokedList
279+ s.revokedSet = make(map[string]struct{}, len(revokedList))
228 for _, id := range revokedList {280 for _, id := range revokedList {
229- if id == tokenID {281+ s.revokedSet[id] = struct{}{}
230- return true, nil
231- }
232 }282 }
283+ s.cacheMu.Unlock()
233 284
234- return false, nil285+ _, revoked := s.revokedSet[tokenID]
286+ return revoked, nil
235 }287 }
236 288
237 // DeleteToken removes token from pass.289 // DeleteToken removes token from pass.
@@ -256,6 +308,11 @@ func (s *PassStore) DeleteToken(ctx context.Context, subject string) error {
256 s.log.Warn().Err(err).Msg("failed to delete token metadata")308 s.log.Warn().Err(err).Msg("failed to delete token metadata")
257 }309 }
258 310
311+ // Clear from cache
312+ s.cacheMu.Lock()
313+ delete(s.tokenCache, subject)
314+ s.cacheMu.Unlock()
315+
259 s.log.Debug().316 s.log.Debug().
260 Str(zerowrap.FieldLayer, "adapter").317 Str(zerowrap.FieldLayer, "adapter").
261 Str(zerowrap.FieldAdapter, "tokenstore").318 Str(zerowrap.FieldAdapter, "tokenstore").
A internal/adapters/out/tokenstore/pass_test.go +254 -0
@@ -0,0 +1,254 @@
1+package tokenstore
2+
3+import (
4+ "context"
5+ "sync"
6+ "testing"
7+ "time"
8+
9+ "github.com/stretchr/testify/assert"
10+ "github.com/stretchr/testify/require"
11+
12+ "gordon/internal/domain"
13+)
14+
15+// These tests verify the in-memory caching behavior of PassStore.
16+// They directly manipulate the cache to test cache hit/miss logic
17+// without requiring the actual pass binary.
18+
19+func newTestPassStore() *PassStore {
20+ return &PassStore{
21+ timeout: 10 * time.Second,
22+ tokenCache: make(map[string]*cachedToken),
23+ revokedSet: make(map[string]struct{}),
24+ }
25+}
26+
27+func TestPassStore_GetToken_CacheHit(t *testing.T) {
28+ store := newTestPassStore()
29+
30+ // Pre-populate cache
31+ expectedToken := &domain.Token{
32+ ID: "test-id",
33+ Subject: "test-subject",
34+ Scopes: []string{"push", "pull"},
35+ IssuedAt: time.Now(),
36+ ExpiresAt: time.Now().Add(24 * time.Hour),
37+ }
38+ expectedJWT := "cached-jwt-token"
39+
40+ store.cacheMu.Lock()
41+ store.tokenCache["test-subject"] = &cachedToken{
42+ jwt: expectedJWT,
43+ token: expectedToken,
44+ }
45+ store.cacheMu.Unlock()
46+
47+ // Should return cached value without calling pass
48+ jwt, token, err := store.GetToken(context.Background(), "test-subject")
49+
50+ require.NoError(t, err)
51+ assert.Equal(t, expectedJWT, jwt)
52+ assert.Equal(t, expectedToken.ID, token.ID)
53+ assert.Equal(t, expectedToken.Subject, token.Subject)
54+ assert.Equal(t, expectedToken.Scopes, token.Scopes)
55+}
56+
57+func TestPassStore_GetToken_CacheMiss_ReturnsNotFound(t *testing.T) {
58+ store := newTestPassStore()
59+
60+ // Cache miss should try to call pass (which will fail in test env)
61+ _, _, err := store.GetToken(context.Background(), "nonexistent")
62+
63+ // Should return ErrTokenNotFound since pass won't have this token
64+ assert.ErrorIs(t, err, domain.ErrTokenNotFound)
65+}
66+
67+func TestPassStore_IsRevoked_CacheHit_Revoked(t *testing.T) {
68+ store := newTestPassStore()
69+
70+ // Pre-populate revocation cache
71+ store.cacheMu.Lock()
72+ store.revokedList = []string{"revoked-token-1", "revoked-token-2"}
73+ store.revokedSet = map[string]struct{}{
74+ "revoked-token-1": {},
75+ "revoked-token-2": {},
76+ }
77+ store.cacheMu.Unlock()
78+
79+ // Check revoked token
80+ revoked, err := store.IsRevoked(context.Background(), "revoked-token-1")
81+ require.NoError(t, err)
82+ assert.True(t, revoked)
83+}
84+
85+func TestPassStore_IsRevoked_CacheHit_NotRevoked(t *testing.T) {
86+ store := newTestPassStore()
87+
88+ // Pre-populate revocation cache with some revoked tokens
89+ store.cacheMu.Lock()
90+ store.revokedList = []string{"revoked-token-1"}
91+ store.revokedSet = map[string]struct{}{
92+ "revoked-token-1": {},
93+ }
94+ store.cacheMu.Unlock()
95+
96+ // Check non-revoked token (cache is populated, so no pass call)
97+ revoked, err := store.IsRevoked(context.Background(), "valid-token")
98+ require.NoError(t, err)
99+ assert.False(t, revoked)
100+}
101+
102+func TestPassStore_CacheUpdate_OnSave(t *testing.T) {
103+ store := newTestPassStore()
104+
105+ token := &domain.Token{
106+ ID: "new-token-id",
107+ Subject: "new-subject",
108+ Scopes: []string{"push"},
109+ IssuedAt: time.Now(),
110+ ExpiresAt: time.Now().Add(24 * time.Hour),
111+ }
112+ jwt := "new-jwt-token"
113+
114+ // Simulate what SaveToken does to the cache
115+ store.cacheMu.Lock()
116+ store.tokenCache[token.Subject] = &cachedToken{jwt: jwt, token: token}
117+ store.cacheMu.Unlock()
118+
119+ // Verify cache was updated - subsequent GetToken should hit cache
120+ gotJWT, gotToken, err := store.GetToken(context.Background(), "new-subject")
121+
122+ require.NoError(t, err)
123+ assert.Equal(t, jwt, gotJWT)
124+ assert.Equal(t, token.ID, gotToken.ID)
125+}
126+
127+func TestPassStore_CacheClear_OnDelete(t *testing.T) {
128+ store := newTestPassStore()
129+
130+ // Pre-populate cache
131+ store.cacheMu.Lock()
132+ store.tokenCache["to-delete"] = &cachedToken{
133+ jwt: "some-jwt",
134+ token: &domain.Token{ID: "id", Subject: "to-delete"},
135+ }
136+ store.cacheMu.Unlock()
137+
138+ // Simulate what DeleteToken does to the cache
139+ store.cacheMu.Lock()
140+ delete(store.tokenCache, "to-delete")
141+ store.cacheMu.Unlock()
142+
143+ // Verify cache entry was removed
144+ store.cacheMu.RLock()
145+ _, ok := store.tokenCache["to-delete"]
146+ store.cacheMu.RUnlock()
147+
148+ assert.False(t, ok)
149+}
150+
151+func TestPassStore_GetToken_ConcurrentAccess(t *testing.T) {
152+ store := newTestPassStore()
153+
154+ // Pre-populate cache
155+ expectedToken := &domain.Token{
156+ ID: "concurrent-id",
157+ Subject: "concurrent-subject",
158+ Scopes: []string{"push", "pull"},
159+ }
160+ expectedJWT := "concurrent-jwt"
161+
162+ store.cacheMu.Lock()
163+ store.tokenCache["concurrent-subject"] = &cachedToken{
164+ jwt: expectedJWT,
165+ token: expectedToken,
166+ }
167+ store.cacheMu.Unlock()
168+
169+ // Simulate concurrent access (like Docker buildx parallel requests)
170+ var wg sync.WaitGroup
171+ errors := make([]error, 0)
172+ var errMu sync.Mutex
173+
174+ for i := 0; i < 100; i++ {
175+ wg.Add(1)
176+ go func() {
177+ defer wg.Done()
178+ jwt, token, err := store.GetToken(context.Background(), "concurrent-subject")
179+ if err != nil {
180+ errMu.Lock()
181+ errors = append(errors, err)
182+ errMu.Unlock()
183+ return
184+ }
185+ if jwt != expectedJWT || token.ID != expectedToken.ID {
186+ errMu.Lock()
187+ errors = append(errors, assert.AnError)
188+ errMu.Unlock()
189+ }
190+ }()
191+ }
192+
193+ wg.Wait()
194+ assert.Empty(t, errors, "concurrent access should not produce errors")
195+}
196+
197+func TestPassStore_IsRevoked_ConcurrentAccess(t *testing.T) {
198+ store := newTestPassStore()
199+
200+ // Pre-populate revocation cache
201+ store.cacheMu.Lock()
202+ store.revokedList = []string{"revoked-1"}
203+ store.revokedSet = map[string]struct{}{"revoked-1": {}}
204+ store.cacheMu.Unlock()
205+
206+ // Simulate concurrent access
207+ var wg sync.WaitGroup
208+ errors := make([]error, 0)
209+ var errMu sync.Mutex
210+
211+ for i := 0; i < 100; i++ {
212+ wg.Add(1)
213+ go func() {
214+ defer wg.Done()
215+ revoked, err := store.IsRevoked(context.Background(), "revoked-1")
216+ if err != nil {
217+ errMu.Lock()
218+ errors = append(errors, err)
219+ errMu.Unlock()
220+ return
221+ }
222+ if !revoked {
223+ errMu.Lock()
224+ errors = append(errors, assert.AnError)
225+ errMu.Unlock()
226+ }
227+ }()
228+ }
229+
230+ wg.Wait()
231+ assert.Empty(t, errors, "concurrent access should not produce errors")
232+}
233+
234+func TestPassStore_RevokeUpdatesCache(t *testing.T) {
235+ store := newTestPassStore()
236+
237+ // Initialize empty revocation cache
238+ store.cacheMu.Lock()
239+ store.revokedList = []string{}
240+ store.revokedSet = make(map[string]struct{})
241+ store.cacheMu.Unlock()
242+
243+ // Simulate what Revoke does to the cache
244+ tokenID := "newly-revoked"
245+ store.cacheMu.Lock()
246+ store.revokedList = append(store.revokedList, tokenID)
247+ store.revokedSet[tokenID] = struct{}{}
248+ store.cacheMu.Unlock()
249+
250+ // Verify IsRevoked now returns true from cache
251+ revoked, err := store.IsRevoked(context.Background(), tokenID)
252+ require.NoError(t, err)
253+ assert.True(t, revoked)
254+}
@@ -0,0 +1,254 @@
1+package tokenstore
2+
3+import (
4+ "context"
5+ "sync"
6+ "testing"
7+ "time"
8+
9+ "github.com/stretchr/testify/assert"
10+ "github.com/stretchr/testify/require"
11+
12+ "gordon/internal/domain"
13+)
14+
15+// These tests verify the in-memory caching behavior of PassStore.
16+// They directly manipulate the cache to test cache hit/miss logic
17+// without requiring the actual pass binary.
18+
19+func newTestPassStore() *PassStore {
20+ return &PassStore{
21+ timeout: 10 * time.Second,
22+ tokenCache: make(map[string]*cachedToken),
23+ revokedSet: make(map[string]struct{}),
24+ }
25+}
26+
27+func TestPassStore_GetToken_CacheHit(t *testing.T) {
28+ store := newTestPassStore()
29+
30+ // Pre-populate cache
31+ expectedToken := &domain.Token{
32+ ID: "test-id",
33+ Subject: "test-subject",
34+ Scopes: []string{"push", "pull"},
35+ IssuedAt: time.Now(),
36+ ExpiresAt: time.Now().Add(24 * time.Hour),
37+ }
38+ expectedJWT := "cached-jwt-token"
39+
40+ store.cacheMu.Lock()
41+ store.tokenCache["test-subject"] = &cachedToken{
42+ jwt: expectedJWT,
43+ token: expectedToken,
44+ }
45+ store.cacheMu.Unlock()
46+
47+ // Should return cached value without calling pass
48+ jwt, token, err := store.GetToken(context.Background(), "test-subject")
49+
50+ require.NoError(t, err)
51+ assert.Equal(t, expectedJWT, jwt)
52+ assert.Equal(t, expectedToken.ID, token.ID)
53+ assert.Equal(t, expectedToken.Subject, token.Subject)
54+ assert.Equal(t, expectedToken.Scopes, token.Scopes)
55+}
56+
57+func TestPassStore_GetToken_CacheMiss_ReturnsNotFound(t *testing.T) {
58+ store := newTestPassStore()
59+
60+ // Cache miss should try to call pass (which will fail in test env)
61+ _, _, err := store.GetToken(context.Background(), "nonexistent")
62+
63+ // Should return ErrTokenNotFound since pass won't have this token
64+ assert.ErrorIs(t, err, domain.ErrTokenNotFound)
65+}
66+
67+func TestPassStore_IsRevoked_CacheHit_Revoked(t *testing.T) {
68+ store := newTestPassStore()
69+
70+ // Pre-populate revocation cache
71+ store.cacheMu.Lock()
72+ store.revokedList = []string{"revoked-token-1", "revoked-token-2"}
73+ store.revokedSet = map[string]struct{}{
74+ "revoked-token-1": {},
75+ "revoked-token-2": {},
76+ }
77+ store.cacheMu.Unlock()
78+
79+ // Check revoked token
80+ revoked, err := store.IsRevoked(context.Background(), "revoked-token-1")
81+ require.NoError(t, err)
82+ assert.True(t, revoked)
83+}
84+
85+func TestPassStore_IsRevoked_CacheHit_NotRevoked(t *testing.T) {
86+ store := newTestPassStore()
87+
88+ // Pre-populate revocation cache with some revoked tokens
89+ store.cacheMu.Lock()
90+ store.revokedList = []string{"revoked-token-1"}
91+ store.revokedSet = map[string]struct{}{
92+ "revoked-token-1": {},
93+ }
94+ store.cacheMu.Unlock()
95+
96+ // Check non-revoked token (cache is populated, so no pass call)
97+ revoked, err := store.IsRevoked(context.Background(), "valid-token")
98+ require.NoError(t, err)
99+ assert.False(t, revoked)
100+}
101+
102+func TestPassStore_CacheUpdate_OnSave(t *testing.T) {
103+ store := newTestPassStore()
104+
105+ token := &domain.Token{
106+ ID: "new-token-id",
107+ Subject: "new-subject",
108+ Scopes: []string{"push"},
109+ IssuedAt: time.Now(),
110+ ExpiresAt: time.Now().Add(24 * time.Hour),
111+ }
112+ jwt := "new-jwt-token"
113+
114+ // Simulate what SaveToken does to the cache
115+ store.cacheMu.Lock()
116+ store.tokenCache[token.Subject] = &cachedToken{jwt: jwt, token: token}
117+ store.cacheMu.Unlock()
118+
119+ // Verify cache was updated - subsequent GetToken should hit cache
120+ gotJWT, gotToken, err := store.GetToken(context.Background(), "new-subject")
121+
122+ require.NoError(t, err)
123+ assert.Equal(t, jwt, gotJWT)
124+ assert.Equal(t, token.ID, gotToken.ID)
125+}
126+
127+func TestPassStore_CacheClear_OnDelete(t *testing.T) {
128+ store := newTestPassStore()
129+
130+ // Pre-populate cache
131+ store.cacheMu.Lock()
132+ store.tokenCache["to-delete"] = &cachedToken{
133+ jwt: "some-jwt",
134+ token: &domain.Token{ID: "id", Subject: "to-delete"},
135+ }
136+ store.cacheMu.Unlock()
137+
138+ // Simulate what DeleteToken does to the cache
139+ store.cacheMu.Lock()
140+ delete(store.tokenCache, "to-delete")
141+ store.cacheMu.Unlock()
142+
143+ // Verify cache entry was removed
144+ store.cacheMu.RLock()
145+ _, ok := store.tokenCache["to-delete"]
146+ store.cacheMu.RUnlock()
147+
148+ assert.False(t, ok)
149+}
150+
151+func TestPassStore_GetToken_ConcurrentAccess(t *testing.T) {
152+ store := newTestPassStore()
153+
154+ // Pre-populate cache
155+ expectedToken := &domain.Token{
156+ ID: "concurrent-id",
157+ Subject: "concurrent-subject",
158+ Scopes: []string{"push", "pull"},
159+ }
160+ expectedJWT := "concurrent-jwt"
161+
162+ store.cacheMu.Lock()
163+ store.tokenCache["concurrent-subject"] = &cachedToken{
164+ jwt: expectedJWT,
165+ token: expectedToken,
166+ }
167+ store.cacheMu.Unlock()
168+
169+ // Simulate concurrent access (like Docker buildx parallel requests)
170+ var wg sync.WaitGroup
171+ errors := make([]error, 0)
172+ var errMu sync.Mutex
173+
174+ for i := 0; i < 100; i++ {
175+ wg.Add(1)
176+ go func() {
177+ defer wg.Done()
178+ jwt, token, err := store.GetToken(context.Background(), "concurrent-subject")
179+ if err != nil {
180+ errMu.Lock()
181+ errors = append(errors, err)
182+ errMu.Unlock()
183+ return
184+ }
185+ if jwt != expectedJWT || token.ID != expectedToken.ID {
186+ errMu.Lock()
187+ errors = append(errors, assert.AnError)
188+ errMu.Unlock()
189+ }
190+ }()
191+ }
192+
193+ wg.Wait()
194+ assert.Empty(t, errors, "concurrent access should not produce errors")
195+}
196+
197+func TestPassStore_IsRevoked_ConcurrentAccess(t *testing.T) {
198+ store := newTestPassStore()
199+
200+ // Pre-populate revocation cache
201+ store.cacheMu.Lock()
202+ store.revokedList = []string{"revoked-1"}
203+ store.revokedSet = map[string]struct{}{"revoked-1": {}}
204+ store.cacheMu.Unlock()
205+
206+ // Simulate concurrent access
207+ var wg sync.WaitGroup
208+ errors := make([]error, 0)
209+ var errMu sync.Mutex
210+
211+ for i := 0; i < 100; i++ {
212+ wg.Add(1)
213+ go func() {
214+ defer wg.Done()
215+ revoked, err := store.IsRevoked(context.Background(), "revoked-1")
216+ if err != nil {
217+ errMu.Lock()
218+ errors = append(errors, err)
219+ errMu.Unlock()
220+ return
221+ }
222+ if !revoked {
223+ errMu.Lock()
224+ errors = append(errors, assert.AnError)
225+ errMu.Unlock()
226+ }
227+ }()
228+ }
229+
230+ wg.Wait()
231+ assert.Empty(t, errors, "concurrent access should not produce errors")
232+}
233+
234+func TestPassStore_RevokeUpdatesCache(t *testing.T) {
235+ store := newTestPassStore()
236+
237+ // Initialize empty revocation cache
238+ store.cacheMu.Lock()
239+ store.revokedList = []string{}
240+ store.revokedSet = make(map[string]struct{})
241+ store.cacheMu.Unlock()
242+
243+ // Simulate what Revoke does to the cache
244+ tokenID := "newly-revoked"
245+ store.cacheMu.Lock()
246+ store.revokedList = append(store.revokedList, tokenID)
247+ store.revokedSet[tokenID] = struct{}{}
248+ store.cacheMu.Unlock()
249+
250+ // Verify IsRevoked now returns true from cache
251+ revoked, err := store.IsRevoked(context.Background(), tokenID)
252+ require.NoError(t, err)
253+ assert.True(t, revoked)
254+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close