Skip to content

Commit 4c805a7

Browse files
ceritiumclaude
andcommitted
Implement secure credential management and global-config command
Add a new global-config command that allows users to securely set up their API token once and reuse it across all commands. Implements a three-tier credential resolution system with proper priority: 1. Environment variable (STD_TOKEN) - highest priority for CI/CD 2. OS Keychain (macOS Keychain, Linux Secret Service, Windows Credential Manager) 3. Fallback to ~/.stacktodate/credentials.yaml file storage Key changes: - Add helpers/credentials.go with centralized credential management functions (GetToken, SetToken, DeleteToken, GetTokenSource) - Implement global-config command with three subcommands: * set: Interactive token setup with secure hidden input * status: Show current token configuration and storage location * delete: Remove stored credentials with confirmation - Update push command to use new credential system instead of requiring env var - Update init command to prompt for token setup if not configured - Add zalando/go-keyring dependency for cross-platform OS keychain support - Add golang.org/x/term dependency for secure password input (no echo) Benefits: - Users no longer need to set STD_TOKEN env var for local development - Token is stored securely in OS keychain by default - Seamless CI/CD integration via environment variables - Better error messages guide users to set up credentials 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Haiku 4.5 <[email protected]>
1 parent dcb6e74 commit 4c805a7

File tree

11 files changed

+409
-4
lines changed

11 files changed

+409
-4
lines changed

cmd/globalconfig/delete.go

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
package globalconfig
2+
3+
import (
4+
"bufio"
5+
"fmt"
6+
"os"
7+
"strings"
8+
9+
"github.com/stacktodate/stacktodate-cli/cmd/helpers"
10+
"github.com/spf13/cobra"
11+
)
12+
13+
var deleteCmd = &cobra.Command{
14+
Use: "delete",
15+
Short: "Remove stored authentication token",
16+
Long: `Remove your stored authentication token from keychain or credential storage.`,
17+
Run: func(cmd *cobra.Command, args []string) {
18+
// Confirm deletion
19+
source, _, _ := helpers.GetTokenSource()
20+
if source == "not configured" {
21+
fmt.Println("No credentials to delete")
22+
return
23+
}
24+
25+
fmt.Printf("This will remove your token from: %s\n", source)
26+
fmt.Print("Are you sure you want to delete your credentials? (type 'yes' to confirm): ")
27+
28+
reader := bufio.NewReader(os.Stdin)
29+
response, err := reader.ReadString('\n')
30+
if err != nil {
31+
helpers.ExitOnError(err, "failed to read input")
32+
}
33+
34+
response = strings.TrimSpace(response)
35+
if response != "yes" {
36+
fmt.Println("Cancelled - credentials not deleted")
37+
return
38+
}
39+
40+
// Delete the token
41+
if err := helpers.DeleteToken(); err != nil {
42+
helpers.ExitOnError(err, "")
43+
}
44+
45+
fmt.Println("✓ Credentials deleted successfully")
46+
},
47+
}

cmd/globalconfig/get.go

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
package globalconfig
2+
3+
import (
4+
"fmt"
5+
6+
"github.com/stacktodate/stacktodate-cli/cmd/helpers"
7+
"github.com/spf13/cobra"
8+
)
9+
10+
var getCmd = &cobra.Command{
11+
Use: "status",
12+
Short: "Show current authentication configuration",
13+
Long: `Display information about where your authentication token is stored and its status.`,
14+
Run: func(cmd *cobra.Command, args []string) {
15+
source, isSecure, err := helpers.GetTokenSource()
16+
17+
if err != nil {
18+
fmt.Println("Status: Not configured")
19+
fmt.Println("")
20+
fmt.Println("To set up authentication, run:")
21+
fmt.Println(" stacktodate global-config set")
22+
return
23+
}
24+
25+
fmt.Println("Status: Configured")
26+
fmt.Printf("Source: %s\n", source)
27+
28+
if !isSecure {
29+
fmt.Println("")
30+
fmt.Println("⚠️ Warning: Token stored in plain text file")
31+
fmt.Println("For better security, use a system with OS keychain support")
32+
}
33+
},
34+
}

cmd/globalconfig/globalconfig.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
package globalconfig
2+
3+
import "github.com/spf13/cobra"
4+
5+
var GlobalConfigCmd = &cobra.Command{
6+
Use: "global-config",
7+
Short: "Manage global configuration and authentication",
8+
Long: `Configure authentication tokens and other global settings for stacktodate-cli`,
9+
}
10+
11+
func init() {
12+
GlobalConfigCmd.AddCommand(setCmd)
13+
GlobalConfigCmd.AddCommand(getCmd)
14+
GlobalConfigCmd.AddCommand(deleteCmd)
15+
}

cmd/globalconfig/set.go

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
package globalconfig
2+
3+
import (
4+
"fmt"
5+
"strings"
6+
"syscall"
7+
8+
"github.com/stacktodate/stacktodate-cli/cmd/helpers"
9+
"golang.org/x/term"
10+
11+
"github.com/spf13/cobra"
12+
)
13+
14+
var setCmd = &cobra.Command{
15+
Use: "set",
16+
Short: "Set up authentication token",
17+
Long: `Set up your stacktodate API token for authentication.\n\nThe token will be securely stored in your system's keychain or credential store.`,
18+
Run: func(cmd *cobra.Command, args []string) {
19+
token, err := promptForToken()
20+
if err != nil {
21+
helpers.ExitOnError(err, "failed to read token")
22+
}
23+
24+
if token == "" {
25+
helpers.ExitOnError(fmt.Errorf("token cannot be empty"), "")
26+
}
27+
28+
// Store the token
29+
if err := helpers.SetToken(token); err != nil {
30+
helpers.ExitOnError(err, "")
31+
}
32+
33+
source, _, _ := helpers.GetTokenSource()
34+
fmt.Printf("✓ Token successfully configured\n")
35+
fmt.Printf(" Storage: %s\n", source)
36+
},
37+
}
38+
39+
// promptForToken prompts the user for their API token without echoing it to the terminal
40+
func promptForToken() (string, error) {
41+
fmt.Print("Enter your stacktodate API token: ")
42+
43+
// Read password without echoing
44+
bytePassword, err := term.ReadPassword(int(syscall.Stdin))
45+
if err != nil {
46+
return "", fmt.Errorf("failed to read token: %w", err)
47+
}
48+
49+
fmt.Println() // Print newline after hidden input
50+
51+
token := strings.TrimSpace(string(bytePassword))
52+
return token, nil
53+
}

cmd/helpers/credentials.go

Lines changed: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,222 @@
1+
package helpers
2+
3+
import (
4+
"fmt"
5+
"os"
6+
"path/filepath"
7+
8+
"github.com/zalando/go-keyring"
9+
"gopkg.in/yaml.v3"
10+
)
11+
12+
const (
13+
serviceName = "stacktodate"
14+
username = "token"
15+
)
16+
17+
// CredentialSource indicates where a credential came from
18+
type CredentialSource string
19+
20+
const (
21+
SourceEnvVar CredentialSource = "environment variable"
22+
SourceKeyring CredentialSource = "OS keychain"
23+
SourceFile CredentialSource = "config file"
24+
)
25+
26+
// CredentialInfo contains information about stored credentials
27+
type CredentialInfo struct {
28+
Token string
29+
Source CredentialSource
30+
}
31+
32+
// credentialsFile represents the structure of the credentials YAML file
33+
type credentialsFile struct {
34+
Token string `yaml:"token"`
35+
}
36+
37+
// GetToken retrieves the API token using the priority order:
38+
// 1. STD_TOKEN environment variable (highest priority)
39+
// 2. OS Keychain (macOS/Linux/Windows)
40+
// 3. Returns error if not found (Option B - fail securely)
41+
func GetToken() (string, error) {
42+
// Check environment variable first
43+
if token := os.Getenv("STD_TOKEN"); token != "" {
44+
return token, nil
45+
}
46+
47+
// Try to get from keychain
48+
token, err := keyring.Get(serviceName, username)
49+
if err == nil && token != "" {
50+
return token, nil
51+
}
52+
53+
// Try to get from fallback file (for migration purposes, but don't use it by default)
54+
if token, err := getTokenFromFile(); err == nil && token != "" {
55+
return token, nil
56+
}
57+
58+
// No token found anywhere
59+
return "", fmt.Errorf("no authentication token found\n\nSetup your token with one of these methods:\n 1. Interactive setup: stacktodate global-config set\n 2. Environment variable: export STD_TOKEN=<your_token>\n\nFor more help: stacktodate global-config --help")
60+
}
61+
62+
// GetTokenWithSource retrieves the token and returns information about its source
63+
func GetTokenWithSource() (*CredentialInfo, error) {
64+
// Check environment variable first
65+
if token := os.Getenv("STD_TOKEN"); token != "" {
66+
return &CredentialInfo{
67+
Token: token,
68+
Source: SourceEnvVar,
69+
}, nil
70+
}
71+
72+
// Try to get from keychain
73+
token, err := keyring.Get(serviceName, username)
74+
if err == nil && token != "" {
75+
return &CredentialInfo{
76+
Token: token,
77+
Source: SourceKeyring,
78+
}, nil
79+
}
80+
81+
// Try to get from fallback file
82+
if token, err := getTokenFromFile(); err == nil && token != "" {
83+
return &CredentialInfo{
84+
Token: token,
85+
Source: SourceFile,
86+
}, nil
87+
}
88+
89+
// No token found anywhere
90+
return nil, fmt.Errorf("no authentication token found")
91+
}
92+
93+
// SetToken stores the token in the OS keychain
94+
// Falls back to file storage if keychain is unavailable
95+
// Per Option B: Fails if keychain is unavailable and no fallback
96+
func SetToken(token string) error {
97+
// Ensure config directory exists
98+
if err := EnsureConfigDir(); err != nil {
99+
return fmt.Errorf("failed to create config directory: %w", err)
100+
}
101+
102+
// Try to store in keychain first
103+
err := keyring.Set(serviceName, username, token)
104+
if err == nil {
105+
return nil
106+
}
107+
108+
// If keychain fails, also try file storage as a fallback
109+
// This allows local development to work
110+
if err := setTokenInFile(token); err != nil {
111+
return fmt.Errorf("failed to store token securely:\n Keychain error: %v\n File storage error: %v\n\nFor CI/headless environments, use: export STD_TOKEN=<your_token>", err, err)
112+
}
113+
114+
fmt.Println("⚠️ Warning: Token stored in plain text file at ~/.stacktodate/credentials.yaml")
115+
fmt.Println("For better security, consider using a system with OS keychain support")
116+
return nil
117+
}
118+
119+
// DeleteToken removes the token from keychain and file storage
120+
func DeleteToken() error {
121+
var keychainErr error
122+
var fileErr error
123+
124+
// Try to delete from keychain
125+
keychainErr = keyring.Delete(serviceName, username)
126+
127+
// Try to delete from file
128+
fileErr = deleteTokenFromFile()
129+
130+
// If both failed, return error
131+
if keychainErr != nil && fileErr != nil {
132+
return fmt.Errorf("failed to delete token: keychain error: %v, file error: %v", keychainErr, fileErr)
133+
}
134+
135+
return nil
136+
}
137+
138+
// GetTokenSource returns information about where the token is currently stored
139+
func GetTokenSource() (string, bool, error) {
140+
// Check environment variable
141+
if os.Getenv("STD_TOKEN") != "" {
142+
return "STD_TOKEN environment variable", true, nil
143+
}
144+
145+
// Check keychain
146+
_, err := keyring.Get(serviceName, username)
147+
if err == nil {
148+
return "OS keychain", true, nil
149+
}
150+
151+
// Check file
152+
if _, err := getTokenFromFile(); err == nil {
153+
return "credentials file (~/.stacktodate/credentials.yaml)", false, nil
154+
}
155+
156+
return "not configured", false, fmt.Errorf("no token found")
157+
}
158+
159+
// EnsureConfigDir creates the ~/.stacktodate directory if it doesn't exist
160+
func EnsureConfigDir() error {
161+
configDir := getConfigDir()
162+
return os.MkdirAll(configDir, 0700)
163+
}
164+
165+
// Helper functions
166+
167+
func getConfigDir() string {
168+
home, err := os.UserHomeDir()
169+
if err != nil {
170+
// Fallback to current directory if home can't be determined
171+
return ".stacktodate"
172+
}
173+
return filepath.Join(home, ".stacktodate")
174+
}
175+
176+
func getCredentialsFilePath() string {
177+
return filepath.Join(getConfigDir(), "credentials.yaml")
178+
}
179+
180+
func getTokenFromFile() (string, error) {
181+
filePath := getCredentialsFilePath()
182+
183+
content, err := os.ReadFile(filePath)
184+
if err != nil {
185+
return "", fmt.Errorf("failed to read credentials file: %w", err)
186+
}
187+
188+
var creds credentialsFile
189+
if err := yaml.Unmarshal(content, &creds); err != nil {
190+
return "", fmt.Errorf("failed to parse credentials file: %w", err)
191+
}
192+
193+
return creds.Token, nil
194+
}
195+
196+
func setTokenInFile(token string) error {
197+
filePath := getCredentialsFilePath()
198+
199+
creds := credentialsFile{
200+
Token: token,
201+
}
202+
203+
content, err := yaml.Marshal(creds)
204+
if err != nil {
205+
return fmt.Errorf("failed to marshal credentials: %w", err)
206+
}
207+
208+
// Write with restricted permissions (0600 = read/write for owner only)
209+
if err := os.WriteFile(filePath, content, 0600); err != nil {
210+
return fmt.Errorf("failed to write credentials file: %w", err)
211+
}
212+
213+
return nil
214+
}
215+
216+
func deleteTokenFromFile() error {
217+
filePath := getCredentialsFilePath()
218+
if err := os.Remove(filePath); err != nil && !os.IsNotExist(err) {
219+
return fmt.Errorf("failed to delete credentials file: %w", err)
220+
}
221+
return nil
222+
}

0 commit comments

Comments
 (0)