From 0ed4eb34d39dccf4c5e868d98e68af3e5b80b6eb Mon Sep 17 00:00:00 2001 From: Jesse Suen Date: Wed, 24 Oct 2018 23:49:11 -0700 Subject: [PATCH] Support for external OIDC providers and implicit login flows --- Gopkg.lock | 10 - Procfile | 4 +- VERSION | 2 +- cmd/argocd-application-controller/main.go | 12 +- cmd/argocd-repo-server/main.go | 5 +- cmd/argocd-server/commands/root.go | 26 +- cmd/argocd/commands/account.go | 4 +- cmd/argocd/commands/common.go | 4 + cmd/argocd/commands/login.go | 184 +++++---- cmd/argocd/commands/relogin.go | 32 +- cmd/argocd/commands/root.go | 18 +- manifests/base/dex-server-deployment.yaml | 2 +- manifests/install.yaml | 2 +- manifests/namespace-install.yaml | 2 +- pkg/apiclient/apiclient.go | 133 ++++--- server/server.go | 21 +- server/settings/settings.go | 17 +- server/settings/settings.pb.go | 391 +++++++++++++++++-- server/settings/settings.proto | 7 + server/swagger.json | 17 + util/cli/cli.go | 44 ++- util/dex/config.go | 8 +- util/dex/dex.go | 306 +-------------- util/http/http.go | 15 + util/oidc/oidc.go | 407 ++++++++++++++++++++ util/oidc/oidc_test.go | 49 +++ util/{dex => oidc}/templates.go | 2 +- util/oidc/testdata/auth0.json | 70 ++++ util/oidc/testdata/dex.json | 36 ++ util/oidc/testdata/okta.json | 115 ++++++ util/oidc/testdata/onelogin.json | 87 +++++ util/rand/rand.go | 37 ++ util/{dex/dex_test.go => rand/rand_test.go} | 15 +- util/session/sessionmanager.go | 64 +-- util/settings/settings.go | 113 ++++-- 35 files changed, 1634 insertions(+), 627 deletions(-) create mode 100644 util/http/http.go create mode 100644 util/oidc/oidc.go create mode 100644 util/oidc/oidc_test.go rename util/{dex => oidc}/templates.go (99%) create mode 100644 util/oidc/testdata/auth0.json create mode 100644 util/oidc/testdata/dex.json create mode 100644 util/oidc/testdata/okta.json create mode 100644 util/oidc/testdata/onelogin.json create mode 100644 util/rand/rand.go rename util/{dex/dex_test.go => rand/rand_test.go} (50%) diff --git a/Gopkg.lock b/Gopkg.lock index eadf614fd01a2..27ad654b6e61c 100644 --- a/Gopkg.lock +++ b/Gopkg.lock @@ -88,14 +88,6 @@ revision = "d71629e497929858300c38cd442098c178121c30" version = "v1.5.0" -[[projects]] - digest = "1:65bad35bfcdd839cb26bb4ff31de49be39dd6bd2ade0c7c57d010f7d0412a4a5" - name = "github.com/coreos/dex" - packages = ["api"] - pruneopts = "" - revision = "218d671a96865df2a4cf7f310efb99b8bfc5a5e2" - version = "v2.10.0" - [[projects]] branch = "v2" digest = "1:d8ee1b165eb7f4fd9ada718e1e7eeb0bc1fd462592d0bd823df694443f448681" @@ -1439,7 +1431,6 @@ "github.com/argoproj/pkg/time", "github.com/casbin/casbin", "github.com/casbin/casbin/model", - "github.com/coreos/dex/api", "github.com/coreos/go-oidc", "github.com/dgrijalva/jwt-go", "github.com/dustin/go-humanize", @@ -1453,7 +1444,6 @@ "github.com/gogo/protobuf/proto", "github.com/gogo/protobuf/protoc-gen-gofast", "github.com/gogo/protobuf/protoc-gen-gogofast", - "github.com/golang/glog", "github.com/golang/protobuf/proto", "github.com/golang/protobuf/protoc-gen-go", "github.com/golang/protobuf/ptypes/empty", diff --git a/Procfile b/Procfile index 78946f4c275b6..4f2513eb4bd53 100644 --- a/Procfile +++ b/Procfile @@ -1,4 +1,4 @@ controller: go run ./cmd/argocd-application-controller/main.go -api-server: go run ./cmd/argocd-server/main.go --insecure --disable-auth +api-server: go run ./cmd/argocd-server/main.go --insecure --dex-server http://localhost:5556 --repo-server localhost:8081 repo-server: go run ./cmd/argocd-repo-server/main.go --loglevel debug -dex: sh -c "go run ./cmd/argocd-util/main.go gendexcfg -o `pwd`/dist/dex.yaml && docker run --rm -p 5556:5556 -p 5557:5557 -v `pwd`/dist/dex.yaml:/dex.yaml quay.io/coreos/dex:v2.10.0 serve /dex.yaml" +dex: sh -c "go run ./cmd/argocd-util/main.go gendexcfg -o `pwd`/dist/dex.yaml && docker run --rm -p 5556:5556 -v `pwd`/dist/dex.yaml:/dex.yaml quay.io/dexidp/dex:v2.12.0 serve /dex.yaml" diff --git a/VERSION b/VERSION index 78bc1abd14f2c..d9df1bbc0c7be 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.10.0 +0.11.0 diff --git a/cmd/argocd-application-controller/main.go b/cmd/argocd-application-controller/main.go index da6397feecfa1..e0032639d46fd 100644 --- a/cmd/argocd-application-controller/main.go +++ b/cmd/argocd-application-controller/main.go @@ -2,10 +2,8 @@ package main import ( "context" - "flag" "fmt" "os" - "strconv" "time" log "github.com/sirupsen/logrus" @@ -48,14 +46,8 @@ func newCommand() *cobra.Command { Use: cliName, Short: "application-controller is a controller to operate on applications CRD", RunE: func(c *cobra.Command, args []string) error { - level, err := log.ParseLevel(logLevel) - errors.CheckError(err) - log.SetLevel(level) - - // Set the glog level for the k8s go-client - _ = flag.CommandLine.Parse([]string{}) - _ = flag.Lookup("logtostderr").Value.Set("true") - _ = flag.Lookup("v").Value.Set(strconv.Itoa(glogLevel)) + cli.SetLogLevel(logLevel) + cli.SetGLogLevel(glogLevel) config, err := clientConfig.ClientConfig() errors.CheckError(err) diff --git a/cmd/argocd-repo-server/main.go b/cmd/argocd-repo-server/main.go index 6ad19287c1e20..bc950bed3451c 100644 --- a/cmd/argocd-repo-server/main.go +++ b/cmd/argocd-repo-server/main.go @@ -14,6 +14,7 @@ import ( "github.com/argoproj/argo-cd/reposerver" "github.com/argoproj/argo-cd/reposerver/repository" "github.com/argoproj/argo-cd/util/cache" + "github.com/argoproj/argo-cd/util/cli" "github.com/argoproj/argo-cd/util/git" "github.com/argoproj/argo-cd/util/ksonnet" "github.com/argoproj/argo-cd/util/stats" @@ -35,9 +36,7 @@ func newCommand() *cobra.Command { Use: cliName, Short: "Run argocd-repo-server", RunE: func(c *cobra.Command, args []string) error { - level, err := log.ParseLevel(logLevel) - errors.CheckError(err) - log.SetLevel(level) + cli.SetLogLevel(logLevel) tlsConfigCustomizer, err := tlsConfigCustomizerSrc() errors.CheckError(err) diff --git a/cmd/argocd-server/commands/root.go b/cmd/argocd-server/commands/root.go index 1fa37849fcef7..e48082be6f65b 100644 --- a/cmd/argocd-server/commands/root.go +++ b/cmd/argocd-server/commands/root.go @@ -2,11 +2,8 @@ package commands import ( "context" - "flag" - "strconv" "time" - log "github.com/sirupsen/logrus" "github.com/spf13/cobra" "k8s.io/client-go/kubernetes" "k8s.io/client-go/tools/clientcmd" @@ -20,6 +17,14 @@ import ( "github.com/argoproj/argo-cd/util/tls" ) +const ( + // DefaultDexServerAddr is the HTTP address of the Dex OIDC server, which we run a reverse proxy against + DefaultDexServerAddr = "http://dex-server:5556" + + // DefaultRepoServerAddr is the gRPC address of the ArgoCD repo server + DefaultRepoServerAddr = "argocd-repo-server:8081" +) + // NewCommand returns a new instance of an argocd command func NewCommand() *cobra.Command { var ( @@ -29,6 +34,7 @@ func NewCommand() *cobra.Command { clientConfig clientcmd.ClientConfig staticAssetsDir string repoServerAddress string + dexServerAddress string disableAuth bool tlsConfigCustomizerSrc func() (tls.ConfigCustomizer, error) ) @@ -37,14 +43,8 @@ func NewCommand() *cobra.Command { Short: "Run the argocd API server", Long: "Run the argocd API server", Run: func(c *cobra.Command, args []string) { - level, err := log.ParseLevel(logLevel) - errors.CheckError(err) - log.SetLevel(level) - - // Set the glog level for the k8s go-client - _ = flag.CommandLine.Parse([]string{}) - _ = flag.Lookup("logtostderr").Value.Set("true") - _ = flag.Lookup("v").Value.Set(strconv.Itoa(glogLevel)) + cli.SetLogLevel(logLevel) + cli.SetGLogLevel(glogLevel) config, err := clientConfig.ClientConfig() errors.CheckError(err) @@ -66,6 +66,7 @@ func NewCommand() *cobra.Command { KubeClientset: kubeclientset, AppClientset: appclientset, RepoClientset: repoclientset, + DexServerAddr: dexServerAddress, DisableAuth: disableAuth, TLSConfigCustomizer: tlsConfigCustomizer, } @@ -89,7 +90,8 @@ func NewCommand() *cobra.Command { command.Flags().StringVar(&staticAssetsDir, "staticassets", "", "Static assets directory path") command.Flags().StringVar(&logLevel, "loglevel", "info", "Set the logging level. One of: debug|info|warn|error") command.Flags().IntVar(&glogLevel, "gloglevel", 0, "Set the glog logging level") - command.Flags().StringVar(&repoServerAddress, "repo-server", "localhost:8081", "Repo server address.") + command.Flags().StringVar(&repoServerAddress, "repo-server", DefaultRepoServerAddr, "Repo server address") + command.Flags().StringVar(&dexServerAddress, "dex-server", DefaultDexServerAddr, "Dex server address") command.Flags().BoolVar(&disableAuth, "disable-auth", false, "Disable client authentication") command.AddCommand(cli.NewVersionCmd(cliName)) tlsConfigCustomizerSrc = tls.AddTLSFlagsToCmd(command) diff --git a/cmd/argocd/commands/account.go b/cmd/argocd/commands/account.go index ca5067a6ef558..220e71f7070d0 100644 --- a/cmd/argocd/commands/account.go +++ b/cmd/argocd/commands/account.go @@ -10,7 +10,7 @@ import ( argocdclient "github.com/argoproj/argo-cd/pkg/apiclient" "github.com/argoproj/argo-cd/server/account" "github.com/argoproj/argo-cd/util" - "github.com/argoproj/argo-cd/util/settings" + "github.com/argoproj/argo-cd/util/cli" "github.com/spf13/cobra" "golang.org/x/crypto/ssh/terminal" ) @@ -51,7 +51,7 @@ func NewAccountUpdatePasswordCommand(clientOpts *argocdclient.ClientOptions) *co } if newPassword == "" { var err error - newPassword, err = settings.ReadAndConfirmPassword() + newPassword, err = cli.ReadAndConfirmPassword() errors.CheckError(err) } diff --git a/cmd/argocd/commands/common.go b/cmd/argocd/commands/common.go index 5e3033ce1ca33..6373e27cf507d 100644 --- a/cmd/argocd/commands/common.go +++ b/cmd/argocd/commands/common.go @@ -2,4 +2,8 @@ package commands const ( cliName = "argocd" + + // DefaultSSOLocalPort is the localhost port to listen on for the temporary web server performing + // the OAuth2 login flow. + DefaultSSOLocalPort = 8085 ) diff --git a/cmd/argocd/commands/login.go b/cmd/argocd/commands/login.go index 3480c8fa9fde2..344a5cfbdbde8 100644 --- a/cmd/argocd/commands/login.go +++ b/cmd/argocd/commands/login.go @@ -2,15 +2,19 @@ package commands import ( "context" - "crypto/tls" "fmt" - "net" "net/http" "os" "strconv" "time" - "github.com/argoproj/argo-cd/common" + oidc "github.com/coreos/go-oidc" + jwt "github.com/dgrijalva/jwt-go" + log "github.com/sirupsen/logrus" + "github.com/skratchdot/open-golang/open" + "github.com/spf13/cobra" + "golang.org/x/oauth2" + "github.com/argoproj/argo-cd/errors" argocdclient "github.com/argoproj/argo-cd/pkg/apiclient" "github.com/argoproj/argo-cd/server/session" @@ -19,11 +23,8 @@ import ( "github.com/argoproj/argo-cd/util/cli" grpc_util "github.com/argoproj/argo-cd/util/grpc" "github.com/argoproj/argo-cd/util/localconfig" - jwt "github.com/dgrijalva/jwt-go" - log "github.com/sirupsen/logrus" - "github.com/skratchdot/open-golang/open" - "github.com/spf13/cobra" - "golang.org/x/oauth2" + oidcutil "github.com/argoproj/argo-cd/util/oidc" + "github.com/argoproj/argo-cd/util/rand" ) // NewLoginCommand returns a new instance of `argocd login` command @@ -33,6 +34,7 @@ func NewLoginCommand(globalClientOpts *argocdclient.ClientOptions) *cobra.Comman username string password string sso bool + ssoPort int ) var command = &cobra.Command{ Use: "login SERVER", @@ -81,12 +83,15 @@ func NewLoginCommand(globalClientOpts *argocdclient.ClientOptions) *cobra.Comman if !sso { tokenString = passwordLogin(acdClient, username, password) } else { - acdSet, err := setIf.Get(context.Background(), &settings.SettingsQuery{}) + ctx := context.Background() + httpClient, err := acdClient.HTTPClient() errors.CheckError(err) - if !ssoConfigured(acdSet) { - log.Fatalf("ArgoCD instance is not configured with SSO") - } - tokenString, refreshToken = oauth2Login(server, clientOpts.PlainText) + ctx = oidc.ClientContext(ctx, httpClient) + acdSet, err := setIf.Get(ctx, &settings.SettingsQuery{}) + errors.CheckError(err) + oauth2conf, provider, err := acdClient.OIDCConfig(ctx, acdSet) + errors.CheckError(err) + tokenString, refreshToken = oauth2Login(ctx, ssoPort, oauth2conf, provider) } parser := &jwt.Parser{ @@ -130,7 +135,8 @@ func NewLoginCommand(globalClientOpts *argocdclient.ClientOptions) *cobra.Comman 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().BoolVar(&sso, "sso", false, "perform SSO login") + command.Flags().IntVar(&ssoPort, "sso-port", DefaultSSOLocalPort, "port to run local OAuth2 login application") return command } @@ -144,97 +150,107 @@ func userDisplayName(claims jwt.MapClaims) string { return claims["sub"].(string) } -func ssoConfigured(set *settings.Settings) bool { - return set.DexConfig != nil && len(set.DexConfig.Connectors) > 0 -} - -// getFreePort asks the kernel for a free open port that is ready to use. -func getFreePort() (int, error) { - ln, err := net.Listen("tcp", "[::]:0") - if err != nil { - return 0, err - } - return ln.Addr().(*net.TCPAddr).Port, ln.Close() -} - // 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(host string, plaintext bool) (string, string) { - ctx := context.Background() - port, err := getFreePort() +func oauth2Login(ctx context.Context, port int, oauth2conf *oauth2.Config, provider *oidc.Provider) (string, string) { + oauth2conf.RedirectURL = fmt.Sprintf("http://localhost:%d/auth/callback", port) + oidcConf, err := oidcutil.ParseConfig(provider) errors.CheckError(err) - var scheme = "https" - if plaintext { - scheme = "http" - } - conf := &oauth2.Config{ - ClientID: common.ArgoCDCLIClientAppID, - Scopes: []string{"openid", "profile", "email", "groups", "offline_access"}, - Endpoint: oauth2.Endpoint{ - AuthURL: fmt.Sprintf("%s://%s%s/auth", scheme, host, common.DexAPIEndpoint), - TokenURL: fmt.Sprintf("%s://%s%s/token", scheme, host, common.DexAPIEndpoint), - }, - RedirectURL: fmt.Sprintf("http://localhost:%d/auth/callback", port), - } - srv := &http.Server{Addr: ":" + strconv.Itoa(port)} + 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 + stateNonce := rand.RandString(10) var tokenString string var refreshToken string - loginCompleted := make(chan struct{}) + handleErr := func(w http.ResponseWriter, errMsg string) { + http.Error(w, errMsg, http.StatusBadRequest) + completionChan <- errMsg + } + + // Authorization redirect callback from OAuth2 auth flow. + // Handles both implicit and authorization code flow callbackHandler := func(w http.ResponseWriter, r *http.Request) { - defer func() { - loginCompleted <- struct{}{} - }() + log.Debugf("Callback: %s", r.URL) - // Authorization redirect callback from OAuth2 auth flow. - if errMsg := r.FormValue("error"); errMsg != "" { - http.Error(w, errMsg+": "+r.FormValue("error_description"), http.StatusBadRequest) - log.Fatal(errMsg) + if formErr := r.FormValue("error"); formErr != "" { + handleErr(w, formErr+": "+r.FormValue("error_description")) return } - code := r.FormValue("code") - if code == "" { - errMsg := fmt.Sprintf("no code in request: %q", r.Form) - http.Error(w, errMsg, http.StatusBadRequest) - log.Fatal(errMsg) + + 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 } - tok, err := conf.Exchange(ctx, code) - errors.CheckError(err) - log.Info("Authentication successful") - var ok bool - tokenString, ok = tok.Extra("id_token").(string) - if !ok { - errMsg := "no id_token in token response" - http.Error(w, errMsg, http.StatusInternalServerError) - log.Fatal(errMsg) + 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.Fprintf(w, ``) return } - refreshToken, _ = tok.Extra("refresh_token").(string) - log.Debugf("Token: %s", tokenString) - log.Debugf("Refresh Token: %s", tokenString) + + 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 + } + tok, err := oauth2conf.Exchange(ctx, code) + 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 := `
Authentication successful!

Authentication was successful, you can now return to CLI. This page will close automatically

` fmt.Fprintf(w, successPage) + completionChan <- "" } + srv := &http.Server{Addr: ":" + strconv.Itoa(port)} http.HandleFunc("/auth/callback", callbackHandler) - // add transport for self-signed certificate to context - sslcli := &http.Client{ - Transport: &http.Transport{ - TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, - }, - } - ctx = context.WithValue(ctx, oauth2.HTTPClient, sslcli) - // Redirect user to login & consent page to ask for permission for the scopes specified above. log.Info("Opening browser for authentication") - url := conf.AuthCodeURL("state", oauth2.AccessTypeOffline) - log.Infof("Authentication URL: %s", url) + + var url string + grantType := oidcutil.InferGrantType(oauth2conf, oidcConf) + switch grantType { + case oidcutil.GrantTypeAuthorizationCode: + url = oauth2conf.AuthCodeURL(stateNonce, oauth2.AccessTypeOffline) + case oidcutil.GrantTypeImplicit: + url = oidcutil.ImplicitFlowURL(oauth2conf, stateNonce, oauth2.AccessTypeOffline) + default: + log.Fatalf("Unsupported grant type: %v", grantType) + } + log.Infof("Performing %s flow login: %s", grantType, url) time.Sleep(1 * time.Second) err = open.Run(url) errors.CheckError(err) @@ -243,8 +259,16 @@ func oauth2Login(host string, plaintext bool) (string, string) { log.Fatalf("listen: %s\n", err) } }() - <-loginCompleted + errMsg := <-completionChan + if errMsg != "" { + log.Fatal(errMsg) + } + log.Info("Authentication successful") + 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 } diff --git a/cmd/argocd/commands/relogin.go b/cmd/argocd/commands/relogin.go index dc2d2cb4acf3b..4237860b95979 100644 --- a/cmd/argocd/commands/relogin.go +++ b/cmd/argocd/commands/relogin.go @@ -1,15 +1,19 @@ package commands import ( + "context" "fmt" "os" + oidc "github.com/coreos/go-oidc" jwt "github.com/dgrijalva/jwt-go" log "github.com/sirupsen/logrus" "github.com/spf13/cobra" "github.com/argoproj/argo-cd/errors" argocdclient "github.com/argoproj/argo-cd/pkg/apiclient" + "github.com/argoproj/argo-cd/server/settings" + "github.com/argoproj/argo-cd/util" "github.com/argoproj/argo-cd/util/localconfig" "github.com/argoproj/argo-cd/util/session" ) @@ -18,6 +22,7 @@ import ( func NewReloginCommand(globalClientOpts *argocdclient.ClientOptions) *cobra.Command { var ( password string + ssoPort int ) var command = &cobra.Command{ Use: "relogin", @@ -45,19 +50,29 @@ func NewReloginCommand(globalClientOpts *argocdclient.ClientOptions) *cobra.Comm var tokenString string var refreshToken string + clientOpts := argocdclient.ClientOptions{ + ConfigPath: "", + ServerAddr: configCtx.Server.Server, + Insecure: configCtx.Server.Insecure, + PlainText: configCtx.Server.PlainText, + } + acdClient := argocdclient.NewClientOrDie(&clientOpts) if claims.Issuer == session.SessionManagerClaimsIssuer { - clientOpts := argocdclient.ClientOptions{ - ConfigPath: "", - ServerAddr: configCtx.Server.Server, - Insecure: configCtx.Server.Insecure, - PlainText: configCtx.Server.PlainText, - } - acdClient := argocdclient.NewClientOrDie(&clientOpts) fmt.Printf("Relogging in as '%s'\n", claims.Subject) tokenString = passwordLogin(acdClient, claims.Subject, password) } else { fmt.Println("Reinitiating SSO login") - tokenString, refreshToken = oauth2Login(configCtx.Server.Server, configCtx.Server.PlainText) + setConn, setIf := acdClient.NewSettingsClientOrDie() + defer util.Close(setConn) + ctx := context.Background() + httpClient, err := acdClient.HTTPClient() + errors.CheckError(err) + ctx = oidc.ClientContext(ctx, httpClient) + acdSet, err := setIf.Get(ctx, &settings.SettingsQuery{}) + errors.CheckError(err) + oauth2conf, provider, err := acdClient.OIDCConfig(ctx, acdSet) + errors.CheckError(err) + tokenString, refreshToken = oauth2Login(ctx, ssoPort, oauth2conf, provider) } localCfg.UpsertUser(localconfig.User{ @@ -71,5 +86,6 @@ func NewReloginCommand(globalClientOpts *argocdclient.ClientOptions) *cobra.Comm }, } command.Flags().StringVar(&password, "password", "", "the password of an account to authenticate") + command.Flags().IntVar(&ssoPort, "sso-port", DefaultSSOLocalPort, "port to run local OAuth2 login application") return command } diff --git a/cmd/argocd/commands/root.go b/cmd/argocd/commands/root.go index 161c269b3d432..bb6ac42538cc9 100644 --- a/cmd/argocd/commands/root.go +++ b/cmd/argocd/commands/root.go @@ -1,13 +1,25 @@ package commands import ( + "github.com/spf13/cobra" + "k8s.io/client-go/tools/clientcmd" + "github.com/argoproj/argo-cd/errors" argocdclient "github.com/argoproj/argo-cd/pkg/apiclient" + "github.com/argoproj/argo-cd/util/cli" "github.com/argoproj/argo-cd/util/localconfig" - "github.com/spf13/cobra" - "k8s.io/client-go/tools/clientcmd" ) +func init() { + cobra.OnInitialize(initConfig) +} + +var logLevel string + +func initConfig() { + cli.SetLogLevel(logLevel) +} + // NewCommand returns a new instance of an argocd command func NewCommand() *cobra.Command { var ( @@ -41,6 +53,6 @@ func NewCommand() *cobra.Command { command.PersistentFlags().BoolVar(&clientOpts.Insecure, "insecure", false, "Skip server certificate and domain verification") command.PersistentFlags().StringVar(&clientOpts.CertFile, "server-crt", "", "Server certificate file") command.PersistentFlags().StringVar(&clientOpts.AuthToken, "auth-token", "", "Authentication token") - + command.PersistentFlags().StringVar(&logLevel, "loglevel", "info", "Set the logging level. One of: debug|info|warn|error") return command } diff --git a/manifests/base/dex-server-deployment.yaml b/manifests/base/dex-server-deployment.yaml index 75181a8e93485..ec42f3974ff4e 100644 --- a/manifests/base/dex-server-deployment.yaml +++ b/manifests/base/dex-server-deployment.yaml @@ -21,7 +21,7 @@ spec: name: static-files containers: - name: dex - image: quay.io/dexidp/dex:v2.11.0 + image: quay.io/dexidp/dex:v2.12.0 command: [/shared/argocd-util, rundex] ports: - containerPort: 5556 diff --git a/manifests/install.yaml b/manifests/install.yaml index 72dea1894538b..55f86b2f05366 100644 --- a/manifests/install.yaml +++ b/manifests/install.yaml @@ -416,7 +416,7 @@ spec: - command: - /shared/argocd-util - rundex - image: quay.io/dexidp/dex:v2.11.0 + image: quay.io/dexidp/dex:v2.12.0 name: dex ports: - containerPort: 5556 diff --git a/manifests/namespace-install.yaml b/manifests/namespace-install.yaml index cb86470c7cc66..6ea7298a5de0e 100644 --- a/manifests/namespace-install.yaml +++ b/manifests/namespace-install.yaml @@ -356,7 +356,7 @@ spec: - command: - /shared/argocd-util - rundex - image: quay.io/dexidp/dex:v2.11.0 + image: quay.io/dexidp/dex:v2.12.0 name: dex ports: - containerPort: 5556 diff --git a/pkg/apiclient/apiclient.go b/pkg/apiclient/apiclient.go index c90eb56bc886b..a885eaea23390 100644 --- a/pkg/apiclient/apiclient.go +++ b/pkg/apiclient/apiclient.go @@ -44,10 +44,16 @@ const ( MaxGRPCMessageSize = 100 * 1024 * 1024 ) +var ( + clientScopes = []string{"openid", "profile", "email", "groups", "offline_access"} +) + // Client defines an interface for interaction with an Argo CD server. type Client interface { ClientOptions() ClientOptions NewConn() (*grpc.ClientConn, error) + HTTPClient() (*http.Client, error) + OIDCConfig(context.Context, *settings.Settings) (*oauth2.Config, *oidc.Provider, error) NewRepoClient() (*grpc.ClientConn, repository.RepositoryServiceClient, error) NewRepoClientOrDie() (*grpc.ClientConn, repository.RepositoryServiceClient) NewClusterClient() (*grpc.ClientConn, cluster.ClusterServiceClient, error) @@ -160,35 +166,39 @@ func NewClient(opts *ClientOptions) (Client, error) { return &c, nil } -// refreshAuthToken refreshes a JWT auth token if it is invalid (e.g. expired) -func (c *client) refreshAuthToken(localCfg *localconfig.LocalConfig, ctxName, configPath string) error { - configCtx, err := localCfg.ResolveContext(ctxName) - if err != nil { - return err - } - if c.RefreshToken == "" { - // If we have no refresh token, there's no point in doing anything - return nil - } - parser := &jwt.Parser{ - SkipClaimsValidation: true, +// OIDCConfig returns OAuth2 client config and a OpenID Provider based on ArgoCD settings +// ctx can hold an appropriate http.Client to use for the exchange +func (c *client) OIDCConfig(ctx context.Context, set *settings.Settings) (*oauth2.Config, *oidc.Provider, error) { + var clientID string + var issuerURL string + if set.DexConfig != nil && len(set.DexConfig.Connectors) > 0 { + clientID = common.ArgoCDCLIClientAppID + issuerURL = fmt.Sprintf("%s%s", set.URL, common.DexAPIEndpoint) + } else if set.OIDCConfig != nil && set.OIDCConfig.Issuer != "" { + clientID = set.OIDCConfig.ClientID + issuerURL = set.OIDCConfig.Issuer + } else { + return nil, nil, fmt.Errorf("%s is not configured with SSO", c.ServerAddr) } - var claims jwt.StandardClaims - _, _, err = parser.ParseUnverified(configCtx.User.AuthToken, &claims) + provider, err := oidc.NewProvider(ctx, issuerURL) if err != nil { - return err + return nil, nil, fmt.Errorf("Failed to query provider %q: %v", issuerURL, err) } - if claims.Valid() == nil { - // token is still valid - return nil + oauth2conf := oauth2.Config{ + ClientID: clientID, + Scopes: clientScopes, + Endpoint: provider.Endpoint(), } + return &oauth2conf, provider, nil +} - log.Debug("Auth token no longer valid. Refreshing") +// HTTPClient returns a HTTPClient appropriate for performing OAuth, based on TLS settings +func (c *client) HTTPClient() (*http.Client, error) { tlsConfig, err := c.tlsConfig() if err != nil { - return err + return nil, err } - httpClient := &http.Client{ + return &http.Client{ Transport: &http.Transport{ TLSClientConfig: tlsConfig, Proxy: http.ProxyFromEnvironment, @@ -199,35 +209,37 @@ func (c *client) refreshAuthToken(localCfg *localconfig.LocalConfig, ctxName, co TLSHandshakeTimeout: 10 * time.Second, ExpectContinueTimeout: 1 * time.Second, }, + }, nil +} + +// refreshAuthToken refreshes a JWT auth token if it is invalid (e.g. expired) +func (c *client) refreshAuthToken(localCfg *localconfig.LocalConfig, ctxName, configPath string) error { + if c.RefreshToken == "" { + // If we have no refresh token, there's no point in doing anything + return nil } - ctx := oidc.ClientContext(context.Background(), httpClient) - var scheme string - if c.PlainText { - scheme = "http" - } else { - scheme = "https" - } - conf := &oauth2.Config{ - ClientID: common.ArgoCDCLIClientAppID, - Scopes: []string{"openid", "profile", "email", "groups", "offline_access"}, - Endpoint: oauth2.Endpoint{ - AuthURL: fmt.Sprintf("%s://%s%s/auth", scheme, c.ServerAddr, common.DexAPIEndpoint), - TokenURL: fmt.Sprintf("%s://%s%s/token", scheme, c.ServerAddr, common.DexAPIEndpoint), - }, - RedirectURL: fmt.Sprintf("%s://%s/auth/callback", scheme, c.ServerAddr), + configCtx, err := localCfg.ResolveContext(ctxName) + if err != nil { + return err } - t := &oauth2.Token{ - RefreshToken: c.RefreshToken, + parser := &jwt.Parser{ + SkipClaimsValidation: true, } - token, err := conf.TokenSource(ctx, t).Token() + var claims jwt.StandardClaims + _, _, err = parser.ParseUnverified(configCtx.User.AuthToken, &claims) if err != nil { return err } - rawIDToken, ok := token.Extra("id_token").(string) - if !ok { - return errors.New("no id_token in token response") + if claims.Valid() == nil { + // token is still valid + return nil + } + + log.Debug("Auth token no longer valid. Refreshing") + rawIDToken, refreshToken, err := c.redeemRefreshToken() + if err != nil { + return err } - refreshToken, _ := token.Extra("refresh_token").(string) c.AuthToken = rawIDToken c.RefreshToken = refreshToken localCfg.UpsertUser(localconfig.User{ @@ -242,6 +254,41 @@ func (c *client) refreshAuthToken(localCfg *localconfig.LocalConfig, ctxName, co return nil } +// redeemRefreshToken performs the exchange of a refresh_token for a new id_token and refresh_token +func (c *client) redeemRefreshToken() (string, string, error) { + setConn, setIf, err := c.NewSettingsClient() + if err != nil { + return "", "", err + } + defer func() { _ = setConn.Close() }() + httpClient, err := c.HTTPClient() + if err != nil { + return "", "", err + } + ctx := oidc.ClientContext(context.Background(), httpClient) + acdSet, err := setIf.Get(ctx, &settings.SettingsQuery{}) + if err != nil { + return "", "", err + } + oauth2conf, _, err := c.OIDCConfig(ctx, acdSet) + if err != nil { + return "", "", err + } + t := &oauth2.Token{ + RefreshToken: c.RefreshToken, + } + token, err := oauth2conf.TokenSource(ctx, t).Token() + if err != nil { + return "", "", err + } + rawIDToken, ok := token.Extra("id_token").(string) + if !ok { + return "", "", errors.New("no id_token in token response") + } + refreshToken, _ := token.Extra("refresh_token").(string) + return rawIDToken, refreshToken, nil +} + // NewClientOrDie creates a new API client from a set of config options, or fails fatally if the new client creation fails. func NewClientOrDie(opts *ClientOptions) Client { client, err := NewClient(opts) diff --git a/server/server.go b/server/server.go index 94ff8871caa23..1f01e3687c705 100644 --- a/server/server.go +++ b/server/server.go @@ -58,9 +58,11 @@ import ( dexutil "github.com/argoproj/argo-cd/util/dex" grpc_util "github.com/argoproj/argo-cd/util/grpc" "github.com/argoproj/argo-cd/util/healthz" + httputil "github.com/argoproj/argo-cd/util/http" jsonutil "github.com/argoproj/argo-cd/util/json" jwtutil "github.com/argoproj/argo-cd/util/jwt" "github.com/argoproj/argo-cd/util/kube" + "github.com/argoproj/argo-cd/util/oidc" projectutil "github.com/argoproj/argo-cd/util/project" "github.com/argoproj/argo-cd/util/rbac" util_session "github.com/argoproj/argo-cd/util/session" @@ -104,7 +106,7 @@ func init() { type ArgoCDServer struct { ArgoCDServerOpts - ssoClientApp *dexutil.ClientApp + ssoClientApp *oidc.ClientApp settings *settings_util.ArgoCDSettings log *log.Entry sessionMgr *util_session.SessionManager @@ -121,6 +123,7 @@ type ArgoCDServerOpts struct { DisableAuth bool Insecure bool Namespace string + DexServerAddr string StaticAssetsDir string KubeClientset kubernetes.Interface AppClientset appclientset.Interface @@ -306,6 +309,8 @@ func (a *ArgoCDServer) watchSettings(ctx context.Context) { updateCh := make(chan struct{}, 1) a.settingsMgr.Subscribe(updateCh) + prevURL := a.settings.URL + prevOIDCConfig := a.settings.OIDCConfigRAW prevDexCfgBytes, err := dex.GenerateDexConfigYAML(a.settings) errors.CheckError(err) prevGitHubSecret := a.settings.WebhookGitHubSecret @@ -324,6 +329,14 @@ func (a *ArgoCDServer) watchSettings(ctx context.Context) { log.Infof("dex config modified. restarting") break } + if prevOIDCConfig != a.settings.OIDCConfigRAW { + log.Infof("odic config modified. restarting") + break + } + if prevURL != a.settings.URL { + log.Infof("url modified. restarting") + break + } if prevGitHubSecret != a.settings.WebhookGitHubSecret { log.Infof("github secret modified. restarting") break @@ -430,7 +443,7 @@ func (a *ArgoCDServer) translateGrpcCookieHeader(ctx context.Context, w http.Res if !a.Insecure { flags = append(flags, "Secure") } - cookie := util_session.MakeCookieMetadata(common.AuthCookieName, sessionResp.Token, flags...) + cookie := httputil.MakeCookieMetadata(common.AuthCookieName, sessionResp.Token, flags...) w.Header().Set("Set-Cookie", cookie) } return nil @@ -523,10 +536,10 @@ func (a *ArgoCDServer) registerDexHandlers(mux *http.ServeMux) { } // Run dex OpenID Connect Identity Provider behind a reverse proxy (served at /api/dex) var err error - mux.HandleFunc(common.DexAPIEndpoint+"/", dexutil.NewDexHTTPReverseProxy()) + mux.HandleFunc(common.DexAPIEndpoint+"/", dexutil.NewDexHTTPReverseProxy(a.DexServerAddr)) tlsConfig := a.settings.TLSConfig() tlsConfig.InsecureSkipVerify = true - a.ssoClientApp, err = dexutil.NewClientApp(a.settings, a.sessionMgr) + a.ssoClientApp, err = oidc.NewClientApp(a.settings) errors.CheckError(err) mux.HandleFunc(common.LoginEndpoint, a.ssoClientApp.HandleLogin) mux.HandleFunc(common.CallbackEndpoint, a.ssoClientApp.HandleCallback) diff --git a/server/settings/settings.go b/server/settings/settings.go index 6ae720b6189e4..15781e25f280e 100644 --- a/server/settings/settings.go +++ b/server/settings/settings.go @@ -27,10 +27,19 @@ func (s *Server) Get(ctx context.Context, q *SettingsQuery) (*Settings, error) { set := Settings{ URL: argoCDSettings.URL, } - var cfg DexConfig - err = yaml.Unmarshal([]byte(argoCDSettings.DexConfig), &cfg) - if err == nil { - set.DexConfig = &cfg + if argoCDSettings.DexConfig != "" { + var cfg DexConfig + err = yaml.Unmarshal([]byte(argoCDSettings.DexConfig), &cfg) + if err == nil { + set.DexConfig = &cfg + } + } + if oidcConfig := argoCDSettings.OIDCConfig(); oidcConfig != nil { + set.OIDCConfig = &OIDCConfig{ + Name: oidcConfig.Name, + Issuer: oidcConfig.Issuer, + ClientID: oidcConfig.ClientID, + } } return &set, nil } diff --git a/server/settings/settings.pb.go b/server/settings/settings.pb.go index 4d870a30d4abd..a825a3ae6c6c9 100644 --- a/server/settings/settings.pb.go +++ b/server/settings/settings.pb.go @@ -42,7 +42,7 @@ func (m *SettingsQuery) Reset() { *m = SettingsQuery{} } func (m *SettingsQuery) String() string { return proto.CompactTextString(m) } func (*SettingsQuery) ProtoMessage() {} func (*SettingsQuery) Descriptor() ([]byte, []int) { - return fileDescriptor_settings_71506a99e4ff7448, []int{0} + return fileDescriptor_settings_843b05f20a608370, []int{0} } func (m *SettingsQuery) XXX_Unmarshal(b []byte) error { return m.Unmarshal(b) @@ -72,18 +72,19 @@ func (m *SettingsQuery) XXX_DiscardUnknown() { var xxx_messageInfo_SettingsQuery proto.InternalMessageInfo type Settings struct { - URL string `protobuf:"bytes,1,opt,name=url,proto3" json:"url,omitempty"` - DexConfig *DexConfig `protobuf:"bytes,2,opt,name=dexConfig" json:"dexConfig,omitempty"` - XXX_NoUnkeyedLiteral struct{} `json:"-"` - XXX_unrecognized []byte `json:"-"` - XXX_sizecache int32 `json:"-"` + URL string `protobuf:"bytes,1,opt,name=url,proto3" json:"url,omitempty"` + DexConfig *DexConfig `protobuf:"bytes,2,opt,name=dexConfig" json:"dexConfig,omitempty"` + OIDCConfig *OIDCConfig `protobuf:"bytes,3,opt,name=oidcConfig" json:"oidcConfig,omitempty"` + XXX_NoUnkeyedLiteral struct{} `json:"-"` + XXX_unrecognized []byte `json:"-"` + XXX_sizecache int32 `json:"-"` } func (m *Settings) Reset() { *m = Settings{} } func (m *Settings) String() string { return proto.CompactTextString(m) } func (*Settings) ProtoMessage() {} func (*Settings) Descriptor() ([]byte, []int) { - return fileDescriptor_settings_71506a99e4ff7448, []int{1} + return fileDescriptor_settings_843b05f20a608370, []int{1} } func (m *Settings) XXX_Unmarshal(b []byte) error { return m.Unmarshal(b) @@ -126,6 +127,13 @@ func (m *Settings) GetDexConfig() *DexConfig { return nil } +func (m *Settings) GetOIDCConfig() *OIDCConfig { + if m != nil { + return m.OIDCConfig + } + return nil +} + type DexConfig struct { Connectors []*Connector `protobuf:"bytes,1,rep,name=connectors" json:"connectors,omitempty"` XXX_NoUnkeyedLiteral struct{} `json:"-"` @@ -137,7 +145,7 @@ func (m *DexConfig) Reset() { *m = DexConfig{} } func (m *DexConfig) String() string { return proto.CompactTextString(m) } func (*DexConfig) ProtoMessage() {} func (*DexConfig) Descriptor() ([]byte, []int) { - return fileDescriptor_settings_71506a99e4ff7448, []int{2} + return fileDescriptor_settings_843b05f20a608370, []int{2} } func (m *DexConfig) XXX_Unmarshal(b []byte) error { return m.Unmarshal(b) @@ -185,7 +193,7 @@ func (m *Connector) Reset() { *m = Connector{} } func (m *Connector) String() string { return proto.CompactTextString(m) } func (*Connector) ProtoMessage() {} func (*Connector) Descriptor() ([]byte, []int) { - return fileDescriptor_settings_71506a99e4ff7448, []int{3} + return fileDescriptor_settings_843b05f20a608370, []int{3} } func (m *Connector) XXX_Unmarshal(b []byte) error { return m.Unmarshal(b) @@ -228,11 +236,75 @@ func (m *Connector) GetType() string { return "" } +type OIDCConfig struct { + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + Issuer string `protobuf:"bytes,2,opt,name=issuer,proto3" json:"issuer,omitempty"` + ClientID string `protobuf:"bytes,3,opt,name=clientID,proto3" json:"clientID,omitempty"` + XXX_NoUnkeyedLiteral struct{} `json:"-"` + XXX_unrecognized []byte `json:"-"` + XXX_sizecache int32 `json:"-"` +} + +func (m *OIDCConfig) Reset() { *m = OIDCConfig{} } +func (m *OIDCConfig) String() string { return proto.CompactTextString(m) } +func (*OIDCConfig) ProtoMessage() {} +func (*OIDCConfig) Descriptor() ([]byte, []int) { + return fileDescriptor_settings_843b05f20a608370, []int{4} +} +func (m *OIDCConfig) XXX_Unmarshal(b []byte) error { + return m.Unmarshal(b) +} +func (m *OIDCConfig) XXX_Marshal(b []byte, deterministic bool) ([]byte, error) { + if deterministic { + return xxx_messageInfo_OIDCConfig.Marshal(b, m, deterministic) + } else { + b = b[:cap(b)] + n, err := m.MarshalTo(b) + if err != nil { + return nil, err + } + return b[:n], nil + } +} +func (dst *OIDCConfig) XXX_Merge(src proto.Message) { + xxx_messageInfo_OIDCConfig.Merge(dst, src) +} +func (m *OIDCConfig) XXX_Size() int { + return m.Size() +} +func (m *OIDCConfig) XXX_DiscardUnknown() { + xxx_messageInfo_OIDCConfig.DiscardUnknown(m) +} + +var xxx_messageInfo_OIDCConfig proto.InternalMessageInfo + +func (m *OIDCConfig) GetName() string { + if m != nil { + return m.Name + } + return "" +} + +func (m *OIDCConfig) GetIssuer() string { + if m != nil { + return m.Issuer + } + return "" +} + +func (m *OIDCConfig) GetClientID() string { + if m != nil { + return m.ClientID + } + return "" +} + func init() { proto.RegisterType((*SettingsQuery)(nil), "cluster.SettingsQuery") proto.RegisterType((*Settings)(nil), "cluster.Settings") proto.RegisterType((*DexConfig)(nil), "cluster.DexConfig") proto.RegisterType((*Connector)(nil), "cluster.Connector") + proto.RegisterType((*OIDCConfig)(nil), "cluster.OIDCConfig") } // Reference imports to suppress errors if they are not otherwise used. @@ -361,6 +433,16 @@ func (m *Settings) MarshalTo(dAtA []byte) (int, error) { } i += n1 } + if m.OIDCConfig != nil { + dAtA[i] = 0x1a + i++ + i = encodeVarintSettings(dAtA, i, uint64(m.OIDCConfig.Size())) + n2, err := m.OIDCConfig.MarshalTo(dAtA[i:]) + if err != nil { + return 0, err + } + i += n2 + } if m.XXX_unrecognized != nil { i += copy(dAtA[i:], m.XXX_unrecognized) } @@ -433,6 +515,45 @@ func (m *Connector) MarshalTo(dAtA []byte) (int, error) { return i, nil } +func (m *OIDCConfig) Marshal() (dAtA []byte, err error) { + size := m.Size() + dAtA = make([]byte, size) + n, err := m.MarshalTo(dAtA) + if err != nil { + return nil, err + } + return dAtA[:n], nil +} + +func (m *OIDCConfig) MarshalTo(dAtA []byte) (int, error) { + var i int + _ = i + var l int + _ = l + if len(m.Name) > 0 { + dAtA[i] = 0xa + i++ + i = encodeVarintSettings(dAtA, i, uint64(len(m.Name))) + i += copy(dAtA[i:], m.Name) + } + if len(m.Issuer) > 0 { + dAtA[i] = 0x12 + i++ + i = encodeVarintSettings(dAtA, i, uint64(len(m.Issuer))) + i += copy(dAtA[i:], m.Issuer) + } + if len(m.ClientID) > 0 { + dAtA[i] = 0x1a + i++ + i = encodeVarintSettings(dAtA, i, uint64(len(m.ClientID))) + i += copy(dAtA[i:], m.ClientID) + } + if m.XXX_unrecognized != nil { + i += copy(dAtA[i:], m.XXX_unrecognized) + } + return i, nil +} + func encodeVarintSettings(dAtA []byte, offset int, v uint64) int { for v >= 1<<7 { dAtA[offset] = uint8(v&0x7f | 0x80) @@ -462,6 +583,10 @@ func (m *Settings) Size() (n int) { l = m.DexConfig.Size() n += 1 + l + sovSettings(uint64(l)) } + if m.OIDCConfig != nil { + l = m.OIDCConfig.Size() + n += 1 + l + sovSettings(uint64(l)) + } if m.XXX_unrecognized != nil { n += len(m.XXX_unrecognized) } @@ -500,6 +625,27 @@ func (m *Connector) Size() (n int) { return n } +func (m *OIDCConfig) Size() (n int) { + var l int + _ = l + l = len(m.Name) + if l > 0 { + n += 1 + l + sovSettings(uint64(l)) + } + l = len(m.Issuer) + if l > 0 { + n += 1 + l + sovSettings(uint64(l)) + } + l = len(m.ClientID) + if l > 0 { + n += 1 + l + sovSettings(uint64(l)) + } + if m.XXX_unrecognized != nil { + n += len(m.XXX_unrecognized) + } + return n +} + func sovSettings(x uint64) (n int) { for { n++ @@ -655,6 +801,39 @@ func (m *Settings) Unmarshal(dAtA []byte) error { return err } iNdEx = postIndex + case 3: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field OIDCConfig", wireType) + } + var msglen int + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflowSettings + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + msglen |= (int(b) & 0x7F) << shift + if b < 0x80 { + break + } + } + if msglen < 0 { + return ErrInvalidLengthSettings + } + postIndex := iNdEx + msglen + if postIndex > l { + return io.ErrUnexpectedEOF + } + if m.OIDCConfig == nil { + m.OIDCConfig = &OIDCConfig{} + } + if err := m.OIDCConfig.Unmarshal(dAtA[iNdEx:postIndex]); err != nil { + return err + } + iNdEx = postIndex default: iNdEx = preIndex skippy, err := skipSettings(dAtA[iNdEx:]) @@ -868,6 +1047,144 @@ func (m *Connector) Unmarshal(dAtA []byte) error { } return nil } +func (m *OIDCConfig) Unmarshal(dAtA []byte) error { + l := len(dAtA) + iNdEx := 0 + for iNdEx < l { + preIndex := iNdEx + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflowSettings + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= (uint64(b) & 0x7F) << shift + if b < 0x80 { + break + } + } + fieldNum := int32(wire >> 3) + wireType := int(wire & 0x7) + if wireType == 4 { + return fmt.Errorf("proto: OIDCConfig: wiretype end group for non-group") + } + if fieldNum <= 0 { + return fmt.Errorf("proto: OIDCConfig: illegal tag %d (wire type %d)", fieldNum, wire) + } + switch fieldNum { + case 1: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Name", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflowSettings + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= (uint64(b) & 0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLengthSettings + } + postIndex := iNdEx + intStringLen + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Name = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex + case 2: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Issuer", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflowSettings + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= (uint64(b) & 0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLengthSettings + } + postIndex := iNdEx + intStringLen + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Issuer = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex + case 3: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field ClientID", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflowSettings + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= (uint64(b) & 0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLengthSettings + } + postIndex := iNdEx + intStringLen + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.ClientID = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex + default: + iNdEx = preIndex + skippy, err := skipSettings(dAtA[iNdEx:]) + if err != nil { + return err + } + if skippy < 0 { + return ErrInvalidLengthSettings + } + if (iNdEx + skippy) > l { + return io.ErrUnexpectedEOF + } + m.XXX_unrecognized = append(m.XXX_unrecognized, dAtA[iNdEx:iNdEx+skippy]...) + iNdEx += skippy + } + } + + if iNdEx > l { + return io.ErrUnexpectedEOF + } + return nil +} func skipSettings(dAtA []byte) (n int, err error) { l := len(dAtA) iNdEx := 0 @@ -974,30 +1291,34 @@ var ( ) func init() { - proto.RegisterFile("server/settings/settings.proto", fileDescriptor_settings_71506a99e4ff7448) -} - -var fileDescriptor_settings_71506a99e4ff7448 = []byte{ - // 322 bytes of a gzipped FileDescriptorProto - 0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0xff, 0x64, 0x91, 0x41, 0x4b, 0xc3, 0x40, - 0x10, 0x85, 0xd9, 0x46, 0xac, 0x19, 0x91, 0xea, 0x22, 0x12, 0x8b, 0xc4, 0x92, 0x53, 0x41, 0x4c, - 0xb4, 0x3d, 0x79, 0x12, 0x5a, 0x41, 0x10, 0x2f, 0xa6, 0x88, 0x20, 0x78, 0x48, 0xd3, 0x71, 0x8d, - 0xb4, 0x3b, 0x65, 0xb3, 0x29, 0xf6, 0xea, 0x5f, 0xf0, 0x4f, 0x79, 0x14, 0xbc, 0x8b, 0x04, 0x7f, - 0x88, 0x74, 0xdb, 0x44, 0xab, 0xb7, 0xc7, 0xf7, 0x66, 0x92, 0xb7, 0xf3, 0xc0, 0x4d, 0x51, 0x4d, - 0x50, 0x05, 0x29, 0x6a, 0x9d, 0x48, 0x91, 0x96, 0xc2, 0x1f, 0x2b, 0xd2, 0xc4, 0xab, 0xf1, 0x30, - 0x4b, 0x35, 0xaa, 0xfa, 0xb6, 0x20, 0x41, 0x86, 0x05, 0x33, 0x35, 0xb7, 0xeb, 0x7b, 0x82, 0x48, - 0x0c, 0x31, 0x88, 0xc6, 0x49, 0x10, 0x49, 0x49, 0x3a, 0xd2, 0x09, 0xc9, 0xc5, 0xb2, 0x57, 0x83, - 0x8d, 0xde, 0xe2, 0x73, 0x57, 0x19, 0xaa, 0xa9, 0x77, 0x03, 0x6b, 0x05, 0xe0, 0xbb, 0x60, 0x65, - 0x6a, 0xe8, 0xb0, 0x06, 0x6b, 0xda, 0x9d, 0x6a, 0xfe, 0xb1, 0x6f, 0x5d, 0x87, 0x97, 0xe1, 0x8c, - 0xf1, 0x23, 0xb0, 0x07, 0xf8, 0xd4, 0x25, 0x79, 0x9f, 0x08, 0xa7, 0xd2, 0x60, 0xcd, 0xf5, 0x16, - 0xf7, 0x17, 0x41, 0xfc, 0xb3, 0xc2, 0x09, 0x7f, 0x86, 0xbc, 0x53, 0xb0, 0x4b, 0xce, 0x5b, 0x00, - 0x31, 0x49, 0x89, 0xb1, 0x26, 0x95, 0x3a, 0xac, 0x61, 0x2d, 0xed, 0x77, 0x0b, 0x2b, 0xfc, 0x35, - 0xe5, 0xb5, 0xc1, 0x2e, 0x0d, 0xce, 0x61, 0x45, 0x46, 0x23, 0x9c, 0x67, 0x0b, 0x8d, 0x9e, 0x31, - 0x3d, 0x1d, 0xa3, 0x89, 0x63, 0x87, 0x46, 0xb7, 0xee, 0xa0, 0x56, 0x3c, 0xa7, 0x87, 0x6a, 0x92, - 0xc4, 0xc8, 0x2f, 0xc0, 0x3a, 0x47, 0xcd, 0x77, 0xca, 0xdf, 0x2d, 0x1d, 0xa0, 0xbe, 0xf5, 0x8f, - 0x7b, 0xce, 0xf3, 0xfb, 0xd7, 0x4b, 0x85, 0xf3, 0x4d, 0x73, 0xc4, 0xc9, 0x71, 0xd9, 0x40, 0xe7, - 0xe4, 0x35, 0x77, 0xd9, 0x5b, 0xee, 0xb2, 0xcf, 0xdc, 0x65, 0xb7, 0x07, 0x22, 0xd1, 0x0f, 0x59, - 0xdf, 0x8f, 0x69, 0x14, 0x44, 0xca, 0x74, 0xf1, 0x68, 0xc4, 0x61, 0x3c, 0x08, 0xfe, 0xb4, 0xd8, - 0x5f, 0x35, 0x05, 0xb4, 0xbf, 0x03, 0x00, 0x00, 0xff, 0xff, 0xef, 0x0e, 0xd5, 0xb9, 0xdf, 0x01, - 0x00, 0x00, + proto.RegisterFile("server/settings/settings.proto", fileDescriptor_settings_843b05f20a608370) +} + +var fileDescriptor_settings_843b05f20a608370 = []byte{ + // 397 bytes of a gzipped FileDescriptorProto + 0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0xff, 0x6c, 0x52, 0xcd, 0x8a, 0xdb, 0x30, + 0x18, 0x44, 0x71, 0x49, 0xe2, 0xaf, 0x3f, 0x69, 0xd5, 0x12, 0xdc, 0x50, 0x9c, 0xe0, 0x53, 0xa0, + 0xd4, 0x6e, 0x93, 0x53, 0x4f, 0x05, 0x3b, 0x50, 0x52, 0x0a, 0xa5, 0x0a, 0xbd, 0x14, 0x7a, 0x70, + 0x14, 0xd5, 0x55, 0x71, 0xa4, 0x20, 0xcb, 0xa1, 0xb9, 0xf6, 0x15, 0xf6, 0xba, 0x0f, 0xb4, 0xc7, + 0x85, 0xbd, 0x87, 0xc5, 0xec, 0x83, 0x2c, 0x51, 0x6c, 0x27, 0xd9, 0xdd, 0xdb, 0x68, 0x46, 0x23, + 0xbe, 0xd1, 0x37, 0xe0, 0x66, 0x4c, 0xad, 0x99, 0x0a, 0x32, 0xa6, 0x35, 0x17, 0x49, 0x56, 0x03, + 0x7f, 0xa5, 0xa4, 0x96, 0xb8, 0x45, 0xd3, 0x3c, 0xd3, 0x4c, 0xf5, 0x5e, 0x25, 0x32, 0x91, 0x86, + 0x0b, 0x76, 0x68, 0x2f, 0xf7, 0xde, 0x24, 0x52, 0x26, 0x29, 0x0b, 0xe2, 0x15, 0x0f, 0x62, 0x21, + 0xa4, 0x8e, 0x35, 0x97, 0xa2, 0x34, 0x7b, 0x1d, 0x78, 0x3a, 0x2b, 0x9f, 0xfb, 0x9e, 0x33, 0xb5, + 0xf1, 0xce, 0x11, 0xb4, 0x2b, 0x06, 0xbf, 0x06, 0x2b, 0x57, 0xa9, 0x83, 0x06, 0x68, 0x68, 0x87, + 0xad, 0x62, 0xdb, 0xb7, 0x7e, 0x90, 0xaf, 0x64, 0xc7, 0xe1, 0xf7, 0x60, 0x2f, 0xd8, 0xbf, 0x48, + 0x8a, 0xdf, 0x3c, 0x71, 0x1a, 0x03, 0x34, 0x7c, 0x3c, 0xc2, 0x7e, 0x39, 0x89, 0x3f, 0xa9, 0x14, + 0x72, 0xb8, 0x84, 0x23, 0x00, 0xc9, 0x17, 0xb4, 0xb4, 0x58, 0xc6, 0xf2, 0xb2, 0xb6, 0x7c, 0x9b, + 0x4e, 0xa2, 0xbd, 0x14, 0x3e, 0x2b, 0xb6, 0x7d, 0x38, 0x9c, 0xc9, 0x91, 0xcd, 0xfb, 0x04, 0x76, + 0xfd, 0x38, 0x1e, 0x01, 0x50, 0x29, 0x04, 0xa3, 0x5a, 0xaa, 0xcc, 0x41, 0x03, 0xeb, 0x64, 0x88, + 0xa8, 0x92, 0xc8, 0xd1, 0x2d, 0x6f, 0x0c, 0x76, 0x2d, 0x60, 0x0c, 0x8f, 0x44, 0xbc, 0x64, 0xfb, + 0x80, 0xc4, 0xe0, 0x1d, 0xa7, 0x37, 0x2b, 0x66, 0x32, 0xd9, 0xc4, 0x60, 0x6f, 0x0e, 0x47, 0xf3, + 0x3c, 0xe8, 0xea, 0x42, 0x93, 0x67, 0x59, 0xce, 0x54, 0xe9, 0x2b, 0x4f, 0x78, 0x08, 0x6d, 0x9a, + 0x72, 0x26, 0xf4, 0x74, 0x62, 0x22, 0xdb, 0xe1, 0x93, 0x62, 0xdb, 0x6f, 0x47, 0x25, 0x47, 0x6a, + 0x75, 0xf4, 0x0b, 0x3a, 0xd5, 0xbf, 0xcf, 0x98, 0x5a, 0x73, 0xca, 0xf0, 0x17, 0xb0, 0x3e, 0x33, + 0x8d, 0xbb, 0x75, 0xa4, 0x93, 0x55, 0xf5, 0x5e, 0xdc, 0xe3, 0x3d, 0xe7, 0xff, 0xd5, 0xcd, 0x59, + 0x03, 0xe3, 0xe7, 0x66, 0xdd, 0xeb, 0x0f, 0x75, 0x57, 0xc2, 0x8f, 0x17, 0x85, 0x8b, 0x2e, 0x0b, + 0x17, 0x5d, 0x17, 0x2e, 0xfa, 0xf9, 0x36, 0xe1, 0xfa, 0x4f, 0x3e, 0xf7, 0xa9, 0x5c, 0x06, 0xb1, + 0x32, 0xad, 0xf9, 0x6b, 0xc0, 0x3b, 0xba, 0x08, 0xee, 0xf4, 0x6d, 0xde, 0x34, 0x55, 0x19, 0xdf, + 0x06, 0x00, 0x00, 0xff, 0xff, 0xbf, 0xa2, 0x52, 0xc6, 0x89, 0x02, 0x00, 0x00, } diff --git a/server/settings/settings.proto b/server/settings/settings.proto index 00176f1f39042..9d7c0c7f8b281 100644 --- a/server/settings/settings.proto +++ b/server/settings/settings.proto @@ -16,6 +16,7 @@ message SettingsQuery { message Settings { string url = 1 [(gogoproto.customname) = "URL"]; DexConfig dexConfig = 2; + OIDCConfig oidcConfig = 3 [(gogoproto.customname) = "OIDCConfig"]; } message DexConfig { @@ -27,6 +28,12 @@ message Connector { string type = 2; } +message OIDCConfig { + string name = 1; + string issuer = 2; + string clientID = 3 [(gogoproto.customname) = "ClientID"]; +} + // SettingsService service SettingsService { diff --git a/server/swagger.json b/server/swagger.json index e8632e3af353d..95c85aa792354 100644 --- a/server/swagger.json +++ b/server/swagger.json @@ -1349,12 +1349,29 @@ } } }, + "clusterOIDCConfig": { + "type": "object", + "properties": { + "clientID": { + "type": "string" + }, + "issuer": { + "type": "string" + }, + "name": { + "type": "string" + } + } + }, "clusterSettings": { "type": "object", "properties": { "dexConfig": { "$ref": "#/definitions/clusterDexConfig" }, + "oidcConfig": { + "$ref": "#/definitions/clusterOIDCConfig" + }, "url": { "type": "string" } diff --git a/util/cli/cli.go b/util/cli/cli.go index 157053310846f..445e4a2b0f83b 100644 --- a/util/cli/cli.go +++ b/util/cli/cli.go @@ -4,16 +4,20 @@ package cli import ( "bufio" + "flag" "fmt" "os" + "strconv" "strings" "syscall" - argocd "github.com/argoproj/argo-cd" - "github.com/argoproj/argo-cd/errors" + log "github.com/sirupsen/logrus" "github.com/spf13/cobra" "golang.org/x/crypto/ssh/terminal" "k8s.io/client-go/tools/clientcmd" + + argocd "github.com/argoproj/argo-cd" + "github.com/argoproj/argo-cd/errors" ) // NewVersionCmd returns a new `version` command to be used as a sub-command to root @@ -105,3 +109,39 @@ func AskToProceed(message string) bool { } } } + +// ReadAndConfirmPassword is a helper to read and confirm a password from stdin +func ReadAndConfirmPassword() (string, error) { + for { + fmt.Print("*** Enter new password: ") + password, err := terminal.ReadPassword(syscall.Stdin) + if err != nil { + return "", err + } + fmt.Print("\n") + fmt.Print("*** Confirm new password: ") + confirmPassword, err := terminal.ReadPassword(syscall.Stdin) + if err != nil { + return "", err + } + fmt.Print("\n") + if string(password) == string(confirmPassword) { + return string(password), nil + } + log.Error("Passwords do not match") + } +} + +// SetLogLevel parses and sets a logrus log level +func SetLogLevel(logLevel string) { + level, err := log.ParseLevel(logLevel) + errors.CheckError(err) + log.SetLevel(level) +} + +// SetGLogLevel set the glog level for the k8s go-client +func SetGLogLevel(glogLevel int) { + _ = flag.CommandLine.Parse([]string{}) + _ = flag.Lookup("logtostderr").Value.Set("true") + _ = flag.Lookup("v").Value.Set(strconv.Itoa(glogLevel)) +} diff --git a/util/dex/config.go b/util/dex/config.go index 0ce7dd681e0aa..7c481e249efb9 100644 --- a/util/dex/config.go +++ b/util/dex/config.go @@ -11,7 +11,7 @@ import ( ) func GenerateDexConfigYAML(settings *settings.ArgoCDSettings) ([]byte, error) { - if !settings.IsSSOConfigured() { + if !settings.IsDexConfigured() { return nil, nil } var dexCfg map[string]interface{} @@ -36,7 +36,7 @@ func GenerateDexConfigYAML(settings *settings.ArgoCDSettings) ([]byte, error) { { "id": common.ArgoCDClientAppID, "name": common.ArgoCDClientAppName, - "secret": settings.OAuth2ClientSecret(), + "secret": settings.DexOAuth2ClientSecret(), "redirectURIs": []string{ settings.RedirectURL(), }, @@ -118,10 +118,10 @@ func replaceStringSecret(val string, secretValues map[string]string) string { // needsRedirectURI returns whether or not the given connector type needs a redirectURI // Update this list as necessary, as new connectors are added -// https://github.com/coreos/dex/tree/master/Documentation/connectors +// https://github.com/dexidp/dex/tree/master/Documentation/connectors func needsRedirectURI(connectorType string) bool { switch connectorType { - case "oidc", "saml", "microsoft", "linkedin", "gitlab", "github": + case "oidc", "saml", "microsoft", "linkedin", "gitlab", "github", "bitbucket-cloud": return true } return false diff --git a/util/dex/dex.go b/util/dex/dex.go index 0998430603110..91a40db3986e2 100644 --- a/util/dex/dex.go +++ b/util/dex/dex.go @@ -1,56 +1,26 @@ package dex import ( - "context" - "crypto/rand" - "encoding/json" "fmt" "html" "io/ioutil" - "math/big" - "net" "net/http" "net/http/httputil" "net/url" - "os" "regexp" "strconv" - "strings" - "time" - "github.com/coreos/dex/api" - oidc "github.com/coreos/go-oidc" - log "github.com/sirupsen/logrus" - "golang.org/x/oauth2" - "google.golang.org/grpc" - - "github.com/argoproj/argo-cd/common" "github.com/argoproj/argo-cd/errors" - "github.com/argoproj/argo-cd/util/cache" - "github.com/argoproj/argo-cd/util/session" - "github.com/argoproj/argo-cd/util/settings" -) - -const ( - // DexReverseProxyAddr is the address of the Dex OIDC server, which we run a reverse proxy against - DexReverseProxyAddr = "http://dex-server:5556" - // DexgRPCAPIAddr is the address to the Dex gRPC API server for managing dex. This is assumed to run - // locally (as a sidecar) - DexgRPCAPIAddr = "dex-server:5557" ) var messageRe = regexp.MustCompile(`

(.*)([\s\S]*?)<\/p>`) -type DexAPIClient struct { - api.DexClient -} - -// NewDexHTTPReverseProxy returns a reverse proxy to the DEX server. Dex is assumed to be configured +// NewDexHTTPReverseProxy returns a reverse proxy to the Dex server. Dex is assumed to be configured // with the external issuer URL muxed to the same path configured in server.go. In other words, if // ArgoCD API server wants to proxy requests at /api/dex, then the dex config yaml issuer URL should // also be /api/dex (e.g. issuer: https://argocd.example.com/api/dex) -func NewDexHTTPReverseProxy() func(writer http.ResponseWriter, request *http.Request) { - target, err := url.Parse(DexReverseProxyAddr) +func NewDexHTTPReverseProxy(serverAddr string) func(writer http.ResponseWriter, request *http.Request) { + target, err := url.Parse(serverAddr) errors.CheckError(err) proxy := httputil.NewSingleHostReverseProxy(target) proxy.ModifyResponse = func(resp *http.Response) error { @@ -82,273 +52,3 @@ func NewDexHTTPReverseProxy() func(writer http.ResponseWriter, request *http.Req proxy.ServeHTTP(w, r) } } - -func NewDexClient() (*DexAPIClient, error) { - conn, err := grpc.Dial(DexgRPCAPIAddr, grpc.WithInsecure()) - if err != nil { - return nil, fmt.Errorf("failed to dial %s: %v", DexgRPCAPIAddr, err) - } - apiClient := DexAPIClient{ - api.NewDexClient(conn), - } - return &apiClient, nil -} - -// WaitUntilReady waits until the dex gRPC server is responding -func (d *DexAPIClient) WaitUntilReady() { - log.Info("Waiting for dex to become ready") - ctx := context.Background() - for { - vers, err := d.GetVersion(ctx, &api.VersionReq{}) - if err == nil { - log.Infof("Dex %s (API: %d) up and running", vers.Server, vers.Api) - return - } - time.Sleep(1 * time.Second) - } -} - -type ClientApp struct { - // OAuth2 client ID of this application (e.g. argo-cd) - clientID string - // OAuth2 client secret of this application - clientSecret string - // Callback URL for OAuth2 responses (e.g. https://argocd.example.com/auth/callback) - redirectURI string - // URL of the issuer (e.g. https://argocd.example.com/api/dex) - issuerURL string - // client is the HTTP client which is used to query the IDp - client *http.Client - // secureCookie indicates if the cookie should be set with the Secure flag, meaning it should - // only ever be sent over HTTPS. This value is inferred by the scheme of the redirectURI. - secureCookie bool - // settings holds ArgoCD settings - settings *settings.ArgoCDSettings - // sessionMgr holds an ArgoCD session manager - sessionMgr *session.SessionManager - // states holds temporary nonce tokens to which hold application state values - // See http://tools.ietf.org/html/rfc6749#section-10.12 for more info. - states cache.Cache -} - -type appState struct { - // ReturnURL is the URL in which to redirect a user back to after completing an OAuth2 login - ReturnURL string `json:"returnURL"` -} - -// NewClientApp will register the ArgoCD client app in Dex and return an object which has HTTP -// handlers for handling the HTTP responses for login and callback -func NewClientApp(settings *settings.ArgoCDSettings, sessionMgr *session.SessionManager) (*ClientApp, error) { - log.Infof("Creating client app (%s)", common.ArgoCDClientAppID) - a := ClientApp{ - clientID: common.ArgoCDClientAppID, - clientSecret: settings.OAuth2ClientSecret(), - redirectURI: settings.RedirectURL(), - issuerURL: settings.IssuerURL(), - } - u, err := url.Parse(settings.URL) - if err != nil { - return nil, fmt.Errorf("parse redirect-uri: %v", err) - } - tlsConfig := settings.TLSConfig() - if tlsConfig != nil { - tlsConfig.InsecureSkipVerify = true - } - a.client = &http.Client{ - Transport: &http.Transport{ - TLSClientConfig: tlsConfig, - Proxy: http.ProxyFromEnvironment, - Dial: (&net.Dialer{ - Timeout: 30 * time.Second, - KeepAlive: 30 * time.Second, - }).Dial, - TLSHandshakeTimeout: 10 * time.Second, - ExpectContinueTimeout: 1 * time.Second, - }, - } - // NOTE: if we ever have replicas of ArgoCD, this needs to switch to Redis cache - a.states = cache.NewInMemoryCache(3 * time.Minute) - a.secureCookie = bool(u.Scheme == "https") - a.settings = settings - a.sessionMgr = sessionMgr - return &a, nil -} - -func (a *ClientApp) oauth2Config(scopes []string) (*oauth2.Config, error) { - provider, err := a.sessionMgr.OIDCProvider() - if err != nil { - return nil, err - } - return &oauth2.Config{ - ClientID: a.clientID, - ClientSecret: a.clientSecret, - Endpoint: provider.Endpoint(), - Scopes: scopes, - RedirectURL: a.redirectURI, - }, nil -} - -// RandString generates, from a given charset, a cryptographically-secure pseudo-random string of a given length. -// If the random number reader is unable to gather enough entropy to generate a secure random number, an error will be returned. -func randString(n int, charset string) (string, error) { - var b strings.Builder - rr := []rune(charset) - m := big.NewInt(int64(len(rr))) - - for i := 0; i < n; i++ { - pos, err := rand.Int(rand.Reader, m) - if err != nil { - return b.String(), err - } - _, _ = b.WriteRune(rr[pos.Int64()]) - } - return b.String(), nil -} - -// generateAppState creates an app state nonce -func (a *ClientApp) generateAppState(returnURL string) string { - const letters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" - randStr, err := randString(10, letters) - if err != nil { - log.Fatalf("Could not generate entropy: %v", err) - } - if returnURL == "" { - returnURL = "/" - } - err = a.states.Set(&cache.Item{ - Key: randStr, - Object: &appState{ - ReturnURL: returnURL, - }, - }) - if err != nil { - // This should never happen with the in-memory cache - log.Errorf("Failed to set app state: %v", err) - } - return randStr -} - -func (a *ClientApp) verifyAppState(state string) (*appState, error) { - var aState appState - err := a.states.Get(state, &aState) - if err != nil { - if err == cache.ErrCacheMiss { - return nil, fmt.Errorf("unknown app state %s", state) - } else { - return nil, fmt.Errorf("failed to verify app state %s: %v", state, err) - } - } - // TODO: purge the state string from the cache so that it is a true nonce - return &aState, nil -} - -func (a *ClientApp) HandleLogin(w http.ResponseWriter, r *http.Request) { - var opts []oauth2.AuthCodeOption - returnURL := r.FormValue("return_url") - scopes := []string{"openid", "profile", "email", "groups"} - appState := a.generateAppState(returnURL) - if r.FormValue("offline_access") != "yes" { - // no-op - } else if a.sessionMgr.OfflineAsScope() { - scopes = append(scopes, "offline_access") - } else { - opts = append(opts, oauth2.AccessTypeOffline) - } - oauth2Config, err := a.oauth2Config(scopes) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - authCodeURL := oauth2Config.AuthCodeURL(appState, opts...) - http.Redirect(w, r, authCodeURL, http.StatusSeeOther) -} - -func (a *ClientApp) HandleCallback(w http.ResponseWriter, r *http.Request) { - var ( - err error - token *oauth2.Token - returnURL string - ) - - ctx := oidc.ClientContext(r.Context(), a.client) - oauth2Config, err := a.oauth2Config(nil) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - switch r.Method { - case "GET": - // Authorization redirect callback from OAuth2 auth flow. - if errMsg := r.FormValue("error"); errMsg != "" { - http.Error(w, errMsg+": "+r.FormValue("error_description"), http.StatusBadRequest) - return - } - code := r.FormValue("code") - if code == "" { - http.Error(w, fmt.Sprintf("no code in request: %q", r.Form), http.StatusBadRequest) - return - } - var aState *appState - aState, err = a.verifyAppState(r.FormValue("state")) - if err != nil { - http.Error(w, fmt.Sprintf("%v", err), http.StatusBadRequest) - return - } - returnURL = aState.ReturnURL - token, err = oauth2Config.Exchange(ctx, code) - case "POST": - // Form request from frontend to refresh a token. - refresh := r.FormValue("refresh_token") - if refresh == "" { - http.Error(w, fmt.Sprintf("no refresh_token in request: %q", r.Form), http.StatusBadRequest) - return - } - t := &oauth2.Token{ - RefreshToken: refresh, - Expiry: time.Now().UTC().Add(-time.Hour), - } - token, err = oauth2Config.TokenSource(ctx, t).Token() - default: - http.Error(w, fmt.Sprintf("method not implemented: %s", r.Method), http.StatusBadRequest) - return - } - - if err != nil { - http.Error(w, fmt.Sprintf("failed to get token: %v", err), http.StatusInternalServerError) - return - } - - rawIDToken, ok := token.Extra("id_token").(string) - if !ok { - http.Error(w, "no id_token in token response", http.StatusInternalServerError) - return - } - - claims, err := a.sessionMgr.VerifyToken(rawIDToken) - if err != nil { - http.Error(w, fmt.Sprintf("invalid session token: %v", err), http.StatusInternalServerError) - return - } - flags := []string{"path=/"} - if a.secureCookie { - flags = append(flags, "Secure") - } - cookie := session.MakeCookieMetadata(common.AuthCookieName, rawIDToken, flags...) - w.Header().Set("Set-Cookie", cookie) - log.Infof("Web login successful claims: %v", claims) - if os.Getenv(common.EnvVarSSODebug) == "1" { - claimsJSON, _ := json.MarshalIndent(claims, "", " ") - renderToken(w, a.redirectURI, rawIDToken, token.RefreshToken, claimsJSON) - } else { - http.Redirect(w, r, returnURL, http.StatusSeeOther) - } -} - -func (a *ClientApp) verify(tokenString string) (*oidc.IDToken, error) { - provider, err := a.sessionMgr.OIDCProvider() - if err != nil { - return nil, err - } - verifier := provider.Verifier(&oidc.Config{ClientID: a.clientID}) - return verifier.Verify(context.Background(), tokenString) -} diff --git a/util/http/http.go b/util/http/http.go new file mode 100644 index 0000000000000..fb670cc494b94 --- /dev/null +++ b/util/http/http.go @@ -0,0 +1,15 @@ +package http + +import ( + "fmt" + "strings" +) + +// MakeCookieMetadata generates a string representing a Web cookie. Yum! +func MakeCookieMetadata(key, value string, flags ...string) string { + components := []string{ + fmt.Sprintf("%s=%s", key, value), + } + components = append(components, flags...) + return strings.Join(components, "; ") +} diff --git a/util/oidc/oidc.go b/util/oidc/oidc.go new file mode 100644 index 0000000000000..a157c82512c88 --- /dev/null +++ b/util/oidc/oidc.go @@ -0,0 +1,407 @@ +package oidc + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "html/template" + "net" + "net/http" + "net/url" + "os" + "strings" + "time" + + gooidc "github.com/coreos/go-oidc" + jwt "github.com/dgrijalva/jwt-go" + log "github.com/sirupsen/logrus" + "golang.org/x/oauth2" + + "github.com/argoproj/argo-cd/common" + "github.com/argoproj/argo-cd/util/cache" + httputil "github.com/argoproj/argo-cd/util/http" + "github.com/argoproj/argo-cd/util/rand" + "github.com/argoproj/argo-cd/util/settings" +) + +const ( + GrantTypeAuthorizationCode = "authorization_code" + GrantTypeImplicit = "implicit" + ResponseTypeCode = "code" +) + +// OIDCConfiguration holds a subset of interested fields from the OIDC configuration spec +type OIDCConfiguration struct { + Issuer string `json:"issuer"` + ScopesSupported []string `json:"scopes_supported"` + ResponseTypesSupported []string `json:"response_types_supported"` + GrantTypesSupported []string `json:"grant_types_supported,omitempty"` +} + +type ClientApp struct { + // OAuth2 client ID of this application (e.g. argo-cd) + clientID string + // OAuth2 client secret of this application + clientSecret string + // Callback URL for OAuth2 responses (e.g. https://argocd.example.com/auth/callback) + redirectURI string + // URL of the issuer (e.g. https://argocd.example.com/api/dex) + issuerURL string + // client is the HTTP client which is used to query the IDp + client *http.Client + // secureCookie indicates if the cookie should be set with the Secure flag, meaning it should + // only ever be sent over HTTPS. This value is inferred by the scheme of the redirectURI. + secureCookie bool + // settings holds ArgoCD settings + settings *settings.ArgoCDSettings + // provider is the OIDC configuration + provider *gooidc.Provider + // states holds temporary nonce tokens to which hold application state values + // See http://tools.ietf.org/html/rfc6749#section-10.12 for more info. + states cache.Cache +} + +type appState struct { + // ReturnURL is the URL in which to redirect a user back to after completing an OAuth2 login + ReturnURL string `json:"returnURL"` +} + +// NewClientApp will register the ArgoCD client app (either via Dex or external OIDC) and return an +// object which has HTTP handlers for handling the HTTP responses for login and callback +func NewClientApp(settings *settings.ArgoCDSettings) (*ClientApp, error) { + a := ClientApp{ + clientID: settings.OAuth2ClientID(), + clientSecret: settings.OAuth2ClientSecret(), + redirectURI: settings.RedirectURL(), + issuerURL: settings.IssuerURL(), + } + log.Infof("Creating client app (%s)", a.clientID) + u, err := url.Parse(settings.URL) + if err != nil { + return nil, fmt.Errorf("parse redirect-uri: %v", err) + } + tlsConfig := settings.TLSConfig() + if tlsConfig != nil { + tlsConfig.InsecureSkipVerify = true + } + a.client = &http.Client{ + Transport: &http.Transport{ + TLSClientConfig: tlsConfig, + Proxy: http.ProxyFromEnvironment, + Dial: (&net.Dialer{ + Timeout: 30 * time.Second, + KeepAlive: 30 * time.Second, + }).Dial, + TLSHandshakeTimeout: 10 * time.Second, + ExpectContinueTimeout: 1 * time.Second, + }, + } + // NOTE: if we ever have replicas of ArgoCD, this needs to switch to Redis cache + a.states = cache.NewInMemoryCache(3 * time.Minute) + a.secureCookie = bool(u.Scheme == "https") + a.settings = settings + return &a, nil +} + +func (a *ClientApp) oauth2Config(scopes []string) (*oauth2.Config, error) { + provider, err := a.oidcProvider() + if err != nil { + return nil, err + } + return &oauth2.Config{ + ClientID: a.clientID, + ClientSecret: a.clientSecret, + Endpoint: provider.Endpoint(), + Scopes: scopes, + RedirectURL: a.redirectURI, + }, nil +} + +// generateAppState creates an app state nonce +func (a *ClientApp) generateAppState(returnURL string) string { + randStr := rand.RandString(10) + if returnURL == "" { + returnURL = "/" + } + err := a.states.Set(&cache.Item{ + Key: randStr, + Object: &appState{ + ReturnURL: returnURL, + }, + }) + if err != nil { + // This should never happen with the in-memory cache + log.Errorf("Failed to set app state: %v", err) + } + return randStr +} + +func (a *ClientApp) verifyAppState(state string) (*appState, error) { + var aState appState + err := a.states.Get(state, &aState) + if err != nil { + if err == cache.ErrCacheMiss { + return nil, fmt.Errorf("unknown app state %s", state) + } else { + return nil, fmt.Errorf("failed to verify app state %s: %v", state, err) + } + } + // TODO: purge the state string from the cache so that it is a true nonce + return &aState, nil +} + +// HandleLogin formulates the proper OAuth2 URL (auth code or implicit) and redirects the user to +// the IDp login & consent page +func (a *ClientApp) HandleLogin(w http.ResponseWriter, r *http.Request) { + provider, err := a.oidcProvider() + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + oidcConf, err := ParseConfig(provider) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + scopes := []string{"openid", "profile", "email", "groups"} + oauth2Config, err := a.oauth2Config(scopes) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + returnURL := r.FormValue("return_url") + stateNonce := a.generateAppState(returnURL) + grantType := InferGrantType(oauth2Config, oidcConf) + var url string + switch grantType { + case GrantTypeAuthorizationCode: + url = oauth2Config.AuthCodeURL(stateNonce) + case GrantTypeImplicit: + url = ImplicitFlowURL(oauth2Config, stateNonce) + default: + http.Error(w, fmt.Sprintf("Unsupported grant type: %v", grantType), http.StatusInternalServerError) + return + } + log.Infof("Performing %s flow login: %s", grantType, url) + http.Redirect(w, r, url, http.StatusSeeOther) +} + +// HandleCallback is the callback handler for an OAuth2 login flow +func (a *ClientApp) HandleCallback(w http.ResponseWriter, r *http.Request) { + ctx := gooidc.ClientContext(r.Context(), a.client) + oauth2Config, err := a.oauth2Config(nil) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + log.Infof("Callback: %s", r.URL) + if errMsg := r.FormValue("error"); errMsg != "" { + http.Error(w, errMsg+": "+r.FormValue("error_description"), http.StatusBadRequest) + return + } + code := r.FormValue("code") + state := r.FormValue("state") + if code == "" { + // If code was not given, it implies implicit flow + a.handleImplicitFlow(w, r, state) + return + } + appState, err := a.verifyAppState(state) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + token, err := oauth2Config.Exchange(ctx, code) + if err != nil { + http.Error(w, fmt.Sprintf("failed to get token: %v", err), http.StatusInternalServerError) + return + } + idTokenRAW, ok := token.Extra("id_token").(string) + if !ok { + http.Error(w, "no id_token in token response", http.StatusInternalServerError) + return + } + idToken, err := a.verify(idTokenRAW) + if err != nil { + http.Error(w, fmt.Sprintf("invalid session token: %v", err), http.StatusInternalServerError) + return + } + flags := []string{"path=/"} + if a.secureCookie { + flags = append(flags, "Secure") + } + cookie := httputil.MakeCookieMetadata(common.AuthCookieName, idTokenRAW, flags...) + w.Header().Set("Set-Cookie", cookie) + + var claims jwt.MapClaims + err = idToken.Claims(&claims) + claimsJSON, _ := json.Marshal(claims) + log.Infof("Web login successful. Claims: %s", claimsJSON) + if os.Getenv(common.EnvVarSSODebug) == "1" { + claimsJSON, _ := json.MarshalIndent(claims, "", " ") + renderToken(w, a.redirectURI, idTokenRAW, token.RefreshToken, claimsJSON) + } else { + http.Redirect(w, r, appState.ReturnURL, http.StatusSeeOther) + } +} + +var implicitFlowTmpl = template.Must(template.New("implicit.html").Parse(``)) + +// handleImplicitFlow completes an implicit OAuth2 flow. The id_token and state will be contained +// in the URL fragment. The javascript client first redirects to the callback URL, supplying the +// state nonce for verification, as well as looking up the return URL. Once verified, the client +// stores the id_token from the fragment as a cookie. Finally it performs the final redirect back to +// the return URL. +func (a *ClientApp) handleImplicitFlow(w http.ResponseWriter, r *http.Request, state string) { + type implicitFlowValues struct { + CookieName string + ReturnURL string + } + vals := implicitFlowValues{ + CookieName: common.AuthCookieName, + } + if state != "" { + appState, err := a.verifyAppState(state) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + vals.ReturnURL = appState.ReturnURL + } + renderTemplate(w, implicitFlowTmpl, vals) +} + +func (a *ClientApp) oidcProvider() (*gooidc.Provider, error) { + if a.provider != nil { + return a.provider, nil + } + provider, err := NewOIDCProvider(a.issuerURL, a.client) + if err != nil { + return nil, err + } + a.provider = provider + return a.provider, nil +} + +func (a *ClientApp) verify(tokenString string) (*gooidc.IDToken, error) { + provider, err := a.oidcProvider() + if err != nil { + return nil, err + } + verifier := provider.Verifier(&gooidc.Config{ClientID: a.clientID}) + return verifier.Verify(context.Background(), tokenString) +} + +// NewOIDCProvider initializes an OIDC provider, querying the well known oidc configuration path +// http://example-argocd.com/api/dex/.well-known/openid-configuration +func NewOIDCProvider(issuerURL string, client *http.Client) (*gooidc.Provider, error) { + log.Infof("Initializing OIDC provider (issuer: %s)", issuerURL) + ctx := gooidc.ClientContext(context.Background(), client) + provider, err := gooidc.NewProvider(ctx, issuerURL) + if err != nil { + return nil, fmt.Errorf("Failed to query provider %q: %v", issuerURL, err) + } + s, _ := ParseConfig(provider) + log.Infof("OIDC supported scopes: %v", s.ScopesSupported) + return provider, nil +} + +// ImplicitFlowURL is an adaptation of oauth2.Config::AuthCodeURL() which returns a URL +// appropriate for an OAuth2 implicit login flow (as opposed to authorization code flow). +func ImplicitFlowURL(c *oauth2.Config, state string, opts ...oauth2.AuthCodeOption) string { + var buf bytes.Buffer + buf.WriteString(c.Endpoint.AuthURL) + v := url.Values{ + "response_type": {"id_token"}, + "nonce": {rand.RandString(10)}, + "client_id": {c.ClientID}, + "redirect_uri": condVal(c.RedirectURL), + "scope": condVal(strings.Join(c.Scopes, " ")), + "state": condVal(state), + } + for _, opt := range opts { + switch opt { + case oauth2.AccessTypeOnline: + v.Set("access_type", "online") + case oauth2.AccessTypeOffline: + v.Set("access_type", "offline") + case oauth2.ApprovalForce: + v.Set("approval_prompt", "force") + } + } + if strings.Contains(c.Endpoint.AuthURL, "?") { + buf.WriteByte('&') + } else { + buf.WriteByte('?') + } + buf.WriteString(v.Encode()) + return buf.String() +} + +func condVal(v string) []string { + if v == "" { + return nil + } + return []string{v} +} + +// OfflineAccess returns whether or not 'offline_access' is a supported scope +func OfflineAccess(scopes []string) bool { + if len(scopes) == 0 { + // scopes_supported is a "RECOMMENDED" discovery claim, not a required + // one. If missing, assume that the provider follows the spec and has + // an "offline_access" scope. + return true + } + // See if scopes_supported has the "offline_access" scope. + for _, scope := range scopes { + if scope == gooidc.ScopeOfflineAccess { + return true + } + } + return false +} + +// ParseConfig parses the OIDC Config into the concrete datastructure +func ParseConfig(provider *gooidc.Provider) (*OIDCConfiguration, error) { + var conf OIDCConfiguration + err := provider.Claims(&conf) + if err != nil { + return nil, err + } + return &conf, nil +} + +// InferGrantType infers the proper grant flow depending on the OAuth2 client config and OIDC configuration. +// Returns either: "authorization_code" or "implicit" +func InferGrantType(oauth2conf *oauth2.Config, oidcConf *OIDCConfiguration) string { + if oauth2conf.ClientSecret != "" { + // If we know the client secret, we are using the 'authorization_code' flow + return GrantTypeAuthorizationCode + } + if len(oidcConf.ResponseTypesSupported) == 1 && oidcConf.ResponseTypesSupported[0] == ResponseTypeCode { + // If we don't have the secret, check the supported response types. If the list is a single + // response type of type 'code', then grant type is 'authorization_code'. This is the Dex + // case, which does not support implicit login flow (https://github.com/dexidp/dex/issues/1254) + return GrantTypeAuthorizationCode + } + // If we don't have the client secret (e.g. SPA app), we can assume to be implicit + return GrantTypeImplicit +} diff --git a/util/oidc/oidc_test.go b/util/oidc/oidc_test.go new file mode 100644 index 0000000000000..a52bef667b539 --- /dev/null +++ b/util/oidc/oidc_test.go @@ -0,0 +1,49 @@ +package oidc + +import ( + "encoding/json" + "io/ioutil" + "testing" + + "golang.org/x/oauth2" + + "github.com/stretchr/testify/assert" +) + +var ( + spaOauth2Conf = &oauth2.Config{ + ClientID: "spa-id", + } + + webOauth2Conf = &oauth2.Config{ + ClientID: "spa-id", + ClientSecret: "my-super-secret", + } +) + +func TestInferGrantType(t *testing.T) { + var grantType string + dexRAW, err := ioutil.ReadFile("testdata/dex.json") + assert.NoError(t, err) + var dexConfig OIDCConfiguration + err = json.Unmarshal(dexRAW, &dexConfig) + assert.NoError(t, err) + grantType = InferGrantType(spaOauth2Conf, &dexConfig) + // Dex does not support implicit login flow (https://github.com/dexidp/dex/issues/1254) + assert.Equal(t, GrantTypeAuthorizationCode, grantType) + grantType = InferGrantType(webOauth2Conf, &dexConfig) + assert.Equal(t, GrantTypeAuthorizationCode, grantType) + + testFiles := []string{"testdata/okta.json", "testdata/auth0.json", "testdata/onelogin.json"} + for _, path := range testFiles { + oktaRAW, err := ioutil.ReadFile(path) + assert.NoError(t, err) + var oktaConfig OIDCConfiguration + err = json.Unmarshal(oktaRAW, &oktaConfig) + assert.NoError(t, err) + grantType = InferGrantType(spaOauth2Conf, &oktaConfig) + assert.Equal(t, GrantTypeImplicit, grantType) + grantType = InferGrantType(webOauth2Conf, &oktaConfig) + assert.Equal(t, GrantTypeAuthorizationCode, grantType) + } +} diff --git a/util/dex/templates.go b/util/oidc/templates.go similarity index 99% rename from util/dex/templates.go rename to util/oidc/templates.go index c5d33da2e7981..9c71618c95ffa 100644 --- a/util/dex/templates.go +++ b/util/oidc/templates.go @@ -1,4 +1,4 @@ -package dex +package oidc import ( "html/template" diff --git a/util/oidc/testdata/auth0.json b/util/oidc/testdata/auth0.json new file mode 100644 index 0000000000000..bf1f03d941b88 --- /dev/null +++ b/util/oidc/testdata/auth0.json @@ -0,0 +1,70 @@ +{ + "issuer": "https://argocd-test.auth0.com/", + "authorization_endpoint": "https://argocd-test.auth0.com/authorize", + "token_endpoint": "https://argocd-test.auth0.com/oauth/token", + "userinfo_endpoint": "https://argocd-test.auth0.com/userinfo", + "mfa_challenge_endpoint": "https://argocd-test.auth0.com/mfa/challenge", + "jwks_uri": "https://argocd-test.auth0.com/.well-known/jwks.json", + "registration_endpoint": "https://argocd-test.auth0.com/oidc/register", + "revocation_endpoint": "https://argocd-test.auth0.com/oauth/revoke", + "scopes_supported": [ + "openid", + "profile", + "offline_access", + "name", + "given_name", + "family_name", + "nickname", + "email", + "email_verified", + "picture", + "created_at", + "identities", + "phone", + "address" + ], + "response_types_supported": [ + "code", + "token", + "id_token", + "code token", + "code id_token", + "token id_token", + "code token id_token" + ], + "response_modes_supported": [ + "query", + "fragment", + "form_post" + ], + "subject_types_supported": [ + "public" + ], + "id_token_signing_alg_values_supported": [ + "HS256", + "RS256" + ], + "token_endpoint_auth_methods_supported": [ + "client_secret_basic", + "client_secret_post" + ], + "claims_supported": [ + "aud", + "auth_time", + "created_at", + "email", + "email_verified", + "exp", + "family_name", + "given_name", + "iat", + "identities", + "iss", + "name", + "nickname", + "phone_number", + "picture", + "sub" + ], + "request_uri_parameter_supported": false +} \ No newline at end of file diff --git a/util/oidc/testdata/dex.json b/util/oidc/testdata/dex.json new file mode 100644 index 0000000000000..1371ef825ec13 --- /dev/null +++ b/util/oidc/testdata/dex.json @@ -0,0 +1,36 @@ +{ + "issuer": "https://argocd.example.com/api/dex", + "authorization_endpoint": "https://argocd.example.com/api/dex/auth", + "token_endpoint": "https://argocd.example.com/api/dex/token", + "jwks_uri": "https://argocd.example.com/api/dex/keys", + "response_types_supported": [ + "code" + ], + "subject_types_supported": [ + "public" + ], + "id_token_signing_alg_values_supported": [ + "RS256" + ], + "scopes_supported": [ + "openid", + "email", + "groups", + "profile", + "offline_access" + ], + "token_endpoint_auth_methods_supported": [ + "client_secret_basic" + ], + "claims_supported": [ + "aud", + "email", + "email_verified", + "exp", + "iat", + "iss", + "locale", + "name", + "sub" + ] +} \ No newline at end of file diff --git a/util/oidc/testdata/okta.json b/util/oidc/testdata/okta.json new file mode 100644 index 0000000000000..d4fb3c40be90b --- /dev/null +++ b/util/oidc/testdata/okta.json @@ -0,0 +1,115 @@ +{ + "issuer": "https://dev-123456.oktapreview.com", + "authorization_endpoint": "https://dev-123456.oktapreview.com/oauth2/v1/authorize", + "token_endpoint": "https://dev-123456.oktapreview.com/oauth2/v1/token", + "userinfo_endpoint": "https://dev-123456.oktapreview.com/oauth2/v1/userinfo", + "registration_endpoint": "https://dev-123456.oktapreview.com/oauth2/v1/clients", + "jwks_uri": "https://dev-123456.oktapreview.com/oauth2/v1/keys", + "response_types_supported": [ + "code", + "id_token", + "code id_token", + "code token", + "id_token token", + "code id_token token" + ], + "response_modes_supported": [ + "query", + "fragment", + "form_post", + "okta_post_message" + ], + "grant_types_supported": [ + "authorization_code", + "implicit", + "refresh_token", + "password" + ], + "subject_types_supported": [ + "public" + ], + "id_token_signing_alg_values_supported": [ + "RS256" + ], + "scopes_supported": [ + "openid", + "email", + "profile", + "address", + "phone", + "offline_access", + "groups" + ], + "token_endpoint_auth_methods_supported": [ + "client_secret_basic", + "client_secret_post", + "client_secret_jwt", + "private_key_jwt", + "none" + ], + "claims_supported": [ + "iss", + "ver", + "sub", + "aud", + "iat", + "exp", + "jti", + "auth_time", + "amr", + "idp", + "nonce", + "name", + "nickname", + "preferred_username", + "given_name", + "middle_name", + "family_name", + "email", + "email_verified", + "profile", + "zoneinfo", + "locale", + "address", + "phone_number", + "picture", + "website", + "gender", + "birthdate", + "updated_at", + "at_hash", + "c_hash" + ], + "code_challenge_methods_supported": [ + "S256" + ], + "introspection_endpoint": "https://dev-123456.oktapreview.com/oauth2/v1/introspect", + "introspection_endpoint_auth_methods_supported": [ + "client_secret_basic", + "client_secret_post", + "client_secret_jwt", + "private_key_jwt", + "none" + ], + "revocation_endpoint": "https://dev-123456.oktapreview.com/oauth2/v1/revoke", + "revocation_endpoint_auth_methods_supported": [ + "client_secret_basic", + "client_secret_post", + "client_secret_jwt", + "private_key_jwt", + "none" + ], + "end_session_endpoint": "https://dev-123456.oktapreview.com/oauth2/v1/logout", + "request_parameter_supported": true, + "request_object_signing_alg_values_supported": [ + "HS256", + "HS384", + "HS512", + "RS256", + "RS384", + "RS512", + "ES256", + "ES384", + "ES512" + ] +} \ No newline at end of file diff --git a/util/oidc/testdata/onelogin.json b/util/oidc/testdata/onelogin.json new file mode 100644 index 0000000000000..eecff381be9b2 --- /dev/null +++ b/util/oidc/testdata/onelogin.json @@ -0,0 +1,87 @@ +{ + "acr_values_supported": [ + "onelogin:nist:level:1:re-auth" + ], + "authorization_endpoint": "https://argocd-dev.onelogin.com/oidc/auth", + "claims_parameter_supported": true, + "claims_supported": [ + "acr", + "auth_time", + "company", + "custom_fields", + "department", + "email", + "family_name", + "given_name", + "groups", + "iss", + "locale_code", + "name", + "phone_number", + "preferred_username", + "sub", + "title", + "updated_at" + ], + "grant_types_supported": [ + "authorization_code", + "implicit", + "refresh_token", + "password" + ], + "id_token_signing_alg_values_supported": [ + "RS256" + ], + "issuer": "https://openid-connect.onelogin.com/oidc", + "jwks_uri": "https://argocd-dev.onelogin.com/oidc/certs", + "request_parameter_supported": false, + "request_uri_parameter_supported": false, + "response_modes_supported": [ + "form_post", + "fragment", + "query" + ], + "response_types_supported": [ + "code", + "id_token token", + "id_token" + ], + "scopes_supported": [ + "openid", + "name", + "profile", + "groups", + "email", + "phone" + ], + "subject_types_supported": [ + "public" + ], + "token_endpoint": "https://argocd-dev.onelogin.com/oidc/token", + "token_endpoint_auth_methods_supported": [ + "client_secret_basic", + "client_secret_post", + "none" + ], + "userinfo_endpoint": "https://argocd-dev.onelogin.com/oidc/me", + "userinfo_signing_alg_values_supported": [], + "code_challenge_methods_supported": [ + "plain", + "S256" + ], + "introspection_endpoint": "https://argocd-dev.onelogin.com/oidc/token/introspection", + "introspection_endpoint_auth_methods_supported": [ + "client_secret_basic", + "client_secret_post", + "none" + ], + "revocation_endpoint": "https://argocd-dev.onelogin.com/oidc/token/revocation", + "revocation_endpoint_auth_methods_supported": [ + "client_secret_basic", + "client_secret_post", + "none" + ], + "claim_types_supported": [ + "normal" + ] +} \ No newline at end of file diff --git a/util/rand/rand.go b/util/rand/rand.go new file mode 100644 index 0000000000000..8a942c8123b84 --- /dev/null +++ b/util/rand/rand.go @@ -0,0 +1,37 @@ +package rand + +import ( + "math/rand" + "time" +) + +const letterBytes = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" +const ( + letterIdxBits = 6 // 6 bits to represent a letter index + letterIdxMask = 1<= 0; { + if remain == 0 { + cache, remain = src.Int63(), letterIdxMax + } + if idx := int(cache & letterIdxMask); idx < len(charset) { + b[i] = charset[idx] + i-- + } + cache >>= letterIdxBits + remain-- + } + return string(b) +} diff --git a/util/dex/dex_test.go b/util/rand/rand_test.go similarity index 50% rename from util/dex/dex_test.go rename to util/rand/rand_test.go index df1ec952a323e..b9537f7ead078 100644 --- a/util/dex/dex_test.go +++ b/util/rand/rand_test.go @@ -1,4 +1,4 @@ -package dex +package rand import ( "testing" @@ -6,20 +6,11 @@ import ( func TestRandString(t *testing.T) { var ss string - var err error - - ss, err = randString(10, "A") - if err != nil { - t.Fatalf("Could not generate entropy: %v", err) - } + ss = RandStringCharset(10, "A") if ss != "AAAAAAAAAA" { t.Errorf("Expected 10 As, but got %q", ss) } - - ss, err = randString(5, "ABC123") - if err != nil { - t.Fatalf("Could not generate entropy: %v", err) - } + ss = RandStringCharset(5, "ABC123") if len(ss) != 5 { t.Errorf("Expected random string of length 10, but got %q", ss) } diff --git a/util/session/sessionmanager.go b/util/session/sessionmanager.go index ab4c4a01a9b09..f84f7c60fac68 100644 --- a/util/session/sessionmanager.go +++ b/util/session/sessionmanager.go @@ -18,6 +18,7 @@ import ( "github.com/argoproj/argo-cd/common" jwtutil "github.com/argoproj/argo-cd/util/jwt" + oidcutil "github.com/argoproj/argo-cd/util/oidc" passwordutil "github.com/argoproj/argo-cd/util/password" "github.com/argoproj/argo-cd/util/settings" ) @@ -27,10 +28,6 @@ type SessionManager struct { settings *settings.ArgoCDSettings client *http.Client provider *oidc.Provider - - // Does the provider use "offline_access" scope to request a refresh token - // or does it use "access_type=offline" (e.g. Google)? - offlineAsScope bool } const ( @@ -153,7 +150,7 @@ func (mgr *SessionManager) VerifyToken(tokenString string) (jwt.Claims, error) { return mgr.Parse(tokenString) default: // Dex signed token - provider, err := mgr.OIDCProvider() + provider, err := mgr.oidcProvider() if err != nil { return nil, err } @@ -208,20 +205,11 @@ func Username(ctx context.Context) string { } } -// MakeCookieMetadata generates a string representing a Web cookie. Yum! -func MakeCookieMetadata(key, value string, flags ...string) string { - components := []string{ - fmt.Sprintf("%s=%s", key, value), - } - components = append(components, flags...) - return strings.Join(components, "; ") -} - -// OIDCProvider lazily initializes, memoizes, and returns the OIDC provider. -// We have to initialize the provider lazily since ArgoCD is an OIDC client to itself, which -// presents a chicken-and-egg problem of (1) serving dex over HTTP, and (2) querying the OIDC -// provider (ourselves) to initialize the app. -func (mgr *SessionManager) OIDCProvider() (*oidc.Provider, error) { +// oidcProvider lazily initializes, memoizes, and returns the OIDC provider. +// We have to initialize the provider lazily since ArgoCD can be an OIDC client to itself (in the +// case of dex reverse proxy), which presents a chicken-and-egg problem of (1) serving dex over +// HTTP, and (2) querying the OIDC provider (ourself) to initialize the app. +func (mgr *SessionManager) oidcProvider() (*oidc.Provider, error) { if mgr.provider != nil { return mgr.provider, nil } @@ -234,48 +222,14 @@ func (mgr *SessionManager) initializeOIDCProvider() (*oidc.Provider, error) { if !mgr.settings.IsSSOConfigured() { return nil, fmt.Errorf("SSO is not configured") } - issuerURL := mgr.settings.IssuerURL() - log.Infof("Initializing OIDC provider (issuer: %s)", issuerURL) - ctx := oidc.ClientContext(context.Background(), mgr.client) - provider, err := oidc.NewProvider(ctx, issuerURL) + provider, err := oidcutil.NewOIDCProvider(mgr.settings.IssuerURL(), mgr.client) if err != nil { - return nil, fmt.Errorf("Failed to query provider %q: %v", issuerURL, err) - } - // Returns the scopes the provider supports - // See: https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderMetadata - var s struct { - ScopesSupported []string `json:"scopes_supported"` - } - if err := provider.Claims(&s); err != nil { - return nil, fmt.Errorf("Failed to parse provider scopes_supported: %v", err) - } - log.Infof("OpenID supported scopes: %v", s.ScopesSupported) - offlineAsScope := false - if len(s.ScopesSupported) == 0 { - // scopes_supported is a "RECOMMENDED" discovery claim, not a required - // one. If missing, assume that the provider follows the spec and has - // an "offline_access" scope. - offlineAsScope = true - } else { - // See if scopes_supported has the "offline_access" scope. - for _, scope := range s.ScopesSupported { - if scope == oidc.ScopeOfflineAccess { - offlineAsScope = true - break - } - } + return nil, err } mgr.provider = provider - mgr.offlineAsScope = offlineAsScope return mgr.provider, nil } -// OfflineAsScope returns whether or not the OIDC provider supports offline as a scope -func (mgr *SessionManager) OfflineAsScope() bool { - _, _ = mgr.OIDCProvider() // forces offlineAsScope to be determined - return mgr.offlineAsScope -} - type debugTransport struct { t http.RoundTripper } diff --git a/util/settings/settings.go b/util/settings/settings.go index 0f34200da8297..26b52b317d4f8 100644 --- a/util/settings/settings.go +++ b/util/settings/settings.go @@ -8,12 +8,10 @@ import ( "encoding/base64" "fmt" "sync" - "syscall" "time" "github.com/ghodss/yaml" log "github.com/sirupsen/logrus" - "golang.org/x/crypto/ssh/terminal" apiv1 "k8s.io/api/core/v1" apierr "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -24,6 +22,7 @@ import ( "github.com/argoproj/argo-cd/common" "github.com/argoproj/argo-cd/util" + "github.com/argoproj/argo-cd/util/cli" "github.com/argoproj/argo-cd/util/password" tlsutil "github.com/argoproj/argo-cd/util/tls" ) @@ -36,8 +35,10 @@ type ArgoCDSettings struct { // Admin superuser password storage AdminPasswordHash string `json:"adminPasswordHash,omitempty"` AdminPasswordMtime time.Time `json:"adminPasswordMtime,omitempty"` - // DexConfig is contains portions of a dex config yaml + // DexConfig contains portions of a dex config yaml DexConfig string `json:"dexConfig,omitempty"` + // OIDCConfigRAW holds OIDC configuration as a raw string + OIDCConfigRAW string `json:"oidcConfig,omitempty"` // ServerSignature holds the key used to generate JWT tokens. ServerSignature []byte `json:"serverSignature,omitempty"` // Certificate holds the certificate/private key for the ArgoCD API server. @@ -53,6 +54,13 @@ type ArgoCDSettings struct { Secrets map[string]string `json:"secrets,omitempty"` } +type OIDCConfig struct { + Name string `json:"name,omitempty"` + Issuer string `json:"issuer,omitempty"` + ClientID string `json:"clientID,omitempty"` + ClientSecret string `json:"clientSecret,omitempty"` +} + const ( // settingAdminPasswordHashKey designates the key for a root password hash inside a Kubernetes secret. settingAdminPasswordHashKey = "admin.password" @@ -68,6 +76,8 @@ const ( settingURLKey = "url" // settingDexConfigKey designates the key for the dex config settingDexConfigKey = "dex.config" + // settingsOIDCConfigKey designates the key for OIDC config + settingsOIDCConfigKey = "oidc.config" // settingsWebhookGitHubSecret is the key for the GitHub shared webhook secret settingsWebhookGitHubSecretKey = "webhook.github.secret" // settingsWebhookGitLabSecret is the key for the GitLab shared webhook secret @@ -115,6 +125,7 @@ func (mgr *SettingsManager) GetSettings() (*ArgoCDSettings, error) { func updateSettingsFromConfigMap(settings *ArgoCDSettings, argoCDCM *apiv1.ConfigMap) { settings.DexConfig = argoCDCM.Data[settingDexConfigKey] + settings.OIDCConfigRAW = argoCDCM.Data[settingsOIDCConfigKey] settings.URL = argoCDCM.Data[settingURLKey] } @@ -183,8 +194,22 @@ func (mgr *SettingsManager) SaveSettings(settings *ArgoCDSettings) error { if argoCDCM.Data == nil { argoCDCM.Data = make(map[string]string) } - argoCDCM.Data[settingURLKey] = settings.URL - argoCDCM.Data[settingDexConfigKey] = settings.DexConfig + if settings.URL != "" { + argoCDCM.Data[settingURLKey] = settings.URL + } else { + delete(argoCDCM.Data, settingURLKey) + } + if settings.DexConfig != "" { + argoCDCM.Data[settingDexConfigKey] = settings.DexConfig + } else { + delete(argoCDCM.Data, settings.DexConfig) + } + if settings.OIDCConfigRAW != "" { + argoCDCM.Data[settingsOIDCConfigKey] = settings.OIDCConfigRAW + } else { + delete(argoCDCM.Data, settingsOIDCConfigKey) + } + if createCM { _, err = mgr.clientset.CoreV1().ConfigMaps(mgr.namespace).Create(argoCDCM) } else { @@ -253,6 +278,16 @@ func NewSettingsManager(clientset kubernetes.Interface, namespace string) *Setti // IsSSOConfigured returns whether or not single-sign-on is configured func (a *ArgoCDSettings) IsSSOConfigured() bool { + if a.IsDexConfigured() { + return true + } + if a.OIDCConfig() != nil { + return true + } + return false +} + +func (a *ArgoCDSettings) IsDexConfigured() bool { if a.URL == "" { return false } @@ -265,6 +300,19 @@ func (a *ArgoCDSettings) IsSSOConfigured() bool { return len(dexCfg) > 0 } +func (a *ArgoCDSettings) OIDCConfig() *OIDCConfig { + if a.OIDCConfigRAW == "" { + return nil + } + var oidcConfig OIDCConfig + err := yaml.Unmarshal([]byte(a.OIDCConfigRAW), &oidcConfig) + if err != nil { + log.Warnf("invalid oidc config: %v", err) + return nil + } + return &oidcConfig +} + // TLSConfig returns a tls.Config with the configured certificates func (a *ArgoCDSettings) TLSConfig() *tls.Config { if a.Certificate == nil { @@ -282,18 +330,44 @@ func (a *ArgoCDSettings) TLSConfig() *tls.Config { } func (a *ArgoCDSettings) IssuerURL() string { - return a.URL + common.DexAPIEndpoint + if oidcConfig := a.OIDCConfig(); oidcConfig != nil { + return oidcConfig.Issuer + } + if a.DexConfig != "" { + return a.URL + common.DexAPIEndpoint + } + return "" +} + +func (a *ArgoCDSettings) OAuth2ClientID() string { + if oidcConfig := a.OIDCConfig(); oidcConfig != nil { + return oidcConfig.ClientID + } + if a.DexConfig != "" { + return common.ArgoCDClientAppID + } + return "" +} + +func (a *ArgoCDSettings) OAuth2ClientSecret() string { + if oidcConfig := a.OIDCConfig(); oidcConfig != nil { + return oidcConfig.ClientSecret + } + if a.DexConfig != "" { + return a.DexOAuth2ClientSecret() + } + return "" } func (a *ArgoCDSettings) RedirectURL() string { return a.URL + common.CallbackEndpoint } -// OAuth2ClientSecret calculates an arbitrary, but predictable OAuth2 client secret string derived +// DexOAuth2ClientSecret calculates an arbitrary, but predictable OAuth2 client secret string derived // from the server secret. This is called by the dex startup wrapper (argocd-util rundex), as well // as the API server, such that they both independently come to the same conclusion of what the // OAuth2 shared client secret should be. -func (a *ArgoCDSettings) OAuth2ClientSecret() string { +func (a *ArgoCDSettings) DexOAuth2ClientSecret() string { h := sha256.New() _, err := h.Write(a.ServerSignature) if err != nil { @@ -407,27 +481,6 @@ func (mgr *SettingsManager) notifySubscribers() { } } -func ReadAndConfirmPassword() (string, error) { - for { - fmt.Print("*** Enter new password: ") - password, err := terminal.ReadPassword(syscall.Stdin) - if err != nil { - return "", err - } - fmt.Print("\n") - fmt.Print("*** Confirm new password: ") - confirmPassword, err := terminal.ReadPassword(syscall.Stdin) - if err != nil { - return "", err - } - fmt.Print("\n") - if string(password) == string(confirmPassword) { - return string(password), nil - } - log.Error("Passwords do not match") - } -} - func isIncompleteSettingsError(err error) bool { _, ok := err.(*incompleteSettingsError) return ok @@ -454,7 +507,7 @@ func UpdateSettings(defaultPassword string, settingsMgr *SettingsManager, update if cdSettings.AdminPasswordHash == "" || updateSuperuser { passwordRaw := defaultPassword if passwordRaw == "" { - passwordRaw, err = ReadAndConfirmPassword() + passwordRaw, err = cli.ReadAndConfirmPassword() if err != nil { return nil, err }