1package tokenstore
2
3import (
4 "context"
5 "crypto/sha256"
6 "encoding/hex"
7 "encoding/json"
8 "fmt"
9 "os"
10 "path/filepath"
11 "strings"
12
13 "github.com/bnema/zerowrap"
14
15 "gordon/internal/domain"
16)
17
18// sanitizeSubject creates a safe filename from a subject by hashing it.
19// This prevents path traversal attacks where subject contains "../" sequences.
20func sanitizeSubject(subject string) string {
21 hash := sha256.Sum256([]byte(subject))
22 return hex.EncodeToString(hash[:])
23}
24
25const (
26 // unsafeTokenDir is the subdirectory for tokens in the data directory.
27 unsafeTokenDir = "secrets/gordon/registry/tokens"
28 // unsafeRevokedFile is the filename for the revocation list.
29 unsafeRevokedFile = "secrets/gordon/registry/revoked.json"
30)
31
32// UnsafeStore implements TokenStore using plain text files.
33// WARNING: This store does not encrypt secrets. Only use when pass/sops are unavailable.
34type UnsafeStore struct {
35 dataDir string
36 log zerowrap.Logger
37}
38
39// NewUnsafeStore creates a new file-based token store.
40// dataDir is the base directory for storing secrets (typically gordon's data_dir).
41func NewUnsafeStore(dataDir string, log zerowrap.Logger) *UnsafeStore {
42 store := &UnsafeStore{
43 dataDir: dataDir,
44 log: log,
45 }
46
47 log.Warn().
48 Str(zerowrap.FieldLayer, "adapter").
49 Str(zerowrap.FieldAdapter, "tokenstore").
50 Str("provider", "unsafe").
51 Str("data_dir", dataDir).
52 Msg("using unsafe secrets backend - secrets are stored in plain text")
53
54 return store
55}
56
57// unsafeTokenData holds both JWT and metadata in a single file.
58type unsafeTokenData struct {
59 JWT string `json:"jwt"`
60 Metadata tokenMetadata `json:"metadata"`
61}
62
63// SaveToken stores a token JWT and metadata as a JSON file.
64func (s *UnsafeStore) SaveToken(_ context.Context, token *domain.Token, jwt string) error {
65 tokenDir := filepath.Join(s.dataDir, unsafeTokenDir)
66 if err := os.MkdirAll(tokenDir, 0700); err != nil {
67 return fmt.Errorf("failed to create token directory: %w", err)
68 }
69
70 data := unsafeTokenData{
71 JWT: jwt,
72 Metadata: tokenMetadata{
73 ID: token.ID,
74 Subject: token.Subject, // Store original subject in metadata
75 Scopes: token.Scopes,
76 IssuedAt: token.IssuedAt,
77 ExpiresAt: token.ExpiresAt,
78 Revoked: token.Revoked,
79 },
80 }
81
82 dataJSON, err := json.MarshalIndent(data, "", " ")
83 if err != nil {
84 return fmt.Errorf("failed to marshal token data: %w", err)
85 }
86
87 // SECURITY: Use sanitized filename to prevent path traversal
88 safeFilename := sanitizeSubject(token.Subject)
89 tokenFile := filepath.Join(tokenDir, safeFilename+".json")
90 if err := os.WriteFile(tokenFile, dataJSON, 0600); err != nil {
91 return fmt.Errorf("failed to write token file: %w", err)
92 }
93
94 s.log.Debug().
95 Str(zerowrap.FieldLayer, "adapter").
96 Str(zerowrap.FieldAdapter, "tokenstore").
97 Str("provider", "unsafe").
98 Str("subject", token.Subject).
99 Msg("token stored in file")
100
101 return nil
102}
103
104// GetToken retrieves token JWT by subject from file.
105func (s *UnsafeStore) GetToken(_ context.Context, subject string) (string, *domain.Token, error) {
106 // SECURITY: Use sanitized filename to prevent path traversal
107 safeFilename := sanitizeSubject(subject)
108 tokenFile := filepath.Join(s.dataDir, unsafeTokenDir, safeFilename+".json")
109
110 dataJSON, err := os.ReadFile(tokenFile)
111 if err != nil {
112 if os.IsNotExist(err) {
113 return "", nil, domain.ErrTokenNotFound
114 }
115 return "", nil, fmt.Errorf("failed to read token file: %w", err)
116 }
117
118 var data unsafeTokenData
119 if err := json.Unmarshal(dataJSON, &data); err != nil {
120 return "", nil, fmt.Errorf("failed to unmarshal token data: %w", err)
121 }
122
123 token := &domain.Token{
124 ID: data.Metadata.ID,
125 Subject: data.Metadata.Subject,
126 Scopes: data.Metadata.Scopes,
127 IssuedAt: data.Metadata.IssuedAt,
128 ExpiresAt: data.Metadata.ExpiresAt,
129 Revoked: data.Metadata.Revoked,
130 }
131
132 return data.JWT, token, nil
133}
134
135// ListTokens returns all stored tokens from files.
136func (s *UnsafeStore) ListTokens(_ context.Context) ([]domain.Token, error) {
137 tokenDir := filepath.Join(s.dataDir, unsafeTokenDir)
138
139 entries, err := os.ReadDir(tokenDir)
140 if err != nil {
141 if os.IsNotExist(err) {
142 return []domain.Token{}, nil
143 }
144 return nil, fmt.Errorf("failed to read token directory: %w", err)
145 }
146
147 var tokens []domain.Token
148 for _, entry := range entries {
149 if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") {
150 continue
151 }
152
153 // Read file directly since filenames are now hashed
154 tokenFile := filepath.Join(tokenDir, entry.Name())
155 dataJSON, err := os.ReadFile(tokenFile)
156 if err != nil {
157 s.log.Warn().Err(err).Str("file", entry.Name()).Msg("failed to read token file")
158 continue
159 }
160
161 var data unsafeTokenData
162 if err := json.Unmarshal(dataJSON, &data); err != nil {
163 s.log.Warn().Err(err).Str("file", entry.Name()).Msg("failed to unmarshal token data")
164 continue
165 }
166
167 token := domain.Token{
168 ID: data.Metadata.ID,
169 Subject: data.Metadata.Subject,
170 Scopes: data.Metadata.Scopes,
171 IssuedAt: data.Metadata.IssuedAt,
172 ExpiresAt: data.Metadata.ExpiresAt,
173 Revoked: data.Metadata.Revoked,
174 }
175
176 tokens = append(tokens, token)
177 }
178
179 return tokens, nil
180}
181
182// Revoke adds token ID to revocation list file.
183func (s *UnsafeStore) Revoke(_ context.Context, tokenID string) error {
184 revokedFile := filepath.Join(s.dataDir, unsafeRevokedFile)
185
186 // Ensure parent directory exists
187 if err := os.MkdirAll(filepath.Dir(revokedFile), 0700); err != nil {
188 return fmt.Errorf("failed to create revoked directory: %w", err)
189 }
190
191 // Get current list
192 revokedList, err := s.getRevokedList()
193 if err != nil {
194 return err
195 }
196
197 // Add to list if not already present
198 for _, id := range revokedList {
199 if id == tokenID {
200 return nil // Already revoked
201 }
202 }
203 revokedList = append(revokedList, tokenID)
204
205 // Store updated list
206 listJSON, err := json.MarshalIndent(revokedList, "", " ")
207 if err != nil {
208 return fmt.Errorf("failed to marshal revocation list: %w", err)
209 }
210
211 if err := os.WriteFile(revokedFile, listJSON, 0600); err != nil {
212 return fmt.Errorf("failed to write revocation list: %w", err)
213 }
214
215 s.log.Info().
216 Str(zerowrap.FieldLayer, "adapter").
217 Str(zerowrap.FieldAdapter, "tokenstore").
218 Str("token_id", tokenID).
219 Msg("token revoked")
220
221 return nil
222}
223
224// IsRevoked checks if token ID is in revocation list.
225func (s *UnsafeStore) IsRevoked(_ context.Context, tokenID string) (bool, error) {
226 revokedList, err := s.getRevokedList()
227 if err != nil {
228 return false, err
229 }
230
231 for _, id := range revokedList {
232 if id == tokenID {
233 return true, nil
234 }
235 }
236
237 return false, nil
238}
239
240// DeleteToken removes token file.
241func (s *UnsafeStore) DeleteToken(_ context.Context, subject string) error {
242 // SECURITY: Use sanitized filename to prevent path traversal
243 safeFilename := sanitizeSubject(subject)
244 tokenFile := filepath.Join(s.dataDir, unsafeTokenDir, safeFilename+".json")
245
246 if err := os.Remove(tokenFile); err != nil {
247 if os.IsNotExist(err) {
248 return domain.ErrTokenNotFound
249 }
250 return fmt.Errorf("failed to delete token file: %w", err)
251 }
252
253 s.log.Debug().
254 Str(zerowrap.FieldLayer, "adapter").
255 Str(zerowrap.FieldAdapter, "tokenstore").
256 Str("subject", subject).
257 Msg("token deleted from file")
258
259 return nil
260}
261
262// getRevokedList retrieves the current revocation list from file.
263func (s *UnsafeStore) getRevokedList() ([]string, error) {
264 revokedFile := filepath.Join(s.dataDir, unsafeRevokedFile)
265
266 listJSON, err := os.ReadFile(revokedFile)
267 if err != nil {
268 if os.IsNotExist(err) {
269 return []string{}, nil
270 }
271 return nil, fmt.Errorf("failed to read revocation list: %w", err)
272 }
273
274 var revokedList []string
275 if err := json.Unmarshal(listJSON, &revokedList); err != nil {
276 return nil, fmt.Errorf("failed to unmarshal revocation list: %w", err)
277 }
278
279 return revokedList, nil
280}
281
282// IsAvailable always returns true for file-based storage.
283func (s *UnsafeStore) IsAvailable() bool {
284 return true
285}