Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ require (
)

require (
al.essio.dev/pkg/shellescape v1.5.1 // indirect
github.com/agnivade/levenshtein v1.2.1 // indirect
github.com/alecthomas/chroma/v2 v2.14.0 // indirect
github.com/alexflint/go-arg v1.5.1 // indirect
Expand All @@ -41,10 +42,12 @@ require (
github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect
github.com/charmbracelet/x/term v0.2.1 // indirect
github.com/danieljoos/wincred v1.2.2 // indirect
github.com/dlclark/regexp2 v1.11.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/godbus/dbus/v5 v5.1.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/css v1.0.1 // indirect
github.com/microcosm-cc/bluemonday v1.0.27 // indirect
Expand All @@ -54,6 +57,7 @@ require (
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
github.com/yuin/goldmark v1.7.8 // indirect
github.com/yuin/goldmark-emoji v1.0.5 // indirect
github.com/zalando/go-keyring v0.2.6 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
)
Expand Down
8 changes: 8 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
al.essio.dev/pkg/shellescape v1.5.1 h1:86HrALUujYS/h+GtqoB26SBEdkWfmMI6FubjXlsXyho=
al.essio.dev/pkg/shellescape v1.5.1/go.mod h1:6sIqp7X2P6mThCQ7twERpZTuigpr6KbZWtls1U8I890=
dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk=
dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk=
github.com/AlecAivazis/survey/v2 v2.3.7 h1:6I/u8FvytdGsgonrYsVn2t8t4QiRnh6QSTqkkhIiSjQ=
Expand Down Expand Up @@ -97,6 +99,8 @@ github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
github.com/cyphar/filepath-securejoin v0.4.1 h1:JyxxyPEaktOD+GAnqIqTf9A8tHyAG22rowi7HkoSU1s=
github.com/cyphar/filepath-securejoin v0.4.1/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGLDGQL7h7bg04C/+u9jI=
github.com/danieljoos/wincred v1.2.2 h1:774zMFJrqaeYCK2W57BgAem/MLi6mtSE47MB6BOJ0i0=
github.com/danieljoos/wincred v1.2.2/go.mod h1:w7w4Utbrz8lqeMbDAK0lkNJUv5sAOkFi7nd/ogr0Uh8=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
Expand Down Expand Up @@ -130,6 +134,8 @@ github.com/go-git/go-git/v5 v5.16.3 h1:Z8BtvxZ09bYm/yYNgPKCzgWtaRqDTgIKRgIRHBfU6
github.com/go-git/go-git/v5 v5.16.3/go.mod h1:4Ge4alE/5gPs30F2H1esi2gPd69R0C39lolkucHBOp8=
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw=
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
Expand Down Expand Up @@ -262,6 +268,8 @@ github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic=
github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
github.com/yuin/goldmark-emoji v1.0.5 h1:EMVWyCGPlXJfUXBXpuMu+ii3TIaxbVBnEX9uaDC4cIk=
github.com/yuin/goldmark-emoji v1.0.5/go.mod h1:tTkZEbwu5wkPmgTcitqddVxY9osFZiavD+r4AzQrh1U=
github.com/zalando/go-keyring v0.2.6 h1:r7Yc3+H+Ux0+M72zacZoItR3UDxeWfKTcabvkI8ua9s=
github.com/zalando/go-keyring v0.2.6/go.mod h1:2TCrxYrbUNYfNS/Kgy/LSrkSQzZ5UPVH85RwfczwvcI=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
Expand Down
74 changes: 60 additions & 14 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,9 +38,9 @@ const (
// selected_org: buildkite
// organizations:
// buildkite:
// api_token: <token>
// api_token: <token> # (deprecated - tokens now stored in keychain by default)
// buildkite-oss:
// api_token: <token>
// api_token: <token> # (deprecated - tokens now stored in keychain by default)
// pipelines: # (only in local config)
// - first-pipeline
// - second-pipeline
Expand All @@ -51,6 +51,8 @@ type Config struct {
localConfig *viper.Viper
// userConfig is the configuration stored in the users HOME directory.
userConfig *viper.Viper
// tokenStorage is the backend used for storing API tokens (keychain by default, file-based as fallback)
tokenStorage TokenStorage
}

func New(fs afero.Fs, repo *git.Repository) *Config {
Expand Down Expand Up @@ -80,10 +82,19 @@ func New(fs afero.Fs, repo *git.Repository) *Config {
}
_ = localConfig.ReadInConfig()

return &Config{
conf := &Config{
localConfig: localConfig,
userConfig: userConfig,
}

// Initialize token storage backend
if shouldUseKeychain() {
conf.tokenStorage = NewKeychainTokenStorage()
} else {
conf.tokenStorage = NewFileTokenStorage(conf)
}

return conf
}

// OrganizationSlug gets the slug for the currently selected organization. This can be configured locally or per user.
Expand All @@ -108,35 +119,70 @@ func (conf *Config) SelectOrganization(org string, inGitRepo bool) error {
}

// APIToken gets the API token configured for the currently selected organization
// Priority order: BUILDKITE_API_TOKEN env var → keychain → config file (legacy)
func (conf *Config) APIToken() string {
// Environment variable takes precedence
Comment on lines 121 to +124
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@scadu this section should be what you're after

Copy link
Contributor

@scadu scadu Nov 12, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@mcncl ah, thanks for pointing that! Nice!

if envToken := os.Getenv("BUILDKITE_API_TOKEN"); envToken != "" {
return envToken
}

slug := conf.OrganizationSlug()
if slug == "" {
return ""
}

// Try keychain first
token, err := conf.tokenStorage.Get(slug)
if err == nil && token != "" {
return token
}

// Fallback to file-based config for backward compatibility
key := fmt.Sprintf("organizations.%s.api_token", slug)
return firstNonEmpty(
os.Getenv("BUILDKITE_API_TOKEN"),
conf.userConfig.GetString(key),
)
return conf.userConfig.GetString(key)
}

// SetTokenForOrg sets the token for the given org in the user configuration file. Tokens are not stored in the local
// configuration file to reduce the likelihood of tokens being committed to VCS
// SetTokenForOrg sets the token for the given org in the token storage (keychain by default).
// Tokens are not stored in the local configuration file to reduce the likelihood of tokens being committed to VCS
func (conf *Config) SetTokenForOrg(org, token string) error {
key := fmt.Sprintf("organizations.%s.api_token", org)
conf.userConfig.Set(key, token)
return conf.userConfig.WriteConfig()
return conf.tokenStorage.Set(org, token)
}

// GetTokenForOrg gets the API token for a specific organization from the user configuration
// GetTokenForOrg gets the API token for a specific organization from the token storage
// Falls back to file-based config for backward compatibility
func (conf *Config) GetTokenForOrg(org string) string {
// Try token storage first (keychain by default)
token, err := conf.tokenStorage.Get(org)
if err == nil && token != "" {
return token
}

// Fallback to file-based config
key := fmt.Sprintf("organizations.%s.api_token", org)
return conf.userConfig.GetString(key)
}

func (conf *Config) ConfiguredOrganizations() []string {
// Get orgs from file config
m := conf.userConfig.GetStringMap("organizations")
orgs := slices.Collect(maps.Keys(m))

// Add orgs from keychain storage (if using keychain)
if keychainOrgs, err := conf.tokenStorage.List(); err == nil {
for _, org := range keychainOrgs {
if !slices.Contains(orgs, org) {
orgs = append(orgs, org)
}
}
}

// Add org from environment variable if set
if o := os.Getenv("BUILDKITE_ORGANIZATION_SLUG"); o != "" {
orgs = append(orgs, o)
if !slices.Contains(orgs, o) {
orgs = append(orgs, o)
}
}

return orgs
}

Expand Down
8 changes: 4 additions & 4 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,10 +34,9 @@ func prepareTestDirectory(fs afero.Fs, fixturePath, configPath string) error {
}

func TestConfig(t *testing.T) {
t.Parallel()

t.Run("read in local config", func(t *testing.T) {
t.Parallel()
// Use file-based storage for tests by setting the environment variable
t.Setenv("BUILDKITE_TOKEN_STORAGE", "file")

fs := afero.NewMemMapFs()
err := prepareTestDirectory(fs, "local.basic.yaml", localConfigFilePath)
Expand All @@ -62,7 +61,8 @@ func TestConfig(t *testing.T) {
})

t.Run("GetTokenForOrg returns token for specific organization", func(t *testing.T) {
t.Parallel()
// Use file-based storage for tests
t.Setenv("BUILDKITE_TOKEN_STORAGE", "file")

fs := afero.NewMemMapFs()
conf := New(fs, nil)
Expand Down
141 changes: 141 additions & 0 deletions internal/config/keychain.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
package config

import (
"fmt"
"os"

"github.com/zalando/go-keyring"
)

const (
// KeychainServiceName is the service name used when storing tokens in the system keychain
KeychainServiceName = "com.buildkite.cli"

// EnvVarTokenStorage allows users to override token storage mechanism
// Valid values: "keychain" (default), "file"
EnvVarTokenStorage = "BUILDKITE_TOKEN_STORAGE"
)

// TokenStorage defines the interface for storing and retrieving API tokens
type TokenStorage interface {
Get(org string) (string, error)
Set(org, token string) error
Delete(org string) error
List() ([]string, error)
}

// KeychainTokenStorage stores tokens in the system keychain (macOS Keychain, Windows Credential Manager, Linux Secret Service)
type KeychainTokenStorage struct {
serviceName string
}

// NewKeychainTokenStorage creates a new keychain-based token storage
func NewKeychainTokenStorage() *KeychainTokenStorage {
return &KeychainTokenStorage{
serviceName: KeychainServiceName,
}
}

// Get retrieves a token for the given organization from the keychain
func (k *KeychainTokenStorage) Get(org string) (string, error) {
token, err := keyring.Get(k.serviceName, org)
if err == keyring.ErrNotFound {
return "", fmt.Errorf("no token found for organization %q", org)
}
if err != nil {
return "", fmt.Errorf("failed to get token from keychain: %w", err)
}
return token, nil
}

// Set stores a token for the given organization in the keychain
func (k *KeychainTokenStorage) Set(org, token string) error {
if err := keyring.Set(k.serviceName, org, token); err != nil {
return fmt.Errorf("failed to set token in keychain: %w", err)
}
return nil
}

// Delete removes a token for the given organization from the keychain
func (k *KeychainTokenStorage) Delete(org string) error {
if err := keyring.Delete(k.serviceName, org); err != nil {
return fmt.Errorf("failed to delete token from keychain: %w", err)
}
return nil
}

// List returns all organizations that have tokens stored in the keychain
// Note: This is not directly supported by the keychain API, so we'll return an empty list
// and rely on the file-based config for listing organizations
func (k *KeychainTokenStorage) List() ([]string, error) {
return []string{}, nil
}

// FileTokenStorage stores tokens in the configuration file (legacy/fallback mode)
type FileTokenStorage struct {
conf *Config
}

// NewFileTokenStorage creates a new file-based token storage
func NewFileTokenStorage(conf *Config) *FileTokenStorage {
return &FileTokenStorage{
conf: conf,
}
}

// Get retrieves a token for the given organization from the config file
func (f *FileTokenStorage) Get(org string) (string, error) {
key := fmt.Sprintf("organizations.%s.api_token", org)
token := f.conf.userConfig.GetString(key)
if token == "" {
return "", fmt.Errorf("no token found for organization %q", org)
}
return token, nil
}

// Set stores a token for the given organization in the config file
func (f *FileTokenStorage) Set(org, token string) error {
key := fmt.Sprintf("organizations.%s.api_token", org)
f.conf.userConfig.Set(key, token)
return f.conf.userConfig.WriteConfig()
}

// Delete removes a token for the given organization from the config file
func (f *FileTokenStorage) Delete(org string) error {
key := fmt.Sprintf("organizations.%s.api_token", org)
f.conf.userConfig.Set(key, nil)
return f.conf.userConfig.WriteConfig()
}

// List returns all organizations that have tokens stored in the config file
func (f *FileTokenStorage) List() ([]string, error) {
orgsMap := f.conf.userConfig.GetStringMap("organizations")
orgs := make([]string, 0, len(orgsMap))
for org := range orgsMap {
// Check if this org actually has a token
key := fmt.Sprintf("organizations.%s.api_token", org)
if f.conf.userConfig.GetString(key) != "" {
orgs = append(orgs, org)
}
}
return orgs, nil
}

// getTokenStorageBackend determines which token storage backend to use based on environment variables
func getTokenStorageBackend() string {
backend := os.Getenv(EnvVarTokenStorage)
if backend == "" {
return "keychain" // Default to keychain
}
return backend
}

// ShouldUseKeychain returns true if keychain storage should be used
func ShouldUseKeychain() bool {
return getTokenStorageBackend() == "keychain"
}

// Deprecated: use ShouldUseKeychain instead
func shouldUseKeychain() bool {
return ShouldUseKeychain()
}
Loading