github.com/argoproj/argo-cd/v3@v3.2.1/cmd/argocd/commands/login.go (about)

     1  package commands
     2  
     3  import (
     4  	"context"
     5  	"crypto/sha256"
     6  	"encoding/base64"
     7  	"fmt"
     8  	"html"
     9  	"net/http"
    10  	"os"
    11  	"strconv"
    12  	"strings"
    13  	"time"
    14  
    15  	jwtutil "github.com/argoproj/argo-cd/v3/util/jwt"
    16  
    17  	"github.com/coreos/go-oidc/v3/oidc"
    18  	"github.com/golang-jwt/jwt/v5"
    19  	log "github.com/sirupsen/logrus"
    20  	"github.com/skratchdot/open-golang/open"
    21  	"github.com/spf13/cobra"
    22  	"golang.org/x/oauth2"
    23  
    24  	"github.com/argoproj/argo-cd/v3/cmd/argocd/commands/headless"
    25  	argocdclient "github.com/argoproj/argo-cd/v3/pkg/apiclient"
    26  	sessionpkg "github.com/argoproj/argo-cd/v3/pkg/apiclient/session"
    27  	settingspkg "github.com/argoproj/argo-cd/v3/pkg/apiclient/settings"
    28  	"github.com/argoproj/argo-cd/v3/util/cli"
    29  	"github.com/argoproj/argo-cd/v3/util/errors"
    30  	grpc_util "github.com/argoproj/argo-cd/v3/util/grpc"
    31  	utilio "github.com/argoproj/argo-cd/v3/util/io"
    32  	"github.com/argoproj/argo-cd/v3/util/localconfig"
    33  	oidcutil "github.com/argoproj/argo-cd/v3/util/oidc"
    34  	"github.com/argoproj/argo-cd/v3/util/rand"
    35  	oidcconfig "github.com/argoproj/argo-cd/v3/util/settings"
    36  )
    37  
    38  // NewLoginCommand returns a new instance of `argocd login` command
    39  func NewLoginCommand(globalClientOpts *argocdclient.ClientOptions) *cobra.Command {
    40  	var (
    41  		ctxName          string
    42  		username         string
    43  		password         string
    44  		sso              bool
    45  		callback         string
    46  		ssoPort          int
    47  		skipTestTLS      bool
    48  		ssoLaunchBrowser bool
    49  	)
    50  	command := &cobra.Command{
    51  		Use:   "login SERVER",
    52  		Short: "Log in to Argo CD",
    53  		Long:  "Log in to Argo CD",
    54  		Example: `# Login to Argo CD using a username and password
    55  argocd login cd.argoproj.io
    56  
    57  # Login to Argo CD using SSO
    58  argocd login cd.argoproj.io --sso
    59  
    60  # Configure direct access using Kubernetes API server
    61  argocd login cd.argoproj.io --core`,
    62  		Run: func(c *cobra.Command, args []string) {
    63  			ctx := c.Context()
    64  
    65  			var server string
    66  
    67  			if len(args) != 1 && !globalClientOpts.PortForward && !globalClientOpts.Core {
    68  				c.HelpFunc()(c, args)
    69  				os.Exit(1)
    70  			}
    71  
    72  			switch {
    73  			case globalClientOpts.PortForward:
    74  				server = "port-forward"
    75  			case globalClientOpts.Core:
    76  				server = "kubernetes"
    77  			default:
    78  				server = args[0]
    79  
    80  				if !skipTestTLS {
    81  					dialTime := 30 * time.Second
    82  					tlsTestResult, err := grpc_util.TestTLS(server, dialTime)
    83  					errors.CheckError(err)
    84  					if !tlsTestResult.TLS {
    85  						if !globalClientOpts.PlainText {
    86  							if !cli.AskToProceed("WARNING: server is not configured with TLS. Proceed (y/n)? ") {
    87  								os.Exit(1)
    88  							}
    89  							globalClientOpts.PlainText = true
    90  						}
    91  					} else if tlsTestResult.InsecureErr != nil {
    92  						if !globalClientOpts.Insecure {
    93  							if !cli.AskToProceed(fmt.Sprintf("WARNING: server certificate had error: %s. Proceed insecurely (y/n)? ", tlsTestResult.InsecureErr)) {
    94  								os.Exit(1)
    95  							}
    96  							globalClientOpts.Insecure = true
    97  						}
    98  					}
    99  				}
   100  			}
   101  			clientOpts := argocdclient.ClientOptions{
   102  				ConfigPath:           "",
   103  				ServerAddr:           server,
   104  				Insecure:             globalClientOpts.Insecure,
   105  				PlainText:            globalClientOpts.PlainText,
   106  				ClientCertFile:       globalClientOpts.ClientCertFile,
   107  				ClientCertKeyFile:    globalClientOpts.ClientCertKeyFile,
   108  				GRPCWeb:              globalClientOpts.GRPCWeb,
   109  				GRPCWebRootPath:      globalClientOpts.GRPCWebRootPath,
   110  				PortForward:          globalClientOpts.PortForward,
   111  				PortForwardNamespace: globalClientOpts.PortForwardNamespace,
   112  				Headers:              globalClientOpts.Headers,
   113  				KubeOverrides:        globalClientOpts.KubeOverrides,
   114  				ServerName:           globalClientOpts.ServerName,
   115  			}
   116  
   117  			if ctxName == "" {
   118  				ctxName = server
   119  				if globalClientOpts.GRPCWebRootPath != "" {
   120  					rootPath := strings.TrimRight(strings.TrimLeft(globalClientOpts.GRPCWebRootPath, "/"), "/")
   121  					ctxName = fmt.Sprintf("%s/%s", server, rootPath)
   122  				}
   123  			}
   124  
   125  			// Perform the login
   126  			var tokenString string
   127  			var refreshToken string
   128  			if !globalClientOpts.Core {
   129  				acdClient := headless.NewClientOrDie(&clientOpts, c)
   130  				setConn, setIf := acdClient.NewSettingsClientOrDie()
   131  				defer utilio.Close(setConn)
   132  				if !sso {
   133  					tokenString = passwordLogin(ctx, acdClient, username, password)
   134  				} else {
   135  					httpClient, err := acdClient.HTTPClient()
   136  					errors.CheckError(err)
   137  					ctx = oidc.ClientContext(ctx, httpClient)
   138  					acdSet, err := setIf.Get(ctx, &settingspkg.SettingsQuery{})
   139  					errors.CheckError(err)
   140  					oauth2conf, provider, err := acdClient.OIDCConfig(ctx, acdSet)
   141  					errors.CheckError(err)
   142  					tokenString, refreshToken = oauth2Login(ctx, callback, ssoPort, acdSet.GetOIDCConfig(), oauth2conf, provider, ssoLaunchBrowser)
   143  				}
   144  				parser := jwt.NewParser(jwt.WithoutClaimsValidation())
   145  				claims := jwt.MapClaims{}
   146  				_, _, err := parser.ParseUnverified(tokenString, &claims)
   147  				errors.CheckError(err)
   148  				fmt.Printf("'%s' logged in successfully\n", userDisplayName(claims))
   149  			}
   150  
   151  			// login successful. Persist the config
   152  			localCfg, err := localconfig.ReadLocalConfig(globalClientOpts.ConfigPath)
   153  			errors.CheckError(err)
   154  			if localCfg == nil {
   155  				localCfg = &localconfig.LocalConfig{}
   156  			}
   157  			localCfg.UpsertServer(localconfig.Server{
   158  				Server:          server,
   159  				PlainText:       globalClientOpts.PlainText,
   160  				Insecure:        globalClientOpts.Insecure,
   161  				GRPCWeb:         globalClientOpts.GRPCWeb,
   162  				GRPCWebRootPath: globalClientOpts.GRPCWebRootPath,
   163  				Core:            globalClientOpts.Core,
   164  			})
   165  			localCfg.UpsertUser(localconfig.User{
   166  				Name:         ctxName,
   167  				AuthToken:    tokenString,
   168  				RefreshToken: refreshToken,
   169  			})
   170  			if ctxName == "" {
   171  				ctxName = server
   172  			}
   173  			localCfg.CurrentContext = ctxName
   174  			localCfg.UpsertContext(localconfig.ContextRef{
   175  				Name:   ctxName,
   176  				User:   ctxName,
   177  				Server: server,
   178  			})
   179  			err = localconfig.WriteLocalConfig(*localCfg, globalClientOpts.ConfigPath)
   180  			errors.CheckError(err)
   181  			fmt.Printf("Context '%s' updated\n", ctxName)
   182  		},
   183  	}
   184  	command.Flags().StringVar(&ctxName, "name", "", "Name to use for the context")
   185  	command.Flags().StringVar(&username, "username", "", "The username of an account to authenticate")
   186  	command.Flags().StringVar(&password, "password", "", "The password of an account to authenticate")
   187  	command.Flags().BoolVar(&sso, "sso", false, "Perform SSO login")
   188  	command.Flags().IntVar(&ssoPort, "sso-port", DefaultSSOLocalPort, "Port to run local OAuth2 login application")
   189  	command.Flags().StringVar(&callback, "callback", "", "Scheme, Host and Port for the callback URL")
   190  	command.Flags().BoolVar(&skipTestTLS, "skip-test-tls", false, "Skip testing whether the server is configured with TLS (this can help when the command hangs for no apparent reason)")
   191  	command.Flags().BoolVar(&ssoLaunchBrowser, "sso-launch-browser", true, "Automatically launch the system default browser when performing SSO login")
   192  	return command
   193  }
   194  
   195  func userDisplayName(claims jwt.MapClaims) string {
   196  	if email := jwtutil.StringField(claims, "email"); email != "" {
   197  		return email
   198  	}
   199  	if name := jwtutil.StringField(claims, "name"); name != "" {
   200  		return name
   201  	}
   202  	return jwtutil.GetUserIdentifier(claims)
   203  }
   204  
   205  // oauth2Login opens a browser, runs a temporary HTTP server to delegate OAuth2 login flow and
   206  // returns the JWT token and a refresh token (if supported)
   207  func oauth2Login(
   208  	ctx context.Context,
   209  	callback string,
   210  	port int,
   211  	oidcSettings *settingspkg.OIDCConfig,
   212  	oauth2conf *oauth2.Config,
   213  	provider *oidc.Provider,
   214  	ssoLaunchBrowser bool,
   215  ) (string, string) {
   216  	redirectBase := callback
   217  	if redirectBase == "" {
   218  		redirectBase = "http://localhost:" + strconv.Itoa(port)
   219  	}
   220  
   221  	oauth2conf.RedirectURL = redirectBase + "/auth/callback"
   222  	oidcConf, err := oidcutil.ParseConfig(provider)
   223  	errors.CheckError(err)
   224  	log.Debug("OIDC Configuration:")
   225  	log.Debugf("  supported_scopes: %v", oidcConf.ScopesSupported)
   226  	log.Debugf("  response_types_supported: %v", oidcConf.ResponseTypesSupported)
   227  
   228  	// handledRequests ensures we do not handle more requests than necessary
   229  	handledRequests := 0
   230  	// completionChan is to signal flow completed. Non-empty string indicates error
   231  	completionChan := make(chan string)
   232  	// stateNonce is an OAuth2 state nonce
   233  	// According to the spec (https://www.rfc-editor.org/rfc/rfc6749#section-10.10), this must be guessable with
   234  	// probability <= 2^(-128). The following call generates one of 52^24 random strings, ~= 2^136 possibilities.
   235  	stateNonce, err := rand.String(24)
   236  	errors.CheckError(err)
   237  	var tokenString string
   238  	var refreshToken string
   239  
   240  	handleErr := func(w http.ResponseWriter, errMsg string) {
   241  		http.Error(w, html.EscapeString(errMsg), http.StatusBadRequest)
   242  		completionChan <- errMsg
   243  	}
   244  
   245  	// PKCE implementation of https://tools.ietf.org/html/rfc7636
   246  	codeVerifier, err := rand.StringFromCharset(
   247  		43,
   248  		"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~",
   249  	)
   250  	errors.CheckError(err)
   251  	codeChallengeHash := sha256.Sum256([]byte(codeVerifier))
   252  	codeChallenge := base64.RawURLEncoding.EncodeToString(codeChallengeHash[:])
   253  
   254  	// Authorization redirect callback from OAuth2 auth flow.
   255  	// Handles both implicit and authorization code flow
   256  	callbackHandler := func(w http.ResponseWriter, r *http.Request) {
   257  		log.Debugf("Callback: %s", r.URL)
   258  
   259  		if formErr := r.FormValue("error"); formErr != "" {
   260  			handleErr(w, fmt.Sprintf("%s: %s", formErr, r.FormValue("error_description")))
   261  			return
   262  		}
   263  
   264  		handledRequests++
   265  		if handledRequests > 2 {
   266  			// Since implicit flow will redirect back to ourselves, this counter ensures we do not
   267  			// fallinto a redirect loop (e.g. user visits the page by hand)
   268  			handleErr(w, "Unable to complete login flow: too many redirects")
   269  			return
   270  		}
   271  
   272  		if len(r.Form) == 0 {
   273  			// If we get here, no form data was set. We presume to be performing an implicit login
   274  			// flow where the id_token is contained in a URL fragment, making it inaccessible to be
   275  			// read from the request. This javascript will redirect the browser to send the
   276  			// fragments as query parameters so our callback handler can read and return token.
   277  			fmt.Fprintf(w, `<script>window.location.search = window.location.hash.substring(1)</script>`)
   278  			return
   279  		}
   280  
   281  		if state := r.FormValue("state"); state != stateNonce {
   282  			handleErr(w, "Unknown state nonce")
   283  			return
   284  		}
   285  
   286  		tokenString = r.FormValue("id_token")
   287  		if tokenString == "" {
   288  			code := r.FormValue("code")
   289  			if code == "" {
   290  				handleErr(w, fmt.Sprintf("no code in request: %q", r.Form))
   291  				return
   292  			}
   293  			opts := []oauth2.AuthCodeOption{oauth2.SetAuthURLParam("code_verifier", codeVerifier)}
   294  			tok, err := oauth2conf.Exchange(ctx, code, opts...)
   295  			if err != nil {
   296  				handleErr(w, err.Error())
   297  				return
   298  			}
   299  			var ok bool
   300  			tokenString, ok = tok.Extra("id_token").(string)
   301  			if !ok {
   302  				handleErr(w, "no id_token in token response")
   303  				return
   304  			}
   305  			refreshToken, _ = tok.Extra("refresh_token").(string)
   306  		}
   307  		successPage := `
   308  		<div style="height:100px; width:100%!; display:flex; flex-direction: column; justify-content: center; align-items:center; background-color:#2ecc71; color:white; font-size:22"><div>Authentication successful!</div></div>
   309  		<p style="margin-top:20px; font-size:18; text-align:center">Authentication was successful, you can now return to CLI. This page will close automatically</p>
   310  		<script>window.onload=function(){setTimeout(this.close, 4000)}</script>
   311  		`
   312  		fmt.Fprint(w, successPage)
   313  		completionChan <- ""
   314  	}
   315  	srv := &http.Server{Addr: "localhost:" + strconv.Itoa(port)}
   316  	http.HandleFunc("/auth/callback", callbackHandler)
   317  
   318  	// Redirect user to login & consent page to ask for permission for the scopes specified above.
   319  	var url string
   320  	var oidcconfig oidcconfig.OIDCConfig
   321  	grantType := oidcutil.InferGrantType(oidcConf)
   322  	opts := []oauth2.AuthCodeOption{oauth2.AccessTypeOffline}
   323  	if claimsRequested := oidcSettings.GetIDTokenClaims(); claimsRequested != nil {
   324  		opts = oidcutil.AppendClaimsAuthenticationRequestParameter(opts, claimsRequested)
   325  	}
   326  
   327  	switch grantType {
   328  	case oidcutil.GrantTypeAuthorizationCode:
   329  		opts = append(opts, oauth2.SetAuthURLParam("code_challenge", codeChallenge))
   330  		opts = append(opts, oauth2.SetAuthURLParam("code_challenge_method", "S256"))
   331  		if oidcconfig.DomainHint != "" {
   332  			opts = append(opts, oauth2.SetAuthURLParam("domain_hint", oidcconfig.DomainHint))
   333  		}
   334  		url = oauth2conf.AuthCodeURL(stateNonce, opts...)
   335  	case oidcutil.GrantTypeImplicit:
   336  		url, err = oidcutil.ImplicitFlowURL(oauth2conf, stateNonce, opts...)
   337  		errors.CheckError(err)
   338  	default:
   339  		log.Fatalf("Unsupported grant type: %v", grantType)
   340  	}
   341  	fmt.Printf("Performing %s flow login: %s\n", grantType, url)
   342  	time.Sleep(1 * time.Second)
   343  	ssoAuthFlow(url, ssoLaunchBrowser)
   344  	go func() {
   345  		log.Debugf("Listen: %s", srv.Addr)
   346  		if err := srv.ListenAndServe(); err != http.ErrServerClosed {
   347  			log.Fatalf("Temporary HTTP server failed: %s", err)
   348  		}
   349  	}()
   350  	errMsg := <-completionChan
   351  	if errMsg != "" {
   352  		log.Fatal(errMsg)
   353  	}
   354  	fmt.Printf("Authentication successful\n")
   355  	ctx, cancel := context.WithTimeout(ctx, 1*time.Second)
   356  	defer cancel()
   357  	_ = srv.Shutdown(ctx)
   358  	log.Debugf("Token: %s", tokenString)
   359  	log.Debugf("Refresh Token: %s", refreshToken)
   360  	return tokenString, refreshToken
   361  }
   362  
   363  func passwordLogin(ctx context.Context, acdClient argocdclient.Client, username, password string) string {
   364  	username, password = cli.PromptCredentials(username, password)
   365  	sessConn, sessionIf := acdClient.NewSessionClientOrDie()
   366  	defer utilio.Close(sessConn)
   367  	sessionRequest := sessionpkg.SessionCreateRequest{
   368  		Username: username,
   369  		Password: password,
   370  	}
   371  	createdSession, err := sessionIf.Create(ctx, &sessionRequest)
   372  	errors.CheckError(err)
   373  	return createdSession.Token
   374  }
   375  
   376  func ssoAuthFlow(url string, ssoLaunchBrowser bool) {
   377  	if ssoLaunchBrowser {
   378  		fmt.Printf("Opening system default browser for authentication\n")
   379  		err := open.Start(url)
   380  		errors.CheckError(err)
   381  	} else {
   382  		fmt.Printf("To authenticate, copy-and-paste the following URL into your preferred browser: %s\n", url)
   383  	}
   384  }