cli/pkg/cmd/auth/status/status.go
2024-03-18 12:09:27 -04:00

355 lines
9.7 KiB
Go

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")
}