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 }