detached 8197cdd

fix(tokenstore): add mutex to UnsafeStore.Revoke to prevent concurrent revocation loss

commit
8197cddd42a3f9b4da49a06c26246228d7863a0c
author
bnema · · 2026-02-25 01:33 UTC
parent
cdeffe2
signature
unsigned
2 files · +79 -0
M internal/adapters/out/tokenstore/unsafe.go +8 -0
@@ -9,6 +9,7 @@ import (
99 "os"
1010 "path/filepath"
1111 "strings"
12+ "sync"
1213
1314 "github.com/bnema/zerowrap"
1415
@@ -32,6 +33,7 @@ const (
3233 // UnsafeStore implements TokenStore using plain text files.
3334 // WARNING: This store does not encrypt secrets. Only use when pass/sops are unavailable.
3435 type UnsafeStore struct {
36+ mu sync.RWMutex
3537 dataDir string
3638 log zerowrap.Logger
3739 }
@@ -187,6 +189,9 @@ func (s *UnsafeStore) ListTokens(_ context.Context) ([]domain.Token, error) {
187189
188190 // Revoke adds token ID to revocation list file.
189191 func (s *UnsafeStore) Revoke(_ context.Context, tokenID string) error {
192+ s.mu.Lock()
193+ defer s.mu.Unlock()
194+
190195 revokedFile := filepath.Join(s.dataDir, unsafeRevokedFile)
191196
192197 // Ensure parent directory exists
@@ -229,6 +234,9 @@ func (s *UnsafeStore) Revoke(_ context.Context, tokenID string) error {
229234
230235 // IsRevoked checks if token ID is in revocation list.
231236 func (s *UnsafeStore) IsRevoked(_ context.Context, tokenID string) (bool, error) {
237+ s.mu.RLock()
238+ defer s.mu.RUnlock()
239+
232240 revokedList, err := s.getRevokedList()
233241 if err != nil {
234242 return false, err
@@ -9,6 +9,7 @@ import (
9 "os"9 "os"
10 "path/filepath"10 "path/filepath"
11 "strings"11 "strings"
12+ "sync"
12 13
13 "github.com/bnema/zerowrap"14 "github.com/bnema/zerowrap"
14 15
@@ -32,6 +33,7 @@ const (
32 // UnsafeStore implements TokenStore using plain text files.33 // UnsafeStore implements TokenStore using plain text files.
33 // WARNING: This store does not encrypt secrets. Only use when pass/sops are unavailable.34 // WARNING: This store does not encrypt secrets. Only use when pass/sops are unavailable.
34 type UnsafeStore struct {35 type UnsafeStore struct {
36+ mu sync.RWMutex
35 dataDir string37 dataDir string
36 log zerowrap.Logger38 log zerowrap.Logger
37 }39 }
@@ -187,6 +189,9 @@ func (s *UnsafeStore) ListTokens(_ context.Context) ([]domain.Token, error) {
187 189
188 // Revoke adds token ID to revocation list file.190 // Revoke adds token ID to revocation list file.
189 func (s *UnsafeStore) Revoke(_ context.Context, tokenID string) error {191 func (s *UnsafeStore) Revoke(_ context.Context, tokenID string) error {
192+ s.mu.Lock()
193+ defer s.mu.Unlock()
194+
190 revokedFile := filepath.Join(s.dataDir, unsafeRevokedFile)195 revokedFile := filepath.Join(s.dataDir, unsafeRevokedFile)
191 196
192 // Ensure parent directory exists197 // Ensure parent directory exists
@@ -229,6 +234,9 @@ func (s *UnsafeStore) Revoke(_ context.Context, tokenID string) error {
229 234
230 // IsRevoked checks if token ID is in revocation list.235 // IsRevoked checks if token ID is in revocation list.
231 func (s *UnsafeStore) IsRevoked(_ context.Context, tokenID string) (bool, error) {236 func (s *UnsafeStore) IsRevoked(_ context.Context, tokenID string) (bool, error) {
237+ s.mu.RLock()
238+ defer s.mu.RUnlock()
239+
232 revokedList, err := s.getRevokedList()240 revokedList, err := s.getRevokedList()
233 if err != nil {241 if err != nil {
234 return false, err242 return false, err
A internal/adapters/out/tokenstore/unsafe_test.go +71 -0
@@ -0,0 +1,71 @@
1+package tokenstore
2+
3+import (
4+ "context"
5+ "fmt"
6+ "io"
7+ "sync"
8+ "testing"
9+
10+ "github.com/bnema/zerowrap"
11+
12+ "github.com/bnema/gordon/internal/domain"
13+)
14+
15+// newTestUnsafeStore creates a UnsafeStore backed by a temp directory.
16+func newTestUnsafeStore(t *testing.T) *UnsafeStore {
17+ t.Helper()
18+ dir := t.TempDir()
19+ log := zerowrap.New(zerowrap.Config{Level: "disabled", Output: io.Discard})
20+ store, err := NewUnsafeStore(dir, log)
21+ if err != nil {
22+ t.Fatalf("NewUnsafeStore: %v", err)
23+ }
24+ return store
25+}
26+
27+func TestUnsafeStoreRevokeConcurrentNoLoss(t *testing.T) {
28+ // Run with: go test -race ./internal/adapters/out/tokenstore/... -run TestUnsafeStoreRevokeConcurrentNoLoss -v
29+ store := newTestUnsafeStore(t)
30+
31+ ctx := context.Background()
32+ const n = 10
33+
34+ // Save n tokens
35+ tokenIDs := make([]string, n)
36+ for i := 0; i < n; i++ {
37+ id := fmt.Sprintf("token-%d", i)
38+ tokenIDs[i] = id
39+ tok := &domain.Token{
40+ ID: id,
41+ Subject: fmt.Sprintf("user-%d", i),
42+ Scopes: []string{"admin"},
43+ }
44+ if err := store.SaveToken(ctx, tok, "jwt-"+id); err != nil {
45+ t.Fatal(err)
46+ }
47+ }
48+
49+ // Revoke all concurrently
50+ var wg sync.WaitGroup
51+ for _, id := range tokenIDs {
52+ wg.Add(1)
53+ go func(id string) {
54+ defer wg.Done()
55+ _ = store.Revoke(ctx, id)
56+ }(id)
57+ }
58+ wg.Wait()
59+
60+ // Verify all n token IDs are in the revoked list
61+ for _, id := range tokenIDs {
62+ revoked, err := store.IsRevoked(ctx, id)
63+ if err != nil {
64+ t.Errorf("IsRevoked(%q): %v", id, err)
65+ continue
66+ }
67+ if !revoked {
68+ t.Errorf("token %q should be revoked but IsRevoked returned false", id)
69+ }
70+ }
71+}
@@ -0,0 +1,71 @@
1+package tokenstore
2+
3+import (
4+ "context"
5+ "fmt"
6+ "io"
7+ "sync"
8+ "testing"
9+
10+ "github.com/bnema/zerowrap"
11+
12+ "github.com/bnema/gordon/internal/domain"
13+)
14+
15+// newTestUnsafeStore creates a UnsafeStore backed by a temp directory.
16+func newTestUnsafeStore(t *testing.T) *UnsafeStore {
17+ t.Helper()
18+ dir := t.TempDir()
19+ log := zerowrap.New(zerowrap.Config{Level: "disabled", Output: io.Discard})
20+ store, err := NewUnsafeStore(dir, log)
21+ if err != nil {
22+ t.Fatalf("NewUnsafeStore: %v", err)
23+ }
24+ return store
25+}
26+
27+func TestUnsafeStoreRevokeConcurrentNoLoss(t *testing.T) {
28+ // Run with: go test -race ./internal/adapters/out/tokenstore/... -run TestUnsafeStoreRevokeConcurrentNoLoss -v
29+ store := newTestUnsafeStore(t)
30+
31+ ctx := context.Background()
32+ const n = 10
33+
34+ // Save n tokens
35+ tokenIDs := make([]string, n)
36+ for i := 0; i < n; i++ {
37+ id := fmt.Sprintf("token-%d", i)
38+ tokenIDs[i] = id
39+ tok := &domain.Token{
40+ ID: id,
41+ Subject: fmt.Sprintf("user-%d", i),
42+ Scopes: []string{"admin"},
43+ }
44+ if err := store.SaveToken(ctx, tok, "jwt-"+id); err != nil {
45+ t.Fatal(err)
46+ }
47+ }
48+
49+ // Revoke all concurrently
50+ var wg sync.WaitGroup
51+ for _, id := range tokenIDs {
52+ wg.Add(1)
53+ go func(id string) {
54+ defer wg.Done()
55+ _ = store.Revoke(ctx, id)
56+ }(id)
57+ }
58+ wg.Wait()
59+
60+ // Verify all n token IDs are in the revoked list
61+ for _, id := range tokenIDs {
62+ revoked, err := store.IsRevoked(ctx, id)
63+ if err != nil {
64+ t.Errorf("IsRevoked(%q): %v", id, err)
65+ continue
66+ }
67+ if !revoked {
68+ t.Errorf("token %q should be revoked but IsRevoked returned false", id)
69+ }
70+ }
71+}

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