Skip to content

Commit 5913b09

Browse files
committed
feat(auth): integrate system keyring for secure token storage with fallback options
1 parent ae97e6a commit 5913b09

File tree

6 files changed

+265
-26
lines changed

6 files changed

+265
-26
lines changed

go.mod

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,10 +25,12 @@ require (
2525
github.com/stretchr/testify v1.11.1
2626
github.com/testcontainers/testcontainers-go v0.40.0
2727
github.com/tiulpin/instill v0.0.0-20260213180125-bfc25c314930
28+
github.com/zalando/go-keyring v0.2.6
2829
golang.org/x/term v0.39.0
2930
)
3031

3132
require (
33+
al.essio.dev/pkg/shellescape v1.5.1 // indirect
3234
dario.cat/mergo v1.0.2 // indirect
3335
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect
3436
github.com/Microsoft/go-winio v0.6.2 // indirect
@@ -45,6 +47,7 @@ require (
4547
github.com/containerd/log v0.1.0 // indirect
4648
github.com/containerd/platforms v0.2.1 // indirect
4749
github.com/cpuguy83/dockercfg v0.3.2 // indirect
50+
github.com/danieljoos/wincred v1.2.2 // indirect
4851
github.com/davecgh/go-spew v1.1.1 // indirect
4952
github.com/distribution/reference v0.6.0 // indirect
5053
github.com/docker/go-connections v0.6.0 // indirect
@@ -57,6 +60,7 @@ require (
5760
github.com/go-logr/stdr v1.2.2 // indirect
5861
github.com/go-ole/go-ole v1.3.0 // indirect
5962
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
63+
github.com/godbus/dbus/v5 v5.1.0 // indirect
6064
github.com/google/uuid v1.6.0 // indirect
6165
github.com/inconshreveable/mousetrap v1.1.0 // indirect
6266
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect

go.sum

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
al.essio.dev/pkg/shellescape v1.5.1 h1:86HrALUujYS/h+GtqoB26SBEdkWfmMI6FubjXlsXyho=
2+
al.essio.dev/pkg/shellescape v1.5.1/go.mod h1:6sIqp7X2P6mThCQ7twERpZTuigpr6KbZWtls1U8I890=
13
dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
24
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
35
github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk=
@@ -56,6 +58,8 @@ github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6N
5658
github.com/creack/pty v1.1.17/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4=
5759
github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY=
5860
github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4=
61+
github.com/danieljoos/wincred v1.2.2 h1:774zMFJrqaeYCK2W57BgAem/MLi6mtSE47MB6BOJ0i0=
62+
github.com/danieljoos/wincred v1.2.2/go.mod h1:w7w4Utbrz8lqeMbDAK0lkNJUv5sAOkFi7nd/ogr0Uh8=
5963
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
6064
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
6165
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -91,9 +95,13 @@ github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
9195
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
9296
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
9397
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
98+
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
99+
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
94100
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
95101
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
96102
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
103+
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4=
104+
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ=
97105
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
98106
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
99107
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
@@ -220,6 +228,8 @@ github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJu
220228
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
221229
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
222230
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
231+
github.com/zalando/go-keyring v0.2.6 h1:r7Yc3+H+Ux0+M72zacZoItR3UDxeWfKTcabvkI8ua9s=
232+
github.com/zalando/go-keyring v0.2.6/go.mod h1:2TCrxYrbUNYfNS/Kgy/LSrkSQzZ5UPVH85RwfczwvcI=
223233
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
224234
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
225235
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 h1:jq9TW8u3so/bN+JPT166wjOI6/vQPF6Xe7nMNIltagk=

internal/cmd/auth.go

Lines changed: 32 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ func newAuthCmd() *cobra.Command {
3232
func newAuthLoginCmd() *cobra.Command {
3333
var serverURL string
3434
var token string
35+
var insecureStorage bool
3536

3637
cmd := &cobra.Command{
3738
Use: "login",
@@ -43,24 +44,29 @@ This will:
4344
2. Open your browser to generate an access token
4445
3. Validate and store the token securely
4546
47+
The token is stored in your system keyring (macOS Keychain, GNOME Keyring,
48+
Windows Credential Manager) when available. Use --insecure-storage to store
49+
the token in plain text in the config file instead.
50+
4651
For CI/CD, use environment variables instead:
4752
export TEAMCITY_URL="https://teamcity.example.com"
4853
export TEAMCITY_TOKEN="your-access-token"
4954
5055
When running inside a TeamCity build, authentication is automatic using
5156
build-level credentials from the build properties file.`,
5257
RunE: func(cmd *cobra.Command, args []string) error {
53-
return runAuthLogin(serverURL, token)
58+
return runAuthLogin(serverURL, token, insecureStorage)
5459
},
5560
}
5661

5762
cmd.Flags().StringVarP(&serverURL, "server", "s", "", "TeamCity server URL")
5863
cmd.Flags().StringVarP(&token, "token", "t", "", "Access token")
64+
cmd.Flags().BoolVar(&insecureStorage, "insecure-storage", false, "Store token in plain text config file instead of system keyring")
5965

6066
return cmd
6167
}
6268

63-
func runAuthLogin(serverURL, token string) error {
69+
func runAuthLogin(serverURL, token string, insecureStorage bool) error {
6470
isInteractive := !NoInput && output.IsStdinTerminal()
6571

6672
if serverURL == "" {
@@ -130,12 +136,17 @@ func runAuthLogin(serverURL, token string) error {
130136

131137
output.Info("%s", output.Green("✓"))
132138

133-
if err := config.SetServer(serverURL, token, user.Username); err != nil {
139+
insecureFallback, err := config.SetServerWithKeyring(serverURL, token, user.Username, insecureStorage)
140+
if err != nil {
134141
return fmt.Errorf("failed to save configuration: %w", err)
135142
}
136143

137144
output.Success("Logged in as %s", output.Cyan(user.Name))
138-
output.Info("\nConfiguration saved to %s", config.ConfigPath())
145+
if insecureFallback {
146+
fmt.Printf("%s Token stored in plain text at %s\n", output.Yellow("!"), config.ConfigPath())
147+
} else {
148+
fmt.Printf("%s Token stored in system keyring\n", output.Green("✓"))
149+
}
139150

140151
return nil
141152
}
@@ -180,10 +191,10 @@ func newAuthStatusCmd() *cobra.Command {
180191

181192
func runAuthStatus() error {
182193
serverURL := config.GetServerURL()
183-
token := config.GetToken()
194+
token, tokenSource := config.GetTokenWithSource()
184195

185196
if serverURL != "" && token != "" {
186-
return showExplicitAuthStatus(serverURL, token)
197+
return showExplicitAuthStatus(serverURL, token, tokenSource)
187198
}
188199

189200
if buildAuth, ok := config.GetBuildAuth(); ok {
@@ -198,7 +209,20 @@ func runAuthStatus() error {
198209
return nil
199210
}
200211

201-
func showExplicitAuthStatus(serverURL, token string) error {
212+
func tokenSourceLabel(source string) string {
213+
switch source {
214+
case "env":
215+
return "environment variable"
216+
case "keyring":
217+
return "system keyring"
218+
case "config":
219+
return config.ConfigPath()
220+
default:
221+
return "unknown"
222+
}
223+
}
224+
225+
func showExplicitAuthStatus(serverURL, token, tokenSource string) error {
202226
client := api.NewClient(serverURL, token)
203227
user, err := client.GetCurrentUser()
204228
if err != nil {
@@ -208,7 +232,7 @@ func showExplicitAuthStatus(serverURL, token string) error {
208232
}
209233

210234
fmt.Printf("%s Logged in to %s\n", output.Green("✓"), output.Cyan(serverURL))
211-
fmt.Printf(" User: %s (%s)\n", user.Name, user.Username)
235+
fmt.Printf(" User: %s (%s) · %s\n", user.Name, user.Username, tokenSourceLabel(tokenSource))
212236

213237
server, err := client.ServerVersion()
214238
if err == nil {

internal/config/config.go

Lines changed: 46 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,10 @@ func normalizeURL(u string) string {
101101
return u
102102
}
103103

104+
func keyringService(serverURL string) string {
105+
return "tc:" + serverURL
106+
}
107+
104108
func GetServerURL() string {
105109
if url := os.Getenv(EnvServerURL); url != "" {
106110
return normalizeURL(url)
@@ -114,19 +118,31 @@ func GetServerURL() string {
114118
}
115119

116120
func GetToken() string {
121+
token, _ := GetTokenWithSource()
122+
return token
123+
}
124+
125+
func GetTokenWithSource() (token, source string) {
117126
if token := os.Getenv(EnvToken); token != "" {
118-
return token
127+
return token, "env"
119128
}
120129

121130
serverURL := GetServerURL()
122131
if serverURL == "" {
123-
return ""
132+
return "", ""
124133
}
125134

126-
if server, ok := cfg.Servers[serverURL]; ok {
127-
return server.Token
135+
server, ok := cfg.Servers[serverURL]
136+
if ok && server.User != "" {
137+
if t, err := keyringGet(keyringService(serverURL), server.User); err == nil && t != "" {
138+
return t, "keyring"
139+
}
128140
}
129-
return ""
141+
142+
if ok && server.Token != "" {
143+
return server.Token, "config"
144+
}
145+
return "", ""
130146
}
131147

132148
// GetCurrentUser returns the current user from config
@@ -143,23 +159,42 @@ func GetCurrentUser() string {
143159
}
144160

145161
func SetServer(serverURL, token, user string) error {
162+
_, err := SetServerWithKeyring(serverURL, token, user, false)
163+
return err
164+
}
165+
166+
func SetServerWithKeyring(serverURL, token, user string, insecureStorage bool) (insecureFallback bool, err error) {
146167
cfg.DefaultServer = serverURL
147-
cfg.Servers[serverURL] = ServerConfig{
148-
Token: token,
149-
User: user,
168+
169+
if !insecureStorage {
170+
if krErr := keyringSet(keyringService(serverURL), user, token); krErr == nil {
171+
cfg.Servers[serverURL] = ServerConfig{User: user}
172+
return false, writeConfig()
173+
}
150174
}
151175

152-
viper.Set("default_server", serverURL)
176+
cfg.Servers[serverURL] = ServerConfig{Token: token, User: user}
177+
return true, writeConfig()
178+
}
179+
180+
func writeConfig() error {
181+
viper.Set("default_server", cfg.DefaultServer)
153182
viper.Set("servers", cfg.Servers)
154183

155184
if err := viper.WriteConfigAs(configPath); err != nil {
156185
return fmt.Errorf("failed to write config: %w", err)
157186
}
158-
187+
if err := os.Chmod(configPath, 0600); err != nil {
188+
return fmt.Errorf("failed to set config permissions: %w", err)
189+
}
159190
return nil
160191
}
161192

162193
func RemoveServer(serverURL string) error {
194+
if server, ok := cfg.Servers[serverURL]; ok && server.User != "" {
195+
_ = keyringDelete(keyringService(serverURL), server.User)
196+
}
197+
163198
delete(cfg.Servers, serverURL)
164199

165200
if cfg.DefaultServer == serverURL {
@@ -170,14 +205,7 @@ func RemoveServer(serverURL string) error {
170205
}
171206
}
172207

173-
viper.Set("default_server", cfg.DefaultServer)
174-
viper.Set("servers", cfg.Servers)
175-
176-
if err := viper.WriteConfigAs(configPath); err != nil {
177-
return fmt.Errorf("failed to write config: %w", err)
178-
}
179-
180-
return nil
208+
return writeConfig()
181209
}
182210

183211
func ConfigPath() string {

internal/config/config_test.go

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,13 @@ import (
1515
// package-level state (cfg, configPath) and environment variables.
1616

1717
// saveCfgState saves the current cfg state and restores it on cleanup.
18+
// Installs a mock keyring that errors by default so existing tests that
19+
// expect tokens in config continue to work.
1820
func saveCfgState(t *testing.T) {
1921
t.Helper()
2022
oldCfg := cfg
2123
oldPath := configPath
24+
keyringMockInitWithError(errors.New("keyring disabled in test"))
2225
t.Cleanup(func() {
2326
cfg = oldCfg
2427
configPath = oldPath
@@ -642,3 +645,97 @@ func TestDetectServerFromDSLNoMatch(T *testing.T) {
642645
got := DetectServerFromDSL()
643646
assert.Empty(T, got)
644647
}
648+
649+
func TestGetTokenPriority(T *testing.T) {
650+
saveCfgState(T)
651+
keyringMockInit()
652+
653+
serverURL := "https://tc.example.com"
654+
require.NoError(T, keyringSet("tc:"+serverURL, "admin", "keyring-token"))
655+
656+
cfg = &Config{
657+
DefaultServer: serverURL,
658+
Servers: map[string]ServerConfig{
659+
serverURL: {Token: "config-token", User: "admin"},
660+
},
661+
}
662+
663+
T.Run("env wins over keyring", func(t *testing.T) {
664+
t.Setenv(EnvToken, "env-token")
665+
t.Setenv(EnvServerURL, serverURL)
666+
667+
token, source := GetTokenWithSource()
668+
assert.Equal(t, "env-token", token)
669+
assert.Equal(t, "env", source)
670+
})
671+
672+
T.Run("keyring wins over config", func(t *testing.T) {
673+
t.Setenv(EnvToken, "")
674+
t.Setenv(EnvServerURL, serverURL)
675+
676+
token, source := GetTokenWithSource()
677+
assert.Equal(t, "keyring-token", token)
678+
assert.Equal(t, "keyring", source)
679+
})
680+
681+
T.Run("config used when keyring empty", func(t *testing.T) {
682+
t.Setenv(EnvToken, "")
683+
t.Setenv(EnvServerURL, serverURL)
684+
require.NoError(t, keyringDelete("tc:"+serverURL, "admin"))
685+
686+
token, source := GetTokenWithSource()
687+
assert.Equal(t, "config-token", token)
688+
assert.Equal(t, "config", source)
689+
})
690+
}
691+
692+
func TestSetServerWithKeyring(T *testing.T) {
693+
saveCfgState(T)
694+
keyringMockInit()
695+
tmpDir := T.TempDir()
696+
configPath = tmpDir + "/config.yml"
697+
cfg = &Config{Servers: make(map[string]ServerConfig)}
698+
699+
insecure, err := SetServerWithKeyring("https://tc.example.com", "my-token", "admin", false)
700+
require.NoError(T, err)
701+
assert.False(T, insecure)
702+
703+
// Token in keyring, not in config
704+
assert.Empty(T, cfg.Servers["https://tc.example.com"].Token)
705+
assert.Equal(T, "admin", cfg.Servers["https://tc.example.com"].User)
706+
val, err := keyringGet("tc:https://tc.example.com", "admin")
707+
require.NoError(T, err)
708+
assert.Equal(T, "my-token", val)
709+
}
710+
711+
func TestSetServerKeyringFallback(T *testing.T) {
712+
saveCfgState(T)
713+
tmpDir := T.TempDir()
714+
configPath = tmpDir + "/config.yml"
715+
cfg = &Config{Servers: make(map[string]ServerConfig)}
716+
717+
insecure, err := SetServerWithKeyring("https://tc.example.com", "my-token", "admin", false)
718+
require.NoError(T, err)
719+
assert.True(T, insecure)
720+
721+
assert.Equal(T, "my-token", cfg.Servers["https://tc.example.com"].Token)
722+
}
723+
724+
func TestRemoveServerCleansKeyring(T *testing.T) {
725+
saveCfgState(T)
726+
keyringMockInit()
727+
tmpDir := T.TempDir()
728+
configPath = tmpDir + "/config.yml"
729+
cfg = &Config{Servers: make(map[string]ServerConfig)}
730+
731+
_, err := SetServerWithKeyring("https://tc.example.com", "my-token", "admin", false)
732+
require.NoError(T, err)
733+
734+
err = RemoveServer("https://tc.example.com")
735+
require.NoError(T, err)
736+
737+
_, ok := cfg.Servers["https://tc.example.com"]
738+
assert.False(T, ok)
739+
_, err = keyringGet("tc:https://tc.example.com", "admin")
740+
assert.ErrorIs(T, err, errKeyringNotFound)
741+
}

0 commit comments

Comments
 (0)