`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.
417 lines
12 KiB
Go
417 lines
12 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/internal/gh"
|
|
"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 authEntryState string
|
|
|
|
const (
|
|
authEntryStateSuccess = "success"
|
|
authEntryStateTimeout = "timeout"
|
|
authEntryStateError = "error"
|
|
)
|
|
|
|
type authEntry struct {
|
|
State authEntryState `json:"state"`
|
|
Error string `json:"error,omitempty"`
|
|
Active bool `json:"active"`
|
|
Host string `json:"host"`
|
|
Login string `json:"login"`
|
|
TokenSource string `json:"tokenSource"`
|
|
Token string `json:"token,omitempty"`
|
|
Scopes string `json:"scopes,omitempty"`
|
|
GitProtocol string `json:"gitProtocol"`
|
|
}
|
|
|
|
type authStatus struct {
|
|
Hosts map[string][]authEntry `json:"hosts"`
|
|
}
|
|
|
|
func newAuthStatus() *authStatus {
|
|
return &authStatus{
|
|
Hosts: make(map[string][]authEntry),
|
|
}
|
|
}
|
|
|
|
var authStatusFields = []string{
|
|
"hosts",
|
|
}
|
|
|
|
func (a authStatus) ExportData(fields []string) map[string]interface{} {
|
|
return cmdutil.StructExportData(a, fields)
|
|
}
|
|
|
|
func (e authEntry) String(cs *iostreams.ColorScheme) string {
|
|
var sb strings.Builder
|
|
|
|
switch e.State {
|
|
case authEntryStateSuccess:
|
|
sb.WriteString(
|
|
fmt.Sprintf(" %s Logged in to %s account %s (%s)\n", cs.SuccessIcon(), e.Host, cs.Bold(e.Login), 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)))
|
|
}
|
|
}
|
|
}
|
|
|
|
case authEntryStateError:
|
|
if e.Login != "" {
|
|
sb.WriteString(fmt.Sprintf(" %s Failed to log in to %s account %s (%s)\n", cs.Red("X"), e.Host, cs.Bold(e.Login), 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 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)))
|
|
}
|
|
|
|
case authEntryStateTimeout:
|
|
if e.Login != "" {
|
|
sb.WriteString(fmt.Sprintf(" %s Timeout trying to log in to %s account %s (%s)\n", cs.Red("X"), e.Host, cs.Bold(e.Login), 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 StatusOptions struct {
|
|
HttpClient func() (*http.Client, error)
|
|
IO *iostreams.IOStreams
|
|
Config func() (gh.Config, error)
|
|
Exporter cmdutil.Exporter
|
|
|
|
Hostname string
|
|
ShowToken bool
|
|
Active 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: "Display active account and authentication state on each known GitHub host",
|
|
Long: heredoc.Docf(`
|
|
Display active account and authentication state on each known GitHub host.
|
|
|
|
For each host, the authentication state of each known account is tested and any issues are included in the output.
|
|
Each host section will indicate the active account, which will be used when targeting that host.
|
|
|
|
If an account on any host (or only the one given via %[1]s--hostname%[1]s) has authentication issues,
|
|
the command will exit with 1 and output to stderr. Note that when using the %[1]s--json%[1]s option, the command
|
|
will always exit with zero regardless of any authentication issues, unless there is a fatal error.
|
|
|
|
To change the active account for a host, see %[1]sgh auth switch%[1]s.
|
|
`, "`"),
|
|
Example: heredoc.Doc(`
|
|
# Display authentication status for all accounts on all hosts
|
|
$ gh auth status
|
|
|
|
# Display authentication status for the active account on a specific host
|
|
$ gh auth status --active --hostname github.example.com
|
|
|
|
# Display tokens in plain text
|
|
$ gh auth status --show-token
|
|
|
|
# Format authentication status as JSON
|
|
$ gh auth status --json hosts
|
|
|
|
# Include plain text token in JSON output
|
|
$ gh auth status --json hosts --show-token
|
|
|
|
# Format hosts as a flat JSON array
|
|
$ gh auth status --json hosts --jq '.hosts | add'
|
|
`),
|
|
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 only a specific hostname's auth status")
|
|
cmd.Flags().BoolVarP(&opts.ShowToken, "show-token", "t", false, "Display the auth token")
|
|
cmd.Flags().BoolVarP(&opts.Active, "active", "a", false, "Display the active account only")
|
|
|
|
// the json flags are intentionally not given a shorthand to avoid conflict with -t/--show-token
|
|
cmdutil.AddJSONFlagsWithoutShorthand(cmd, &opts.Exporter, authStatusFields)
|
|
|
|
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()
|
|
|
|
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"))
|
|
if opts.Exporter != nil {
|
|
// In machine-friendly mode, we always exit with no error.
|
|
opts.Exporter.Write(opts.IO, newAuthStatus())
|
|
return nil
|
|
}
|
|
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)
|
|
if opts.Exporter != nil {
|
|
// In machine-friendly mode, we always exit with no error.
|
|
opts.Exporter.Write(opts.IO, newAuthStatus())
|
|
return nil
|
|
}
|
|
return cmdutil.SilentError
|
|
}
|
|
|
|
httpClient, err := opts.HttpClient()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
var finalErr error
|
|
statuses := newAuthStatus()
|
|
|
|
for _, hostname := range hostnames {
|
|
if opts.Hostname != "" && opts.Hostname != hostname {
|
|
continue
|
|
}
|
|
|
|
var activeUser string
|
|
gitProtocol := cfg.GitProtocol(hostname).Value
|
|
activeUserToken, activeUserTokenSource := authCfg.ActiveToken(hostname)
|
|
if authTokenWriteable(activeUserTokenSource) {
|
|
activeUser, _ = authCfg.ActiveUser(hostname)
|
|
}
|
|
entry := buildEntry(httpClient, buildEntryOptions{
|
|
active: true,
|
|
gitProtocol: gitProtocol,
|
|
hostname: hostname,
|
|
token: activeUserToken,
|
|
tokenSource: activeUserTokenSource,
|
|
username: activeUser,
|
|
})
|
|
statuses.Hosts[hostname] = append(statuses.Hosts[hostname], entry)
|
|
|
|
if finalErr == nil && entry.State != authEntryStateSuccess {
|
|
finalErr = cmdutil.SilentError
|
|
}
|
|
|
|
if opts.Active {
|
|
continue
|
|
}
|
|
|
|
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,
|
|
token: token,
|
|
tokenSource: tokenSource,
|
|
username: username,
|
|
})
|
|
statuses.Hosts[hostname] = append(statuses.Hosts[hostname], entry)
|
|
|
|
if finalErr == nil && entry.State != authEntryStateSuccess {
|
|
finalErr = cmdutil.SilentError
|
|
}
|
|
}
|
|
}
|
|
|
|
if !opts.ShowToken {
|
|
for _, host := range statuses.Hosts {
|
|
for i := range host {
|
|
if opts.Exporter != nil {
|
|
// In machine-readable we just drop the token
|
|
host[i].Token = ""
|
|
} else {
|
|
host[i].Token = maskToken(host[i].Token)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if opts.Exporter != nil {
|
|
// In machine-friendly mode, we always exit with no error.
|
|
opts.Exporter.Write(opts.IO, statuses)
|
|
return nil
|
|
}
|
|
|
|
prevEntry := false
|
|
for _, hostname := range hostnames {
|
|
entries, ok := statuses.Hosts[hostname]
|
|
if !ok {
|
|
continue
|
|
}
|
|
|
|
stream := stdout
|
|
if finalErr != nil {
|
|
stream = stderr
|
|
}
|
|
|
|
if prevEntry {
|
|
fmt.Fprint(stream, "\n")
|
|
}
|
|
prevEntry = true
|
|
fmt.Fprintf(stream, "%s\n", cs.Bold(hostname))
|
|
for i, entry := range entries {
|
|
fmt.Fprintf(stream, "%s", entry.String(cs))
|
|
if i < len(entries)-1 {
|
|
fmt.Fprint(stream, "\n")
|
|
}
|
|
}
|
|
}
|
|
|
|
return finalErr
|
|
}
|
|
|
|
func maskToken(token string) string {
|
|
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
|
|
token string
|
|
tokenSource string
|
|
username string
|
|
}
|
|
|
|
func buildEntry(httpClient *http.Client, opts buildEntryOptions) authEntry {
|
|
tokenSource := opts.tokenSource
|
|
if 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.
|
|
tokenSource = filepath.Join(config.ConfigDir(), "hosts.yml")
|
|
}
|
|
entry := authEntry{
|
|
Active: opts.active,
|
|
Host: opts.hostname,
|
|
Login: opts.username,
|
|
TokenSource: tokenSource,
|
|
Token: opts.token,
|
|
GitProtocol: opts.gitProtocol,
|
|
}
|
|
|
|
// 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 !authTokenWriteable(tokenSource) {
|
|
// 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
|
|
entry.Login, err = api.CurrentLoginName(apiClient, opts.hostname)
|
|
if err != nil {
|
|
entry.State = authEntryStateError
|
|
entry.Error = err.Error()
|
|
return entry
|
|
}
|
|
}
|
|
|
|
// 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() {
|
|
entry.State = authEntryStateTimeout
|
|
entry.Error = err.Error()
|
|
return entry
|
|
}
|
|
|
|
entry.State = authEntryStateError
|
|
entry.Error = err.Error()
|
|
return entry
|
|
}
|
|
entry.Scopes = scopesHeader
|
|
|
|
entry.State = authEntryStateSuccess
|
|
return entry
|
|
}
|
|
|
|
func authTokenWriteable(src string) bool {
|
|
return !strings.HasSuffix(src, "_TOKEN")
|
|
}
|