package status import ( "errors" "fmt" "net" "net/http" "path/filepath" "slices" "strings" "github.com/MakeNowJust/heredoc" "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/config" "github.com/cli/cli/v2/pkg/cmd/auth/shared" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/iostreams" "github.com/spf13/cobra" ) type validEntry struct { active bool host string user string token string tokenSource string gitProtocol string scopes string } func (e validEntry) String(cs *iostreams.ColorScheme) string { var sb strings.Builder sb.WriteString( fmt.Sprintf(" %s Logged in to %s account %s (%s)\n", cs.SuccessIcon(), e.host, cs.Bold(e.user), e.tokenSource), ) activeStr := fmt.Sprintf("%v", e.active) sb.WriteString(fmt.Sprintf(" - Active account: %s\n", cs.Bold(activeStr))) sb.WriteString(fmt.Sprintf(" - Git operations protocol: %s\n", cs.Bold(e.gitProtocol))) sb.WriteString(fmt.Sprintf(" - Token: %s\n", cs.Bold(e.token))) if expectScopes(e.token) { sb.WriteString(fmt.Sprintf(" - Token scopes: %s\n", cs.Bold(displayScopes(e.scopes)))) if err := shared.HeaderHasMinimumScopes(e.scopes); err != nil { var missingScopesError *shared.MissingScopesError if errors.As(err, &missingScopesError) { missingScopes := strings.Join(missingScopesError.MissingScopes, ",") sb.WriteString(fmt.Sprintf(" %s Missing required token scopes: %s\n", cs.WarningIcon(), cs.Bold(displayScopes(missingScopes)))) refreshInstructions := fmt.Sprintf("gh auth refresh -h %s", e.host) sb.WriteString(fmt.Sprintf(" - To request missing scopes, run: %s\n", cs.Bold(refreshInstructions))) } } } return sb.String() } type invalidTokenEntry struct { active bool host string user string tokenSource string tokenIsWriteable bool } func (e invalidTokenEntry) String(cs *iostreams.ColorScheme) string { var sb strings.Builder if e.user != "" { sb.WriteString(fmt.Sprintf(" %s Failed to log in to %s account %s (%s)\n", cs.Red("X"), e.host, cs.Bold(e.user), e.tokenSource)) } else { sb.WriteString(fmt.Sprintf(" %s Failed to log in to %s using token (%s)\n", cs.Red("X"), e.host, e.tokenSource)) } activeStr := fmt.Sprintf("%v", e.active) sb.WriteString(fmt.Sprintf(" - Active account: %s\n", cs.Bold(activeStr))) sb.WriteString(fmt.Sprintf(" - The token in %s is invalid.\n", e.tokenSource)) if e.tokenIsWriteable { loginInstructions := fmt.Sprintf("gh auth login -h %s", e.host) logoutInstructions := fmt.Sprintf("gh auth logout -h %s -u %s", e.host, e.user) 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))) } return sb.String() } type timeoutErrorEntry struct { active bool host string user string tokenSource string } func (e timeoutErrorEntry) String(cs *iostreams.ColorScheme) string { var sb strings.Builder if e.user != "" { sb.WriteString(fmt.Sprintf(" %s Timeout trying to log in to %s account %s (%s)\n", cs.Red("X"), e.host, cs.Bold(e.user), e.tokenSource)) } else { sb.WriteString(fmt.Sprintf(" %s Timeout trying to log in to %s using token (%s)\n", cs.Red("X"), e.host, e.tokenSource)) } activeStr := fmt.Sprintf("%v", e.active) sb.WriteString(fmt.Sprintf(" - Active account: %s\n", cs.Bold(activeStr))) return sb.String() } type Entry interface { String(cs *iostreams.ColorScheme) string } type Entries []Entry func (e Entries) Strings(cs *iostreams.ColorScheme) []string { var out []string for _, entry := range e { out = append(out, entry.String(cs)) } return out } type StatusOptions struct { HttpClient func() (*http.Client, error) IO *iostreams.IOStreams Config func() (config.Config, error) Hostname string ShowToken bool } func NewCmdStatus(f *cmdutil.Factory, runF func(*StatusOptions) error) *cobra.Command { opts := &StatusOptions{ HttpClient: f.HttpClient, IO: f.IOStreams, Config: f.Config, } cmd := &cobra.Command{ Use: "status", Args: cobra.ExactArgs(0), Short: "View all accounts and authentication status", Long: heredoc.Doc(`Verifies and displays information about your authentication state. This command will test your authentication state for each GitHub host that gh knows about and report on any issues. `), RunE: func(cmd *cobra.Command, args []string) error { if runF != nil { return runF(opts) } return statusRun(opts) }, } cmd.Flags().StringVarP(&opts.Hostname, "hostname", "h", "", "Check a specific hostname's auth status") cmd.Flags().BoolVarP(&opts.ShowToken, "show-token", "t", false, "Display the auth token") return cmd } func statusRun(opts *StatusOptions) error { cfg, err := opts.Config() if err != nil { return err } authCfg := cfg.Authentication() stderr := opts.IO.ErrOut stdout := opts.IO.Out cs := opts.IO.ColorScheme() statuses := make(map[string]Entries) hostnames := authCfg.Hosts() if len(hostnames) == 0 { fmt.Fprintf(stderr, "You are not logged into any GitHub hosts. To log in, run: %s\n", cs.Bold("gh auth login")) return cmdutil.SilentError } if opts.Hostname != "" && !slices.Contains(hostnames, opts.Hostname) { fmt.Fprintf(stderr, "You are not logged into any accounts on %s\n", opts.Hostname) return cmdutil.SilentError } httpClient, err := opts.HttpClient() if err != nil { return err } for _, hostname := range hostnames { if opts.Hostname != "" && opts.Hostname != hostname { continue } var activeUser string gitProtocol := cfg.GitProtocol(hostname) activeUserToken, activeUserTokenSource := authCfg.ActiveToken(hostname) if authTokenWriteable(activeUserTokenSource) { activeUser, _ = authCfg.ActiveUser(hostname) } entry := buildEntry(httpClient, buildEntryOptions{ active: true, gitProtocol: gitProtocol, hostname: hostname, showToken: opts.ShowToken, token: activeUserToken, tokenSource: activeUserTokenSource, username: activeUser, }) statuses[hostname] = append(statuses[hostname], entry) users := authCfg.UsersForHost(hostname) for _, username := range users { if username == activeUser { continue } token, tokenSource, _ := authCfg.TokenForUser(hostname, username) entry := buildEntry(httpClient, buildEntryOptions{ active: false, gitProtocol: gitProtocol, hostname: hostname, showToken: opts.ShowToken, token: token, tokenSource: tokenSource, username: username, }) statuses[hostname] = append(statuses[hostname], entry) } } prevEntry := false for _, hostname := range hostnames { entries, ok := statuses[hostname] if !ok { continue } if prevEntry { fmt.Fprint(stdout, "\n") } prevEntry = true fmt.Fprintf(stdout, "%s\n", cs.Bold(hostname)) fmt.Fprintf(stdout, "%s", strings.Join(entries.Strings(cs), "\n")) } return nil } func displayToken(token string, printRaw bool) string { if printRaw { return token } if idx := strings.LastIndexByte(token, '_'); idx > -1 { prefix := token[0 : idx+1] return prefix + strings.Repeat("*", len(token)-len(prefix)) } return strings.Repeat("*", len(token)) } func displayScopes(scopes string) string { if scopes == "" { return "none" } list := strings.Split(scopes, ",") for i, s := range list { list[i] = fmt.Sprintf("'%s'", strings.TrimSpace(s)) } return strings.Join(list, ", ") } func expectScopes(token string) bool { return strings.HasPrefix(token, "ghp_") || strings.HasPrefix(token, "gho_") } type buildEntryOptions struct { active bool gitProtocol string hostname string showToken bool token string tokenSource string username string } func buildEntry(httpClient *http.Client, opts buildEntryOptions) Entry { tokenIsWriteable := authTokenWriteable(opts.tokenSource) if opts.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. opts.tokenSource = filepath.Join(config.ConfigDir(), "hosts.yml") } // If token is not writeable, then it came from an environment variable and // we need to fetch the username as it won't be stored in the config. if !tokenIsWriteable { // The httpClient will automatically use the correct token here as // the token from the environment variable take highest precedence. apiClient := api.NewClientFromHTTP(httpClient) var err error opts.username, err = api.CurrentLoginName(apiClient, opts.hostname) if err != nil { return invalidTokenEntry{ active: opts.active, host: opts.hostname, user: opts.username, tokenIsWriteable: tokenIsWriteable, tokenSource: opts.tokenSource, } } } // Get scopes for token. scopesHeader, err := shared.GetScopes(httpClient, opts.hostname, opts.token) if err != nil { var networkError net.Error if errors.As(err, &networkError) && networkError.Timeout() { return timeoutErrorEntry{ active: opts.active, host: opts.hostname, user: opts.username, tokenSource: opts.tokenSource, } } return invalidTokenEntry{ active: opts.active, host: opts.hostname, user: opts.username, tokenIsWriteable: tokenIsWriteable, tokenSource: opts.tokenSource, } } return validEntry{ active: opts.active, gitProtocol: opts.gitProtocol, host: opts.hostname, scopes: scopesHeader, token: displayToken(opts.token, opts.showToken), tokenSource: opts.tokenSource, user: opts.username, } } func authTokenWriteable(src string) bool { return !strings.HasSuffix(src, "_TOKEN") }