detached 70e008c

feat(adapters/tokenstore): add pass and unsafe backend implementations

Implement token storage backends:
- Pass backend using unix password store
- Unsafe backend for plain text storage (dev/testing)
- Factory function to create store based on config
commit
70e008cd5faaf72611b313819900a1abfac26015
author
brice · · 2026-01-12 21:04 UTC
committer
brice · · 2026-01-12 22:00 UTC
parent
ccd5bf8
signature
unsigned
3 files · +582 -0
A internal/adapters/out/tokenstore/pass.go +292 -0
@@ -0,0 +1,292 @@
1+// Package tokenstore implements token storage adapters.
2+package tokenstore
3+
4+import (
5+ "context"
6+ "encoding/json"
7+ "fmt"
8+ "os/exec"
9+ "strings"
10+ "time"
11+
12+ "github.com/bnema/zerowrap"
13+
14+ "gordon/internal/domain"
15+)
16+
17+const (
18+ // passTokenPath is the base path for tokens in pass.
19+ passTokenPath = "gordon/registry/tokens"
20+ // passRevokedPath is the path for the revocation list in pass.
21+ passRevokedPath = "gordon/registry/revoked"
22+)
23+
24+// PassStore implements TokenStore using the pass password manager.
25+type PassStore struct {
26+ timeout time.Duration
27+ log zerowrap.Logger
28+}
29+
30+// NewPassStore creates a new pass-based token store.
31+func NewPassStore(log zerowrap.Logger) *PassStore {
32+ return &PassStore{
33+ timeout: 10 * time.Second,
34+ log: log,
35+ }
36+}
37+
38+// tokenMetadata holds the non-sensitive token information.
39+type tokenMetadata struct {
40+ ID string `json:"id"`
41+ Subject string `json:"subject"`
42+ Scopes []string `json:"scopes"`
43+ IssuedAt time.Time `json:"issued_at"`
44+ ExpiresAt time.Time `json:"expires_at,omitempty"`
45+ Revoked bool `json:"revoked"`
46+}
47+
48+// SaveToken stores a token JWT and metadata in pass.
49+func (s *PassStore) SaveToken(ctx context.Context, token *domain.Token, jwt string) error {
50+ ctx, cancel := context.WithTimeout(ctx, s.timeout)
51+ defer cancel()
52+
53+ // Store JWT
54+ jwtPath := fmt.Sprintf("%s/%s", passTokenPath, token.Subject)
55+ if err := s.passInsert(ctx, jwtPath, jwt); err != nil {
56+ return fmt.Errorf("failed to store token: %w", err)
57+ }
58+
59+ // Store metadata
60+ meta := tokenMetadata{
61+ ID: token.ID,
62+ Subject: token.Subject,
63+ Scopes: token.Scopes,
64+ IssuedAt: token.IssuedAt,
65+ ExpiresAt: token.ExpiresAt,
66+ Revoked: token.Revoked,
67+ }
68+ metaJSON, err := json.Marshal(meta)
69+ if err != nil {
70+ return fmt.Errorf("failed to marshal token metadata: %w", err)
71+ }
72+
73+ metaPath := fmt.Sprintf("%s/%s.meta", passTokenPath, token.Subject)
74+ if err := s.passInsert(ctx, metaPath, string(metaJSON)); err != nil {
75+ return fmt.Errorf("failed to store token metadata: %w", err)
76+ }
77+
78+ s.log.Debug().
79+ Str(zerowrap.FieldLayer, "adapter").
80+ Str(zerowrap.FieldAdapter, "tokenstore").
81+ Str("provider", "pass").
82+ Str("subject", token.Subject).
83+ Msg("token stored in pass")
84+
85+ return nil
86+}
87+
88+// GetToken retrieves token JWT by subject from pass.
89+func (s *PassStore) GetToken(ctx context.Context, subject string) (string, *domain.Token, error) {
90+ ctx, cancel := context.WithTimeout(ctx, s.timeout)
91+ defer cancel()
92+
93+ // Get JWT
94+ jwtPath := fmt.Sprintf("%s/%s", passTokenPath, subject)
95+ jwt, err := s.passShow(ctx, jwtPath)
96+ if err != nil {
97+ return "", nil, domain.ErrTokenNotFound
98+ }
99+
100+ // Get metadata
101+ metaPath := fmt.Sprintf("%s/%s.meta", passTokenPath, subject)
102+ metaJSON, err := s.passShow(ctx, metaPath)
103+ if err != nil {
104+ return "", nil, fmt.Errorf("failed to get token metadata: %w", err)
105+ }
106+
107+ var meta tokenMetadata
108+ if err := json.Unmarshal([]byte(metaJSON), &meta); err != nil {
109+ return "", nil, fmt.Errorf("failed to unmarshal token metadata: %w", err)
110+ }
111+
112+ token := &domain.Token{
113+ ID: meta.ID,
114+ Subject: meta.Subject,
115+ Scopes: meta.Scopes,
116+ IssuedAt: meta.IssuedAt,
117+ ExpiresAt: meta.ExpiresAt,
118+ Revoked: meta.Revoked,
119+ }
120+
121+ return jwt, token, nil
122+}
123+
124+// ListTokens returns all stored tokens from pass.
125+func (s *PassStore) ListTokens(ctx context.Context) ([]domain.Token, error) {
126+ ctx, cancel := context.WithTimeout(ctx, s.timeout)
127+ defer cancel()
128+
129+ // List all entries under the token path
130+ cmd := exec.CommandContext(ctx, "pass", "ls", passTokenPath)
131+ output, err := cmd.Output()
132+ if err != nil {
133+ // If the path doesn't exist, return empty list
134+ return []domain.Token{}, nil
135+ }
136+
137+ var tokens []domain.Token
138+ lines := strings.Split(string(output), "\n")
139+
140+ for _, line := range lines {
141+ line = strings.TrimSpace(line)
142+ // Skip tree formatting characters and empty lines
143+ line = strings.TrimPrefix(line, "├── ")
144+ line = strings.TrimPrefix(line, "└── ")
145+ line = strings.TrimPrefix(line, "│ ")
146+
147+ // Skip .meta files and empty lines
148+ if line == "" || strings.HasSuffix(line, ".meta") {
149+ continue
150+ }
151+
152+ // Try to get the token metadata
153+ _, token, err := s.GetToken(ctx, line)
154+ if err != nil {
155+ s.log.Warn().Err(err).Str("subject", line).Msg("failed to get token")
156+ continue
157+ }
158+
159+ tokens = append(tokens, *token)
160+ }
161+
162+ return tokens, nil
163+}
164+
165+// Revoke adds token ID to revocation list in pass.
166+func (s *PassStore) Revoke(ctx context.Context, tokenID string) error {
167+ ctx, cancel := context.WithTimeout(ctx, s.timeout)
168+ defer cancel()
169+
170+ // Get current revocation list
171+ revokedList, err := s.getRevokedList(ctx)
172+ if err != nil {
173+ return err
174+ }
175+
176+ // Add to list if not already present
177+ for _, id := range revokedList {
178+ if id == tokenID {
179+ return nil // Already revoked
180+ }
181+ }
182+ revokedList = append(revokedList, tokenID)
183+
184+ // Store updated list
185+ listJSON, err := json.Marshal(revokedList)
186+ if err != nil {
187+ return fmt.Errorf("failed to marshal revocation list: %w", err)
188+ }
189+
190+ if err := s.passInsert(ctx, passRevokedPath, string(listJSON)); err != nil {
191+ return fmt.Errorf("failed to store revocation list: %w", err)
192+ }
193+
194+ s.log.Info().
195+ Str(zerowrap.FieldLayer, "adapter").
196+ Str(zerowrap.FieldAdapter, "tokenstore").
197+ Str("token_id", tokenID).
198+ Msg("token revoked")
199+
200+ return nil
201+}
202+
203+// IsRevoked checks if token ID is in revocation list.
204+func (s *PassStore) IsRevoked(ctx context.Context, tokenID string) (bool, error) {
205+ ctx, cancel := context.WithTimeout(ctx, s.timeout)
206+ defer cancel()
207+
208+ revokedList, err := s.getRevokedList(ctx)
209+ if err != nil {
210+ return false, err
211+ }
212+
213+ for _, id := range revokedList {
214+ if id == tokenID {
215+ return true, nil
216+ }
217+ }
218+
219+ return false, nil
220+}
221+
222+// DeleteToken removes token from pass.
223+func (s *PassStore) DeleteToken(ctx context.Context, subject string) error {
224+ ctx, cancel := context.WithTimeout(ctx, s.timeout)
225+ defer cancel()
226+
227+ jwtPath := fmt.Sprintf("%s/%s", passTokenPath, subject)
228+ metaPath := fmt.Sprintf("%s/%s.meta", passTokenPath, subject)
229+
230+ // Remove JWT
231+ cmd := exec.CommandContext(ctx, "pass", "rm", "-f", jwtPath)
232+ if err := cmd.Run(); err != nil {
233+ return fmt.Errorf("failed to delete token: %w", err)
234+ }
235+
236+ // Remove metadata
237+ cmd = exec.CommandContext(ctx, "pass", "rm", "-f", metaPath)
238+ if err := cmd.Run(); err != nil {
239+ s.log.Warn().Err(err).Msg("failed to delete token metadata")
240+ }
241+
242+ s.log.Debug().
243+ Str(zerowrap.FieldLayer, "adapter").
244+ Str(zerowrap.FieldAdapter, "tokenstore").
245+ Str("subject", subject).
246+ Msg("token deleted from pass")
247+
248+ return nil
249+}
250+
251+// passInsert inserts a value into pass.
252+func (s *PassStore) passInsert(ctx context.Context, path, value string) error {
253+ cmd := exec.CommandContext(ctx, "pass", "insert", "-m", "-f", path)
254+ cmd.Stdin = strings.NewReader(value)
255+ output, err := cmd.CombinedOutput()
256+ if err != nil {
257+ return fmt.Errorf("pass insert failed: %s: %w", string(output), err)
258+ }
259+ return nil
260+}
261+
262+// passShow retrieves a value from pass.
263+func (s *PassStore) passShow(ctx context.Context, path string) (string, error) {
264+ cmd := exec.CommandContext(ctx, "pass", "show", path)
265+ output, err := cmd.Output()
266+ if err != nil {
267+ return "", fmt.Errorf("pass show failed: %w", err)
268+ }
269+ return strings.TrimSpace(string(output)), nil
270+}
271+
272+// getRevokedList retrieves the current revocation list.
273+func (s *PassStore) getRevokedList(ctx context.Context) ([]string, error) {
274+ listJSON, err := s.passShow(ctx, passRevokedPath)
275+ if err != nil {
276+ // If the list doesn't exist, return empty list
277+ return []string{}, nil
278+ }
279+
280+ var revokedList []string
281+ if err := json.Unmarshal([]byte(listJSON), &revokedList); err != nil {
282+ return nil, fmt.Errorf("failed to unmarshal revocation list: %w", err)
283+ }
284+
285+ return revokedList, nil
286+}
287+
288+// IsAvailable checks if pass is available in the system.
289+func (s *PassStore) IsAvailable() bool {
290+ cmd := exec.Command("pass", "version")
291+ return cmd.Run() == nil
292+}
@@ -0,0 +1,292 @@
1+// Package tokenstore implements token storage adapters.
2+package tokenstore
3+
4+import (
5+ "context"
6+ "encoding/json"
7+ "fmt"
8+ "os/exec"
9+ "strings"
10+ "time"
11+
12+ "github.com/bnema/zerowrap"
13+
14+ "gordon/internal/domain"
15+)
16+
17+const (
18+ // passTokenPath is the base path for tokens in pass.
19+ passTokenPath = "gordon/registry/tokens"
20+ // passRevokedPath is the path for the revocation list in pass.
21+ passRevokedPath = "gordon/registry/revoked"
22+)
23+
24+// PassStore implements TokenStore using the pass password manager.
25+type PassStore struct {
26+ timeout time.Duration
27+ log zerowrap.Logger
28+}
29+
30+// NewPassStore creates a new pass-based token store.
31+func NewPassStore(log zerowrap.Logger) *PassStore {
32+ return &PassStore{
33+ timeout: 10 * time.Second,
34+ log: log,
35+ }
36+}
37+
38+// tokenMetadata holds the non-sensitive token information.
39+type tokenMetadata struct {
40+ ID string `json:"id"`
41+ Subject string `json:"subject"`
42+ Scopes []string `json:"scopes"`
43+ IssuedAt time.Time `json:"issued_at"`
44+ ExpiresAt time.Time `json:"expires_at,omitempty"`
45+ Revoked bool `json:"revoked"`
46+}
47+
48+// SaveToken stores a token JWT and metadata in pass.
49+func (s *PassStore) SaveToken(ctx context.Context, token *domain.Token, jwt string) error {
50+ ctx, cancel := context.WithTimeout(ctx, s.timeout)
51+ defer cancel()
52+
53+ // Store JWT
54+ jwtPath := fmt.Sprintf("%s/%s", passTokenPath, token.Subject)
55+ if err := s.passInsert(ctx, jwtPath, jwt); err != nil {
56+ return fmt.Errorf("failed to store token: %w", err)
57+ }
58+
59+ // Store metadata
60+ meta := tokenMetadata{
61+ ID: token.ID,
62+ Subject: token.Subject,
63+ Scopes: token.Scopes,
64+ IssuedAt: token.IssuedAt,
65+ ExpiresAt: token.ExpiresAt,
66+ Revoked: token.Revoked,
67+ }
68+ metaJSON, err := json.Marshal(meta)
69+ if err != nil {
70+ return fmt.Errorf("failed to marshal token metadata: %w", err)
71+ }
72+
73+ metaPath := fmt.Sprintf("%s/%s.meta", passTokenPath, token.Subject)
74+ if err := s.passInsert(ctx, metaPath, string(metaJSON)); err != nil {
75+ return fmt.Errorf("failed to store token metadata: %w", err)
76+ }
77+
78+ s.log.Debug().
79+ Str(zerowrap.FieldLayer, "adapter").
80+ Str(zerowrap.FieldAdapter, "tokenstore").
81+ Str("provider", "pass").
82+ Str("subject", token.Subject).
83+ Msg("token stored in pass")
84+
85+ return nil
86+}
87+
88+// GetToken retrieves token JWT by subject from pass.
89+func (s *PassStore) GetToken(ctx context.Context, subject string) (string, *domain.Token, error) {
90+ ctx, cancel := context.WithTimeout(ctx, s.timeout)
91+ defer cancel()
92+
93+ // Get JWT
94+ jwtPath := fmt.Sprintf("%s/%s", passTokenPath, subject)
95+ jwt, err := s.passShow(ctx, jwtPath)
96+ if err != nil {
97+ return "", nil, domain.ErrTokenNotFound
98+ }
99+
100+ // Get metadata
101+ metaPath := fmt.Sprintf("%s/%s.meta", passTokenPath, subject)
102+ metaJSON, err := s.passShow(ctx, metaPath)
103+ if err != nil {
104+ return "", nil, fmt.Errorf("failed to get token metadata: %w", err)
105+ }
106+
107+ var meta tokenMetadata
108+ if err := json.Unmarshal([]byte(metaJSON), &meta); err != nil {
109+ return "", nil, fmt.Errorf("failed to unmarshal token metadata: %w", err)
110+ }
111+
112+ token := &domain.Token{
113+ ID: meta.ID,
114+ Subject: meta.Subject,
115+ Scopes: meta.Scopes,
116+ IssuedAt: meta.IssuedAt,
117+ ExpiresAt: meta.ExpiresAt,
118+ Revoked: meta.Revoked,
119+ }
120+
121+ return jwt, token, nil
122+}
123+
124+// ListTokens returns all stored tokens from pass.
125+func (s *PassStore) ListTokens(ctx context.Context) ([]domain.Token, error) {
126+ ctx, cancel := context.WithTimeout(ctx, s.timeout)
127+ defer cancel()
128+
129+ // List all entries under the token path
130+ cmd := exec.CommandContext(ctx, "pass", "ls", passTokenPath)
131+ output, err := cmd.Output()
132+ if err != nil {
133+ // If the path doesn't exist, return empty list
134+ return []domain.Token{}, nil
135+ }
136+
137+ var tokens []domain.Token
138+ lines := strings.Split(string(output), "\n")
139+
140+ for _, line := range lines {
141+ line = strings.TrimSpace(line)
142+ // Skip tree formatting characters and empty lines
143+ line = strings.TrimPrefix(line, "├── ")
144+ line = strings.TrimPrefix(line, "└── ")
145+ line = strings.TrimPrefix(line, "│ ")
146+
147+ // Skip .meta files and empty lines
148+ if line == "" || strings.HasSuffix(line, ".meta") {
149+ continue
150+ }
151+
152+ // Try to get the token metadata
153+ _, token, err := s.GetToken(ctx, line)
154+ if err != nil {
155+ s.log.Warn().Err(err).Str("subject", line).Msg("failed to get token")
156+ continue
157+ }
158+
159+ tokens = append(tokens, *token)
160+ }
161+
162+ return tokens, nil
163+}
164+
165+// Revoke adds token ID to revocation list in pass.
166+func (s *PassStore) Revoke(ctx context.Context, tokenID string) error {
167+ ctx, cancel := context.WithTimeout(ctx, s.timeout)
168+ defer cancel()
169+
170+ // Get current revocation list
171+ revokedList, err := s.getRevokedList(ctx)
172+ if err != nil {
173+ return err
174+ }
175+
176+ // Add to list if not already present
177+ for _, id := range revokedList {
178+ if id == tokenID {
179+ return nil // Already revoked
180+ }
181+ }
182+ revokedList = append(revokedList, tokenID)
183+
184+ // Store updated list
185+ listJSON, err := json.Marshal(revokedList)
186+ if err != nil {
187+ return fmt.Errorf("failed to marshal revocation list: %w", err)
188+ }
189+
190+ if err := s.passInsert(ctx, passRevokedPath, string(listJSON)); err != nil {
191+ return fmt.Errorf("failed to store revocation list: %w", err)
192+ }
193+
194+ s.log.Info().
195+ Str(zerowrap.FieldLayer, "adapter").
196+ Str(zerowrap.FieldAdapter, "tokenstore").
197+ Str("token_id", tokenID).
198+ Msg("token revoked")
199+
200+ return nil
201+}
202+
203+// IsRevoked checks if token ID is in revocation list.
204+func (s *PassStore) IsRevoked(ctx context.Context, tokenID string) (bool, error) {
205+ ctx, cancel := context.WithTimeout(ctx, s.timeout)
206+ defer cancel()
207+
208+ revokedList, err := s.getRevokedList(ctx)
209+ if err != nil {
210+ return false, err
211+ }
212+
213+ for _, id := range revokedList {
214+ if id == tokenID {
215+ return true, nil
216+ }
217+ }
218+
219+ return false, nil
220+}
221+
222+// DeleteToken removes token from pass.
223+func (s *PassStore) DeleteToken(ctx context.Context, subject string) error {
224+ ctx, cancel := context.WithTimeout(ctx, s.timeout)
225+ defer cancel()
226+
227+ jwtPath := fmt.Sprintf("%s/%s", passTokenPath, subject)
228+ metaPath := fmt.Sprintf("%s/%s.meta", passTokenPath, subject)
229+
230+ // Remove JWT
231+ cmd := exec.CommandContext(ctx, "pass", "rm", "-f", jwtPath)
232+ if err := cmd.Run(); err != nil {
233+ return fmt.Errorf("failed to delete token: %w", err)
234+ }
235+
236+ // Remove metadata
237+ cmd = exec.CommandContext(ctx, "pass", "rm", "-f", metaPath)
238+ if err := cmd.Run(); err != nil {
239+ s.log.Warn().Err(err).Msg("failed to delete token metadata")
240+ }
241+
242+ s.log.Debug().
243+ Str(zerowrap.FieldLayer, "adapter").
244+ Str(zerowrap.FieldAdapter, "tokenstore").
245+ Str("subject", subject).
246+ Msg("token deleted from pass")
247+
248+ return nil
249+}
250+
251+// passInsert inserts a value into pass.
252+func (s *PassStore) passInsert(ctx context.Context, path, value string) error {
253+ cmd := exec.CommandContext(ctx, "pass", "insert", "-m", "-f", path)
254+ cmd.Stdin = strings.NewReader(value)
255+ output, err := cmd.CombinedOutput()
256+ if err != nil {
257+ return fmt.Errorf("pass insert failed: %s: %w", string(output), err)
258+ }
259+ return nil
260+}
261+
262+// passShow retrieves a value from pass.
263+func (s *PassStore) passShow(ctx context.Context, path string) (string, error) {
264+ cmd := exec.CommandContext(ctx, "pass", "show", path)
265+ output, err := cmd.Output()
266+ if err != nil {
267+ return "", fmt.Errorf("pass show failed: %w", err)
268+ }
269+ return strings.TrimSpace(string(output)), nil
270+}
271+
272+// getRevokedList retrieves the current revocation list.
273+func (s *PassStore) getRevokedList(ctx context.Context) ([]string, error) {
274+ listJSON, err := s.passShow(ctx, passRevokedPath)
275+ if err != nil {
276+ // If the list doesn't exist, return empty list
277+ return []string{}, nil
278+ }
279+
280+ var revokedList []string
281+ if err := json.Unmarshal([]byte(listJSON), &revokedList); err != nil {
282+ return nil, fmt.Errorf("failed to unmarshal revocation list: %w", err)
283+ }
284+
285+ return revokedList, nil
286+}
287+
288+// IsAvailable checks if pass is available in the system.
289+func (s *PassStore) IsAvailable() bool {
290+ cmd := exec.Command("pass", "version")
291+ return cmd.Run() == nil
292+}
A internal/adapters/out/tokenstore/store.go +36 -0
@@ -0,0 +1,36 @@
1+package tokenstore
2+
3+import (
4+ "fmt"
5+
6+ "github.com/bnema/zerowrap"
7+
8+ "gordon/internal/boundaries/out"
9+ "gordon/internal/domain"
10+)
11+
12+// NewStore creates a TokenStore based on the configured backend.
13+func NewStore(backend domain.SecretsBackend, dataDir string, log zerowrap.Logger) (out.TokenStore, error) {
14+ switch backend {
15+ case domain.SecretsBackendPass:
16+ store := NewPassStore(log)
17+ if !store.IsAvailable() {
18+ return nil, fmt.Errorf("pass is not available in the system")
19+ }
20+ return store, nil
21+
22+ case domain.SecretsBackendSops:
23+ // SOPS implementation would go here
24+ // For now, return an error indicating it's not yet implemented
25+ return nil, fmt.Errorf("sops backend is not yet implemented")
26+
27+ case domain.SecretsBackendUnsafe:
28+ if dataDir == "" {
29+ return nil, fmt.Errorf("data_dir is required for unsafe backend")
30+ }
31+ return NewUnsafeStore(dataDir, log), nil
32+
33+ default:
34+ return nil, fmt.Errorf("unknown secrets backend: %s", backend)
35+ }
36+}
@@ -0,0 +1,36 @@
1+package tokenstore
2+
3+import (
4+ "fmt"
5+
6+ "github.com/bnema/zerowrap"
7+
8+ "gordon/internal/boundaries/out"
9+ "gordon/internal/domain"
10+)
11+
12+// NewStore creates a TokenStore based on the configured backend.
13+func NewStore(backend domain.SecretsBackend, dataDir string, log zerowrap.Logger) (out.TokenStore, error) {
14+ switch backend {
15+ case domain.SecretsBackendPass:
16+ store := NewPassStore(log)
17+ if !store.IsAvailable() {
18+ return nil, fmt.Errorf("pass is not available in the system")
19+ }
20+ return store, nil
21+
22+ case domain.SecretsBackendSops:
23+ // SOPS implementation would go here
24+ // For now, return an error indicating it's not yet implemented
25+ return nil, fmt.Errorf("sops backend is not yet implemented")
26+
27+ case domain.SecretsBackendUnsafe:
28+ if dataDir == "" {
29+ return nil, fmt.Errorf("data_dir is required for unsafe backend")
30+ }
31+ return NewUnsafeStore(dataDir, log), nil
32+
33+ default:
34+ return nil, fmt.Errorf("unknown secrets backend: %s", backend)
35+ }
36+}
A internal/adapters/out/tokenstore/unsafe.go +254 -0
@@ -0,0 +1,254 @@
1+package tokenstore
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "fmt"
7+ "os"
8+ "path/filepath"
9+ "strings"
10+
11+ "github.com/bnema/zerowrap"
12+
13+ "gordon/internal/domain"
14+)
15+
16+const (
17+ // unsafeTokenDir is the subdirectory for tokens in the data directory.
18+ unsafeTokenDir = "secrets/gordon/registry/tokens"
19+ // unsafeRevokedFile is the filename for the revocation list.
20+ unsafeRevokedFile = "secrets/gordon/registry/revoked.json"
21+)
22+
23+// UnsafeStore implements TokenStore using plain text files.
24+// WARNING: This store does not encrypt secrets. Only use when pass/sops are unavailable.
25+type UnsafeStore struct {
26+ dataDir string
27+ log zerowrap.Logger
28+}
29+
30+// NewUnsafeStore creates a new file-based token store.
31+// dataDir is the base directory for storing secrets (typically gordon's data_dir).
32+func NewUnsafeStore(dataDir string, log zerowrap.Logger) *UnsafeStore {
33+ store := &UnsafeStore{
34+ dataDir: dataDir,
35+ log: log,
36+ }
37+
38+ log.Warn().
39+ Str(zerowrap.FieldLayer, "adapter").
40+ Str(zerowrap.FieldAdapter, "tokenstore").
41+ Str("provider", "unsafe").
42+ Str("data_dir", dataDir).
43+ Msg("using unsafe secrets backend - secrets are stored in plain text")
44+
45+ return store
46+}
47+
48+// unsafeTokenData holds both JWT and metadata in a single file.
49+type unsafeTokenData struct {
50+ JWT string `json:"jwt"`
51+ Metadata tokenMetadata `json:"metadata"`
52+}
53+
54+// SaveToken stores a token JWT and metadata as a JSON file.
55+func (s *UnsafeStore) SaveToken(_ context.Context, token *domain.Token, jwt string) error {
56+ tokenDir := filepath.Join(s.dataDir, unsafeTokenDir)
57+ if err := os.MkdirAll(tokenDir, 0700); err != nil {
58+ return fmt.Errorf("failed to create token directory: %w", err)
59+ }
60+
61+ data := unsafeTokenData{
62+ JWT: jwt,
63+ Metadata: tokenMetadata{
64+ ID: token.ID,
65+ Subject: token.Subject,
66+ Scopes: token.Scopes,
67+ IssuedAt: token.IssuedAt,
68+ ExpiresAt: token.ExpiresAt,
69+ Revoked: token.Revoked,
70+ },
71+ }
72+
73+ dataJSON, err := json.MarshalIndent(data, "", " ")
74+ if err != nil {
75+ return fmt.Errorf("failed to marshal token data: %w", err)
76+ }
77+
78+ tokenFile := filepath.Join(tokenDir, token.Subject+".json")
79+ if err := os.WriteFile(tokenFile, dataJSON, 0600); err != nil {
80+ return fmt.Errorf("failed to write token file: %w", err)
81+ }
82+
83+ s.log.Debug().
84+ Str(zerowrap.FieldLayer, "adapter").
85+ Str(zerowrap.FieldAdapter, "tokenstore").
86+ Str("provider", "unsafe").
87+ Str("subject", token.Subject).
88+ Msg("token stored in file")
89+
90+ return nil
91+}
92+
93+// GetToken retrieves token JWT by subject from file.
94+func (s *UnsafeStore) GetToken(_ context.Context, subject string) (string, *domain.Token, error) {
95+ tokenFile := filepath.Join(s.dataDir, unsafeTokenDir, subject+".json")
96+
97+ dataJSON, err := os.ReadFile(tokenFile)
98+ if err != nil {
99+ if os.IsNotExist(err) {
100+ return "", nil, domain.ErrTokenNotFound
101+ }
102+ return "", nil, fmt.Errorf("failed to read token file: %w", err)
103+ }
104+
105+ var data unsafeTokenData
106+ if err := json.Unmarshal(dataJSON, &data); err != nil {
107+ return "", nil, fmt.Errorf("failed to unmarshal token data: %w", err)
108+ }
109+
110+ token := &domain.Token{
111+ ID: data.Metadata.ID,
112+ Subject: data.Metadata.Subject,
113+ Scopes: data.Metadata.Scopes,
114+ IssuedAt: data.Metadata.IssuedAt,
115+ ExpiresAt: data.Metadata.ExpiresAt,
116+ Revoked: data.Metadata.Revoked,
117+ }
118+
119+ return data.JWT, token, nil
120+}
121+
122+// ListTokens returns all stored tokens from files.
123+func (s *UnsafeStore) ListTokens(ctx context.Context) ([]domain.Token, error) {
124+ tokenDir := filepath.Join(s.dataDir, unsafeTokenDir)
125+
126+ entries, err := os.ReadDir(tokenDir)
127+ if err != nil {
128+ if os.IsNotExist(err) {
129+ return []domain.Token{}, nil
130+ }
131+ return nil, fmt.Errorf("failed to read token directory: %w", err)
132+ }
133+
134+ var tokens []domain.Token
135+ for _, entry := range entries {
136+ if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") {
137+ continue
138+ }
139+
140+ subject := strings.TrimSuffix(entry.Name(), ".json")
141+ _, token, err := s.GetToken(ctx, subject)
142+ if err != nil {
143+ s.log.Warn().Err(err).Str("subject", subject).Msg("failed to get token")
144+ continue
145+ }
146+
147+ tokens = append(tokens, *token)
148+ }
149+
150+ return tokens, nil
151+}
152+
153+// Revoke adds token ID to revocation list file.
154+func (s *UnsafeStore) Revoke(_ context.Context, tokenID string) error {
155+ revokedFile := filepath.Join(s.dataDir, unsafeRevokedFile)
156+
157+ // Ensure parent directory exists
158+ if err := os.MkdirAll(filepath.Dir(revokedFile), 0700); err != nil {
159+ return fmt.Errorf("failed to create revoked directory: %w", err)
160+ }
161+
162+ // Get current list
163+ revokedList, err := s.getRevokedList()
164+ if err != nil {
165+ return err
166+ }
167+
168+ // Add to list if not already present
169+ for _, id := range revokedList {
170+ if id == tokenID {
171+ return nil // Already revoked
172+ }
173+ }
174+ revokedList = append(revokedList, tokenID)
175+
176+ // Store updated list
177+ listJSON, err := json.MarshalIndent(revokedList, "", " ")
178+ if err != nil {
179+ return fmt.Errorf("failed to marshal revocation list: %w", err)
180+ }
181+
182+ if err := os.WriteFile(revokedFile, listJSON, 0600); err != nil {
183+ return fmt.Errorf("failed to write revocation list: %w", err)
184+ }
185+
186+ s.log.Info().
187+ Str(zerowrap.FieldLayer, "adapter").
188+ Str(zerowrap.FieldAdapter, "tokenstore").
189+ Str("token_id", tokenID).
190+ Msg("token revoked")
191+
192+ return nil
193+}
194+
195+// IsRevoked checks if token ID is in revocation list.
196+func (s *UnsafeStore) IsRevoked(_ context.Context, tokenID string) (bool, error) {
197+ revokedList, err := s.getRevokedList()
198+ if err != nil {
199+ return false, err
200+ }
201+
202+ for _, id := range revokedList {
203+ if id == tokenID {
204+ return true, nil
205+ }
206+ }
207+
208+ return false, nil
209+}
210+
211+// DeleteToken removes token file.
212+func (s *UnsafeStore) DeleteToken(_ context.Context, subject string) error {
213+ tokenFile := filepath.Join(s.dataDir, unsafeTokenDir, subject+".json")
214+
215+ if err := os.Remove(tokenFile); err != nil {
216+ if os.IsNotExist(err) {
217+ return domain.ErrTokenNotFound
218+ }
219+ return fmt.Errorf("failed to delete token file: %w", err)
220+ }
221+
222+ s.log.Debug().
223+ Str(zerowrap.FieldLayer, "adapter").
224+ Str(zerowrap.FieldAdapter, "tokenstore").
225+ Str("subject", subject).
226+ Msg("token deleted from file")
227+
228+ return nil
229+}
230+
231+// getRevokedList retrieves the current revocation list from file.
232+func (s *UnsafeStore) getRevokedList() ([]string, error) {
233+ revokedFile := filepath.Join(s.dataDir, unsafeRevokedFile)
234+
235+ listJSON, err := os.ReadFile(revokedFile)
236+ if err != nil {
237+ if os.IsNotExist(err) {
238+ return []string{}, nil
239+ }
240+ return nil, fmt.Errorf("failed to read revocation list: %w", err)
241+ }
242+
243+ var revokedList []string
244+ if err := json.Unmarshal(listJSON, &revokedList); err != nil {
245+ return nil, fmt.Errorf("failed to unmarshal revocation list: %w", err)
246+ }
247+
248+ return revokedList, nil
249+}
250+
251+// IsAvailable always returns true for file-based storage.
252+func (s *UnsafeStore) IsAvailable() bool {
253+ return true
254+}
@@ -0,0 +1,254 @@
1+package tokenstore
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "fmt"
7+ "os"
8+ "path/filepath"
9+ "strings"
10+
11+ "github.com/bnema/zerowrap"
12+
13+ "gordon/internal/domain"
14+)
15+
16+const (
17+ // unsafeTokenDir is the subdirectory for tokens in the data directory.
18+ unsafeTokenDir = "secrets/gordon/registry/tokens"
19+ // unsafeRevokedFile is the filename for the revocation list.
20+ unsafeRevokedFile = "secrets/gordon/registry/revoked.json"
21+)
22+
23+// UnsafeStore implements TokenStore using plain text files.
24+// WARNING: This store does not encrypt secrets. Only use when pass/sops are unavailable.
25+type UnsafeStore struct {
26+ dataDir string
27+ log zerowrap.Logger
28+}
29+
30+// NewUnsafeStore creates a new file-based token store.
31+// dataDir is the base directory for storing secrets (typically gordon's data_dir).
32+func NewUnsafeStore(dataDir string, log zerowrap.Logger) *UnsafeStore {
33+ store := &UnsafeStore{
34+ dataDir: dataDir,
35+ log: log,
36+ }
37+
38+ log.Warn().
39+ Str(zerowrap.FieldLayer, "adapter").
40+ Str(zerowrap.FieldAdapter, "tokenstore").
41+ Str("provider", "unsafe").
42+ Str("data_dir", dataDir).
43+ Msg("using unsafe secrets backend - secrets are stored in plain text")
44+
45+ return store
46+}
47+
48+// unsafeTokenData holds both JWT and metadata in a single file.
49+type unsafeTokenData struct {
50+ JWT string `json:"jwt"`
51+ Metadata tokenMetadata `json:"metadata"`
52+}
53+
54+// SaveToken stores a token JWT and metadata as a JSON file.
55+func (s *UnsafeStore) SaveToken(_ context.Context, token *domain.Token, jwt string) error {
56+ tokenDir := filepath.Join(s.dataDir, unsafeTokenDir)
57+ if err := os.MkdirAll(tokenDir, 0700); err != nil {
58+ return fmt.Errorf("failed to create token directory: %w", err)
59+ }
60+
61+ data := unsafeTokenData{
62+ JWT: jwt,
63+ Metadata: tokenMetadata{
64+ ID: token.ID,
65+ Subject: token.Subject,
66+ Scopes: token.Scopes,
67+ IssuedAt: token.IssuedAt,
68+ ExpiresAt: token.ExpiresAt,
69+ Revoked: token.Revoked,
70+ },
71+ }
72+
73+ dataJSON, err := json.MarshalIndent(data, "", " ")
74+ if err != nil {
75+ return fmt.Errorf("failed to marshal token data: %w", err)
76+ }
77+
78+ tokenFile := filepath.Join(tokenDir, token.Subject+".json")
79+ if err := os.WriteFile(tokenFile, dataJSON, 0600); err != nil {
80+ return fmt.Errorf("failed to write token file: %w", err)
81+ }
82+
83+ s.log.Debug().
84+ Str(zerowrap.FieldLayer, "adapter").
85+ Str(zerowrap.FieldAdapter, "tokenstore").
86+ Str("provider", "unsafe").
87+ Str("subject", token.Subject).
88+ Msg("token stored in file")
89+
90+ return nil
91+}
92+
93+// GetToken retrieves token JWT by subject from file.
94+func (s *UnsafeStore) GetToken(_ context.Context, subject string) (string, *domain.Token, error) {
95+ tokenFile := filepath.Join(s.dataDir, unsafeTokenDir, subject+".json")
96+
97+ dataJSON, err := os.ReadFile(tokenFile)
98+ if err != nil {
99+ if os.IsNotExist(err) {
100+ return "", nil, domain.ErrTokenNotFound
101+ }
102+ return "", nil, fmt.Errorf("failed to read token file: %w", err)
103+ }
104+
105+ var data unsafeTokenData
106+ if err := json.Unmarshal(dataJSON, &data); err != nil {
107+ return "", nil, fmt.Errorf("failed to unmarshal token data: %w", err)
108+ }
109+
110+ token := &domain.Token{
111+ ID: data.Metadata.ID,
112+ Subject: data.Metadata.Subject,
113+ Scopes: data.Metadata.Scopes,
114+ IssuedAt: data.Metadata.IssuedAt,
115+ ExpiresAt: data.Metadata.ExpiresAt,
116+ Revoked: data.Metadata.Revoked,
117+ }
118+
119+ return data.JWT, token, nil
120+}
121+
122+// ListTokens returns all stored tokens from files.
123+func (s *UnsafeStore) ListTokens(ctx context.Context) ([]domain.Token, error) {
124+ tokenDir := filepath.Join(s.dataDir, unsafeTokenDir)
125+
126+ entries, err := os.ReadDir(tokenDir)
127+ if err != nil {
128+ if os.IsNotExist(err) {
129+ return []domain.Token{}, nil
130+ }
131+ return nil, fmt.Errorf("failed to read token directory: %w", err)
132+ }
133+
134+ var tokens []domain.Token
135+ for _, entry := range entries {
136+ if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") {
137+ continue
138+ }
139+
140+ subject := strings.TrimSuffix(entry.Name(), ".json")
141+ _, token, err := s.GetToken(ctx, subject)
142+ if err != nil {
143+ s.log.Warn().Err(err).Str("subject", subject).Msg("failed to get token")
144+ continue
145+ }
146+
147+ tokens = append(tokens, *token)
148+ }
149+
150+ return tokens, nil
151+}
152+
153+// Revoke adds token ID to revocation list file.
154+func (s *UnsafeStore) Revoke(_ context.Context, tokenID string) error {
155+ revokedFile := filepath.Join(s.dataDir, unsafeRevokedFile)
156+
157+ // Ensure parent directory exists
158+ if err := os.MkdirAll(filepath.Dir(revokedFile), 0700); err != nil {
159+ return fmt.Errorf("failed to create revoked directory: %w", err)
160+ }
161+
162+ // Get current list
163+ revokedList, err := s.getRevokedList()
164+ if err != nil {
165+ return err
166+ }
167+
168+ // Add to list if not already present
169+ for _, id := range revokedList {
170+ if id == tokenID {
171+ return nil // Already revoked
172+ }
173+ }
174+ revokedList = append(revokedList, tokenID)
175+
176+ // Store updated list
177+ listJSON, err := json.MarshalIndent(revokedList, "", " ")
178+ if err != nil {
179+ return fmt.Errorf("failed to marshal revocation list: %w", err)
180+ }
181+
182+ if err := os.WriteFile(revokedFile, listJSON, 0600); err != nil {
183+ return fmt.Errorf("failed to write revocation list: %w", err)
184+ }
185+
186+ s.log.Info().
187+ Str(zerowrap.FieldLayer, "adapter").
188+ Str(zerowrap.FieldAdapter, "tokenstore").
189+ Str("token_id", tokenID).
190+ Msg("token revoked")
191+
192+ return nil
193+}
194+
195+// IsRevoked checks if token ID is in revocation list.
196+func (s *UnsafeStore) IsRevoked(_ context.Context, tokenID string) (bool, error) {
197+ revokedList, err := s.getRevokedList()
198+ if err != nil {
199+ return false, err
200+ }
201+
202+ for _, id := range revokedList {
203+ if id == tokenID {
204+ return true, nil
205+ }
206+ }
207+
208+ return false, nil
209+}
210+
211+// DeleteToken removes token file.
212+func (s *UnsafeStore) DeleteToken(_ context.Context, subject string) error {
213+ tokenFile := filepath.Join(s.dataDir, unsafeTokenDir, subject+".json")
214+
215+ if err := os.Remove(tokenFile); err != nil {
216+ if os.IsNotExist(err) {
217+ return domain.ErrTokenNotFound
218+ }
219+ return fmt.Errorf("failed to delete token file: %w", err)
220+ }
221+
222+ s.log.Debug().
223+ Str(zerowrap.FieldLayer, "adapter").
224+ Str(zerowrap.FieldAdapter, "tokenstore").
225+ Str("subject", subject).
226+ Msg("token deleted from file")
227+
228+ return nil
229+}
230+
231+// getRevokedList retrieves the current revocation list from file.
232+func (s *UnsafeStore) getRevokedList() ([]string, error) {
233+ revokedFile := filepath.Join(s.dataDir, unsafeRevokedFile)
234+
235+ listJSON, err := os.ReadFile(revokedFile)
236+ if err != nil {
237+ if os.IsNotExist(err) {
238+ return []string{}, nil
239+ }
240+ return nil, fmt.Errorf("failed to read revocation list: %w", err)
241+ }
242+
243+ var revokedList []string
244+ if err := json.Unmarshal(listJSON, &revokedList); err != nil {
245+ return nil, fmt.Errorf("failed to unmarshal revocation list: %w", err)
246+ }
247+
248+ return revokedList, nil
249+}
250+
251+// IsAvailable always returns true for file-based storage.
252+func (s *UnsafeStore) IsAvailable() bool {
253+ return true
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