commands/auth/login/login.go (409 lines of code) (raw):
package login
import (
"errors"
"fmt"
"io"
"os"
"regexp"
"strings"
"gitlab.com/gitlab-org/cli/commands/auth/authutils"
"gitlab.com/gitlab-org/cli/pkg/iostreams"
"github.com/AlecAivazis/survey/v2"
"github.com/MakeNowJust/heredoc/v2"
"github.com/spf13/cobra"
"github.com/zalando/go-keyring"
"gitlab.com/gitlab-org/cli/api"
"gitlab.com/gitlab-org/cli/commands/cmdutils"
"gitlab.com/gitlab-org/cli/internal/config"
"gitlab.com/gitlab-org/cli/pkg/glinstance"
"gitlab.com/gitlab-org/cli/pkg/oauth2"
)
type LoginOptions struct {
IO *iostreams.IOStreams
Config func() (config.Config, error)
Interactive bool
Hostname string
Token string
JobToken string
ApiHost string
ApiProtocol string
GitProtocol string
UseKeyring bool
}
var opts *LoginOptions
func NewCmdLogin(f *cmdutils.Factory) *cobra.Command {
opts = &LoginOptions{
IO: f.IO,
Config: f.Config,
}
var tokenStdin bool
cmd := &cobra.Command{
Use: "login",
Args: cobra.ExactArgs(0),
Short: "Authenticate with a GitLab instance.",
Long: heredoc.Docf(`
Authenticate with a GitLab instance.
You can pass in a token on standard input by using %[1]s--stdin%[1]s.
The minimum required scopes for the token are: %[1]sapi%[1]s, %[1]swrite_repository%[1]s.
Configuration and credentials are stored in the global configuration file (default %[1]s~/.config/glab-cli/config.yml%[1]s)
`, "`"),
Example: heredoc.Docf(`
# Start interactive setup
$ glab auth login
# Authenticate against %[1]sgitlab.com%[1]s by reading the token from a file
$ glab auth login --stdin < myaccesstoken.txt
# Authenticate with GitLab Self-Managed or GitLab Dedicated
$ glab auth login --hostname salsa.debian.org
# Non-interactive setup
$ glab auth login --hostname gitlab.example.org --token glpat-xxx --api-host gitlab.example.org:3443 --api-protocol https --git-protocol ssh
# Non-interactive setup reading token from a file
$ glab auth login --hostname gitlab.example.org --api-host gitlab.example.org:3443 --api-protocol https --git-protocol ssh --stdin < myaccesstoken.txt
# Non-interactive job token setup
$ glab auth login --hostname gitlab.example.org --job-token $CI_JOB_TOKEN
`, "`"),
RunE: func(cmd *cobra.Command, args []string) error {
if !opts.IO.PromptEnabled() && !tokenStdin && opts.Token == "" && opts.JobToken == "" {
return &cmdutils.FlagError{Err: errors.New("'--stdin', '--token', or '--job-token' required when not running interactively.")}
}
if opts.JobToken != "" && (opts.Token != "" || tokenStdin) {
return &cmdutils.FlagError{Err: errors.New("specify one of '--job-token' or '--token' or '--stdin'. You cannot use more than one of these at the same time.")}
}
if opts.Token != "" && tokenStdin {
return &cmdutils.FlagError{Err: errors.New("specify one of '--token' or '--stdin'. You cannot use both flags at the same time.")}
}
if tokenStdin {
defer opts.IO.In.Close()
token, err := io.ReadAll(opts.IO.In)
if err != nil {
return fmt.Errorf("failed to read token from STDIN: %w", err)
}
opts.Token = strings.TrimSpace(string(token))
}
if opts.IO.PromptEnabled() && opts.Token == "" && opts.JobToken == "" && opts.IO.IsaTTY {
opts.Interactive = true
}
if cmd.Flags().Changed("hostname") {
if err := hostnameValidator(opts.Hostname); err != nil {
return &cmdutils.FlagError{Err: fmt.Errorf("error parsing '--hostname': %w", err)}
}
}
if !opts.Interactive && opts.Hostname == "" {
opts.Hostname = glinstance.Default()
}
if opts.Interactive && (opts.ApiHost != "" || opts.ApiProtocol != "" || opts.GitProtocol != "") {
return &cmdutils.FlagError{Err: errors.New("api-host, api-protocol, and git-protocol can only be used in non-interactive mode.")}
}
if err := loginRun(opts); err != nil {
return cmdutils.WrapError(err, "Could not sign in!")
}
return nil
},
}
cmd.Flags().StringVarP(&opts.Hostname, "hostname", "h", "", "The hostname of the GitLab instance to authenticate with.")
cmd.Flags().StringVarP(&opts.Token, "token", "t", "", "Your GitLab access token.")
cmd.Flags().StringVarP(&opts.JobToken, "job-token", "j", "", "CI job token.")
cmd.Flags().BoolVar(&tokenStdin, "stdin", false, "Read token from standard input.")
cmd.Flags().BoolVar(&opts.UseKeyring, "use-keyring", false, "Store token in your operating system's keyring.")
cmd.Flags().StringVarP(&opts.ApiHost, "api-host", "a", "", "API host url.")
cmd.Flags().StringVarP(&opts.ApiProtocol, "api-protocol", "p", "", "API protocol: https, http")
cmd.Flags().StringVarP(&opts.GitProtocol, "git-protocol", "g", "", "Git protocol: ssh, https, http")
return cmd
}
func loginRun(opts *LoginOptions) error {
c := opts.IO.Color()
cfg, err := opts.Config()
if err != nil {
return err
}
if opts.Token != "" {
if opts.Hostname == "" {
return errors.New("empty hostname would leak `oauth_token`")
}
if opts.UseKeyring {
return keyring.Set("glab:"+opts.Hostname, "", opts.Token)
} else {
err := cfg.Set(opts.Hostname, "token", opts.Token)
if err != nil {
return err
}
if token := config.GetFromEnv("token"); token != "" {
fmt.Fprintf(opts.IO.StdErr, "%s One of %s environment variables is set. If you don't want to use it for glab, unset it.\n", c.Yellow("WARNING:"), strings.Join(config.EnvKeyEquivalence("token"), ", "))
}
if opts.ApiHost != "" {
err = cfg.Set(opts.Hostname, "api_host", opts.ApiHost)
if err != nil {
return err
}
}
if opts.ApiProtocol != "" {
err = cfg.Set(opts.Hostname, "api_protocol", opts.ApiProtocol)
if err != nil {
return err
}
}
if opts.GitProtocol != "" {
err = cfg.Set(opts.Hostname, "git_protocol", opts.GitProtocol)
if err != nil {
return err
}
}
return cfg.Write()
}
}
if opts.JobToken != "" {
if opts.Hostname == "" {
return errors.New("empty hostname would leak `oauth_token`")
}
if opts.UseKeyring {
return keyring.Set("glab:"+opts.Hostname, "", opts.JobToken)
} else {
err := cfg.Set(opts.Hostname, "job_token", opts.JobToken)
if err != nil {
return err
}
if opts.ApiHost != "" {
err = cfg.Set(opts.Hostname, "api_host", opts.ApiHost)
if err != nil {
return err
}
}
if opts.ApiProtocol != "" {
err = cfg.Set(opts.Hostname, "api_protocol", opts.ApiProtocol)
if err != nil {
return err
}
}
if opts.GitProtocol != "" {
err = cfg.Set(opts.Hostname, "git_protocol", opts.GitProtocol)
if err != nil {
return err
}
}
return cfg.Write()
}
}
hostname := opts.Hostname
apiHostname := opts.Hostname
if opts.ApiHost != "" {
apiHostname = opts.ApiHost
}
defaultHostname := glinstance.OverridableDefault()
isSelfHosted := false
if hostname == "" {
var hostType int
err := survey.AskOne(&survey.Select{
Message: "What GitLab instance do you want to sign in to?",
Options: []string{
defaultHostname,
"GitLab Self-Managed or GitLab Dedicated instance",
},
}, &hostType)
if err != nil {
return fmt.Errorf("could not prompt: %w", err)
}
isSelfHosted = hostType == 1
hostname = defaultHostname
apiHostname = hostname
if isSelfHosted {
err := survey.AskOne(&survey.Input{
Message: "GitLab hostname:",
}, &hostname, survey.WithValidator(hostnameValidator))
if err != nil {
return fmt.Errorf("could not prompt: %w", err)
}
err = survey.AskOne(&survey.Input{
Message: "API hostname:",
Help: "For instances with a different hostname for the API endpoint.",
Default: hostname,
}, &apiHostname, survey.WithValidator(hostnameValidator))
if err != nil {
return fmt.Errorf("could not prompt: %w", err)
}
}
} else {
isSelfHosted = glinstance.IsSelfHosted(hostname)
}
fmt.Fprintf(opts.IO.StdErr, "- Signing into %s\n", hostname)
if token := config.GetFromEnv("token"); token != "" {
fmt.Fprintf(opts.IO.StdErr, "%s One of %s environment variables is set. If you don't want to use it for glab, unset it.\n", c.Yellow("WARNING:"), strings.Join(config.EnvKeyEquivalence("token"), ", "))
}
existingToken, _, _ := cfg.GetWithSource(hostname, "token", false)
if existingToken != "" && opts.Interactive {
apiClient, err := cmdutils.LabClientFunc(hostname, cfg, false)
if err != nil {
return err
}
user, err := api.CurrentUser(apiClient)
if err == nil {
username := user.Username
var keepGoing bool
err = survey.AskOne(&survey.Confirm{
Message: fmt.Sprintf(
"You're already logged into %s as %s. Do you want to re-authenticate?",
hostname,
username),
Default: false,
}, &keepGoing)
if err != nil {
return fmt.Errorf("could not prompt: %w", err)
}
if !keepGoing {
return nil
}
}
}
var loginType string
if opts.Interactive {
err := survey.AskOne(&survey.Select{
Message: "How would you like to sign in?",
Options: []string{
"Token",
"Web",
},
}, &loginType)
if err != nil {
return fmt.Errorf("could not get sign-in type: %w", err)
}
}
var token string
if strings.EqualFold(loginType, "token") {
token, err = showTokenPrompt(opts.IO, hostname)
if err != nil {
return err
}
} else {
token, err = oauth2.StartFlow(cfg, opts.IO, hostname)
if err != nil {
return err
}
}
if opts.UseKeyring {
err = keyring.Set("glab:"+hostname, "", token)
if err != nil {
return err
}
} else {
err = cfg.Set(hostname, "token", token)
if err != nil {
return err
}
}
if hostname == "" {
return errors.New("empty hostname would leak the token")
}
err = cfg.Set(hostname, "api_host", apiHostname)
if err != nil {
return err
}
gitProtocol := "https"
apiProtocol := "https"
glabExecutable := "glab"
if exe, err := os.Executable(); err == nil {
glabExecutable = exe
}
credentialFlow := &authutils.GitCredentialFlow{Executable: glabExecutable}
if opts.Interactive {
err = survey.AskOne(&survey.Select{
Message: "Choose default Git protocol:",
Options: []string{
"SSH",
"HTTPS",
"HTTP",
},
Default: "HTTPS",
}, &gitProtocol)
if err != nil {
return fmt.Errorf("could not prompt: %w", err)
}
gitProtocol = strings.ToLower(gitProtocol)
if opts.Interactive && gitProtocol != "ssh" {
if err := credentialFlow.Prompt(hostname, gitProtocol); err != nil {
return err
}
}
if isSelfHosted {
err = survey.AskOne(&survey.Select{
Message: "Choose host API protocol:",
Options: []string{
"HTTPS",
"HTTP",
},
Default: "HTTPS",
}, &apiProtocol)
if err != nil {
return fmt.Errorf("could not prompt: %w", err)
}
apiProtocol = strings.ToLower(apiProtocol)
}
fmt.Fprintf(opts.IO.StdErr, "- glab config set -h %s git_protocol %s\n", hostname, gitProtocol)
err = cfg.Set(hostname, "git_protocol", gitProtocol)
if err != nil {
return err
}
fmt.Fprintf(opts.IO.StdErr, "%s Configured Git protocol.\n", c.GreenCheck())
fmt.Fprintf(opts.IO.StdErr, "- glab config set -h %s api_protocol %s\n", hostname, apiProtocol)
err = cfg.Set(hostname, "api_protocol", apiProtocol)
if err != nil {
return err
}
fmt.Fprintf(opts.IO.StdErr, "%s Configured API protocol.\n", c.GreenCheck())
}
apiClient, err := cmdutils.LabClientFunc(hostname, cfg, false)
if err != nil {
return err
}
user, err := api.CurrentUser(apiClient)
if err != nil {
return fmt.Errorf("error using API: %w", err)
}
username := user.Username
err = cfg.Set(hostname, "user", username)
if err != nil {
return err
}
err = cfg.Write()
if err != nil {
return err
}
if credentialFlow.ShouldSetup() {
err := credentialFlow.Setup(hostname, gitProtocol, username, token)
if err != nil {
return err
}
}
fmt.Fprintf(opts.IO.StdErr, "%s Logged in as %s\n", c.GreenCheck(), c.Bold(username))
fmt.Fprintf(opts.IO.StdErr, "%s Configuration saved to %s\n", c.GreenCheck(), config.ConfigFile())
return nil
}
func hostnameValidator(v any) error {
val := fmt.Sprint(v)
if len(strings.TrimSpace(val)) < 1 {
return errors.New("a value is required.")
}
re := regexp.MustCompile(`^(([a-z0-9]|[a-z0-9][a-z0-9\-]*[a-z0-9])\.)*([a-z0-9]|[a-z0-9][a-z0-9\-]*[a-z0-9])(:[0-9]+)?(/[a-z0-9]*)*$`)
if !re.MatchString(val) {
return fmt.Errorf("invalid hostname %q", val)
}
return nil
}
func getAccessTokenTip(hostname string) string {
glHostname := hostname
if glHostname == "" {
glHostname = glinstance.OverridableDefault()
}
return fmt.Sprintf(`
Tip: generate a personal access token at https://%s/-/user_settings/personal_access_tokens?scopes=api,write_repository.
The minimum required scopes are 'api' and 'write_repository'.`, glHostname)
}
func showTokenPrompt(io *iostreams.IOStreams, hostname string) (string, error) {
fmt.Fprintln(io.StdErr)
fmt.Fprintln(io.StdErr, heredoc.Doc(getAccessTokenTip(hostname)))
var token string
err := survey.AskOne(&survey.Password{
Message: "Paste your authentication token:",
}, &token, survey.WithValidator(survey.Required))
if err != nil {
return "", fmt.Errorf("could not prompt: %w", err)
}
return token, nil
}