1// Package tokenstore implements token storage adapters.
2package tokenstore
3
4import (
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
17const (
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.
25type PassStore struct {
26 timeout time.Duration
27 log zerowrap.Logger
28}
29
30// NewPassStore creates a new pass-based token store.
31func 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.
39type 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.
49func (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.
89func (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.
125func (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.
166func (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.
204func (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.
223func (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 // #nosec G204 -- command and args are fixed; subject is treated as data only.
232 cmd := exec.CommandContext(ctx, "pass", "rm", "-f", jwtPath)
233 if err := cmd.Run(); err != nil {
234 return fmt.Errorf("failed to delete token: %w", err)
235 }
236
237 // Remove metadata
238 // #nosec G204 -- command and args are fixed; subject is treated as data only.
239 cmd = exec.CommandContext(ctx, "pass", "rm", "-f", metaPath)
240 if err := cmd.Run(); err != nil {
241 s.log.Warn().Err(err).Msg("failed to delete token metadata")
242 }
243
244 s.log.Debug().
245 Str(zerowrap.FieldLayer, "adapter").
246 Str(zerowrap.FieldAdapter, "tokenstore").
247 Str("subject", subject).
248 Msg("token deleted from pass")
249
250 return nil
251}
252
253// passInsert inserts a value into pass.
254func (s *PassStore) passInsert(ctx context.Context, path, value string) error {
255 cmd := exec.CommandContext(ctx, "pass", "insert", "-m", "-f", path)
256 cmd.Stdin = strings.NewReader(value)
257 output, err := cmd.CombinedOutput()
258 if err != nil {
259 return fmt.Errorf("pass insert failed: %s: %w", string(output), err)
260 }
261 return nil
262}
263
264// passShow retrieves a value from pass.
265func (s *PassStore) passShow(ctx context.Context, path string) (string, error) {
266 cmd := exec.CommandContext(ctx, "pass", "show", path)
267 output, err := cmd.Output()
268 if err != nil {
269 return "", fmt.Errorf("pass show failed: %w", err)
270 }
271 return strings.TrimSpace(string(output)), nil
272}
273
274// getRevokedList retrieves the current revocation list.
275func (s *PassStore) getRevokedList(ctx context.Context) ([]string, error) {
276 listJSON, err := s.passShow(ctx, passRevokedPath)
277 if err != nil {
278 // If the list doesn't exist, return empty list
279 return []string{}, nil
280 }
281
282 var revokedList []string
283 if err := json.Unmarshal([]byte(listJSON), &revokedList); err != nil {
284 return nil, fmt.Errorf("failed to unmarshal revocation list: %w", err)
285 }
286
287 return revokedList, nil
288}
289
290// IsAvailable checks if pass is available in the system.
291func (s *PassStore) IsAvailable() bool {
292 cmd := exec.Command("pass", "version")
293 return cmd.Run() == nil
294}