diff --git a/internal/config/config_setup.go b/internal/config/config_setup.go index d8fcef1ec..2aaf597c8 100644 --- a/internal/config/config_setup.go +++ b/internal/config/config_setup.go @@ -26,8 +26,8 @@ func IsGitHubApp(id string) bool { return id == "178c6fc778ccc68e1d6a" || id == "4d747ba5675d5d66553f" } -func AuthFlowWithConfig(cfg Config, hostname, notice string) (string, error) { - token, userLogin, err := authFlow(hostname, notice) +func AuthFlowWithConfig(cfg Config, hostname, notice string, additionalScopes []string) (string, error) { + token, userLogin, err := authFlow(hostname, notice, additionalScopes) if err != nil { return "", err } @@ -50,17 +50,20 @@ func AuthFlowWithConfig(cfg Config, hostname, notice string) (string, error) { return token, nil } -func authFlow(oauthHost, notice string) (string, string, error) { +func authFlow(oauthHost, notice string, additionalScopes []string) (string, string, error) { var verboseStream io.Writer if strings.Contains(os.Getenv("DEBUG"), "oauth") { verboseStream = os.Stderr } + minimumScopes := []string{"repo", "read:org", "gist"} + scopes := append(minimumScopes, additionalScopes...) + flow := &auth.OAuthFlow{ Hostname: oauthHost, ClientID: oauthClientID, ClientSecret: oauthClientSecret, - Scopes: []string{"repo", "read:org", "gist"}, + Scopes: scopes, WriteSuccessHTML: func(w io.Writer) { fmt.Fprintln(w, oauthSuccessPage) }, diff --git a/pkg/cmd/auth/auth.go b/pkg/cmd/auth/auth.go index 7f2520631..67c6abb31 100644 --- a/pkg/cmd/auth/auth.go +++ b/pkg/cmd/auth/auth.go @@ -3,6 +3,7 @@ package auth import ( authLoginCmd "github.com/cli/cli/pkg/cmd/auth/login" authLogoutCmd "github.com/cli/cli/pkg/cmd/auth/logout" + authRefreshCmd "github.com/cli/cli/pkg/cmd/auth/refresh" authStatusCmd "github.com/cli/cli/pkg/cmd/auth/status" "github.com/cli/cli/pkg/cmdutil" "github.com/spf13/cobra" @@ -18,6 +19,7 @@ func NewCmdAuth(f *cmdutil.Factory) *cobra.Command { cmd.AddCommand(authLoginCmd.NewCmdLogin(f, nil)) cmd.AddCommand(authLogoutCmd.NewCmdLogout(f, nil)) cmd.AddCommand(authStatusCmd.NewCmdStatus(f, nil)) + cmd.AddCommand(authRefreshCmd.NewCmdRefresh(f, nil)) return cmd } diff --git a/pkg/cmd/auth/login/login.go b/pkg/cmd/auth/login/login.go index cd43c536c..7d53839c6 100644 --- a/pkg/cmd/auth/login/login.go +++ b/pkg/cmd/auth/login/login.go @@ -210,7 +210,7 @@ func loginRun(opts *LoginOptions) error { } if authMode == 0 { - _, err := config.AuthFlowWithConfig(cfg, hostname, "") + _, err := config.AuthFlowWithConfig(cfg, hostname, "", []string{}) if err != nil { return fmt.Errorf("failed to authenticate via web browser: %w", err) } diff --git a/pkg/cmd/auth/logout/logout.go b/pkg/cmd/auth/logout/logout.go index 744cd6a3f..6f047a079 100644 --- a/pkg/cmd/auth/logout/logout.go +++ b/pkg/cmd/auth/logout/logout.go @@ -4,6 +4,7 @@ import ( "errors" "fmt" "net/http" + "os" "github.com/AlecAivazis/survey/v2" "github.com/MakeNowJust/heredoc" @@ -62,6 +63,10 @@ func NewCmdLogout(f *cmdutil.Factory, runF func(*LogoutOptions) error) *cobra.Co } func logoutRun(opts *LogoutOptions) error { + if os.Getenv("GITHUB_TOKEN") != "" { + return errors.New("GITHUB_TOKEN is set in your environment. If you no longer want to use it with gh, please unset it.") + } + isTTY := opts.IO.IsStdinTTY() && opts.IO.IsStdoutTTY() hostname := opts.Hostname diff --git a/pkg/cmd/auth/logout/logout_test.go b/pkg/cmd/auth/logout/logout_test.go index 83d13aad3..50e1bed2a 100644 --- a/pkg/cmd/auth/logout/logout_test.go +++ b/pkg/cmd/auth/logout/logout_test.go @@ -3,6 +3,7 @@ package logout import ( "bytes" "net/http" + "os" "regexp" "testing" @@ -183,6 +184,7 @@ func Test_logoutRun_nontty(t *testing.T) { cfgHosts []string wantHosts string wantErr *regexp.Regexp + ghtoken string }{ { name: "no arguments", @@ -211,10 +213,21 @@ func Test_logoutRun_nontty(t *testing.T) { }, wantErr: regexp.MustCompile(`not logged in to any hosts`), }, + { + name: "gh token is set", + opts: &LogoutOptions{}, + ghtoken: "abc123", + wantErr: regexp.MustCompile(`GITHUB_TOKEN is set in your environment`), + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + ghtoken := os.Getenv("GITHUB_TOKEN") + defer func() { + os.Setenv("GITHUB_TOKEN", ghtoken) + }() + os.Setenv("GITHUB_TOKEN", tt.ghtoken) io, _, _, stderr := iostreams.Test() io.SetStdinTTY(false) diff --git a/pkg/cmd/auth/refresh/refresh.go b/pkg/cmd/auth/refresh/refresh.go new file mode 100644 index 000000000..8a69ea0e2 --- /dev/null +++ b/pkg/cmd/auth/refresh/refresh.go @@ -0,0 +1,116 @@ +package refresh + +import ( + "fmt" + "os" + + "github.com/AlecAivazis/survey/v2" + "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/internal/config" + "github.com/cli/cli/pkg/cmdutil" + "github.com/cli/cli/pkg/iostreams" + "github.com/cli/cli/pkg/prompt" + "github.com/spf13/cobra" +) + +type RefreshOptions struct { + IO *iostreams.IOStreams + Config func() (config.Config, error) + + Hostname string + Scopes []string + AuthFlow func(config.Config, string, []string) error +} + +func NewCmdRefresh(f *cmdutil.Factory, runF func(*RefreshOptions) error) *cobra.Command { + opts := &RefreshOptions{ + IO: f.IOStreams, + Config: f.Config, + AuthFlow: func(cfg config.Config, hostname string, scopes []string) error { + _, err := config.AuthFlowWithConfig(cfg, hostname, "", scopes) + return err + }, + } + + cmd := &cobra.Command{ + Use: "refresh", + Args: cobra.ExactArgs(0), + Short: "Refresh stored authentication credentials", + Long: heredoc.Doc(`Expand or fix the permission scopes for stored credentials + + The --scopes flag accepts a comma separated list of scopes you want your gh credentials to have. If + absent, this command ensures that gh has access to a minimum set of scopes. + `), + Example: heredoc.Doc(` + $ gh auth refresh --scopes write:org,read:public_key + # => open a browser to add write:org and read:public_key scopes for use with gh api + + $ gh auth refresh + # => open a browser to ensure your authentication credentials have the correct minimum scopes + `), + RunE: func(cmd *cobra.Command, args []string) error { + if runF != nil { + return runF(opts) + } + + return refreshRun(opts) + }, + } + + cmd.Flags().StringVarP(&opts.Hostname, "hostname", "h", "", "The GitHub host to use for authentication") + cmd.Flags().StringSliceVarP(&opts.Scopes, "scopes", "s", nil, "Additional authentication scopes for gh to have") + + return cmd +} + +func refreshRun(opts *RefreshOptions) error { + if os.Getenv("GITHUB_TOKEN") != "" { + return fmt.Errorf("GITHUB_TOKEN is present in your environment and is incompatible with this command. If you'd like to modify a personal access token, see https://github.com/settings/tokens") + } + + isTTY := opts.IO.IsStdinTTY() && opts.IO.IsStdoutTTY() + + if !isTTY { + return fmt.Errorf("not attached to a terminal; in headless environments, GITHUB_TOKEN is recommended") + } + + cfg, err := opts.Config() + if err != nil { + return err + } + + candidates, err := cfg.Hosts() + if err != nil { + return fmt.Errorf("not logged in to any hosts. Use 'gh auth login' to authenticate with a host") + } + + hostname := opts.Hostname + if hostname == "" { + if len(candidates) == 1 { + hostname = candidates[0] + } else { + err := prompt.SurveyAskOne(&survey.Select{ + Message: "What account do you want to refresh auth for?", + Options: candidates, + }, &hostname) + + if err != nil { + return fmt.Errorf("could not prompt: %w", err) + } + } + } else { + var found bool + for _, c := range candidates { + if c == hostname { + found = true + break + } + } + + if !found { + return fmt.Errorf("not logged in to %s. use 'gh auth login' to authenticate with this host", hostname) + } + } + + return opts.AuthFlow(cfg, hostname, opts.Scopes) +} diff --git a/pkg/cmd/auth/refresh/refresh_test.go b/pkg/cmd/auth/refresh/refresh_test.go new file mode 100644 index 000000000..3a22d507f --- /dev/null +++ b/pkg/cmd/auth/refresh/refresh_test.go @@ -0,0 +1,244 @@ +package refresh + +import ( + "bytes" + "os" + "regexp" + "testing" + + "github.com/cli/cli/internal/config" + "github.com/cli/cli/pkg/cmdutil" + "github.com/cli/cli/pkg/httpmock" + "github.com/cli/cli/pkg/iostreams" + "github.com/cli/cli/pkg/prompt" + "github.com/google/shlex" + "github.com/stretchr/testify/assert" +) + +func Test_NewCmdRefresh(t *testing.T) { + tests := []struct { + name string + cli string + wants RefreshOptions + }{ + { + name: "no arguments", + wants: RefreshOptions{ + Hostname: "", + }, + }, + { + name: "hostname", + cli: "-h aline.cedrac", + wants: RefreshOptions{ + Hostname: "aline.cedrac", + }, + }, + { + name: "one scope", + cli: "--scopes repo:invite", + wants: RefreshOptions{ + Scopes: []string{"repo:invite"}, + }, + }, + { + name: "scopes", + cli: "--scopes repo:invite,read:public_key", + wants: RefreshOptions{ + Scopes: []string{"repo:invite", "read:public_key"}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + io, _, _, _ := iostreams.Test() + f := &cmdutil.Factory{ + IOStreams: io, + } + + argv, err := shlex.Split(tt.cli) + assert.NoError(t, err) + + var gotOpts *RefreshOptions + cmd := NewCmdRefresh(f, func(opts *RefreshOptions) error { + gotOpts = opts + return nil + }) + // TODO cobra hack-around + cmd.Flags().BoolP("help", "x", false, "") + + cmd.SetArgs(argv) + cmd.SetIn(&bytes.Buffer{}) + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + + _, err = cmd.ExecuteC() + assert.NoError(t, err) + assert.Equal(t, tt.wants.Hostname, gotOpts.Hostname) + assert.Equal(t, tt.wants.Scopes, gotOpts.Scopes) + }) + + } +} + +type authArgs struct { + hostname string + scopes []string +} + +func Test_refreshRun(t *testing.T) { + tests := []struct { + name string + opts *RefreshOptions + askStubs func(*prompt.AskStubber) + cfgHosts []string + wantErr *regexp.Regexp + ghtoken string + nontty bool + wantAuthArgs authArgs + }{ + { + name: "GITHUB_TOKEN set", + opts: &RefreshOptions{}, + ghtoken: "abc123", + wantErr: regexp.MustCompile(`GITHUB_TOKEN is present in your environment`), + }, + { + name: "non tty", + opts: &RefreshOptions{}, + nontty: true, + wantErr: regexp.MustCompile(`not attached to a terminal;`), + }, + { + name: "no hosts configured", + opts: &RefreshOptions{}, + wantErr: regexp.MustCompile(`not logged in to any hosts`), + }, + { + name: "hostname given but dne", + cfgHosts: []string{ + "github.com", + "aline.cedrac", + }, + opts: &RefreshOptions{ + Hostname: "obed.morton", + }, + wantErr: regexp.MustCompile(`not logged in to obed.morton`), + }, + { + name: "hostname provided and is configured", + cfgHosts: []string{ + "obed.morton", + "github.com", + }, + opts: &RefreshOptions{ + Hostname: "obed.morton", + }, + wantAuthArgs: authArgs{ + hostname: "obed.morton", + scopes: nil, + }, + }, + { + name: "no hostname, one host configured", + cfgHosts: []string{ + "github.com", + }, + opts: &RefreshOptions{ + Hostname: "", + }, + wantAuthArgs: authArgs{ + hostname: "github.com", + scopes: nil, + }, + }, + { + name: "no hostname, multiple hosts configured", + cfgHosts: []string{ + "github.com", + "aline.cedrac", + }, + opts: &RefreshOptions{ + Hostname: "", + }, + askStubs: func(as *prompt.AskStubber) { + as.StubOne("github.com") + }, + wantAuthArgs: authArgs{ + hostname: "github.com", + scopes: nil, + }, + }, + { + name: "scopes provided", + cfgHosts: []string{ + "github.com", + }, + opts: &RefreshOptions{ + Scopes: []string{"repo:invite", "public_key:read"}, + }, + wantAuthArgs: authArgs{ + hostname: "github.com", + scopes: []string{"repo:invite", "public_key:read"}, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + aa := authArgs{} + tt.opts.AuthFlow = func(_ config.Config, hostname string, scopes []string) error { + aa.hostname = hostname + aa.scopes = scopes + return nil + } + + ghtoken := os.Getenv("GITHUB_TOKEN") + defer func() { + os.Setenv("GITHUB_TOKEN", ghtoken) + }() + os.Setenv("GITHUB_TOKEN", tt.ghtoken) + io, _, _, _ := iostreams.Test() + + io.SetStdinTTY(!tt.nontty) + io.SetStdoutTTY(!tt.nontty) + + tt.opts.IO = io + cfg := config.NewBlankConfig() + tt.opts.Config = func() (config.Config, error) { + return cfg, nil + } + for _, hostname := range tt.cfgHosts { + _ = cfg.Set(hostname, "oauth_token", "abc123") + } + reg := &httpmock.Registry{} + reg.Register( + httpmock.GraphQL(`query UserCurrent\b`), + httpmock.StringResponse(`{"data":{"viewer":{"login":"cybilb"}}}`)) + + mainBuf := bytes.Buffer{} + hostsBuf := bytes.Buffer{} + defer config.StubWriteConfig(&mainBuf, &hostsBuf)() + + as, teardown := prompt.InitAskStubber() + defer teardown() + if tt.askStubs != nil { + tt.askStubs(as) + } + + err := refreshRun(tt.opts) + assert.Equal(t, tt.wantErr == nil, err == nil) + if err != nil { + if tt.wantErr != nil { + assert.True(t, tt.wantErr.MatchString(err.Error())) + return + } else { + t.Fatalf("unexpected error: %s", err) + } + } + + assert.Equal(t, aa.hostname, tt.wantAuthArgs.hostname) + assert.Equal(t, aa.scopes, tt.wantAuthArgs.scopes) + }) + } +} diff --git a/pkg/cmd/factory/http.go b/pkg/cmd/factory/http.go index 8a6dab198..b113100f7 100644 --- a/pkg/cmd/factory/http.go +++ b/pkg/cmd/factory/http.go @@ -35,7 +35,7 @@ func httpClient(io *iostreams.IOStreams, cfg config.Config, appVersion string, s // TODO: check if stdout is TTY too if errors.As(err, ¬Found) && io.IsStdinTTY() { // interactive OAuth flow - token, err = config.AuthFlowWithConfig(cfg, hostname, "Notice: authentication required") + token, err = config.AuthFlowWithConfig(cfg, hostname, "Notice: authentication required", nil) } if err != nil { return "", err