detached f3fca4d

Merge pull request #36 from bnema/fix/registry-auth-scope-and-cache

fix(auth): registry push failures with token cache and scope validation
commit
f3fca4ddef2d4d41034c5647572467cb50ebe3cb
author
bnema · · 2026-01-18 09:54 UTC
committer
GitHub · · 2026-01-18 09:54 UTC
merge of
c6f9c5d + cf4268c · diff against the first parent
signature
unsigned
5 files · +456 -39
M docs/getting-started.md +55 -7
@@ -7,6 +7,7 @@ Deploy your first app with Gordon in under 5 minutes.
77 - A Linux VPS with Docker or Podman installed
88 - A domain pointing to your VPS (DNS A record)
99 - Cloudflare account (free tier) for HTTPS termination
10+- [pass](https://www.passwordstore.org/) (password manager) with GPG key initialized
1011
1112 ## 1. Install Gordon
1213
@@ -16,7 +17,7 @@ curl -fsSL https://gordon.bnema.dev/install | bash
1617
1718 This script automatically detects your OS and architecture, downloads the appropriate binary, and installs it to `/usr/local/bin`.
1819
19-## 2. Start Gordon
20+## 2. Initialize Configuration
2021
2122 ```bash
2223 # First run creates the default config
@@ -26,7 +27,41 @@ gordon serve
2627
2728 Config is created at `~/.config/gordon/gordon.toml`.
2829
29-## 3. Configure Your Registry Domain
30+## 3. Set Up Authentication
31+
32+Gordon requires a `token_secret` for JWT authentication. We recommend using `pass` to store secrets securely.
33+
34+> **Local development?** If you just want to try Gordon locally, you can disable auth temporarily:
35+> ```toml
36+> [auth]
37+> enabled = false
38+> ```
39+> Skip to [Step 4](#4-configure-your-registry-domain). For production, continue below.
40+
41+**Initialize pass (if not already done):**
42+```bash
43+# Generate a GPG key if you don't have one
44+gpg --gen-key
45+
46+# Initialize pass with your GPG key ID
47+pass init <your-gpg-key-id>
48+```
49+
50+**Create the JWT token secret:**
51+```bash
52+# Generate and store a random 32-character secret
53+openssl rand -base64 32 | pass insert -m gordon/auth/token_secret
54+```
55+
56+**Update your config** (`~/.config/gordon/gordon.toml`):
57+```toml
58+[auth]
59+enabled = true
60+secrets_backend = "pass"
61+token_secret = "gordon/auth/token_secret"
62+```
63+
64+## 4. Configure Your Registry Domain
3065
3166 Edit `~/.config/gordon/gordon.toml`:
3267
@@ -34,13 +69,13 @@ Edit `~/.config/gordon/gordon.toml`:
3469 [server]
3570 port = 8080 # Proxy port (use with Cloudflare)
3671 registry_port = 5000 # Registry port
37-gordon_domain = "gordon.mydomain.com" # Your Gordon domain
72+gordon_domain = "gordon.mydomain.com" # Your Gordon domain
3873
3974 [routes]
4075 "app.mydomain.com" = "myapp:latest" # Domain → Image mapping
4176 ```
4277
43-## 4. Set Up DNS
78+## 5. Set Up DNS
4479
4580 In Cloudflare (or your DNS provider):
4681
@@ -51,7 +86,7 @@ In Cloudflare (or your DNS provider):
5186
5287 Enable Cloudflare proxy (orange cloud) for HTTPS.
5388
54-## 5. Start Gordon as a Service
89+## 6. Start Gordon as a Service
5590
5691 ```bash
5792 # Create systemd user service
@@ -75,11 +110,24 @@ systemctl --user enable --now gordon
75110 sudo loginctl enable-linger $USER
76111 ```
77112
78-## 6. Deploy Your First App
113+## 7. Generate a Deploy Token
114+
115+Create a token for pushing images to your registry (skip if auth is disabled):
116+
117+```bash
118+gordon auth token generate --subject deploy --scopes push,pull --expiry 0
119+```
120+
121+Save this token securely - you'll need it for Docker login.
122+
123+## 8. Deploy Your First App
79124
80125 On your local machine:
81126
82127 ```bash
128+# Log in to your Gordon registry (skip if auth is disabled)
129+docker login -u deploy -p <your-token> registry.mydomain.com
130+
83131 # Build your app
84132 docker build -t myapp .
85133
@@ -92,7 +140,7 @@ docker push registry.mydomain.com/myapp:latest
92140
93141 Your app is now live at `https://app.mydomain.com`!
94142
95-## 7. Update Your App
143+## 9. Update Your App
96144
97145 Push a new image to deploy with zero downtime:
98146
@@ -7,6 +7,7 @@ Deploy your first app with Gordon in under 5 minutes.
7 - A Linux VPS with Docker or Podman installed7 - A Linux VPS with Docker or Podman installed
8 - A domain pointing to your VPS (DNS A record)8 - A domain pointing to your VPS (DNS A record)
9 - Cloudflare account (free tier) for HTTPS termination9 - Cloudflare account (free tier) for HTTPS termination
10+- [pass](https://www.passwordstore.org/) (password manager) with GPG key initialized
10 11
11 ## 1. Install Gordon12 ## 1. Install Gordon
12 13
@@ -16,7 +17,7 @@ curl -fsSL https://gordon.bnema.dev/install | bash
16 17
17 This script automatically detects your OS and architecture, downloads the appropriate binary, and installs it to `/usr/local/bin`.18 This script automatically detects your OS and architecture, downloads the appropriate binary, and installs it to `/usr/local/bin`.
18 19
19-## 2. Start Gordon20+## 2. Initialize Configuration
20 21
21 ```bash22 ```bash
22 # First run creates the default config23 # First run creates the default config
@@ -26,7 +27,41 @@ gordon serve
26 27
27 Config is created at `~/.config/gordon/gordon.toml`.28 Config is created at `~/.config/gordon/gordon.toml`.
28 29
29-## 3. Configure Your Registry Domain30+## 3. Set Up Authentication
31+
32+Gordon requires a `token_secret` for JWT authentication. We recommend using `pass` to store secrets securely.
33+
34+> **Local development?** If you just want to try Gordon locally, you can disable auth temporarily:
35+> ```toml
36+> [auth]
37+> enabled = false
38+> ```
39+> Skip to [Step 4](#4-configure-your-registry-domain). For production, continue below.
40+
41+**Initialize pass (if not already done):**
42+```bash
43+# Generate a GPG key if you don't have one
44+gpg --gen-key
45+
46+# Initialize pass with your GPG key ID
47+pass init <your-gpg-key-id>
48+```
49+
50+**Create the JWT token secret:**
51+```bash
52+# Generate and store a random 32-character secret
53+openssl rand -base64 32 | pass insert -m gordon/auth/token_secret
54+```
55+
56+**Update your config** (`~/.config/gordon/gordon.toml`):
57+```toml
58+[auth]
59+enabled = true
60+secrets_backend = "pass"
61+token_secret = "gordon/auth/token_secret"
62+```
63+
64+## 4. Configure Your Registry Domain
30 65
31 Edit `~/.config/gordon/gordon.toml`:66 Edit `~/.config/gordon/gordon.toml`:
32 67
@@ -34,13 +69,13 @@ Edit `~/.config/gordon/gordon.toml`:
34 [server]69 [server]
35 port = 8080 # Proxy port (use with Cloudflare)70 port = 8080 # Proxy port (use with Cloudflare)
36 registry_port = 5000 # Registry port71 registry_port = 5000 # Registry port
37-gordon_domain = "gordon.mydomain.com" # Your Gordon domain72+gordon_domain = "gordon.mydomain.com" # Your Gordon domain
38 73
39 [routes]74 [routes]
40 "app.mydomain.com" = "myapp:latest" # Domain → Image mapping75 "app.mydomain.com" = "myapp:latest" # Domain → Image mapping
41 ```76 ```
42 77
43-## 4. Set Up DNS78+## 5. Set Up DNS
44 79
45 In Cloudflare (or your DNS provider):80 In Cloudflare (or your DNS provider):
46 81
@@ -51,7 +86,7 @@ In Cloudflare (or your DNS provider):
51 86
52 Enable Cloudflare proxy (orange cloud) for HTTPS.87 Enable Cloudflare proxy (orange cloud) for HTTPS.
53 88
54-## 5. Start Gordon as a Service89+## 6. Start Gordon as a Service
55 90
56 ```bash91 ```bash
57 # Create systemd user service92 # Create systemd user service
@@ -75,11 +110,24 @@ systemctl --user enable --now gordon
75 sudo loginctl enable-linger $USER110 sudo loginctl enable-linger $USER
76 ```111 ```
77 112
78-## 6. Deploy Your First App113+## 7. Generate a Deploy Token
114+
115+Create a token for pushing images to your registry (skip if auth is disabled):
116+
117+```bash
118+gordon auth token generate --subject deploy --scopes push,pull --expiry 0
119+```
120+
121+Save this token securely - you'll need it for Docker login.
122+
123+## 8. Deploy Your First App
79 124
80 On your local machine:125 On your local machine:
81 126
82 ```bash127 ```bash
128+# Log in to your Gordon registry (skip if auth is disabled)
129+docker login -u deploy -p <your-token> registry.mydomain.com
130+
83 # Build your app131 # Build your app
84 docker build -t myapp .132 docker build -t myapp .
85 133
@@ -92,7 +140,7 @@ docker push registry.mydomain.com/myapp:latest
92 140
93 Your app is now live at `https://app.mydomain.com`!141 Your app is now live at `https://app.mydomain.com`!
94 142
95-## 7. Update Your App143+## 9. Update Your App
96 144
97 Push a new image to deploy with zero downtime:145 Push a new image to deploy with zero downtime:
98 146
M internal/adapters/in/cli/auth.go +39 -5
@@ -352,6 +352,44 @@ The hash can be stored in your secrets backend and referenced in the config:
352352 }
353353
354354 // runTokenGenerate generates a new authentication token.
355+// parseAndConvertScopes parses a comma-separated scope string and converts
356+// simple scopes (push, pull, *) to Docker v2 format (repository:*:action).
357+func parseAndConvertScopes(scopesStr string) []string {
358+ rawScopes := strings.Split(scopesStr, ",")
359+ for i := range rawScopes {
360+ rawScopes[i] = strings.TrimSpace(rawScopes[i])
361+ }
362+
363+ // Check if scopes are already in v2 format (contain colons)
364+ hasSimpleScope := false
365+ for _, s := range rawScopes {
366+ if !strings.Contains(s, ":") && (s == "push" || s == "pull" || s == "*") {
367+ hasSimpleScope = true
368+ break
369+ }
370+ }
371+
372+ if !hasSimpleScope {
373+ return rawScopes
374+ }
375+
376+ // Convert simple scopes to v2 format: repository:*:push,pull
377+ var scopes []string
378+ var actions []string
379+ for _, s := range rawScopes {
380+ if s == "push" || s == "pull" || s == "*" {
381+ actions = append(actions, s)
382+ } else {
383+ // Keep non-simple scopes as-is (e.g., admin scopes)
384+ scopes = append(scopes, s)
385+ }
386+ }
387+ if len(actions) > 0 {
388+ scopes = append(scopes, "repository:*:"+strings.Join(actions, ","))
389+ }
390+ return scopes
391+}
392+
355393 func runTokenGenerate(subject, scopesStr, expiryStr, configPath string) error {
356394 cfg, err := loadAuthConfig(configPath)
357395 if err != nil {
@@ -367,11 +405,7 @@ func runTokenGenerate(subject, scopesStr, expiryStr, configPath string) error {
367405 }
368406 }
369407
370- // Parse scopes
371- scopes := strings.Split(scopesStr, ",")
372- for i := range scopes {
373- scopes[i] = strings.TrimSpace(scopes[i])
374- }
408+ scopes := parseAndConvertScopes(scopesStr)
375409
376410 // Create auth service
377411 log := zerowrap.New(zerowrap.Config{Level: "warn"})
@@ -352,6 +352,44 @@ The hash can be stored in your secrets backend and referenced in the config:
352 }352 }
353 353
354 // runTokenGenerate generates a new authentication token.354 // runTokenGenerate generates a new authentication token.
355+// parseAndConvertScopes parses a comma-separated scope string and converts
356+// simple scopes (push, pull, *) to Docker v2 format (repository:*:action).
357+func parseAndConvertScopes(scopesStr string) []string {
358+ rawScopes := strings.Split(scopesStr, ",")
359+ for i := range rawScopes {
360+ rawScopes[i] = strings.TrimSpace(rawScopes[i])
361+ }
362+
363+ // Check if scopes are already in v2 format (contain colons)
364+ hasSimpleScope := false
365+ for _, s := range rawScopes {
366+ if !strings.Contains(s, ":") && (s == "push" || s == "pull" || s == "*") {
367+ hasSimpleScope = true
368+ break
369+ }
370+ }
371+
372+ if !hasSimpleScope {
373+ return rawScopes
374+ }
375+
376+ // Convert simple scopes to v2 format: repository:*:push,pull
377+ var scopes []string
378+ var actions []string
379+ for _, s := range rawScopes {
380+ if s == "push" || s == "pull" || s == "*" {
381+ actions = append(actions, s)
382+ } else {
383+ // Keep non-simple scopes as-is (e.g., admin scopes)
384+ scopes = append(scopes, s)
385+ }
386+ }
387+ if len(actions) > 0 {
388+ scopes = append(scopes, "repository:*:"+strings.Join(actions, ","))
389+ }
390+ return scopes
391+}
392+
355 func runTokenGenerate(subject, scopesStr, expiryStr, configPath string) error {393 func runTokenGenerate(subject, scopesStr, expiryStr, configPath string) error {
356 cfg, err := loadAuthConfig(configPath)394 cfg, err := loadAuthConfig(configPath)
357 if err != nil {395 if err != nil {
@@ -367,11 +405,7 @@ func runTokenGenerate(subject, scopesStr, expiryStr, configPath string) error {
367 }405 }
368 }406 }
369 407
370- // Parse scopes408+ scopes := parseAndConvertScopes(scopesStr)
371- scopes := strings.Split(scopesStr, ",")
372- for i := range scopes {
373- scopes[i] = strings.TrimSpace(scopes[i])
374- }
375 409
376 // Create auth service410 // Create auth service
377 log := zerowrap.New(zerowrap.Config{Level: "warn"})411 log := zerowrap.New(zerowrap.Config{Level: "warn"})
M internal/adapters/in/http/middleware/auth.go +36 -21
@@ -312,33 +312,28 @@ func isInternalRegistryAuth(r *http.Request, internalAuth InternalRegistryAuth)
312312 return usernameMatch && passwordMatch
313313 }
314314
315-// checkScopeAccess verifies the token has permission for the requested operation.
316-// Maps HTTP method to registry action and checks if any token scope grants access.
317-func checkScopeAccess(r *http.Request, claims *domain.TokenClaims, log zerowrap.Logger) bool {
318- // Determine required action from HTTP method
319- var action string
320- switch r.Method {
315+// actionFromMethod returns the registry action for an HTTP method.
316+func actionFromMethod(method string) string {
317+ switch method {
321318 case http.MethodGet, http.MethodHead:
322- action = domain.ScopeActionPull
319+ return domain.ScopeActionPull
323320 case http.MethodPut, http.MethodPost, http.MethodPatch, http.MethodDelete:
324- action = domain.ScopeActionPush
321+ return domain.ScopeActionPush
325322 default:
326- action = domain.ScopeActionPull // Default to pull for unknown methods
323+ return domain.ScopeActionPull
327324 }
325+}
328326
329- // Extract repository name from URL path
330- // Paths are like /v2/{name}/manifests/{reference} or /v2/{name}/blobs/{digest}
331- path := r.URL.Path
327+// extractRepoName extracts the repository name from a registry path.
328+// Returns empty string if the path is not a valid registry path or should be allowed.
329+func extractRepoName(path string) (repoName string, shouldAllow bool) {
332330 if !strings.HasPrefix(path, "/v2/") {
333- // Not a registry path, allow
334- return true
331+ return "", true // Not a registry path, allow
335332 }
336333
337- // Remove /v2/ prefix and find repository name
338334 pathParts := strings.Split(strings.TrimPrefix(path, "/v2/"), "/")
339335 if len(pathParts) < 2 {
340- // Malformed path or /v2/ root, allow
341- return true
336+ return "", true // Malformed path or /v2/ root, allow
342337 }
343338
344339 // Find the boundary between repo name and route (manifests, blobs, tags)
@@ -350,17 +345,37 @@ func checkScopeAccess(r *http.Request, claims *domain.TokenClaims, log zerowrap.
350345 }
351346 }
352347 if len(repoNameParts) == 0 {
353- // Could be a special route like /v2/token, allow
354- return true
348+ return "", true // Special route like /v2/token, allow
355349 }
356350
357- repoName := strings.Join(repoNameParts, "/")
351+ return strings.Join(repoNameParts, "/"), false
352+}
353+
354+// checkScopeAccess verifies the token has permission for the requested operation.
355+// Maps HTTP method to registry action and checks if any token scope grants access.
356+func checkScopeAccess(r *http.Request, claims *domain.TokenClaims, log zerowrap.Logger) bool {
357+ action := actionFromMethod(r.Method)
358+
359+ repoName, shouldAllow := extractRepoName(r.URL.Path)
360+ if shouldAllow {
361+ return true
362+ }
358363
359364 // Check if any token scope grants access
360365 for _, scopeStr := range claims.Scopes {
366+ // Handle simple scopes (e.g., "push", "pull") for backwards compatibility
367+ if scopeStr == action || scopeStr == "*" {
368+ log.Debug().
369+ Str("repo", repoName).
370+ Str("action", action).
371+ Str("scope", scopeStr).
372+ Msg("simple scope access granted")
373+ return true
374+ }
375+
376+ // Handle Docker v2 format scopes (e.g., "repository:myrepo:push,pull")
361377 scope, err := domain.ParseScope(scopeStr)
362378 if err != nil {
363- log.Debug().Err(err).Str("scope", scopeStr).Msg("failed to parse scope")
364379 continue
365380 }
366381
@@ -312,33 +312,28 @@ func isInternalRegistryAuth(r *http.Request, internalAuth InternalRegistryAuth)
312 return usernameMatch && passwordMatch312 return usernameMatch && passwordMatch
313 }313 }
314 314
315-// checkScopeAccess verifies the token has permission for the requested operation.315+// actionFromMethod returns the registry action for an HTTP method.
316-// Maps HTTP method to registry action and checks if any token scope grants access.316+func actionFromMethod(method string) string {
317-func checkScopeAccess(r *http.Request, claims *domain.TokenClaims, log zerowrap.Logger) bool {317+ switch method {
318- // Determine required action from HTTP method
319- var action string
320- switch r.Method {
321 case http.MethodGet, http.MethodHead:318 case http.MethodGet, http.MethodHead:
322- action = domain.ScopeActionPull319+ return domain.ScopeActionPull
323 case http.MethodPut, http.MethodPost, http.MethodPatch, http.MethodDelete:320 case http.MethodPut, http.MethodPost, http.MethodPatch, http.MethodDelete:
324- action = domain.ScopeActionPush321+ return domain.ScopeActionPush
325 default:322 default:
326- action = domain.ScopeActionPull // Default to pull for unknown methods323+ return domain.ScopeActionPull
327 }324 }
325+}
328 326
329- // Extract repository name from URL path327+// extractRepoName extracts the repository name from a registry path.
330- // Paths are like /v2/{name}/manifests/{reference} or /v2/{name}/blobs/{digest}328+// Returns empty string if the path is not a valid registry path or should be allowed.
331- path := r.URL.Path329+func extractRepoName(path string) (repoName string, shouldAllow bool) {
332 if !strings.HasPrefix(path, "/v2/") {330 if !strings.HasPrefix(path, "/v2/") {
333- // Not a registry path, allow331+ return "", true // Not a registry path, allow
334- return true
335 }332 }
336 333
337- // Remove /v2/ prefix and find repository name
338 pathParts := strings.Split(strings.TrimPrefix(path, "/v2/"), "/")334 pathParts := strings.Split(strings.TrimPrefix(path, "/v2/"), "/")
339 if len(pathParts) < 2 {335 if len(pathParts) < 2 {
340- // Malformed path or /v2/ root, allow336+ return "", true // Malformed path or /v2/ root, allow
341- return true
342 }337 }
343 338
344 // Find the boundary between repo name and route (manifests, blobs, tags)339 // Find the boundary between repo name and route (manifests, blobs, tags)
@@ -350,17 +345,37 @@ func checkScopeAccess(r *http.Request, claims *domain.TokenClaims, log zerowrap.
350 }345 }
351 }346 }
352 if len(repoNameParts) == 0 {347 if len(repoNameParts) == 0 {
353- // Could be a special route like /v2/token, allow348+ return "", true // Special route like /v2/token, allow
354- return true
355 }349 }
356 350
357- repoName := strings.Join(repoNameParts, "/")351+ return strings.Join(repoNameParts, "/"), false
352+}
353+
354+// checkScopeAccess verifies the token has permission for the requested operation.
355+// Maps HTTP method to registry action and checks if any token scope grants access.
356+func checkScopeAccess(r *http.Request, claims *domain.TokenClaims, log zerowrap.Logger) bool {
357+ action := actionFromMethod(r.Method)
358+
359+ repoName, shouldAllow := extractRepoName(r.URL.Path)
360+ if shouldAllow {
361+ return true
362+ }
358 363
359 // Check if any token scope grants access364 // Check if any token scope grants access
360 for _, scopeStr := range claims.Scopes {365 for _, scopeStr := range claims.Scopes {
366+ // Handle simple scopes (e.g., "push", "pull") for backwards compatibility
367+ if scopeStr == action || scopeStr == "*" {
368+ log.Debug().
369+ Str("repo", repoName).
370+ Str("action", action).
371+ Str("scope", scopeStr).
372+ Msg("simple scope access granted")
373+ return true
374+ }
375+
376+ // Handle Docker v2 format scopes (e.g., "repository:myrepo:push,pull")
361 scope, err := domain.ParseScope(scopeStr)377 scope, err := domain.ParseScope(scopeStr)
362 if err != nil {378 if err != nil {
363- log.Debug().Err(err).Str("scope", scopeStr).Msg("failed to parse scope")
364 continue379 continue
365 }380 }
366 381
M internal/adapters/out/tokenstore/pass.go +66 -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,15 @@ 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 = make(map[string]struct{}, len(revokedList))
246+ for _, id := range revokedList {
247+ s.revokedSet[id] = struct{}{}
248+ }
249+ s.cacheMu.Unlock()
250+
209251 s.log.Info().
210252 Str(zerowrap.FieldLayer, "adapter").
211253 Str(zerowrap.FieldAdapter, "tokenstore").
@@ -217,6 +259,15 @@ func (s *PassStore) Revoke(ctx context.Context, tokenID string) error {
217259
218260 // IsRevoked checks if token ID is in revocation list.
219261 func (s *PassStore) IsRevoked(ctx context.Context, tokenID string) (bool, error) {
262+ // Check cache first
263+ s.cacheMu.RLock()
264+ if s.revokedList != nil {
265+ _, revoked := s.revokedSet[tokenID]
266+ s.cacheMu.RUnlock()
267+ return revoked, nil
268+ }
269+ s.cacheMu.RUnlock()
270+
220271 ctx, cancel := context.WithTimeout(ctx, s.timeout)
221272 defer cancel()
222273
@@ -225,13 +276,17 @@ func (s *PassStore) IsRevoked(ctx context.Context, tokenID string) (bool, error)
225276 return false, err
226277 }
227278
279+ // Cache the revocation list
280+ s.cacheMu.Lock()
281+ s.revokedList = revokedList
282+ s.revokedSet = make(map[string]struct{}, len(revokedList))
228283 for _, id := range revokedList {
229- if id == tokenID {
230- return true, nil
231- }
284+ s.revokedSet[id] = struct{}{}
232285 }
286+ _, revoked := s.revokedSet[tokenID]
287+ s.cacheMu.Unlock()
233288
234- return false, nil
289+ return revoked, nil
235290 }
236291
237292 // DeleteToken removes token from pass.
@@ -256,6 +311,11 @@ func (s *PassStore) DeleteToken(ctx context.Context, subject string) error {
256311 s.log.Warn().Err(err).Msg("failed to delete token metadata")
257312 }
258313
314+ // Clear from cache
315+ s.cacheMu.Lock()
316+ delete(s.tokenCache, subject)
317+ s.cacheMu.Unlock()
318+
259319 s.log.Debug().
260320 Str(zerowrap.FieldLayer, "adapter").
261321 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,15 @@ 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 = make(map[string]struct{}, len(revokedList))
246+ for _, id := range revokedList {
247+ s.revokedSet[id] = struct{}{}
248+ }
249+ s.cacheMu.Unlock()
250+
209 s.log.Info().251 s.log.Info().
210 Str(zerowrap.FieldLayer, "adapter").252 Str(zerowrap.FieldLayer, "adapter").
211 Str(zerowrap.FieldAdapter, "tokenstore").253 Str(zerowrap.FieldAdapter, "tokenstore").
@@ -217,6 +259,15 @@ func (s *PassStore) Revoke(ctx context.Context, tokenID string) error {
217 259
218 // IsRevoked checks if token ID is in revocation list.260 // IsRevoked checks if token ID is in revocation list.
219 func (s *PassStore) IsRevoked(ctx context.Context, tokenID string) (bool, error) {261 func (s *PassStore) IsRevoked(ctx context.Context, tokenID string) (bool, error) {
262+ // Check cache first
263+ s.cacheMu.RLock()
264+ if s.revokedList != nil {
265+ _, revoked := s.revokedSet[tokenID]
266+ s.cacheMu.RUnlock()
267+ return revoked, nil
268+ }
269+ s.cacheMu.RUnlock()
270+
220 ctx, cancel := context.WithTimeout(ctx, s.timeout)271 ctx, cancel := context.WithTimeout(ctx, s.timeout)
221 defer cancel()272 defer cancel()
222 273
@@ -225,13 +276,17 @@ func (s *PassStore) IsRevoked(ctx context.Context, tokenID string) (bool, error)
225 return false, err276 return false, err
226 }277 }
227 278
279+ // Cache the revocation list
280+ s.cacheMu.Lock()
281+ s.revokedList = revokedList
282+ s.revokedSet = make(map[string]struct{}, len(revokedList))
228 for _, id := range revokedList {283 for _, id := range revokedList {
229- if id == tokenID {284+ s.revokedSet[id] = struct{}{}
230- return true, nil
231- }
232 }285 }
286+ _, revoked := s.revokedSet[tokenID]
287+ s.cacheMu.Unlock()
233 288
234- return false, nil289+ return revoked, nil
235 }290 }
236 291
237 // DeleteToken removes token from pass.292 // DeleteToken removes token from pass.
@@ -256,6 +311,11 @@ func (s *PassStore) DeleteToken(ctx context.Context, subject string) error {
256 s.log.Warn().Err(err).Msg("failed to delete token metadata")311 s.log.Warn().Err(err).Msg("failed to delete token metadata")
257 }312 }
258 313
314+ // Clear from cache
315+ s.cacheMu.Lock()
316+ delete(s.tokenCache, subject)
317+ s.cacheMu.Unlock()
318+
259 s.log.Debug().319 s.log.Debug().
260 Str(zerowrap.FieldLayer, "adapter").320 Str(zerowrap.FieldLayer, "adapter").
261 Str(zerowrap.FieldAdapter, "tokenstore").321 Str(zerowrap.FieldAdapter, "tokenstore").
A internal/adapters/out/tokenstore/pass_test.go +260 -0
@@ -0,0 +1,260 @@
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 revocation cache with an older revoked token
238+ store.cacheMu.Lock()
239+ store.revokedList = []string{"oldly-revoked"}
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 = make(map[string]struct{}, len(store.revokedList))
248+ for _, id := range store.revokedList {
249+ store.revokedSet[id] = struct{}{}
250+ }
251+ store.cacheMu.Unlock()
252+
253+ // Verify IsRevoked now returns true from cache
254+ revoked, err := store.IsRevoked(context.Background(), tokenID)
255+ require.NoError(t, err)
256+ assert.True(t, revoked)
257+ revoked, err = store.IsRevoked(context.Background(), "oldly-revoked")
258+ require.NoError(t, err)
259+ assert.True(t, revoked)
260+}
@@ -0,0 +1,260 @@
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 revocation cache with an older revoked token
238+ store.cacheMu.Lock()
239+ store.revokedList = []string{"oldly-revoked"}
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 = make(map[string]struct{}, len(store.revokedList))
248+ for _, id := range store.revokedList {
249+ store.revokedSet[id] = struct{}{}
250+ }
251+ store.cacheMu.Unlock()
252+
253+ // Verify IsRevoked now returns true from cache
254+ revoked, err := store.IsRevoked(context.Background(), tokenID)
255+ require.NoError(t, err)
256+ assert.True(t, revoked)
257+ revoked, err = store.IsRevoked(context.Background(), "oldly-revoked")
258+ require.NoError(t, err)
259+ assert.True(t, revoked)
260+}

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