diff --git a/internal/authflow/flow.go b/internal/authflow/flow.go index 53b65af9e03..4b901b6b67e 100644 --- a/internal/authflow/flow.go +++ b/internal/authflow/flow.go @@ -6,7 +6,6 @@ import ( "io" "net/http" "net/url" - "os" "regexp" "strings" @@ -28,37 +27,7 @@ var ( jsonTypeRE = regexp.MustCompile(`[/+]json($|;)`) ) -type iconfig interface { - Get(string, string) (string, error) - Set(string, string, string) - Write() error -} - -func AuthFlowWithConfig(cfg iconfig, IO *iostreams.IOStreams, hostname, notice string, additionalScopes []string, isInteractive bool) (string, error) { - // TODO this probably shouldn't live in this package. It should probably be in a new package that - // depends on both iostreams and config. - - // FIXME: this duplicates `factory.browserLauncher()` - browserLauncher := os.Getenv("GH_BROWSER") - if browserLauncher == "" { - browserLauncher, _ = cfg.Get("", "browser") - } - if browserLauncher == "" { - browserLauncher = os.Getenv("BROWSER") - } - - token, userLogin, err := authFlow(hostname, IO, notice, additionalScopes, isInteractive, browserLauncher) - if err != nil { - return "", err - } - - cfg.Set(hostname, "user", userLogin) - cfg.Set(hostname, "oauth_token", token) - - return token, cfg.Write() -} - -func authFlow(oauthHost string, IO *iostreams.IOStreams, notice string, additionalScopes []string, isInteractive bool, browserLauncher string) (string, string, error) { +func AuthFlow(oauthHost string, IO *iostreams.IOStreams, notice string, additionalScopes []string, isInteractive bool, b browser.Browser) (string, string, error) { w := IO.ErrOut cs := IO.ColorScheme() @@ -106,7 +75,6 @@ func authFlow(oauthHost string, IO *iostreams.IOStreams, notice string, addition fmt.Fprintf(w, "%s to open %s in your browser... ", cs.Bold("Press Enter"), oauthHost) _ = waitForEnter(IO.In) - b := browser.New(browserLauncher, IO.Out, IO.ErrOut) if err := b.Browse(authURL); err != nil { fmt.Fprintf(w, "%s Failed opening a web browser at %s\n", cs.Red("!"), authURL) fmt.Fprintf(w, " %s\n", err) diff --git a/internal/config/config.go b/internal/config/config.go index 8fbd9e758d0..86beec93b82 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -9,23 +9,29 @@ import ( ) const ( - hosts = "hosts" - aliases = "aliases" + aliases = "aliases" + hosts = "hosts" + oauthToken = "oauth_token" ) // This interface describes interacting with some persistent configuration for gh. // //go:generate moq -rm -out config_mock.go . Config type Config interface { - AuthToken(string) (string, string) Get(string, string) (string, error) GetOrDefault(string, string) (string, error) Set(string, string, string) - UnsetHost(string) - Hosts() []string - DefaultHost() (string, string) - Aliases() *AliasConfig Write() error + + Aliases() *AliasConfig + Authentication() *AuthConfig + + // This is deprecated and will be removed, do not use! + // Please use cfg.Authentication().Token() + AuthToken(string) (string, string) + // This is deprecated and will be removed, do not use! + // Please use cfg.Authentication().DefaultHost() + DefaultHost() (string, string) } func NewConfig() (Config, error) { @@ -41,10 +47,6 @@ type cfg struct { cfg *ghConfig.Config } -func (c *cfg) AuthToken(hostname string) (string, string) { - return ghAuth.TokenForHost(hostname) -} - func (c *cfg) Get(hostname, key string) (string, error) { if hostname != "" { val, err := c.cfg.Get([]string{hosts, hostname, key}) @@ -86,27 +88,26 @@ func (c *cfg) Set(hostname, key, value string) { c.cfg.Set([]string{hosts, hostname, key}, value) } -func (c *cfg) UnsetHost(hostname string) { - if hostname == "" { - return - } - _ = c.cfg.Remove([]string{hosts, hostname}) +func (c *cfg) Write() error { + return ghConfig.Write(c.cfg) } -func (c *cfg) Hosts() []string { - return ghAuth.KnownHosts() +func (c *cfg) Aliases() *AliasConfig { + return &AliasConfig{cfg: c.cfg} } -func (c *cfg) DefaultHost() (string, string) { - return ghAuth.DefaultHost() +func (c *cfg) Authentication() *AuthConfig { + return &AuthConfig{cfg: c.cfg} } -func (c *cfg) Aliases() *AliasConfig { - return &AliasConfig{cfg: c.cfg} +// This is deprecated and will be removed, do not use. +func (c *cfg) AuthToken(hostname string) (string, string) { + return c.Authentication().Token(hostname) } -func (c *cfg) Write() error { - return ghConfig.Write(c.cfg) +// This is deprecated and will be removed, do not use. +func (c *cfg) DefaultHost() (string, string) { + return c.Authentication().DefaultHost() } func defaultFor(key string) string { @@ -127,6 +128,94 @@ func defaultExists(key string) bool { 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 + hostsOverride func() []string + tokenOverride func(string) (string, string) +} + +// Token will retrieve the auth token for the given hostname, +// searching environment variables, plain text config, and +// lastly encypted storage. +func (c *AuthConfig) Token(hostname string) (string, string) { + if c.tokenOverride != nil { + return c.tokenOverride(hostname) + } + return ghAuth.TokenForHost(hostname) +} + +// SetToken will override any token resolution and return the given +// token and source for all calls to Token. Use for testing purposes only. +func (c *AuthConfig) SetToken(token, source string) { + c.tokenOverride = func(_ string) (string, string) { + return token, source + } +} + +// User will retrieve the username for the logged in user at the given hostname. +func (c *AuthConfig) User(hostname string) (string, error) { + return c.cfg.Get([]string{hosts, hostname, "user"}) +} + +// GitProtocol will retrieve the git protocol for the logged in user at the given hostname. +// If none is set it will return the default value. +func (c *AuthConfig) GitProtocol(hostname string) (string, error) { + key := "git_protocol" + val, err := c.cfg.Get([]string{hosts, hostname, key}) + if err == nil { + return val, err + } + return defaultFor(key), nil +} + +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) { + return ghAuth.DefaultHost() +} + +// 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, encrypt bool) error { + if token != "" { + c.cfg.Set([]string{hosts, hostname, oauthToken}, token) + } + if username != "" { + c.cfg.Set([]string{hosts, hostname, "user"}, username) + } + if gitProtocol != "" { + c.cfg.Set([]string{hosts, hostname, "git_protocol"}, gitProtocol) + } + return ghConfig.Write(c.cfg) +} + +// 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 string) error { + if hostname == "" { + return nil + } + _ = c.cfg.Remove([]string{hosts, hostname}) + return ghConfig.Write(c.cfg) +} + type AliasConfig struct { cfg *ghConfig.Config } diff --git a/internal/config/config_mock.go b/internal/config/config_mock.go index 86f45c1c25e..0878d2dae33 100644 --- a/internal/config/config_mock.go +++ b/internal/config/config_mock.go @@ -23,6 +23,9 @@ var _ Config = &ConfigMock{} // AuthTokenFunc: func(s string) (string, string) { // panic("mock out the AuthToken method") // }, +// AuthenticationFunc: func() *AuthConfig { +// panic("mock out the Authentication method") +// }, // DefaultHostFunc: func() (string, string) { // panic("mock out the DefaultHost method") // }, @@ -32,15 +35,9 @@ var _ Config = &ConfigMock{} // GetOrDefaultFunc: func(s1 string, s2 string) (string, error) { // panic("mock out the GetOrDefault method") // }, -// HostsFunc: func() []string { -// panic("mock out the Hosts method") -// }, // SetFunc: func(s1 string, s2 string, s3 string) { // panic("mock out the Set method") // }, -// UnsetHostFunc: func(s string) { -// panic("mock out the UnsetHost method") -// }, // WriteFunc: func() error { // panic("mock out the Write method") // }, @@ -57,6 +54,9 @@ type ConfigMock struct { // AuthTokenFunc mocks the AuthToken method. AuthTokenFunc func(s string) (string, string) + // AuthenticationFunc mocks the Authentication method. + AuthenticationFunc func() *AuthConfig + // DefaultHostFunc mocks the DefaultHost method. DefaultHostFunc func() (string, string) @@ -66,15 +66,9 @@ type ConfigMock struct { // GetOrDefaultFunc mocks the GetOrDefault method. GetOrDefaultFunc func(s1 string, s2 string) (string, error) - // HostsFunc mocks the Hosts method. - HostsFunc func() []string - // SetFunc mocks the Set method. SetFunc func(s1 string, s2 string, s3 string) - // UnsetHostFunc mocks the UnsetHost method. - UnsetHostFunc func(s string) - // WriteFunc mocks the Write method. WriteFunc func() error @@ -88,6 +82,9 @@ type ConfigMock struct { // S is the s argument value. S string } + // Authentication holds details about calls to the Authentication method. + Authentication []struct { + } // DefaultHost holds details about calls to the DefaultHost method. DefaultHost []struct { } @@ -105,9 +102,6 @@ type ConfigMock struct { // S2 is the s2 argument value. S2 string } - // Hosts holds details about calls to the Hosts method. - Hosts []struct { - } // Set holds details about calls to the Set method. Set []struct { // S1 is the s1 argument value. @@ -117,24 +111,18 @@ type ConfigMock struct { // S3 is the s3 argument value. S3 string } - // UnsetHost holds details about calls to the UnsetHost method. - UnsetHost []struct { - // S is the s argument value. - S string - } // Write holds details about calls to the Write method. Write []struct { } } - lockAliases sync.RWMutex - lockAuthToken sync.RWMutex - lockDefaultHost sync.RWMutex - lockGet sync.RWMutex - lockGetOrDefault sync.RWMutex - lockHosts sync.RWMutex - lockSet sync.RWMutex - lockUnsetHost sync.RWMutex - lockWrite sync.RWMutex + lockAliases sync.RWMutex + lockAuthToken sync.RWMutex + lockAuthentication sync.RWMutex + lockDefaultHost sync.RWMutex + lockGet sync.RWMutex + lockGetOrDefault sync.RWMutex + lockSet sync.RWMutex + lockWrite sync.RWMutex } // Aliases calls AliasesFunc. @@ -196,6 +184,33 @@ func (mock *ConfigMock) AuthTokenCalls() []struct { return calls } +// Authentication calls AuthenticationFunc. +func (mock *ConfigMock) Authentication() *AuthConfig { + if mock.AuthenticationFunc == nil { + panic("ConfigMock.AuthenticationFunc: method is nil but Config.Authentication was just called") + } + callInfo := struct { + }{} + mock.lockAuthentication.Lock() + mock.calls.Authentication = append(mock.calls.Authentication, callInfo) + mock.lockAuthentication.Unlock() + return mock.AuthenticationFunc() +} + +// AuthenticationCalls gets all the calls that were made to Authentication. +// Check the length with: +// +// len(mockedConfig.AuthenticationCalls()) +func (mock *ConfigMock) AuthenticationCalls() []struct { +} { + var calls []struct { + } + mock.lockAuthentication.RLock() + calls = mock.calls.Authentication + mock.lockAuthentication.RUnlock() + return calls +} + // DefaultHost calls DefaultHostFunc. func (mock *ConfigMock) DefaultHost() (string, string) { if mock.DefaultHostFunc == nil { @@ -295,33 +310,6 @@ func (mock *ConfigMock) GetOrDefaultCalls() []struct { return calls } -// Hosts calls HostsFunc. -func (mock *ConfigMock) Hosts() []string { - if mock.HostsFunc == nil { - panic("ConfigMock.HostsFunc: method is nil but Config.Hosts was just called") - } - callInfo := struct { - }{} - mock.lockHosts.Lock() - mock.calls.Hosts = append(mock.calls.Hosts, callInfo) - mock.lockHosts.Unlock() - return mock.HostsFunc() -} - -// HostsCalls gets all the calls that were made to Hosts. -// Check the length with: -// -// len(mockedConfig.HostsCalls()) -func (mock *ConfigMock) HostsCalls() []struct { -} { - var calls []struct { - } - mock.lockHosts.RLock() - calls = mock.calls.Hosts - mock.lockHosts.RUnlock() - return calls -} - // Set calls SetFunc. func (mock *ConfigMock) Set(s1 string, s2 string, s3 string) { if mock.SetFunc == nil { @@ -362,38 +350,6 @@ func (mock *ConfigMock) SetCalls() []struct { return calls } -// UnsetHost calls UnsetHostFunc. -func (mock *ConfigMock) UnsetHost(s string) { - if mock.UnsetHostFunc == nil { - panic("ConfigMock.UnsetHostFunc: method is nil but Config.UnsetHost was just called") - } - callInfo := struct { - S string - }{ - S: s, - } - mock.lockUnsetHost.Lock() - mock.calls.UnsetHost = append(mock.calls.UnsetHost, callInfo) - mock.lockUnsetHost.Unlock() - mock.UnsetHostFunc(s) -} - -// UnsetHostCalls gets all the calls that were made to UnsetHost. -// Check the length with: -// -// len(mockedConfig.UnsetHostCalls()) -func (mock *ConfigMock) UnsetHostCalls() []struct { - S string -} { - var calls []struct { - S string - } - mock.lockUnsetHost.RLock() - calls = mock.calls.UnsetHost - mock.lockUnsetHost.RUnlock() - return calls -} - // Write calls WriteFunc. func (mock *ConfigMock) Write() error { if mock.WriteFunc == nil { diff --git a/internal/config/stub.go b/internal/config/stub.go index 0fed79cf55a..fa237ff47c1 100644 --- a/internal/config/stub.go +++ b/internal/config/stub.go @@ -34,10 +34,6 @@ func NewFromString(cfgStr string) *ConfigMock { c := ghConfig.ReadFromString(cfgStr) cfg := cfg{c} mock := &ConfigMock{} - mock.AuthTokenFunc = func(host string) (string, string) { - token, _ := c.Get([]string{"hosts", host, "oauth_token"}) - return token, "oauth_token" - } mock.GetFunc = func(host, key string) (string, error) { return cfg.Get(host, key) } @@ -47,21 +43,35 @@ func NewFromString(cfgStr string) *ConfigMock { mock.SetFunc = func(host, key, value string) { cfg.Set(host, key, value) } - mock.UnsetHostFunc = func(host string) { - cfg.UnsetHost(host) - } - mock.HostsFunc = func() []string { - keys, _ := c.Keys([]string{"hosts"}) - return keys - } - mock.DefaultHostFunc = func() (string, string) { - return "github.com", "default" + mock.WriteFunc = func() error { + return cfg.Write() } mock.AliasesFunc = func() *AliasConfig { return &AliasConfig{cfg: c} } - mock.WriteFunc = func() error { - return cfg.Write() + mock.AuthenticationFunc = func() *AuthConfig { + return &AuthConfig{ + cfg: c, + hostsOverride: func() []string { + keys, _ := c.Keys([]string{"hosts"}) + return keys + }, + tokenOverride: func(hostname string) (string, string) { + token, _ := c.Get([]string{hosts, hostname, oauthToken}) + return token, "oauth_token" + }, + } + } + // This is deprecated and will be removed, do not use! + // Please use cfg.Authentication().Token() + mock.AuthTokenFunc = func(host string) (string, string) { + token, _ := c.Get([]string{"hosts", host, "oauth_token"}) + return token, "oauth_token" + } + // This is deprecated and will be removed, do not use! + // Please use cfg.Authentication().DefaultHost() + mock.DefaultHostFunc = func() (string, string) { + return "github.com", "default" } return mock } diff --git a/pkg/cmd/auth/login/login.go b/pkg/cmd/auth/login/login.go index 50cf111f213..a3f80a5516a 100644 --- a/pkg/cmd/auth/login/login.go +++ b/pkg/cmd/auth/login/login.go @@ -8,6 +8,7 @@ import ( "github.com/MakeNowJust/heredoc" "github.com/cli/cli/v2/git" + "github.com/cli/cli/v2/internal/browser" "github.com/cli/cli/v2/internal/config" "github.com/cli/cli/v2/internal/ghinstance" "github.com/cli/cli/v2/pkg/cmd/auth/shared" @@ -23,6 +24,7 @@ type LoginOptions struct { HttpClient func() (*http.Client, error) GitClient *git.Client Prompter shared.Prompt + Browser browser.Browser MainExecutable string @@ -42,6 +44,7 @@ func NewCmdLogin(f *cmdutil.Factory, runF func(*LoginOptions) error) *cobra.Comm HttpClient: f.HttpClient, GitClient: f.GitClient, Prompter: f.Prompter, + Browser: f.Browser, } var tokenStdin bool @@ -129,6 +132,7 @@ func loginRun(opts *LoginOptions) error { if err != nil { return err } + authCfg := cfg.Authentication() hostname := opts.Hostname if opts.Interactive && hostname == "" { @@ -139,7 +143,7 @@ func loginRun(opts *LoginOptions) error { } } - if src, writeable := shared.AuthTokenWriteable(cfg, hostname); !writeable { + if src, writeable := shared.AuthTokenWriteable(authCfg, hostname); !writeable { fmt.Fprintf(opts.IO.ErrOut, "The value of the %s environment variable is being used for authentication.\n", src) fmt.Fprint(opts.IO.ErrOut, "To have GitHub CLI store credentials instead, first clear the value from the environment.\n") return cmdutil.SilentError @@ -151,18 +155,14 @@ func loginRun(opts *LoginOptions) error { } if opts.Token != "" { - cfg.Set(hostname, "oauth_token", opts.Token) - if err := shared.HasMinimumScopes(httpClient, hostname, opts.Token); err != nil { return fmt.Errorf("error validating token: %w", err) } - if opts.GitProtocol != "" { - cfg.Set(hostname, "git_protocol", opts.GitProtocol) - } - return cfg.Write() + + return authCfg.Login(hostname, "", opts.Token, opts.GitProtocol, false) } - existingToken, _ := cfg.AuthToken(hostname) + existingToken, _ := authCfg.Token(hostname) if existingToken != "" && opts.Interactive { if err := shared.HasMinimumScopes(httpClient, hostname, existingToken); err == nil { keepGoing, err := opts.Prompter.Confirm(fmt.Sprintf("You're already logged into %s. Do you want to re-authenticate?", hostname), false) @@ -177,7 +177,7 @@ func loginRun(opts *LoginOptions) error { return shared.Login(&shared.LoginOptions{ IO: opts.IO, - Config: cfg, + Config: authCfg, HTTPClient: httpClient, Hostname: hostname, Interactive: opts.Interactive, @@ -187,6 +187,7 @@ func loginRun(opts *LoginOptions) error { GitProtocol: opts.GitProtocol, Prompter: opts.Prompter, GitClient: opts.GitClient, + Browser: opts.Browser, }) } diff --git a/pkg/cmd/auth/login/login_test.go b/pkg/cmd/auth/login/login_test.go index 85e133b2bac..168df246e46 100644 --- a/pkg/cmd/auth/login/login_test.go +++ b/pkg/cmd/auth/login/login_test.go @@ -305,8 +305,10 @@ func Test_loginRun_nontty(t *testing.T) { Token: "abc456", }, cfgStubs: func(c *config.ConfigMock) { - c.AuthTokenFunc = func(string) (string, string) { - return "value_from_env", "GH_TOKEN" + authCfg := c.Authentication() + authCfg.SetToken("value_from_env", "GH_TOKEN") + c.AuthenticationFunc = func() *config.AuthConfig { + return authCfg } }, wantErr: "SilentError", @@ -322,8 +324,10 @@ func Test_loginRun_nontty(t *testing.T) { Token: "abc456", }, cfgStubs: func(c *config.ConfigMock) { - c.AuthTokenFunc = func(string) (string, string) { - return "value_from_env", "GH_ENTERPRISE_TOKEN" + authCfg := c.Authentication() + authCfg.SetToken("value_from_env", "GH_ENTERPRISE_TOKEN") + c.AuthenticationFunc = func() *config.AuthConfig { + return authCfg } }, wantErr: "SilentError", @@ -399,8 +403,10 @@ func Test_loginRun_Survey(t *testing.T) { Interactive: true, }, cfgStubs: func(c *config.ConfigMock) { - c.AuthTokenFunc = func(h string) (string, string) { - return "ghi789", "oauth_token" + authCfg := c.Authentication() + authCfg.SetToken("ghi789", "oauth_token") + c.AuthenticationFunc = func() *config.AuthConfig { + return authCfg } }, httpStubs: func(reg *httpmock.Registry) { diff --git a/pkg/cmd/auth/logout/logout.go b/pkg/cmd/auth/logout/logout.go index 4495cd5f6dc..c871a5333dc 100644 --- a/pkg/cmd/auth/logout/logout.go +++ b/pkg/cmd/auth/logout/logout.go @@ -69,8 +69,9 @@ func logoutRun(opts *LogoutOptions) error { if err != nil { return err } + authCfg := cfg.Authentication() - candidates := cfg.Hosts() + candidates := authCfg.Hosts() if len(candidates) == 0 { return fmt.Errorf("not logged in to any hosts") } @@ -100,7 +101,7 @@ func logoutRun(opts *LogoutOptions) error { } } - if src, writeable := shared.AuthTokenWriteable(cfg, hostname); !writeable { + if src, writeable := shared.AuthTokenWriteable(authCfg, hostname); !writeable { fmt.Fprintf(opts.IO.ErrOut, "The value of the %s environment variable is being used for authentication.\n", src) fmt.Fprint(opts.IO.ErrOut, "To erase credentials stored in GitHub CLI, first clear the value from the environment.\n") return cmdutil.SilentError @@ -116,7 +117,7 @@ func logoutRun(opts *LogoutOptions) error { if err != nil { // suppressing; the user is trying to delete this token and it might be bad. // we'll see if the username is in the config and fall back to that. - username, _ = cfg.Get(hostname, "user") + username, _ = authCfg.User(hostname) } usernameStr := "" @@ -124,9 +125,7 @@ func logoutRun(opts *LogoutOptions) error { usernameStr = fmt.Sprintf(" account '%s'", username) } - cfg.UnsetHost(hostname) - err = cfg.Write() - if err != nil { + if err := authCfg.Logout(hostname); err != nil { return fmt.Errorf("failed to write config, authentication configuration not updated: %w", err) } diff --git a/pkg/cmd/auth/refresh/refresh.go b/pkg/cmd/auth/refresh/refresh.go index c66fb523762..7b1a9ef76c9 100644 --- a/pkg/cmd/auth/refresh/refresh.go +++ b/pkg/cmd/auth/refresh/refresh.go @@ -26,7 +26,7 @@ type RefreshOptions struct { Hostname string Scopes []string - AuthFlow func(config.Config, *iostreams.IOStreams, string, []string, bool) error + AuthFlow func(*config.AuthConfig, *iostreams.IOStreams, string, []string, bool) error Interactive bool } @@ -35,9 +35,12 @@ func NewCmdRefresh(f *cmdutil.Factory, runF func(*RefreshOptions) error) *cobra. opts := &RefreshOptions{ IO: f.IOStreams, Config: f.Config, - AuthFlow: func(cfg config.Config, io *iostreams.IOStreams, hostname string, scopes []string, interactive bool) error { - _, err := authflow.AuthFlowWithConfig(cfg, io, hostname, "", scopes, interactive) - return err + AuthFlow: func(authCfg *config.AuthConfig, io *iostreams.IOStreams, hostname string, scopes []string, interactive bool) error { + token, username, err := authflow.AuthFlow(hostname, io, "", scopes, interactive, f.Browser) + if err != nil { + return err + } + return authCfg.Login(hostname, username, token, "", false) }, HttpClient: &http.Client{}, GitClient: f.GitClient, @@ -86,8 +89,9 @@ func refreshRun(opts *RefreshOptions) error { if err != nil { return err } + authCfg := cfg.Authentication() - candidates := cfg.Hosts() + candidates := authCfg.Hosts() if len(candidates) == 0 { return fmt.Errorf("not logged in to any hosts. Use 'gh auth login' to authenticate with a host") } @@ -117,14 +121,14 @@ func refreshRun(opts *RefreshOptions) error { } } - if src, writeable := shared.AuthTokenWriteable(cfg, hostname); !writeable { + if src, writeable := shared.AuthTokenWriteable(authCfg, hostname); !writeable { fmt.Fprintf(opts.IO.ErrOut, "The value of the %s environment variable is being used for authentication.\n", src) fmt.Fprint(opts.IO.ErrOut, "To refresh credentials stored in GitHub CLI, first clear the value from the environment.\n") return cmdutil.SilentError } var additionalScopes []string - if oldToken, _ := cfg.AuthToken(hostname); oldToken != "" { + if oldToken, _ := authCfg.Token(hostname); oldToken != "" { if oldScopes, err := shared.GetScopes(opts.HttpClient, hostname, oldToken); err == nil { for _, s := range strings.Split(oldScopes, ",") { s = strings.TrimSpace(s) @@ -140,7 +144,7 @@ func refreshRun(opts *RefreshOptions) error { Prompter: opts.Prompter, GitClient: opts.GitClient, } - gitProtocol, _ := cfg.GetOrDefault(hostname, "git_protocol") + gitProtocol, _ := authCfg.GitProtocol(hostname) if opts.Interactive && gitProtocol == "https" { if err := credentialFlow.Prompt(hostname); err != nil { return err @@ -148,7 +152,7 @@ func refreshRun(opts *RefreshOptions) error { additionalScopes = append(additionalScopes, credentialFlow.Scopes()...) } - if err := opts.AuthFlow(cfg, opts.IO, hostname, append(opts.Scopes, additionalScopes...), opts.Interactive); err != nil { + if err := opts.AuthFlow(authCfg, opts.IO, hostname, append(opts.Scopes, additionalScopes...), opts.Interactive); err != nil { return err } @@ -156,8 +160,8 @@ func refreshRun(opts *RefreshOptions) error { fmt.Fprintf(opts.IO.ErrOut, "%s Authentication complete.\n", cs.SuccessIcon()) if credentialFlow.ShouldSetup() { - username, _ := cfg.Get(hostname, "user") - password, _ := cfg.AuthToken(hostname) + username, _ := authCfg.User(hostname) + password, _ := authCfg.Token(hostname) if err := credentialFlow.Setup(hostname, username, password); err != nil { return err } diff --git a/pkg/cmd/auth/refresh/refresh_test.go b/pkg/cmd/auth/refresh/refresh_test.go index d1d6042c041..89903ad0d09 100644 --- a/pkg/cmd/auth/refresh/refresh_test.go +++ b/pkg/cmd/auth/refresh/refresh_test.go @@ -234,7 +234,7 @@ func Test_refreshRun(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { aa := authArgs{} - tt.opts.AuthFlow = func(_ config.Config, _ *iostreams.IOStreams, hostname string, scopes []string, interactive bool) error { + tt.opts.AuthFlow = func(_ *config.AuthConfig, _ *iostreams.IOStreams, hostname string, scopes []string, interactive bool) error { aa.hostname = hostname aa.scopes = scopes return nil diff --git a/pkg/cmd/auth/setupgit/setupgit.go b/pkg/cmd/auth/setupgit/setupgit.go index edc531235ed..a041ebfc1b3 100644 --- a/pkg/cmd/auth/setupgit/setupgit.go +++ b/pkg/cmd/auth/setupgit/setupgit.go @@ -54,8 +54,9 @@ func setupGitRun(opts *SetupGitOptions) error { if err != nil { return err } + authCfg := cfg.Authentication() - hostnames := cfg.Hosts() + hostnames := authCfg.Hosts() stderr := opts.IO.ErrOut cs := opts.IO.ColorScheme() diff --git a/pkg/cmd/auth/setupgit/setupgit_test.go b/pkg/cmd/auth/setupgit/setupgit_test.go index 1f3f0be92b5..635cece8c77 100644 --- a/pkg/cmd/auth/setupgit/setupgit_test.go +++ b/pkg/cmd/auth/setupgit/setupgit_test.go @@ -38,8 +38,10 @@ func Test_setupGitRun(t *testing.T) { opts: &SetupGitOptions{ Config: func() (config.Config, error) { cfg := &config.ConfigMock{} - cfg.HostsFunc = func() []string { - return []string{} + cfg.AuthenticationFunc = func() *config.AuthConfig { + authCfg := &config.AuthConfig{} + authCfg.SetHosts([]string{}) + return authCfg } return cfg, nil }, @@ -53,8 +55,10 @@ func Test_setupGitRun(t *testing.T) { Hostname: "foo", Config: func() (config.Config, error) { cfg := &config.ConfigMock{} - cfg.HostsFunc = func() []string { - return []string{"bar"} + cfg.AuthenticationFunc = func() *config.AuthConfig { + authCfg := &config.AuthConfig{} + authCfg.SetHosts([]string{"bar"}) + return authCfg } return cfg, nil }, @@ -70,8 +74,10 @@ func Test_setupGitRun(t *testing.T) { }, Config: func() (config.Config, error) { cfg := &config.ConfigMock{} - cfg.HostsFunc = func() []string { - return []string{"bar"} + cfg.AuthenticationFunc = func() *config.AuthConfig { + authCfg := &config.AuthConfig{} + authCfg.SetHosts([]string{"bar"}) + return authCfg } return cfg, nil }, @@ -85,8 +91,10 @@ func Test_setupGitRun(t *testing.T) { gitConfigure: &mockGitConfigurer{}, Config: func() (config.Config, error) { cfg := &config.ConfigMock{} - cfg.HostsFunc = func() []string { - return []string{"bar"} + cfg.AuthenticationFunc = func() *config.AuthConfig { + authCfg := &config.AuthConfig{} + authCfg.SetHosts([]string{"bar"}) + return authCfg } return cfg, nil }, @@ -99,8 +107,10 @@ func Test_setupGitRun(t *testing.T) { gitConfigure: &mockGitConfigurer{}, Config: func() (config.Config, error) { cfg := &config.ConfigMock{} - cfg.HostsFunc = func() []string { - return []string{"bar", "yes"} + cfg.AuthenticationFunc = func() *config.AuthConfig { + authCfg := &config.AuthConfig{} + authCfg.SetHosts([]string{"bar", "yes"}) + return authCfg } return cfg, nil }, diff --git a/pkg/cmd/auth/shared/login_flow.go b/pkg/cmd/auth/shared/login_flow.go index 99d82b5f79f..4f96eada3be 100644 --- a/pkg/cmd/auth/shared/login_flow.go +++ b/pkg/cmd/auth/shared/login_flow.go @@ -10,6 +10,7 @@ import ( "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/git" "github.com/cli/cli/v2/internal/authflow" + "github.com/cli/cli/v2/internal/browser" "github.com/cli/cli/v2/internal/ghinstance" "github.com/cli/cli/v2/pkg/cmd/ssh-key/add" "github.com/cli/cli/v2/pkg/iostreams" @@ -19,9 +20,7 @@ import ( const defaultSSHKeyTitle = "GitHub CLI" type iconfig interface { - Get(string, string) (string, error) - Set(string, string, string) - Write() error + Login(string, string, string, string, bool) error } type LoginOptions struct { @@ -36,6 +35,7 @@ type LoginOptions struct { Executable string GitProtocol string Prompter Prompt + Browser browser.Browser sshContext ssh.Context } @@ -145,16 +145,15 @@ func Login(opts *LoginOptions) error { } var authToken string - userValidated := false + var username string if authMode == 0 { var err error - authToken, err = authflow.AuthFlowWithConfig(cfg, opts.IO, hostname, "", append(opts.Scopes, additionalScopes...), opts.Interactive) + authToken, username, err = authflow.AuthFlow(hostname, opts.IO, "", append(opts.Scopes, additionalScopes...), opts.Interactive, opts.Browser) if err != nil { return fmt.Errorf("failed to authenticate via web browser: %w", err) } fmt.Fprintf(opts.IO.ErrOut, "%s Authentication complete.\n", cs.SuccessIcon()) - userValidated = true } else { minimumScopes := append([]string{"repo", "read:org"}, additionalScopes...) fmt.Fprint(opts.IO.ErrOut, heredoc.Docf(` @@ -162,7 +161,8 @@ func Login(opts *LoginOptions) error { The minimum required scopes are %s. `, hostname, scopesSentence(minimumScopes, ghinstance.IsEnterprise(hostname)))) - authToken, err := opts.Prompter.AuthToken() + var err error + authToken, err = opts.Prompter.AuthToken() if err != nil { return err } @@ -170,32 +170,23 @@ func Login(opts *LoginOptions) error { if err := HasMinimumScopes(httpClient, hostname, authToken); err != nil { return fmt.Errorf("error validating token: %w", err) } - - cfg.Set(hostname, "oauth_token", authToken) } - var username string - if userValidated { - username, _ = cfg.Get(hostname, "user") - } else { + if username == "" { apiClient := api.NewClientFromHTTP(httpClient) var err error username, err = api.CurrentLoginName(apiClient, hostname) if err != nil { return fmt.Errorf("error using api: %w", err) } - - cfg.Set(hostname, "user", username) } if gitProtocol != "" { fmt.Fprintf(opts.IO.ErrOut, "- gh config set -h %s git_protocol %s\n", hostname, gitProtocol) - cfg.Set(hostname, "git_protocol", gitProtocol) fmt.Fprintf(opts.IO.ErrOut, "%s Configured git protocol\n", cs.SuccessIcon()) } - err := cfg.Write() - if err != nil { + if err := cfg.Login(hostname, username, authToken, gitProtocol, false); err != nil { return err } diff --git a/pkg/cmd/auth/shared/login_flow_test.go b/pkg/cmd/auth/shared/login_flow_test.go index 40d04566178..f01ba46ff8b 100644 --- a/pkg/cmd/auth/shared/login_flow_test.go +++ b/pkg/cmd/auth/shared/login_flow_test.go @@ -18,15 +18,10 @@ import ( type tinyConfig map[string]string -func (c tinyConfig) Get(host, key string) (string, error) { - return c[fmt.Sprintf("%s:%s", host, key)], nil -} - -func (c tinyConfig) Set(host string, key string, value string) { - c[fmt.Sprintf("%s:%s", host, key)] = value -} - -func (c tinyConfig) Write() error { +func (c tinyConfig) Login(host, username, token, gitProtocol string, encrypt bool) error { + c[fmt.Sprintf("%s:%s", host, "user")] = username + c[fmt.Sprintf("%s:%s", host, "oauth_token")] = token + c[fmt.Sprintf("%s:%s", host, "git_protocol")] = gitProtocol return nil } diff --git a/pkg/cmd/auth/shared/writeable.go b/pkg/cmd/auth/shared/writeable.go index ef117f32d00..2de17054bb7 100644 --- a/pkg/cmd/auth/shared/writeable.go +++ b/pkg/cmd/auth/shared/writeable.go @@ -8,7 +8,7 @@ const ( oauthToken = "oauth_token" ) -func AuthTokenWriteable(cfg config.Config, hostname string) (string, bool) { - token, src := cfg.AuthToken(hostname) +func AuthTokenWriteable(authCfg *config.AuthConfig, hostname string) (string, bool) { + token, src := authCfg.Token(hostname) return src, (token == "" || src == oauthToken) } diff --git a/pkg/cmd/auth/status/status.go b/pkg/cmd/auth/status/status.go index c8c4f0ee622..8070fe9aed3 100644 --- a/pkg/cmd/auth/status/status.go +++ b/pkg/cmd/auth/status/status.go @@ -61,6 +61,7 @@ func statusRun(opts *StatusOptions) error { if err != nil { return err } + authCfg := cfg.Authentication() // TODO check tty @@ -70,7 +71,7 @@ func statusRun(opts *StatusOptions) error { statusInfo := map[string][]string{} - hostnames := cfg.Hosts() + hostnames := authCfg.Hosts() if len(hostnames) == 0 { fmt.Fprintf(stderr, "You are not logged into any GitHub hosts. Run %s to authenticate.\n", cs.Bold("gh auth login")) @@ -91,13 +92,13 @@ func statusRun(opts *StatusOptions) error { } isHostnameFound = true - token, tokenSource := cfg.AuthToken(hostname) + token, tokenSource := authCfg.Token(hostname) if tokenSource == "oauth_token" { // The go-gh function TokenForHost returns this value as source for tokens read from the // config file, but we want the file path instead. This attempts to reconstruct it. tokenSource = filepath.Join(config.ConfigDir(), "hosts.yml") } - _, tokenIsWriteable := shared.AuthTokenWriteable(cfg, hostname) + _, tokenIsWriteable := shared.AuthTokenWriteable(authCfg, hostname) statusInfo[hostname] = []string{} addMsg := func(x string, ys ...interface{}) { @@ -138,7 +139,7 @@ func statusRun(opts *StatusOptions) error { } addMsg("%s Logged in to %s as %s (%s)", cs.SuccessIcon(), hostname, cs.Bold(username), tokenSource) - proto, _ := cfg.GetOrDefault(hostname, "git_protocol") + proto, _ := authCfg.GitProtocol(hostname) if proto != "" { addMsg("%s Git operations for %s configured to use %s protocol.", cs.SuccessIcon(), hostname, cs.Bold(proto)) diff --git a/pkg/cmd/factory/default_test.go b/pkg/cmd/factory/default_test.go index f9b316bceff..6ff628cca6c 100644 --- a/pkg/cmd/factory/default_test.go +++ b/pkg/cmd/factory/default_test.go @@ -71,12 +71,14 @@ func Test_BaseRepo(t *testing.T) { }, getConfig: func() (config.Config, error) { cfg := &config.ConfigMock{} - cfg.HostsFunc = func() []string { + cfg.AuthenticationFunc = func() *config.AuthConfig { + authCfg := &config.AuthConfig{} hosts := []string{"nonsense.com"} if tt.override != "" { hosts = append([]string{tt.override}, hosts...) } - return hosts + authCfg.SetHosts(hosts) + return authCfg } cfg.DefaultHostFunc = func() (string, string) { if tt.override != "" { @@ -214,12 +216,14 @@ func Test_SmartBaseRepo(t *testing.T) { cfg.AuthTokenFunc = func(_ string) (string, string) { return "", "" } - cfg.HostsFunc = func() []string { + cfg.AuthenticationFunc = func() *config.AuthConfig { + authCfg := &config.AuthConfig{} hosts := []string{"nonsense.com"} if tt.override != "" { hosts = append([]string{tt.override}, hosts...) } - return hosts + authCfg.SetHosts(hosts) + return authCfg } cfg.DefaultHostFunc = func() (string, string) { if tt.override != "" { diff --git a/pkg/cmd/factory/remote_resolver.go b/pkg/cmd/factory/remote_resolver.go index 86a0aa3241b..fc52d03d009 100644 --- a/pkg/cmd/factory/remote_resolver.go +++ b/pkg/cmd/factory/remote_resolver.go @@ -53,7 +53,7 @@ func (rr *remoteResolver) Resolver() func() (context.Remotes, error) { return nil, err } - authedHosts := cfg.Hosts() + authedHosts := cfg.Authentication().Hosts() if len(authedHosts) == 0 { return nil, errors.New("could not find any host configurations") } diff --git a/pkg/cmd/factory/remote_resolver_test.go b/pkg/cmd/factory/remote_resolver_test.go index bc20686a902..9761a709562 100644 --- a/pkg/cmd/factory/remote_resolver_test.go +++ b/pkg/cmd/factory/remote_resolver_test.go @@ -32,8 +32,10 @@ func Test_remoteResolver(t *testing.T) { }, config: func() config.Config { cfg := &config.ConfigMock{} - cfg.HostsFunc = func() []string { - return []string{} + cfg.AuthenticationFunc = func() *config.AuthConfig { + authCfg := &config.AuthConfig{} + authCfg.SetHosts([]string{}) + return authCfg } cfg.DefaultHostFunc = func() (string, string) { return "github.com", "default" @@ -49,8 +51,10 @@ func Test_remoteResolver(t *testing.T) { }, config: func() config.Config { cfg := &config.ConfigMock{} - cfg.HostsFunc = func() []string { - return []string{"example.com"} + cfg.AuthenticationFunc = func() *config.AuthConfig { + authCfg := &config.AuthConfig{} + authCfg.SetHosts([]string{"example.com"}) + return authCfg } cfg.DefaultHostFunc = func() (string, string) { return "example.com", "hosts" @@ -68,8 +72,10 @@ func Test_remoteResolver(t *testing.T) { }, config: func() config.Config { cfg := &config.ConfigMock{} - cfg.HostsFunc = func() []string { - return []string{"example.com"} + cfg.AuthenticationFunc = func() *config.AuthConfig { + authCfg := &config.AuthConfig{} + authCfg.SetHosts([]string{"example.com"}) + return authCfg } cfg.DefaultHostFunc = func() (string, string) { return "example.com", "hosts" @@ -90,8 +96,10 @@ func Test_remoteResolver(t *testing.T) { }, config: func() config.Config { cfg := &config.ConfigMock{} - cfg.HostsFunc = func() []string { - return []string{"example.com"} + cfg.AuthenticationFunc = func() *config.AuthConfig { + authCfg := &config.AuthConfig{} + authCfg.SetHosts([]string{"example.com"}) + return authCfg } cfg.DefaultHostFunc = func() (string, string) { return "example.com", "hosts" @@ -109,8 +117,10 @@ func Test_remoteResolver(t *testing.T) { }, config: func() config.Config { cfg := &config.ConfigMock{} - cfg.HostsFunc = func() []string { - return []string{"example.com"} + cfg.AuthenticationFunc = func() *config.AuthConfig { + authCfg := &config.AuthConfig{} + authCfg.SetHosts([]string{"example.com"}) + return authCfg } cfg.DefaultHostFunc = func() (string, string) { return "example.com", "default" @@ -131,8 +141,10 @@ func Test_remoteResolver(t *testing.T) { }, config: func() config.Config { cfg := &config.ConfigMock{} - cfg.HostsFunc = func() []string { - return []string{"example.com"} + cfg.AuthenticationFunc = func() *config.AuthConfig { + authCfg := &config.AuthConfig{} + authCfg.SetHosts([]string{"example.com"}) + return authCfg } cfg.DefaultHostFunc = func() (string, string) { return "example.com", "default" @@ -150,8 +162,10 @@ func Test_remoteResolver(t *testing.T) { }, config: func() config.Config { cfg := &config.ConfigMock{} - cfg.HostsFunc = func() []string { - return []string{"example.com", "github.com"} + cfg.AuthenticationFunc = func() *config.AuthConfig { + authCfg := &config.AuthConfig{} + authCfg.SetHosts([]string{"example.com", "github.com"}) + return authCfg } cfg.DefaultHostFunc = func() (string, string) { return "github.com", "default" @@ -173,8 +187,10 @@ func Test_remoteResolver(t *testing.T) { }, config: func() config.Config { cfg := &config.ConfigMock{} - cfg.HostsFunc = func() []string { - return []string{"example.com", "github.com"} + cfg.AuthenticationFunc = func() *config.AuthConfig { + authCfg := &config.AuthConfig{} + authCfg.SetHosts([]string{"example.com", "github.com"}) + return authCfg } cfg.DefaultHostFunc = func() (string, string) { return "github.com", "default" @@ -196,8 +212,10 @@ func Test_remoteResolver(t *testing.T) { }, config: func() config.Config { cfg := &config.ConfigMock{} - cfg.HostsFunc = func() []string { - return []string{"example.com", "github.com"} + cfg.AuthenticationFunc = func() *config.AuthConfig { + authCfg := &config.AuthConfig{} + authCfg.SetHosts([]string{"example.com", "github.com"}) + return authCfg } cfg.DefaultHostFunc = func() (string, string) { return "github.com", "default" @@ -215,8 +233,10 @@ func Test_remoteResolver(t *testing.T) { }, config: func() config.Config { cfg := &config.ConfigMock{} - cfg.HostsFunc = func() []string { - return []string{"example.com"} + cfg.AuthenticationFunc = func() *config.AuthConfig { + authCfg := &config.AuthConfig{} + authCfg.SetHosts([]string{"example.com"}) + return authCfg } cfg.DefaultHostFunc = func() (string, string) { return "test.com", "GH_HOST" @@ -235,8 +255,10 @@ func Test_remoteResolver(t *testing.T) { }, config: func() config.Config { cfg := &config.ConfigMock{} - cfg.HostsFunc = func() []string { - return []string{"example.com"} + cfg.AuthenticationFunc = func() *config.AuthConfig { + authCfg := &config.AuthConfig{} + authCfg.SetHosts([]string{"example.com"}) + return authCfg } cfg.DefaultHostFunc = func() (string, string) { return "test.com", "GH_HOST" @@ -256,8 +278,10 @@ func Test_remoteResolver(t *testing.T) { }, config: func() config.Config { cfg := &config.ConfigMock{} - cfg.HostsFunc = func() []string { - return []string{"example.com", "test.com"} + cfg.AuthenticationFunc = func() *config.AuthConfig { + authCfg := &config.AuthConfig{} + authCfg.SetHosts([]string{"example.com", "test.com"}) + return authCfg } cfg.DefaultHostFunc = func() (string, string) { return "test.com", "GH_HOST" diff --git a/pkg/cmdutil/auth_check.go b/pkg/cmdutil/auth_check.go index 03f76de6fb1..4cc1830520e 100644 --- a/pkg/cmdutil/auth_check.go +++ b/pkg/cmdutil/auth_check.go @@ -23,7 +23,7 @@ func CheckAuth(cfg config.Config) bool { return true } - if len(cfg.Hosts()) > 0 { + if len(cfg.Authentication().Hosts()) > 0 { return true }