From f1c0d04bc0f3bbcb7f415392fab61ec7affab35d Mon Sep 17 00:00:00 2001 From: vilmibm Date: Fri, 7 Aug 2020 12:37:58 -0500 Subject: [PATCH] gh auth refresh --- pkg/cmd/auth/logout/logout.go | 1 + pkg/cmd/auth/refresh/refresh.go | 127 ++++++++++++++ pkg/cmd/auth/refresh/refresh_test.go | 249 +++++++++++++++++++++++++++ 3 files changed, 377 insertions(+) create mode 100644 pkg/cmd/auth/refresh/refresh.go create mode 100644 pkg/cmd/auth/refresh/refresh_test.go diff --git a/pkg/cmd/auth/logout/logout.go b/pkg/cmd/auth/logout/logout.go index 744cd6a3f..5192f2bf7 100644 --- a/pkg/cmd/auth/logout/logout.go +++ b/pkg/cmd/auth/logout/logout.go @@ -62,6 +62,7 @@ func NewCmdLogout(f *cmdutil.Factory, runF func(*LogoutOptions) error) *cobra.Co } func logoutRun(opts *LogoutOptions) error { + // TODO check for GITHUB_TOKEN and error if found isTTY := opts.IO.IsStdinTTY() && opts.IO.IsStdoutTTY() hostname := opts.Hostname diff --git a/pkg/cmd/auth/refresh/refresh.go b/pkg/cmd/auth/refresh/refresh.go new file mode 100644 index 000000000..61880cbba --- /dev/null +++ b/pkg/cmd/auth/refresh/refresh.go @@ -0,0 +1,127 @@ +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 +} + +func NewCmdRefresh(f *cmdutil.Factory, runF func(*RefreshOptions) error) *cobra.Command { + opts := &RefreshOptions{ + IO: f.IOStreams, + Config: f.Config, + } + + cmd := &cobra.Command{ + Use: "refresh", + Args: cobra.ExactArgs(0), + Short: "Request new scopes for a token", + Long: heredoc.Doc(`Expand the permission scopes for a given host's token. + + This command allows you to add additional scopes to an existing authentication token via a web + browser. This enables gh to access more of the GitHub API, which may be required as gh adds + features or as you use the gh api command. + + Unfortunately at this time there is no way to add scopes without a web browser's involvement + due to how GitHub authentication works. + + The --hostname flag allows you to operate on a GitHub host other than github.com. + + The --scopes flag accepts a comma separated list of scopes you want to add to a token. If + absent, this command ensures that a host's token has the default set of scopes required by gh. + + Note that if GITHUB_TOKEN is in the current environment, this command will not work. + `), + 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 + # => ensure that the required minimum scopes are enabled for a token and open a browser to add if not + `), + 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", []string{}, "Additional scopes to add to a token") + + 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 doAuthFlow(cfg, hostname, opts.Scopes) +} + +var doAuthFlow = func(cfg config.Config, hostname string, scopes []string) error { + _, err := config.AuthFlowWithConfig(cfg, hostname, "", scopes) + return err +} diff --git a/pkg/cmd/auth/refresh/refresh_test.go b/pkg/cmd/auth/refresh/refresh_test.go new file mode 100644 index 000000000..1c29b2024 --- /dev/null +++ b/pkg/cmd/auth/refresh/refresh_test.go @@ -0,0 +1,249 @@ +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: "", + Scopes: []string{}, + }, + }, + { + name: "hostname", + cli: "-h aline.cedrac", + wants: RefreshOptions{ + Hostname: "aline.cedrac", + Scopes: []string{}, + }, + }, + { + 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", + Scopes: []string{}, + }, + wantAuthArgs: authArgs{ + hostname: "obed.morton", + scopes: []string{}, + }, + }, + { + name: "no hostname, one host configured", + cfgHosts: []string{ + "github.com", + }, + opts: &RefreshOptions{ + Hostname: "", + Scopes: []string{}, + }, + wantAuthArgs: authArgs{ + hostname: "github.com", + scopes: []string{}, + }, + }, + { + name: "no hostname, multiple hosts configured", + cfgHosts: []string{ + "github.com", + "aline.cedrac", + }, + opts: &RefreshOptions{ + Hostname: "", + Scopes: []string{}, + }, + askStubs: func(as *prompt.AskStubber) { + as.StubOne("github.com") + }, + wantAuthArgs: authArgs{ + hostname: "github.com", + scopes: []string{}, + }, + }, + { + 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{} + doAuthFlow = 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) + }) + } +}