package config import ( "errors" "fmt" "os" "path/filepath" "slices" "github.com/cli/cli/v2/internal/keyring" ghAuth "github.com/cli/go-gh/v2/pkg/auth" ghConfig "github.com/cli/go-gh/v2/pkg/config" ) const ( aliasesKey = "aliases" browserKey = "browser" editorKey = "editor" gitProtocolKey = "git_protocol" hostsKey = "hosts" httpUnixSocketKey = "http_unix_socket" oauthTokenKey = "oauth_token" pagerKey = "pager" promptKey = "prompt" userKey = "user" usersKey = "users" versionKey = "version" ) // This interface describes interacting with some persistent configuration for gh. // //go:generate moq -rm -out config_mock.go . Config type Config interface { GetOrDefault(string, string) (string, error) Set(string, string, string) Write() error Migrate(Migration) error CacheDir() string Aliases() *AliasConfig Authentication() *AuthConfig Browser(string) string Editor(string) string GitProtocol(string) string HTTPUnixSocket(string) string Pager(string) string Prompt(string) string Version() string } // Migration is the interface that config migrations must implement. // // Migrations will receive a copy of the config, and should modify that copy // as necessary. After migration has completed, the modified config contents // will be used. // // The calling code is expected to verify that the current version of the config // matches the PreVersion of the migration before calling Do, and will set the // config version to the PostVersion after the migration has completed successfully. // //go:generate moq -rm -out migration_mock.go . Migration type Migration interface { // PreVersion is the required config version for this to be applied PreVersion() string // PostVersion is the config version that must be applied after migration PostVersion() string // Do is expected to apply any necessary changes to the config in place Do(*ghConfig.Config) error } func NewConfig() (Config, error) { c, err := ghConfig.Read(fallbackConfig()) if err != nil { return nil, err } return &cfg{c}, nil } // Implements Config interface type cfg struct { cfg *ghConfig.Config } func (c *cfg) Get(hostname, key string) (string, error) { if hostname != "" { val, err := c.cfg.Get([]string{hostsKey, hostname, key}) if err == nil { return val, err } } return c.cfg.Get([]string{key}) } func (c *cfg) GetOrDefault(hostname, key string) (string, error) { val, err := c.Get(hostname, key) if err == nil { return val, err } if val, ok := defaultFor(key); ok { return val, nil } return val, err } func (c *cfg) Set(hostname, key, value string) { if hostname == "" { c.cfg.Set([]string{key}, value) return } c.cfg.Set([]string{hostsKey, hostname, key}, value) if user, _ := c.cfg.Get([]string{hostsKey, hostname, userKey}); user != "" { c.cfg.Set([]string{hostsKey, hostname, usersKey, user, key}, value) } } func (c *cfg) Write() error { return ghConfig.Write(c.cfg) } func (c *cfg) Aliases() *AliasConfig { return &AliasConfig{cfg: c.cfg} } func (c *cfg) Authentication() *AuthConfig { return &AuthConfig{cfg: c.cfg} } func (c *cfg) Browser(hostname string) string { val, _ := c.GetOrDefault(hostname, browserKey) return val } func (c *cfg) Editor(hostname string) string { val, _ := c.GetOrDefault(hostname, editorKey) return val } func (c *cfg) GitProtocol(hostname string) string { val, _ := c.GetOrDefault(hostname, gitProtocolKey) return val } func (c *cfg) HTTPUnixSocket(hostname string) string { val, _ := c.GetOrDefault(hostname, httpUnixSocketKey) return val } func (c *cfg) Pager(hostname string) string { val, _ := c.GetOrDefault(hostname, pagerKey) return val } func (c *cfg) Prompt(hostname string) string { val, _ := c.GetOrDefault(hostname, promptKey) return val } func (c *cfg) Version() string { val, _ := c.GetOrDefault("", versionKey) return val } func (c *cfg) Migrate(m Migration) error { version := c.Version() // If migration has already occurred then do not attempt to migrate again. if m.PostVersion() == version { return nil } // If migration is incompatible with current version then return an error. if m.PreVersion() != version { return fmt.Errorf("failed to migrate as %q pre migration version did not match config version %q", m.PreVersion(), version) } if err := m.Do(c.cfg); err != nil { return fmt.Errorf("failed to migrate config: %s", err) } c.Set("", versionKey, m.PostVersion()) // Then write out our migrated config. if err := c.Write(); err != nil { return fmt.Errorf("failed to write config after migration: %s", err) } return nil } func (c *cfg) CacheDir() string { return ghConfig.CacheDir() } func defaultFor(key string) (string, bool) { for _, co := range ConfigOptions() { if co.Key == key { return co.DefaultValue, true } } return "", false } // AuthConfig is used for interacting with some persistent configuration for gh, // with knowledge on how to access encrypted storage when neccesarry. // Behavior is scoped to authentication specific tasks. type AuthConfig struct { cfg *ghConfig.Config defaultHostOverride func() (string, string) hostsOverride func() []string tokenOverride func(string) (string, string) } // ActiveToken will retrieve the active auth token for the given hostname, // searching environment variables, plain text config, and // lastly encrypted storage. func (c *AuthConfig) ActiveToken(hostname string) (string, string) { if c.tokenOverride != nil { return c.tokenOverride(hostname) } token, source := ghAuth.TokenFromEnvOrConfig(hostname) if token == "" { var err error token, err = c.TokenFromKeyring(hostname) if err == nil { source = "keyring" } } return token, source } // HasEnvToken returns true when a token has been specified in an // environment variable, else returns false. func (c *AuthConfig) HasEnvToken() bool { // This will check if there are any environment variable // authentication tokens set for enterprise hosts. // Any non-github.com hostname is fine here hostname := "example.com" if c.tokenOverride != nil { token, _ := c.tokenOverride(hostname) if token != "" { return true } } // TODO: This is _extremely_ knowledgeable about the implementation of TokenFromEnvOrConfig // It has to use a hostname that is not going to be found in the hosts so that it // can guarantee that tokens will only be returned from a set env var. // Discussed here, but maybe worth revisiting: https://github.com/cli/cli/pull/7169#discussion_r1136979033 token, _ := ghAuth.TokenFromEnvOrConfig(hostname) return token != "" } // SetActiveToken will override any token resolution and return the given // token and source for all calls to ActiveToken. Use for testing purposes only. func (c *AuthConfig) SetActiveToken(token, source string) { c.tokenOverride = func(_ string) (string, string) { return token, source } } // TokenFromKeyring will retrieve the auth token for the given hostname, // only searching in encrypted storage. func (c *AuthConfig) TokenFromKeyring(hostname string) (string, error) { return keyring.Get(keyringServiceName(hostname), "") } // TokenFromKeyringForUser will retrieve the auth token for the given hostname // and username, only searching in encrypted storage. // // An empty username will return an error because the potential to return // the currently active token under surprising cases is just too high to risk // compared to the utility of having the function being smart. func (c *AuthConfig) TokenFromKeyringForUser(hostname, username string) (string, error) { if username == "" { return "", errors.New("username cannot be blank") } return keyring.Get(keyringServiceName(hostname), username) } // ActiveUser will retrieve the username for the active user at the given hostname. // This will not be accurate if the oauth token is set from an environment variable. func (c *AuthConfig) ActiveUser(hostname string) (string, error) { return c.cfg.Get([]string{hostsKey, hostname, userKey}) } func (c *AuthConfig) Hosts() []string { if c.hostsOverride != nil { return c.hostsOverride() } return ghAuth.KnownHosts() } // SetHosts will override any hosts resolution and return the given // hosts for all calls to Hosts. Use for testing purposes only. func (c *AuthConfig) SetHosts(hosts []string) { c.hostsOverride = func() []string { return hosts } } func (c *AuthConfig) DefaultHost() (string, string) { if c.defaultHostOverride != nil { return c.defaultHostOverride() } return ghAuth.DefaultHost() } // SetDefaultHost will override any host resolution and return the given // host and source for all calls to DefaultHost. Use for testing purposes only. func (c *AuthConfig) SetDefaultHost(host, source string) { c.defaultHostOverride = func() (string, string) { return host, source } } // Login will set user, git protocol, and auth token for the given hostname. // If the encrypt option is specified it will first try to store the auth token // in encrypted storage and will fall back to the plain text config file. func (c *AuthConfig) Login(hostname, username, token, gitProtocol string, secureStorage bool) (bool, error) { // In this section we set up the users config var setErr error if secureStorage { // Try to set the token for this user in the encrypted storage for later switching setErr = keyring.Set(keyringServiceName(hostname), username, token) if setErr == nil { // Clean up the previous oauth_token from the config file, if there were one _ = c.cfg.Remove([]string{hostsKey, hostname, usersKey, username, oauthTokenKey}) } } insecureStorageUsed := false if !secureStorage || setErr != nil { // And set the oauth token under the user for later switching c.cfg.Set([]string{hostsKey, hostname, usersKey, username, oauthTokenKey}, token) insecureStorageUsed = true } if gitProtocol != "" { // Set the host level git protocol // Although it might be expected that this is handled by switch, git protocol // is currently a host level config and not a user level config, so any change // will overwrite the protocol for all users on the host. c.cfg.Set([]string{hostsKey, hostname, gitProtocolKey}, gitProtocol) } // Create the username key with an empty value so it will be // written even when there are no keys set under it. if _, getErr := c.cfg.Get([]string{hostsKey, hostname, usersKey, username}); getErr != nil { c.cfg.Set([]string{hostsKey, hostname, usersKey, username}, "") } // Then we activate the new user return insecureStorageUsed, c.activateUser(hostname, username) } func (c *AuthConfig) SwitchUser(hostname, user string) error { previouslyActiveUser, err := c.ActiveUser(hostname) if err != nil { return fmt.Errorf("failed to get active user: %s", err) } previouslyActiveToken, previousSource := c.ActiveToken(hostname) if previousSource != "keyring" && previousSource != "oauth_token" { return fmt.Errorf("currently active token for %s is from %s", hostname, previousSource) } err = c.activateUser(hostname, user) if err != nil { // Given that activateUser can only fail before the config is written, or when writing the config // we know for sure that the config has not been written. However, we still should restore it back // to its previous clean state just in case something else tries to make use of the config, or tries // to write it again. if previousSource == "keyring" { if setErr := keyring.Set(keyringServiceName(hostname), "", previouslyActiveToken); setErr != nil { err = errors.Join(err, setErr) } } if previousSource == "oauth_token" { c.cfg.Set([]string{hostsKey, hostname, oauthTokenKey}, previouslyActiveToken) } c.cfg.Set([]string{hostsKey, hostname, userKey}, previouslyActiveUser) return err } return nil } // Logout will remove user, git protocol, and auth token for the given hostname. // It will remove the auth token from the encrypted storage if it exists there. func (c *AuthConfig) Logout(hostname, username string) error { users := c.UsersForHost(hostname) // If there is only one (or zero) users, then we remove the host // and unset the keyring tokens. if len(users) < 2 { _ = c.cfg.Remove([]string{hostsKey, hostname}) _ = keyring.Delete(keyringServiceName(hostname), "") _ = keyring.Delete(keyringServiceName(hostname), username) return ghConfig.Write(c.cfg) } // Otherwise, we remove the user from this host _ = c.cfg.Remove([]string{hostsKey, hostname, usersKey, username}) // This error is ignorable because we already know there is an active user for the host activeUser, _ := c.ActiveUser(hostname) // If the user we're removing isn't active, then we just write the config if activeUser != username { return ghConfig.Write(c.cfg) } // Otherwise we get the first user in the slice that isn't the user we're removing switchUserIdx := slices.IndexFunc(users, func(n string) bool { return n != username }) // And activate them return c.activateUser(hostname, users[switchUserIdx]) } func (c *AuthConfig) activateUser(hostname, user string) error { // We first need to idempotently clear out any set tokens for the host _ = keyring.Delete(keyringServiceName(hostname), "") _ = c.cfg.Remove([]string{hostsKey, hostname, oauthTokenKey}) // Then we'll move the keyring token or insecure token as necessary, only one of the // following branches should be true. // If there is a token in the secure keyring for the user, move it to the active slot var tokenSwitched bool if token, err := keyring.Get(keyringServiceName(hostname), user); err == nil { if err = keyring.Set(keyringServiceName(hostname), "", token); err != nil { return fmt.Errorf("failed to move active token in keyring: %v", err) } tokenSwitched = true } // If there is a token in the insecure config for the user, move it to the active field if token, err := c.cfg.Get([]string{hostsKey, hostname, usersKey, user, oauthTokenKey}); err == nil { c.cfg.Set([]string{hostsKey, hostname, oauthTokenKey}, token) tokenSwitched = true } if !tokenSwitched { return fmt.Errorf("no token found for %s", user) } // Then we'll update the active user for the host c.cfg.Set([]string{hostsKey, hostname, userKey}, user) return ghConfig.Write(c.cfg) } func (c *AuthConfig) UsersForHost(hostname string) []string { users, err := c.cfg.Keys([]string{hostsKey, hostname, usersKey}) if err != nil { return nil } return users } func (c *AuthConfig) TokenForUser(hostname, user string) (string, string, error) { if token, err := keyring.Get(keyringServiceName(hostname), user); err == nil { return token, "keyring", nil } if token, err := c.cfg.Get([]string{hostsKey, hostname, usersKey, user, oauthTokenKey}); err == nil { return token, "oauth_token", nil } return "", "default", fmt.Errorf("no token found for '%s'", user) } func keyringServiceName(hostname string) string { return "gh:" + hostname } type AliasConfig struct { cfg *ghConfig.Config } func (a *AliasConfig) Get(alias string) (string, error) { return a.cfg.Get([]string{aliasesKey, alias}) } func (a *AliasConfig) Add(alias, expansion string) { a.cfg.Set([]string{aliasesKey, alias}, expansion) } func (a *AliasConfig) Delete(alias string) error { return a.cfg.Remove([]string{aliasesKey, alias}) } func (a *AliasConfig) All() map[string]string { out := map[string]string{} keys, err := a.cfg.Keys([]string{aliasesKey}) if err != nil { return out } for _, key := range keys { val, _ := a.cfg.Get([]string{aliasesKey, key}) out[key] = val } return out } func fallbackConfig() *ghConfig.Config { return ghConfig.ReadFromString(defaultConfigStr) } // The schema version in here should match the PostVersion of whatever the // last migration we decided to run is. Therefore, if we run a new migration, // this should be bumped. const defaultConfigStr = ` # The default config file, auto-generated by gh. Run 'gh environment' to learn more about # environment variables respected by gh and their precedence. # The current version of the config schema version: 1 # What protocol to use when performing git operations. Supported values: ssh, https git_protocol: https # What editor gh should run when creating issues, pull requests, etc. If blank, will refer to environment. editor: # When to interactively prompt. This is a global config that cannot be overridden by hostname. Supported values: enabled, disabled prompt: enabled # A pager program to send command output to, e.g. "less". If blank, will refer to environment. Set the value to "cat" to disable the pager. pager: # Aliases allow you to create nicknames for gh commands aliases: co: pr checkout # The path to a unix socket through which send HTTP connections. If blank, HTTP traffic will be handled by net/http.DefaultTransport. http_unix_socket: # What web browser gh should use when opening URLs. If blank, will refer to environment. browser: ` type ConfigOption struct { Key string Description string DefaultValue string AllowedValues []string } func ConfigOptions() []ConfigOption { return []ConfigOption{ { Key: gitProtocolKey, Description: "the protocol to use for git clone and push operations", DefaultValue: "https", AllowedValues: []string{"https", "ssh"}, }, { Key: editorKey, Description: "the text editor program to use for authoring text", DefaultValue: "", }, { Key: promptKey, Description: "toggle interactive prompting in the terminal", DefaultValue: "enabled", AllowedValues: []string{"enabled", "disabled"}, }, { Key: pagerKey, Description: "the terminal pager program to send standard output to", DefaultValue: "", }, { Key: httpUnixSocketKey, Description: "the path to a Unix socket through which to make an HTTP connection", DefaultValue: "", }, { Key: browserKey, Description: "the web browser to use for opening URLs", DefaultValue: "", }, } } func HomeDirPath(subdir string) (string, error) { homeDir, err := os.UserHomeDir() if err != nil { return "", err } newPath := filepath.Join(homeDir, subdir) return newPath, nil } func StateDir() string { return ghConfig.StateDir() } func DataDir() string { return ghConfig.DataDir() } func ConfigDir() string { return ghConfig.ConfigDir() }