Handle multi account golden path in auth status

This commit is contained in:
William Martin 2023-11-30 13:17:23 +01:00
parent e4ed4041cd
commit b2997cc7bd
2 changed files with 163 additions and 39 deletions

View file

@ -16,6 +16,120 @@ import (
"github.com/spf13/cobra"
)
type validEntry struct {
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 as %s (%s)\n", cs.SuccessIcon(), e.host, cs.Bold(e.user), e.tokenSource),
)
if e.gitProtocol != "" {
sb.WriteString(fmt.Sprintf(" %s Git operations for %s configured to use %s protocol.\n",
cs.SuccessIcon(), e.host, cs.Bold(e.gitProtocol)))
}
sb.WriteString(fmt.Sprintf(" %s Token: %s\n", cs.SuccessIcon(), e.token))
if e.scopes != "" {
sb.WriteString(fmt.Sprintf(" %s Token scopes: %s\n", cs.SuccessIcon(), e.scopes))
} else if expectScopes(e.token) {
sb.WriteString(fmt.Sprintf(" %s Token scopes: none\n", cs.Red("X")))
}
return sb.String()
}
type missingScopes []string
func (ms missingScopes) String() string {
var missing []string
for _, s := range ms {
missing = append(missing, fmt.Sprintf("'%s'", s))
}
scopes := strings.Join(missing, ", ")
if len(ms) == 1 {
return "missing required scope " + scopes
}
return "missing required scopes " + scopes
}
type missingScopesEntry struct {
host string
tokenSource string
missingScopes missingScopes
tokenIsWriteable bool
}
func (e missingScopesEntry) String(cs *iostreams.ColorScheme) string {
var sb strings.Builder
sb.WriteString(
fmt.Sprintf(" %s %s: the token in %s is %s\n", cs.Red("X"), e.host, e.tokenSource, e.missingScopes),
)
if e.tokenIsWriteable {
sb.WriteString(fmt.Sprintf(" - To request missing scopes, run: %s %s\n",
cs.Bold("gh auth refresh -h"),
cs.Bold(e.host)))
}
return sb.String()
}
type invalidTokenEntry struct {
host string
tokenSource string
tokenIsWriteable bool
}
func (e invalidTokenEntry) String(cs *iostreams.ColorScheme) string {
var sb strings.Builder
sb.WriteString(fmt.Sprintf(" %s %s: authentication failed\n", cs.Red("X"), e.host))
sb.WriteString(fmt.Sprintf(" - The %s token in %s is invalid.\n", cs.Bold(e.host), e.tokenSource))
if e.tokenIsWriteable {
sb.WriteString(fmt.Sprintf(" - To re-authenticate, run: %s %s\n",
cs.Bold("gh auth login -h"), cs.Bold(e.host)))
sb.WriteString(fmt.Sprintf(" - To forget about this host, run: %s %s\n",
cs.Bold("gh auth logout -h"), cs.Bold(e.host)))
}
return sb.String()
}
type timeoutErrorEntry struct {
host string
}
func (e timeoutErrorEntry) String(cs *iostreams.ColorScheme) string {
var sb strings.Builder
sb.WriteString(fmt.Sprintf(" %s %s: timeout trying to connect to host\n", cs.Red("X"), e.host))
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
@ -69,7 +183,7 @@ func statusRun(opts *StatusOptions) error {
stdout := opts.IO.Out
cs := opts.IO.ColorScheme()
statusInfo := map[string][]string{}
statuses := make(map[string]Entries)
hostnames := authCfg.Hosts()
if len(hostnames) == 0 {
@ -91,8 +205,6 @@ func statusRun(opts *StatusOptions) error {
}
isHostnameFound = true
statusInfo[hostname] = []string{}
users, _ := authCfg.UsersForHost(hostname)
for _, username := range users {
token, tokenSource, _ := authCfg.TokenForUser(hostname, username)
@ -103,52 +215,42 @@ func statusRun(opts *StatusOptions) error {
}
_, tokenIsWriteable := shared.AuthTokenWriteable(authCfg, hostname)
addMsg := func(x string, ys ...interface{}) {
statusInfo[hostname] = append(statusInfo[hostname], fmt.Sprintf(x, ys...))
}
scopesHeader, err := shared.GetScopes(httpClient, hostname, token)
if err != nil {
var networkError net.Error
if errors.As(err, &networkError) && networkError.Timeout() {
addMsg("%s %s: timeout trying to connect to host", cs.Red("X"), hostname)
statuses[hostname] = append(statuses[hostname], timeoutErrorEntry{
host: hostname,
})
} else {
addMsg("%s %s: authentication failed", cs.Red("X"), hostname)
addMsg("- The %s token in %s is invalid.", cs.Bold(hostname), tokenSource)
if tokenIsWriteable {
addMsg("- To re-authenticate, run: %s %s",
cs.Bold("gh auth login -h"), cs.Bold(hostname))
addMsg("- To forget about this host, run: %s %s",
cs.Bold("gh auth logout -h"), cs.Bold(hostname))
}
statuses[hostname] = append(statuses[hostname], invalidTokenEntry{
host: hostname,
tokenSource: tokenSource,
tokenIsWriteable: tokenIsWriteable,
})
}
continue
}
if err := shared.HeaderHasMinimumScopes(scopesHeader); err != nil {
var missingScopes *shared.MissingScopesError
if errors.As(err, &missingScopes) {
addMsg("%s %s: the token in %s is %s", cs.Red("X"), hostname, tokenSource, err)
if tokenIsWriteable {
addMsg("- To request missing scopes, run: %s %s",
cs.Bold("gh auth refresh -h"),
cs.Bold(hostname))
}
statuses[hostname] = append(statuses[hostname], missingScopesEntry{
host: hostname,
tokenSource: tokenSource,
missingScopes: missingScopes.MissingScopes,
tokenIsWriteable: tokenIsWriteable,
})
}
} else {
addMsg("%s Logged in to %s as %s (%s)", cs.SuccessIcon(), hostname, cs.Bold(username), tokenSource)
proto := cfg.GitProtocol(hostname)
if proto != "" {
addMsg("%s Git operations for %s configured to use %s protocol.",
cs.SuccessIcon(), hostname, cs.Bold(proto))
}
addMsg("%s Token: %s", cs.SuccessIcon(), displayToken(token, opts.ShowToken))
if scopesHeader != "" {
addMsg("%s Token scopes: %s", cs.SuccessIcon(), scopesHeader)
} else if expectScopes(token) {
addMsg("%s Token scopes: none", cs.Red("X"))
}
statuses[hostname] = append(statuses[hostname], validEntry{
host: hostname,
user: username,
token: displayToken(token, opts.ShowToken),
tokenSource: tokenSource,
gitProtocol: cfg.GitProtocol(hostname),
scopes: scopesHeader})
}
}
}
@ -161,7 +263,7 @@ func statusRun(opts *StatusOptions) error {
prevEntry := false
for _, hostname := range hostnames {
lines, ok := statusInfo[hostname]
entries, ok := statuses[hostname]
if !ok {
continue
}
@ -171,9 +273,7 @@ func statusRun(opts *StatusOptions) error {
}
prevEntry = true
fmt.Fprintf(stdout, "%s\n", cs.Bold(hostname))
for _, line := range lines {
fmt.Fprintf(stdout, " %s\n", line)
}
fmt.Fprintf(stdout, "%s", strings.Join(entries.Strings(cs), "\n"))
}
return nil

View file

@ -269,6 +269,30 @@ func Test_statusRun(t *testing.T) {
wantErr: cmdutil.SilentError,
wantErrOut: "Hostname \"github.example.com\" not found among authenticated GitHub hosts\n",
},
{
name: "multiple accounts on a host",
opts: StatusOptions{},
cfgStubs: func(c config.Config) {
login(t, c, "github.com", "monalisa", "abc123", "https")
login(t, c, "github.com", "monalisa-2", "abc123", "https")
},
httpStubs: func(reg *httpmock.Registry) {
reg.Register(httpmock.REST("GET", ""), httpmock.ScopesResponder("repo,read:org"))
reg.Register(httpmock.REST("GET", ""), httpmock.ScopesResponder("repo,read:org,project:read"))
},
wantOut: heredoc.Doc(`
github.com
Logged in to github.com as monalisa (GH_CONFIG_DIR/hosts.yml)
Git operations for github.com configured to use https protocol.
Token: ******
Token scopes: repo,read:org
Logged in to github.com as monalisa-2 (GH_CONFIG_DIR/hosts.yml)
Git operations for github.com configured to use https protocol.
Token: ******
Token scopes: repo,read:org,project:read
`),
},
}
for _, tt := range tests {