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 | + | } | |||