argo-cd/cmd/argocd/commands/login.go
Matthieu MOREL dce3f6e8a5
chore: enable unnecessary-format rule from revive (#26958)
Signed-off-by: Matthieu MOREL <matthieu.morel35@gmail.com>
2026-04-16 11:21:56 -04:00

384 lines
14 KiB
Go

package commands
import (
"context"
"crypto/sha256"
"encoding/base64"
"fmt"
"html"
"net/http"
"os"
"strconv"
"strings"
"time"
jwtutil "github.com/argoproj/argo-cd/v3/util/jwt"
"github.com/coreos/go-oidc/v3/oidc"
"github.com/golang-jwt/jwt/v5"
log "github.com/sirupsen/logrus"
"github.com/skratchdot/open-golang/open"
"github.com/spf13/cobra"
"golang.org/x/oauth2"
"github.com/argoproj/argo-cd/v3/cmd/argocd/commands/headless"
argocdclient "github.com/argoproj/argo-cd/v3/pkg/apiclient"
sessionpkg "github.com/argoproj/argo-cd/v3/pkg/apiclient/session"
settingspkg "github.com/argoproj/argo-cd/v3/pkg/apiclient/settings"
"github.com/argoproj/argo-cd/v3/util/cli"
"github.com/argoproj/argo-cd/v3/util/errors"
grpc_util "github.com/argoproj/argo-cd/v3/util/grpc"
utilio "github.com/argoproj/argo-cd/v3/util/io"
"github.com/argoproj/argo-cd/v3/util/localconfig"
oidcutil "github.com/argoproj/argo-cd/v3/util/oidc"
"github.com/argoproj/argo-cd/v3/util/rand"
oidcconfig "github.com/argoproj/argo-cd/v3/util/settings"
)
// NewLoginCommand returns a new instance of `argocd login` command
func NewLoginCommand(clientOpts *argocdclient.ClientOptions) *cobra.Command {
var (
ctxName string
username string
password string
sso bool
callback string
ssoPort int
skipTestTLS bool
ssoLaunchBrowser bool
)
command := &cobra.Command{
Use: "login SERVER",
Short: "Log in to Argo CD",
Long: "Log in to Argo CD",
Example: `# Login to Argo CD using a username and password
argocd login cd.argoproj.io
# Login to Argo CD using SSO
argocd login cd.argoproj.io --sso
# Configure direct access using Kubernetes API server
argocd login cd.argoproj.io --core`,
Run: func(c *cobra.Command, args []string) {
ctx := c.Context()
var server string
if len(args) != 1 && !clientOpts.PortForward && !clientOpts.Core {
c.HelpFunc()(c, args)
os.Exit(1)
}
switch {
case clientOpts.PortForward:
server = "port-forward"
case clientOpts.Core:
server = "kubernetes"
default:
server = args[0]
if !skipTestTLS {
dialTime := 30 * time.Second
tlsTestResult, err := grpc_util.TestTLS(server, dialTime)
errors.CheckError(err)
if !tlsTestResult.TLS {
if !clientOpts.PlainText {
if !cli.AskToProceed("WARNING: server is not configured with TLS. Proceed (y/n)? ") {
os.Exit(1)
}
clientOpts.PlainText = true
}
} else if tlsTestResult.InsecureErr != nil {
if !clientOpts.Insecure {
if !cli.AskToProceed(fmt.Sprintf("WARNING: server certificate had error: %s. Proceed insecurely (y/n)? ", tlsTestResult.InsecureErr)) {
os.Exit(1)
}
clientOpts.Insecure = true
}
}
}
}
loginOpts := argocdclient.ClientOptions{
ConfigPath: "",
ServerAddr: server,
Insecure: clientOpts.Insecure,
PlainText: clientOpts.PlainText,
ClientCertFile: clientOpts.ClientCertFile,
ClientCertKeyFile: clientOpts.ClientCertKeyFile,
GRPCWeb: clientOpts.GRPCWeb,
GRPCWebRootPath: clientOpts.GRPCWebRootPath,
PortForward: clientOpts.PortForward,
PortForwardNamespace: clientOpts.PortForwardNamespace,
Headers: clientOpts.Headers,
KubeOverrides: clientOpts.KubeOverrides,
ServerName: clientOpts.ServerName,
}
if ctxName == "" {
ctxName = server
if loginOpts.GRPCWebRootPath != "" {
rootPath := strings.TrimRight(strings.TrimLeft(loginOpts.GRPCWebRootPath, "/"), "/")
ctxName = fmt.Sprintf("%s/%s", server, rootPath)
}
}
// Perform the login
var tokenString string
var refreshToken string
if !clientOpts.Core {
acdClient := headless.NewClientOrDie(&loginOpts, c)
setConn, setIf := acdClient.NewSettingsClientOrDie()
defer utilio.Close(setConn)
if !sso {
tokenString = passwordLogin(ctx, acdClient, username, password)
} else {
httpClient, err := acdClient.HTTPClient()
errors.CheckError(err)
ctx = oidc.ClientContext(ctx, httpClient)
acdSet, err := setIf.Get(ctx, &settingspkg.SettingsQuery{})
errors.CheckError(err)
oauth2conf, provider, err := acdClient.OIDCConfig(ctx, acdSet)
errors.CheckError(err)
tokenString, refreshToken = oauth2Login(ctx, callback, ssoPort, acdSet.GetOIDCConfig(), oauth2conf, provider, ssoLaunchBrowser)
}
parser := jwt.NewParser(jwt.WithoutClaimsValidation())
claims := jwt.MapClaims{}
_, _, err := parser.ParseUnverified(tokenString, &claims)
errors.CheckError(err)
fmt.Printf("'%s' logged in successfully\n", userDisplayName(claims))
}
// login successful. Persist the config
localCfg, err := localconfig.ReadLocalConfig(clientOpts.ConfigPath)
errors.CheckError(err)
if localCfg == nil {
localCfg = &localconfig.LocalConfig{}
}
localCfg.UpsertServer(localconfig.Server{
Server: server,
PlainText: clientOpts.PlainText,
Insecure: clientOpts.Insecure,
GRPCWeb: clientOpts.GRPCWeb,
GRPCWebRootPath: clientOpts.GRPCWebRootPath,
Core: clientOpts.Core,
})
localCfg.UpsertUser(localconfig.User{
Name: ctxName,
AuthToken: tokenString,
RefreshToken: refreshToken,
})
if ctxName == "" {
ctxName = server
}
localCfg.CurrentContext = ctxName
localCfg.UpsertContext(localconfig.ContextRef{
Name: ctxName,
User: ctxName,
Server: server,
})
err = localconfig.WriteLocalConfig(*localCfg, clientOpts.ConfigPath)
errors.CheckError(err)
fmt.Printf("Context '%s' updated\n", ctxName)
},
}
command.Flags().StringVar(&ctxName, "name", "", "Name to use for the context")
command.Flags().StringVar(&username, "username", "", "The username of an account to authenticate")
command.Flags().StringVar(&password, "password", "", "The password of an account to authenticate")
command.Flags().BoolVar(&sso, "sso", false, "Perform SSO login")
command.Flags().IntVar(&ssoPort, "sso-port", DefaultSSOLocalPort, "Port to run local OAuth2 login application")
command.Flags().StringVar(&callback, "callback", "", "Scheme, Host and Port for the callback URL")
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)")
command.Flags().BoolVar(&ssoLaunchBrowser, "sso-launch-browser", true, "Automatically launch the system default browser when performing SSO login")
return command
}
func userDisplayName(claims jwt.MapClaims) string {
if email := jwtutil.StringField(claims, "email"); email != "" {
return email
}
if name := jwtutil.StringField(claims, "name"); name != "" {
return name
}
return jwtutil.GetUserIdentifier(claims)
}
// oauth2Login opens a browser, runs a temporary HTTP server to delegate OAuth2 login flow and
// returns the JWT token and a refresh token (if supported)
func oauth2Login(
ctx context.Context,
callback string,
port int,
oidcSettings *settingspkg.OIDCConfig,
oauth2conf *oauth2.Config,
provider *oidc.Provider,
ssoLaunchBrowser bool,
) (string, string) {
redirectBase := callback
if redirectBase == "" {
redirectBase = "http://localhost:" + strconv.Itoa(port)
}
oauth2conf.RedirectURL = redirectBase + "/auth/callback"
oidcConf, err := oidcutil.ParseConfig(provider)
errors.CheckError(err)
log.Debug("OIDC Configuration:")
log.Debugf(" supported_scopes: %v", oidcConf.ScopesSupported)
log.Debugf(" response_types_supported: %v", oidcConf.ResponseTypesSupported)
// handledRequests ensures we do not handle more requests than necessary
handledRequests := 0
// completionChan is to signal flow completed. Non-empty string indicates error
completionChan := make(chan string)
// stateNonce is an OAuth2 state nonce
// According to the spec (https://www.rfc-editor.org/rfc/rfc6749#section-10.10), this must be guessable with
// probability <= 2^(-128). The following call generates one of 52^24 random strings, ~= 2^136 possibilities.
stateNonce, err := rand.String(24)
errors.CheckError(err)
var tokenString string
var refreshToken string
handleErr := func(w http.ResponseWriter, errMsg string) {
http.Error(w, html.EscapeString(errMsg), http.StatusBadRequest)
completionChan <- errMsg
}
// PKCE implementation of https://tools.ietf.org/html/rfc7636
codeVerifier, err := rand.StringFromCharset(
43,
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~",
)
errors.CheckError(err)
codeChallengeHash := sha256.Sum256([]byte(codeVerifier))
codeChallenge := base64.RawURLEncoding.EncodeToString(codeChallengeHash[:])
// Authorization redirect callback from OAuth2 auth flow.
// Handles both implicit and authorization code flow
callbackHandler := func(w http.ResponseWriter, r *http.Request) {
log.Debugf("Callback: %s", r.URL)
if formErr := r.FormValue("error"); formErr != "" {
handleErr(w, fmt.Sprintf("%s: %s", formErr, r.FormValue("error_description")))
return
}
handledRequests++
if handledRequests > 2 {
// Since implicit flow will redirect back to ourselves, this counter ensures we do not
// fallinto a redirect loop (e.g. user visits the page by hand)
handleErr(w, "Unable to complete login flow: too many redirects")
return
}
if len(r.Form) == 0 {
// If we get here, no form data was set. We presume to be performing an implicit login
// flow where the id_token is contained in a URL fragment, making it inaccessible to be
// read from the request. This javascript will redirect the browser to send the
// fragments as query parameters so our callback handler can read and return token.
fmt.Fprint(w, `<script>window.location.search = window.location.hash.substring(1)</script>`)
return
}
if state := r.FormValue("state"); state != stateNonce {
handleErr(w, "Unknown state nonce")
return
}
tokenString = r.FormValue("id_token")
if tokenString == "" {
code := r.FormValue("code")
if code == "" {
handleErr(w, fmt.Sprintf("no code in request: %q", r.Form))
return
}
opts := []oauth2.AuthCodeOption{oauth2.SetAuthURLParam("code_verifier", codeVerifier)}
tok, err := oauth2conf.Exchange(ctx, code, opts...)
if err != nil {
handleErr(w, err.Error())
return
}
var ok bool
tokenString, ok = tok.Extra("id_token").(string)
if !ok {
handleErr(w, "no id_token in token response")
return
}
refreshToken, _ = tok.Extra("refresh_token").(string)
}
successPage := `
<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>
<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>
<script>window.onload=function(){setTimeout(this.close, 4000)}</script>
`
fmt.Fprint(w, successPage)
completionChan <- ""
}
srv := &http.Server{Addr: "localhost:" + strconv.Itoa(port)}
http.HandleFunc("/auth/callback", callbackHandler)
// Redirect user to login & consent page to ask for permission for the scopes specified above.
var url string
var oidcconfig oidcconfig.OIDCConfig
grantType := oidcutil.InferGrantType(oidcConf)
opts := []oauth2.AuthCodeOption{oauth2.AccessTypeOffline}
if claimsRequested := oidcSettings.GetIDTokenClaims(); claimsRequested != nil {
opts = oidcutil.AppendClaimsAuthenticationRequestParameter(opts, claimsRequested)
}
switch grantType {
case oidcutil.GrantTypeAuthorizationCode:
opts = append(opts, oauth2.SetAuthURLParam("code_challenge", codeChallenge))
opts = append(opts, oauth2.SetAuthURLParam("code_challenge_method", "S256"))
if oidcconfig.DomainHint != "" {
opts = append(opts, oauth2.SetAuthURLParam("domain_hint", oidcconfig.DomainHint))
}
url = oauth2conf.AuthCodeURL(stateNonce, opts...)
case oidcutil.GrantTypeImplicit:
url, err = oidcutil.ImplicitFlowURL(oauth2conf, stateNonce, opts...)
errors.CheckError(err)
default:
log.Fatalf("Unsupported grant type: %v", grantType)
}
fmt.Printf("Performing %s flow login: %s\n", grantType, url)
time.Sleep(1 * time.Second)
ssoAuthFlow(url, ssoLaunchBrowser)
go func() {
log.Debugf("Listen: %s", srv.Addr)
if err := srv.ListenAndServe(); err != http.ErrServerClosed {
log.Fatalf("Temporary HTTP server failed: %s", err)
}
}()
errMsg := <-completionChan
if errMsg != "" {
log.Fatal(errMsg)
}
fmt.Print("Authentication successful\n")
ctx, cancel := context.WithTimeout(ctx, 1*time.Second)
defer cancel()
_ = srv.Shutdown(ctx)
log.Debugf("Token: %s", tokenString)
log.Debugf("Refresh Token: %s", refreshToken)
return tokenString, refreshToken
}
func passwordLogin(ctx context.Context, acdClient argocdclient.Client, username, password string) string {
username, password = cli.PromptCredentials(username, password)
sessConn, sessionIf := acdClient.NewSessionClientOrDie()
defer utilio.Close(sessConn)
sessionRequest := sessionpkg.SessionCreateRequest{
Username: username,
Password: password,
}
createdSession, err := sessionIf.Create(ctx, &sessionRequest)
errors.CheckError(err)
return createdSession.Token
}
func ssoAuthFlow(url string, ssoLaunchBrowser bool) {
if ssoLaunchBrowser {
fmt.Print("Opening system default browser for authentication\n")
err := open.Start(url)
errors.CheckError(err)
} else {
fmt.Printf("To authenticate, copy-and-paste the following URL into your preferred browser: %s\n", url)
}
}