Merge pull request #2449 from cli/git-credentials

Set up git authentication when logging in to gh
This commit is contained in:
Mislav Marohnić 2020-12-15 16:14:07 +01:00 committed by GitHub
commit b1f93426eb
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 543 additions and 43 deletions

View file

@ -63,7 +63,7 @@ func authFlow(oauthHost string, IO *iostreams.IOStreams, notice string, addition
verboseStream = w
}
minimumScopes := []string{"repo", "read:org", "gist"}
minimumScopes := []string{"repo", "read:org", "gist", "workflow"}
scopes := append(minimumScopes, additionalScopes...)
flow := &auth.OAuthFlow{

View file

@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"sort"
"strings"
"github.com/cli/cli/internal/ghinstance"
"gopkg.in/yaml.v3"
@ -378,7 +379,7 @@ func (c *fileConfig) configForHost(hostname string) (*HostConfig, error) {
}
for _, hc := range hosts {
if hc.Host == hostname {
if strings.EqualFold(hc.Host, hostname) {
return hc, nil
}
}

View file

@ -1,6 +1,7 @@
package auth
import (
gitCredentialCmd "github.com/cli/cli/pkg/cmd/auth/gitcredential"
authLoginCmd "github.com/cli/cli/pkg/cmd/auth/login"
authLogoutCmd "github.com/cli/cli/pkg/cmd/auth/logout"
authRefreshCmd "github.com/cli/cli/pkg/cmd/auth/refresh"
@ -22,6 +23,7 @@ func NewCmdAuth(f *cmdutil.Factory) *cobra.Command {
cmd.AddCommand(authLogoutCmd.NewCmdLogout(f, nil))
cmd.AddCommand(authStatusCmd.NewCmdStatus(f, nil))
cmd.AddCommand(authRefreshCmd.NewCmdRefresh(f, nil))
cmd.AddCommand(gitCredentialCmd.NewCmdCredential(f, nil))
return cmd
}

View file

@ -0,0 +1,117 @@
package login
import (
"bufio"
"fmt"
"net/url"
"strings"
"github.com/cli/cli/pkg/cmdutil"
"github.com/cli/cli/pkg/iostreams"
"github.com/spf13/cobra"
)
type config interface {
Get(string, string) (string, error)
}
type CredentialOptions struct {
IO *iostreams.IOStreams
Config func() (config, error)
Operation string
}
func NewCmdCredential(f *cmdutil.Factory, runF func(*CredentialOptions) error) *cobra.Command {
opts := &CredentialOptions{
IO: f.IOStreams,
Config: func() (config, error) {
return f.Config()
},
}
cmd := &cobra.Command{
Use: "git-credential",
Args: cobra.ExactArgs(1),
Short: "Implements git credential helper protocol",
Hidden: true,
RunE: func(cmd *cobra.Command, args []string) error {
opts.Operation = args[0]
if runF != nil {
return runF(opts)
}
return helperRun(opts)
},
}
return cmd
}
func helperRun(opts *CredentialOptions) error {
if opts.Operation == "store" {
// We pretend to implement the "store" operation, but do nothing since we already have a cached token.
return cmdutil.SilentError
}
if opts.Operation != "get" {
return fmt.Errorf("gh auth git-credential: %q operation not supported", opts.Operation)
}
wants := map[string]string{}
s := bufio.NewScanner(opts.IO.In)
for s.Scan() {
line := s.Text()
if line == "" {
break
}
parts := strings.SplitN(line, "=", 2)
if len(parts) < 2 {
continue
}
key, value := parts[0], parts[1]
if key == "url" {
u, err := url.Parse(value)
if err != nil {
return err
}
wants["protocol"] = u.Scheme
wants["host"] = u.Host
wants["path"] = u.Path
wants["username"] = u.User.Username()
wants["password"], _ = u.User.Password()
} else {
wants[key] = value
}
}
if err := s.Err(); err != nil {
return err
}
if wants["protocol"] != "https" {
return cmdutil.SilentError
}
cfg, err := opts.Config()
if err != nil {
return err
}
gotUser, _ := cfg.Get(wants["host"], "user")
gotToken, _ := cfg.Get(wants["host"], "oauth_token")
if gotUser == "" || gotToken == "" {
return cmdutil.SilentError
}
if wants["username"] != "" && !strings.EqualFold(wants["username"], gotUser) {
return cmdutil.SilentError
}
fmt.Fprint(opts.IO.Out, "protocol=https\n")
fmt.Fprintf(opts.IO.Out, "host=%s\n", wants["host"])
fmt.Fprintf(opts.IO.Out, "username=%s\n", gotUser)
fmt.Fprintf(opts.IO.Out, "password=%s\n", gotToken)
return nil
}

View file

@ -0,0 +1,154 @@
package login
import (
"fmt"
"testing"
"github.com/MakeNowJust/heredoc"
"github.com/cli/cli/pkg/iostreams"
)
type tinyConfig map[string]string
func (c tinyConfig) Get(host, key string) (string, error) {
return c[fmt.Sprintf("%s:%s", host, key)], nil
}
func Test_helperRun(t *testing.T) {
tests := []struct {
name string
opts CredentialOptions
input string
wantStdout string
wantStderr string
wantErr bool
}{
{
name: "host only, credentials found",
opts: CredentialOptions{
Operation: "get",
Config: func() (config, error) {
return tinyConfig{
"example.com:user": "monalisa",
"example.com:oauth_token": "OTOKEN",
}, nil
},
},
input: heredoc.Doc(`
protocol=https
host=example.com
`),
wantErr: false,
wantStdout: heredoc.Doc(`
protocol=https
host=example.com
username=monalisa
password=OTOKEN
`),
wantStderr: "",
},
{
name: "host plus user",
opts: CredentialOptions{
Operation: "get",
Config: func() (config, error) {
return tinyConfig{
"example.com:user": "monalisa",
"example.com:oauth_token": "OTOKEN",
}, nil
},
},
input: heredoc.Doc(`
protocol=https
host=example.com
username=monalisa
`),
wantErr: false,
wantStdout: heredoc.Doc(`
protocol=https
host=example.com
username=monalisa
password=OTOKEN
`),
wantStderr: "",
},
{
name: "url input",
opts: CredentialOptions{
Operation: "get",
Config: func() (config, error) {
return tinyConfig{
"example.com:user": "monalisa",
"example.com:oauth_token": "OTOKEN",
}, nil
},
},
input: heredoc.Doc(`
url=https://monalisa@example.com
`),
wantErr: false,
wantStdout: heredoc.Doc(`
protocol=https
host=example.com
username=monalisa
password=OTOKEN
`),
wantStderr: "",
},
{
name: "host only, no credentials found",
opts: CredentialOptions{
Operation: "get",
Config: func() (config, error) {
return tinyConfig{
"example.com:user": "monalisa",
}, nil
},
},
input: heredoc.Doc(`
protocol=https
host=example.com
`),
wantErr: true,
wantStdout: "",
wantStderr: "",
},
{
name: "user mismatch",
opts: CredentialOptions{
Operation: "get",
Config: func() (config, error) {
return tinyConfig{
"example.com:user": "monalisa",
"example.com:oauth_token": "OTOKEN",
}, nil
},
},
input: heredoc.Doc(`
protocol=https
host=example.com
username=hubot
`),
wantErr: true,
wantStdout: "",
wantStderr: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
io, stdin, stdout, stderr := iostreams.Test()
fmt.Fprint(stdin, tt.input)
opts := &tt.opts
opts.IO = io
if err := helperRun(opts); (err != nil) != tt.wantErr {
t.Fatalf("helperRun() error = %v, wantErr %v", err, tt.wantErr)
}
if tt.wantStdout != stdout.String() {
t.Errorf("stdout: got %q, wants %q", stdout.String(), tt.wantStdout)
}
if tt.wantStderr != stderr.String() {
t.Errorf("stderr: got %q, wants %q", stderr.String(), tt.wantStderr)
}
})
}
}

View file

@ -12,7 +12,7 @@ import (
"github.com/cli/cli/internal/authflow"
"github.com/cli/cli/internal/config"
"github.com/cli/cli/internal/ghinstance"
"github.com/cli/cli/pkg/cmd/auth/client"
"github.com/cli/cli/pkg/cmd/auth/shared"
"github.com/cli/cli/pkg/cmdutil"
"github.com/cli/cli/pkg/iostreams"
"github.com/cli/cli/pkg/prompt"
@ -133,7 +133,7 @@ func loginRun(opts *LoginOptions) error {
return err
}
err = client.ValidateHostCfg(opts.Hostname, cfg)
err = shared.ValidateHostCfg(opts.Hostname, cfg)
if err != nil {
return err
}
@ -177,9 +177,9 @@ func loginRun(opts *LoginOptions) error {
existingToken, _ := cfg.Get(hostname, "oauth_token")
if existingToken != "" && opts.Interactive {
err := client.ValidateHostCfg(hostname, cfg)
err := shared.ValidateHostCfg(hostname, cfg)
if err == nil {
apiClient, err := client.ClientFromCfg(hostname, cfg)
apiClient, err := shared.ClientFromCfg(hostname, cfg)
if err != nil {
return err
}
@ -226,11 +226,13 @@ func loginRun(opts *LoginOptions) error {
}
}
userValidated := false
if authMode == 0 {
_, err := authflow.AuthFlowWithConfig(cfg, opts.IO, hostname, "", opts.Scopes)
if err != nil {
return fmt.Errorf("failed to authenticate via web browser: %w", err)
}
userValidated = true
} else {
fmt.Fprintln(opts.IO.ErrOut)
fmt.Fprintln(opts.IO.ErrOut, heredoc.Doc(getAccessTokenTip(hostname)))
@ -251,7 +253,7 @@ func loginRun(opts *LoginOptions) error {
return err
}
err = client.ValidateHostCfg(hostname, cfg)
err = shared.ValidateHostCfg(hostname, cfg)
if err != nil {
return err
}
@ -259,7 +261,7 @@ func loginRun(opts *LoginOptions) error {
cs := opts.IO.ColorScheme()
gitProtocol := "https"
var gitProtocol string
if opts.Interactive {
err = prompt.SurveyAskOne(&survey.Select{
Message: "Choose default git protocol",
@ -283,19 +285,24 @@ func loginRun(opts *LoginOptions) error {
fmt.Fprintf(opts.IO.ErrOut, "%s Configured git protocol\n", cs.SuccessIcon())
}
apiClient, err := client.ClientFromCfg(hostname, cfg)
if err != nil {
return err
}
var username string
if userValidated {
username, _ = cfg.Get(hostname, "user")
} else {
apiClient, err := shared.ClientFromCfg(hostname, cfg)
if err != nil {
return err
}
username, err := api.CurrentLoginName(apiClient, hostname)
if err != nil {
return fmt.Errorf("error using api: %w", err)
}
username, err = api.CurrentLoginName(apiClient, hostname)
if err != nil {
return fmt.Errorf("error using api: %w", err)
}
err = cfg.Set(hostname, "user", username)
if err != nil {
return err
err = cfg.Set(hostname, "user", username)
if err != nil {
return err
}
}
err = cfg.Write()
@ -303,6 +310,13 @@ func loginRun(opts *LoginOptions) error {
return err
}
if opts.Interactive && gitProtocol == "https" {
err := shared.GitCredentialSetup(cfg, hostname, username)
if err != nil {
return err
}
}
fmt.Fprintf(opts.IO.ErrOut, "%s Logged in as %s\n", cs.SuccessIcon(), cs.Bold(username))
return nil

View file

@ -8,7 +8,7 @@ import (
"github.com/cli/cli/api"
"github.com/cli/cli/internal/config"
"github.com/cli/cli/pkg/cmd/auth/client"
"github.com/cli/cli/pkg/cmd/auth/shared"
"github.com/cli/cli/pkg/cmdutil"
"github.com/cli/cli/pkg/httpmock"
"github.com/cli/cli/pkg/iostreams"
@ -262,11 +262,11 @@ func Test_loginRun_nontty(t *testing.T) {
tt.opts.IO = io
t.Run(tt.name, func(t *testing.T) {
reg := &httpmock.Registry{}
origClientFromCfg := client.ClientFromCfg
origClientFromCfg := shared.ClientFromCfg
defer func() {
client.ClientFromCfg = origClientFromCfg
shared.ClientFromCfg = origClientFromCfg
}()
client.ClientFromCfg = func(_ string, _ config.Config) (*api.Client, error) {
shared.ClientFromCfg = func(_ string, _ config.Config) (*api.Client, error) {
httpClient := &http.Client{Transport: reg}
return api.NewClientFromHTTP(httpClient), nil
}
@ -342,6 +342,7 @@ func Test_loginRun_Survey(t *testing.T) {
as.StubOne(1) // auth mode: token
as.StubOne("def456") // auth token
as.StubOne("HTTPS") // git_protocol
as.StubOne(false) // cache credentials
},
httpStubs: func(reg *httpmock.Registry) {
reg.Register(httpmock.REST("GET", "api/v3/"), httpmock.ScopesResponder("repo,read:org,"))
@ -363,6 +364,7 @@ func Test_loginRun_Survey(t *testing.T) {
as.StubOne(1) // auth mode: token
as.StubOne("def456") // auth token
as.StubOne("HTTPS") // git_protocol
as.StubOne(false) // cache credentials
},
httpStubs: func(reg *httpmock.Registry) {
reg.Register(httpmock.REST("GET", "api/v3/"), httpmock.ScopesResponder("repo,read:org,"))
@ -383,6 +385,7 @@ func Test_loginRun_Survey(t *testing.T) {
as.StubOne(1) // auth mode: token
as.StubOne("def456") // auth token
as.StubOne("HTTPS") // git_protocol
as.StubOne(false) // cache credentials
},
wantErrOut: regexp.MustCompile("Tip: you can generate a Personal Access Token here https://github.com/settings/tokens"),
},
@ -426,11 +429,11 @@ func Test_loginRun_Survey(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
reg := &httpmock.Registry{}
origClientFromCfg := client.ClientFromCfg
origClientFromCfg := shared.ClientFromCfg
defer func() {
client.ClientFromCfg = origClientFromCfg
shared.ClientFromCfg = origClientFromCfg
}()
client.ClientFromCfg = func(_ string, _ config.Config) (*api.Client, error) {
shared.ClientFromCfg = func(_ string, _ config.Config) (*api.Client, error) {
httpClient := &http.Client{Transport: reg}
return api.NewClientFromHTTP(httpClient), nil
}

View file

@ -8,6 +8,7 @@ import (
"github.com/MakeNowJust/heredoc"
"github.com/cli/cli/internal/authflow"
"github.com/cli/cli/internal/config"
"github.com/cli/cli/pkg/cmd/auth/shared"
"github.com/cli/cli/pkg/cmdutil"
"github.com/cli/cli/pkg/iostreams"
"github.com/cli/cli/pkg/prompt"
@ -21,6 +22,8 @@ type RefreshOptions struct {
Hostname string
Scopes []string
AuthFlow func(config.Config, *iostreams.IOStreams, string, []string) error
Interactive bool
}
func NewCmdRefresh(f *cmdutil.Factory, runF func(*RefreshOptions) error) *cobra.Command {
@ -50,21 +53,15 @@ func NewCmdRefresh(f *cmdutil.Factory, runF func(*RefreshOptions) error) *cobra.
# => open a browser to ensure your authentication credentials have the correct minimum scopes
`),
RunE: func(cmd *cobra.Command, args []string) error {
isTTY := opts.IO.IsStdinTTY() && opts.IO.IsStdoutTTY()
opts.Interactive = opts.IO.CanPrompt()
if !isTTY {
return fmt.Errorf("not attached to a terminal; in headless environments, GITHUB_TOKEN is recommended")
}
if opts.Hostname == "" && !opts.IO.CanPrompt() {
// here, we know we are attached to a TTY but prompts are disabled
if !opts.Interactive && opts.Hostname == "" {
return &cmdutil.FlagError{Err: errors.New("--hostname required when not running interactively")}
}
if runF != nil {
return runF(opts)
}
return refreshRun(opts)
},
}
@ -118,5 +115,17 @@ func refreshRun(opts *RefreshOptions) error {
return err
}
return opts.AuthFlow(cfg, opts.IO, hostname, opts.Scopes)
if err := opts.AuthFlow(cfg, opts.IO, hostname, opts.Scopes); err != nil {
return err
}
protocol, _ := cfg.Get(hostname, "git_protocol")
if opts.Interactive && protocol == "https" {
username, _ := cfg.Get(hostname, "user")
if err := shared.GitCredentialSetup(cfg, hostname, username); err != nil {
return err
}
}
return nil
}

View file

@ -37,9 +37,11 @@ func Test_NewCmdRefresh(t *testing.T) {
wantsErr: true,
},
{
name: "nontty hostname",
cli: "-h aline.cedrac",
wantsErr: true,
name: "nontty hostname",
cli: "-h aline.cedrac",
wants: RefreshOptions{
Hostname: "aline.cedrac",
},
},
{
name: "tty hostname",

View file

@ -1,4 +1,4 @@
package client
package shared
import (
"fmt"

View file

@ -0,0 +1,110 @@
package shared
import (
"bytes"
"fmt"
"path/filepath"
"strings"
"github.com/AlecAivazis/survey/v2"
"github.com/MakeNowJust/heredoc"
"github.com/cli/cli/git"
"github.com/cli/cli/internal/run"
"github.com/cli/cli/pkg/prompt"
"github.com/google/shlex"
)
type configReader interface {
Get(string, string) (string, error)
}
func GitCredentialSetup(cfg configReader, hostname, username string) error {
helper, _ := gitCredentialHelper(hostname)
if isOurCredentialHelper(helper) {
return nil
}
var primeCredentials bool
err := prompt.SurveyAskOne(&survey.Confirm{
Message: "Authenticate Git with your GitHub credentials?",
Default: true,
}, &primeCredentials)
if err != nil {
return fmt.Errorf("could not prompt: %w", err)
}
if !primeCredentials {
return nil
}
if helper == "" {
// use GitHub CLI as a credential helper (for this host only)
configureCmd, err := git.GitCommand("config", "--global", gitCredentialHelperKey(hostname), "!gh auth git-credential")
if err != nil {
return err
}
return run.PrepareCmd(configureCmd).Run()
}
// clear previous cached credentials
rejectCmd, err := git.GitCommand("credential", "reject")
if err != nil {
return err
}
rejectCmd.Stdin = bytes.NewBufferString(heredoc.Docf(`
protocol=https
host=%s
`, hostname))
err = run.PrepareCmd(rejectCmd).Run()
if err != nil {
return err
}
approveCmd, err := git.GitCommand("credential", "approve")
if err != nil {
return err
}
password, _ := cfg.Get(hostname, "oauth_token")
approveCmd.Stdin = bytes.NewBufferString(heredoc.Docf(`
protocol=https
host=%s
username=%s
password=%s
`, hostname, username, password))
err = run.PrepareCmd(approveCmd).Run()
if err != nil {
return err
}
return nil
}
func gitCredentialHelperKey(hostname string) string {
return fmt.Sprintf("credential.https://%s.helper", hostname)
}
func gitCredentialHelper(hostname string) (helper string, err error) {
helper, err = git.Config(gitCredentialHelperKey(hostname))
if helper != "" {
return
}
helper, err = git.Config("credential.helper")
return
}
func isOurCredentialHelper(cmd string) bool {
if !strings.HasPrefix(cmd, "!") {
return false
}
args, err := shlex.Split(cmd[1:])
if err != nil || len(args) == 0 {
return false
}
return strings.TrimSuffix(filepath.Base(args[0]), ".exe") == "gh"
}

View file

@ -0,0 +1,88 @@
package shared
import (
"fmt"
"testing"
"github.com/cli/cli/internal/run"
"github.com/cli/cli/pkg/prompt"
)
type tinyConfig map[string]string
func (c tinyConfig) Get(host, key string) (string, error) {
return c[fmt.Sprintf("%s:%s", host, key)], nil
}
func TestGitCredentialSetup_configureExisting(t *testing.T) {
cfg := tinyConfig{"example.com:oauth_token": "OTOKEN"}
cs, restoreRun := run.Stub()
defer restoreRun(t)
cs.Register(`git config credential\.https://example\.com\.helper`, 1, "")
cs.Register(`git config credential\.helper`, 0, "osxkeychain\n")
cs.Register(`git credential reject`, 0, "")
cs.Register(`git credential approve`, 0, "")
as, restoreAsk := prompt.InitAskStubber()
defer restoreAsk()
as.StubOne(true)
if err := GitCredentialSetup(cfg, "example.com", "monalisa"); err != nil {
t.Errorf("GitCredentialSetup() error = %v", err)
}
}
func TestGitCredentialSetup_setOurs(t *testing.T) {
cfg := tinyConfig{"example.com:oauth_token": "OTOKEN"}
cs, restoreRun := run.Stub()
defer restoreRun(t)
cs.Register(`git config credential\.https://example\.com\.helper`, 1, "")
cs.Register(`git config credential\.helper`, 1, "")
cs.Register(`git config --global credential\.https://example\.com\.helper`, 0, "", func(args []string) {
if val := args[len(args)-1]; val != "!gh auth git-credential" {
t.Errorf("global credential helper configured to %q", val)
}
})
as, restoreAsk := prompt.InitAskStubber()
defer restoreAsk()
as.StubOne(true)
if err := GitCredentialSetup(cfg, "example.com", "monalisa"); err != nil {
t.Errorf("GitCredentialSetup() error = %v", err)
}
}
func TestGitCredentialSetup_promptDeny(t *testing.T) {
cfg := tinyConfig{"example.com:oauth_token": "OTOKEN"}
cs, restoreRun := run.Stub()
defer restoreRun(t)
cs.Register(`git config credential\.https://example\.com\.helper`, 1, "")
cs.Register(`git config credential\.helper`, 1, "")
as, restoreAsk := prompt.InitAskStubber()
defer restoreAsk()
as.StubOne(false)
if err := GitCredentialSetup(cfg, "example.com", "monalisa"); err != nil {
t.Errorf("GitCredentialSetup() error = %v", err)
}
}
func TestGitCredentialSetup_isOurs(t *testing.T) {
cfg := tinyConfig{"example.com:oauth_token": "OTOKEN"}
cs, restoreRun := run.Stub()
defer restoreRun(t)
cs.Register(`git config credential\.https://example\.com\.helper`, 0, "!/path/to/gh auth\n")
_, restoreAsk := prompt.InitAskStubber()
defer restoreAsk()
if err := GitCredentialSetup(cfg, "example.com", "monalisa"); err != nil {
t.Errorf("GitCredentialSetup() error = %v", err)
}
}

View file

@ -8,7 +8,7 @@ import (
"github.com/cli/cli/api"
"github.com/cli/cli/internal/config"
"github.com/cli/cli/pkg/cmd/auth/client"
"github.com/cli/cli/pkg/cmd/auth/shared"
"github.com/cli/cli/pkg/cmdutil"
"github.com/cli/cli/pkg/httpmock"
"github.com/cli/cli/pkg/iostreams"
@ -217,11 +217,11 @@ func Test_statusRun(t *testing.T) {
}
reg := &httpmock.Registry{}
origClientFromCfg := client.ClientFromCfg
origClientFromCfg := shared.ClientFromCfg
defer func() {
client.ClientFromCfg = origClientFromCfg
shared.ClientFromCfg = origClientFromCfg
}()
client.ClientFromCfg = func(_ string, _ config.Config) (*api.Client, error) {
shared.ClientFromCfg = func(_ string, _ config.Config) (*api.Client, error) {
httpClient := &http.Client{Transport: reg}
return api.NewClientFromHTTP(httpClient), nil
}