Merge pull request #2449 from cli/git-credentials
Set up git authentication when logging in to gh
This commit is contained in:
commit
b1f93426eb
13 changed files with 543 additions and 43 deletions
|
|
@ -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{
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
117
pkg/cmd/auth/gitcredential/helper.go
Normal file
117
pkg/cmd/auth/gitcredential/helper.go
Normal 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
|
||||
}
|
||||
154
pkg/cmd/auth/gitcredential/helper_test.go
Normal file
154
pkg/cmd/auth/gitcredential/helper_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
package client
|
||||
package shared
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
110
pkg/cmd/auth/shared/git_credential.go
Normal file
110
pkg/cmd/auth/shared/git_credential.go
Normal 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"
|
||||
}
|
||||
88
pkg/cmd/auth/shared/git_credential_test.go
Normal file
88
pkg/cmd/auth/shared/git_credential_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue