From a656271f26a591c958f561874ddced4aace18bed Mon Sep 17 00:00:00 2001 From: Fredric Silberberg Date: Mon, 30 Mar 2026 11:58:53 -0700 Subject: [PATCH 1/2] Print `gh auth refresh` for 401 returns `gh auth refresh` exists to make it simpler for users to refresh their tokens on expiration/scope mismatch, but help messages only suggest using it in limited scenarios, and not in a common case of a token expiring and the user receiving a 401 error. Now, the auth flow will detect this case, and for refreshable tokens (namely, tokens created by logging in with `gh auth login` in the first place), it will suggest using `gh auth refresh` for these cases. --- internal/ghcmd/cmd.go | 21 ++++++++- internal/ghcmd/cmd_test.go | 75 ++++++++++++++++++++++++++++++ pkg/cmd/auth/shared/writeable.go | 6 +++ pkg/cmd/auth/status/status.go | 3 ++ pkg/cmd/auth/status/status_test.go | 8 ++-- 5 files changed, 108 insertions(+), 5 deletions(-) diff --git a/internal/ghcmd/cmd.go b/internal/ghcmd/cmd.go index 34806f87449..b76bc146fb5 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" @@ -215,7 +216,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) @@ -279,6 +284,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 "gh auth login" +} + 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 65bcc0f288e..f7443511b84 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", + }, + { + name: "env token", + token: "gho_abc123", + source: "GH_TOKEN", + requestURL: "https://api.github.com/graphql", + want: "gh auth login", + }, + { + 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 381c7e02a66..bcc7da14e42 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 348b9531d73..658a8d8bc79 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 4246b1e863f..cb2abb90ecf 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 `), }, From c139b17e9fe007126d9e44d1bd8bab7bac2943f5 Mon Sep 17 00:00:00 2001 From: Fredric Silberberg Date: Mon, 20 Apr 2026 11:52:03 -0700 Subject: [PATCH 2/2] Apply patch from code review feedback. --- internal/ghcmd/cmd.go | 2 +- internal/ghcmd/cmd_test.go | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/internal/ghcmd/cmd.go b/internal/ghcmd/cmd.go index b76bc146fb5..5b840eb4eb4 100644 --- a/internal/ghcmd/cmd.go +++ b/internal/ghcmd/cmd.go @@ -295,7 +295,7 @@ func authRecoveryCommand(cfg gh.Config, httpErr api.HTTPError) string { return fmt.Sprintf("gh auth refresh -h %s", hostname) } - return "gh auth login" + return fmt.Sprintf("gh auth login -h %s", hostname) } func checkForUpdate(ctx context.Context, f *cmdutil.Factory, currentVersion string) (*update.ReleaseInfo, error) { diff --git a/internal/ghcmd/cmd_test.go b/internal/ghcmd/cmd_test.go index f7443511b84..d389bd7448f 100644 --- a/internal/ghcmd/cmd_test.go +++ b/internal/ghcmd/cmd_test.go @@ -536,14 +536,14 @@ func Test_authRecoveryCommand(t *testing.T) { token: "github_pat_abc123", source: "oauth_token", requestURL: "https://api.github.com/graphql", - want: "gh auth login", + 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", + want: "gh auth login -h github.com", }, { name: "missing request url",