diff --git a/internal/ghcmd/cmd.go b/internal/ghcmd/cmd.go index 67b1564e2..9d4908bd8 100644 --- a/internal/ghcmd/cmd.go +++ b/internal/ghcmd/cmd.go @@ -26,6 +26,7 @@ import ( "github.com/cli/cli/v2/internal/gh/ghtelemetry" "github.com/cli/cli/v2/internal/telemetry" "github.com/cli/cli/v2/internal/update" + "github.com/cli/cli/v2/pkg/cmd/auth/shared" "github.com/cli/cli/v2/pkg/cmd/factory" "github.com/cli/cli/v2/pkg/cmd/root" "github.com/cli/cli/v2/pkg/cmdutil" @@ -227,7 +228,11 @@ func Main() exitCode { var httpErr api.HTTPError if errors.As(err, &httpErr) && httpErr.StatusCode == 401 { - fmt.Fprintln(stderr, "Try authenticating with: gh auth login") + authCommand := "gh auth login" + if cfg, cfgErr := cmdFactory.Config(); cfgErr == nil { + authCommand = authRecoveryCommand(cfg, httpErr) + } + fmt.Fprintf(stderr, "Try authenticating with: %s\n", authCommand) } else if u := factory.SSOURL(); u != "" { // handles organization SAML enforcement error fmt.Fprintf(stderr, "Authorize in your web browser: %s\n", u) @@ -291,6 +296,20 @@ func printError(out io.Writer, err error, cmd *cobra.Command, debug bool) { } } +func authRecoveryCommand(cfg gh.Config, httpErr api.HTTPError) string { + if httpErr.RequestURL == nil { + return "gh auth login" + } + + hostname := ghauth.NormalizeHostname(httpErr.RequestURL.Hostname()) + token, source := cfg.Authentication().ActiveToken(hostname) + if shared.AuthTokenRefreshable(token, source) { + return fmt.Sprintf("gh auth refresh -h %s", hostname) + } + + return fmt.Sprintf("gh auth login -h %s", hostname) +} + func checkForUpdate(ctx context.Context, f *cmdutil.Factory, currentVersion string) (*update.ReleaseInfo, error) { if updaterEnabled == "" || !update.ShouldCheckForUpdate() { return nil, nil diff --git a/internal/ghcmd/cmd_test.go b/internal/ghcmd/cmd_test.go index 65bcc0f28..d389bd744 100644 --- a/internal/ghcmd/cmd_test.go +++ b/internal/ghcmd/cmd_test.go @@ -5,11 +5,15 @@ import ( "errors" "fmt" "net" + "net/url" "testing" + "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/config" "github.com/cli/cli/v2/internal/gh" + ghmock "github.com/cli/cli/v2/internal/gh/mock" "github.com/cli/cli/v2/pkg/cmdutil" + ghAPI "github.com/cli/go-gh/v2/pkg/api" "github.com/spf13/cobra" "github.com/stretchr/testify/assert" ) @@ -511,3 +515,74 @@ func disableColorLabelsConfig() gh.Config { func enableColorLabelsConfig() gh.Config { return config.NewFromString("color_labels: enabled") } + +func Test_authRecoveryCommand(t *testing.T) { + tests := []struct { + name string + token string + source string + requestURL string + want string + }{ + { + name: "stored oauth token", + token: "gho_abc123", + source: "oauth_token", + requestURL: "https://api.github.com/graphql", + want: "gh auth refresh -h github.com", + }, + { + name: "stored pat", + token: "github_pat_abc123", + source: "oauth_token", + requestURL: "https://api.github.com/graphql", + want: "gh auth login -h github.com", + }, + { + name: "env token", + token: "gho_abc123", + source: "GH_TOKEN", + requestURL: "https://api.github.com/graphql", + want: "gh auth login -h github.com", + }, + { + name: "missing request url", + token: "gho_abc123", + source: "oauth_token", + want: "gh auth login", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + authCfg := config.NewBlankConfig().Authentication() + authCfg.SetActiveToken(tt.token, tt.source) + cfg := &ghmock.ConfigMock{ + AuthenticationFunc: func() gh.AuthConfig { + return authCfg + }, + } + + var requestURL *url.URL + if tt.requestURL != "" { + var err error + requestURL, err = url.Parse(tt.requestURL) + if err != nil { + t.Fatalf("failed to parse request URL: %v", err) + } + } + + httpErr := api.HTTPError{ + HTTPError: &ghAPI.HTTPError{ + RequestURL: requestURL, + StatusCode: 401, + }, + } + + got := authRecoveryCommand(cfg, httpErr) + if got != tt.want { + t.Errorf("authRecoveryCommand() = %q, want %q", got, tt.want) + } + }) + } +} diff --git a/pkg/cmd/auth/shared/writeable.go b/pkg/cmd/auth/shared/writeable.go index 381c7e02a..bcc7da14e 100644 --- a/pkg/cmd/auth/shared/writeable.go +++ b/pkg/cmd/auth/shared/writeable.go @@ -6,6 +6,12 @@ import ( "github.com/cli/cli/v2/internal/gh" ) +// AuthTokenRefreshable reports whether the token is stored by gh and can be +// renewed with `gh auth refresh`. +func AuthTokenRefreshable(token, src string) bool { + return token != "" && !strings.HasSuffix(src, "_TOKEN") && strings.HasPrefix(token, "gho_") +} + func AuthTokenWriteable(authCfg gh.AuthConfig, hostname string) (string, bool) { token, src := authCfg.ActiveToken(hostname) return src, (token == "" || !strings.HasSuffix(src, "_TOKEN")) diff --git a/pkg/cmd/auth/status/status.go b/pkg/cmd/auth/status/status.go index 348b9531d..658a8d8bc 100644 --- a/pkg/cmd/auth/status/status.go +++ b/pkg/cmd/auth/status/status.go @@ -96,6 +96,9 @@ func (e authEntry) String(cs *iostreams.ColorScheme) string { sb.WriteString(fmt.Sprintf(" - The token in %s is invalid.\n", e.TokenSource)) if authTokenWriteable(e.TokenSource) { loginInstructions := fmt.Sprintf("gh auth login -h %s", e.Host) + if shared.AuthTokenRefreshable(e.Token, e.TokenSource) { + loginInstructions = fmt.Sprintf("gh auth refresh -h %s", e.Host) + } logoutInstructions := fmt.Sprintf("gh auth logout -h %s -u %s", e.Host, e.Login) sb.WriteString(fmt.Sprintf(" - To re-authenticate, run: %s\n", cs.Bold(loginInstructions))) sb.WriteString(fmt.Sprintf(" - To forget about this account, run: %s\n", cs.Bold(logoutInstructions))) diff --git a/pkg/cmd/auth/status/status_test.go b/pkg/cmd/auth/status/status_test.go index 4246b1e86..cb2abb90e 100644 --- a/pkg/cmd/auth/status/status_test.go +++ b/pkg/cmd/auth/status/status_test.go @@ -184,7 +184,7 @@ func Test_statusRun(t *testing.T) { X Failed to log in to ghe.io account monalisa-ghe (GH_CONFIG_DIR/hosts.yml) - Active account: true - The token in GH_CONFIG_DIR/hosts.yml is invalid. - - To re-authenticate, run: gh auth login -h ghe.io + - To re-authenticate, run: gh auth refresh -h ghe.io - To forget about this account, run: gh auth logout -h ghe.io -u monalisa-ghe `), }, @@ -229,7 +229,7 @@ func Test_statusRun(t *testing.T) { X Failed to log in to ghe.io account monalisa-ghe (GH_CONFIG_DIR/hosts.yml) - Active account: true - The token in GH_CONFIG_DIR/hosts.yml is invalid. - - To re-authenticate, run: gh auth login -h ghe.io + - To re-authenticate, run: gh auth refresh -h ghe.io - To forget about this account, run: gh auth logout -h ghe.io -u monalisa-ghe `), }, @@ -447,7 +447,7 @@ func Test_statusRun(t *testing.T) { X Failed to log in to ghe.io account monalisa-ghe (GH_CONFIG_DIR/hosts.yml) - Active account: false - The token in GH_CONFIG_DIR/hosts.yml is invalid. - - To re-authenticate, run: gh auth login -h ghe.io + - To re-authenticate, run: gh auth refresh -h ghe.io - To forget about this account, run: gh auth logout -h ghe.io -u monalisa-ghe `), }, @@ -535,7 +535,7 @@ func Test_statusRun(t *testing.T) { X Failed to log in to ghe.io account monalisa-ghe-2 (GH_CONFIG_DIR/hosts.yml) - Active account: true - The token in GH_CONFIG_DIR/hosts.yml is invalid. - - To re-authenticate, run: gh auth login -h ghe.io + - To re-authenticate, run: gh auth refresh -h ghe.io - To forget about this account, run: gh auth logout -h ghe.io -u monalisa-ghe-2 `), },