From 5f427d0a6ce8bc52e45b69673c1498dac9ce10a2 Mon Sep 17 00:00:00 2001 From: Harsh4902 Date: Wed, 14 May 2025 15:42:15 +0530 Subject: [PATCH 1/2] fixes #154 Feat: Add Login command to perform login and keep credentials in config file to perform further operations Signed-off-by: Harsh4902 --- cmd/cmd.go | 1 + cmd/login.go | 341 ++++++++++++++++++++++++++++++ go.mod | 9 +- go.sum | 26 ++- pkg/connectors/keycloak_client.go | 84 ++++++++ pkg/connectors/microcks_client.go | 9 +- pkg/util/rand/rand.go | 30 +++ 7 files changed, 491 insertions(+), 9 deletions(-) create mode 100644 cmd/login.go create mode 100644 pkg/util/rand/rand.go diff --git a/cmd/cmd.go b/cmd/cmd.go index 3a92caf..8ce5d2f 100644 --- a/cmd/cmd.go +++ b/cmd/cmd.go @@ -48,4 +48,5 @@ func init() { rootCmd.AddCommand(NewStartCommand()) rootCmd.AddCommand(NewStopCommand()) rootCmd.AddCommand(NewContextCommand()) + rootCmd.AddCommand(NewLoginCommand()) } diff --git a/cmd/login.go b/cmd/login.go new file mode 100644 index 0000000..c4aae03 --- /dev/null +++ b/cmd/login.go @@ -0,0 +1,341 @@ +package cmd + +import ( + "bufio" + "context" + "crypto/sha256" + "encoding/base64" + "fmt" + "html" + "log" + "net/http" + "os" + "strconv" + "strings" + "time" + + "github.com/coreos/go-oidc/v3/oidc" + "github.com/golang-jwt/jwt/v4" + "github.com/microcks/microcks-cli/pkg/config" + "github.com/microcks/microcks-cli/pkg/connectors" + "github.com/microcks/microcks-cli/pkg/errors" + "github.com/microcks/microcks-cli/pkg/util/rand" + "github.com/skratchdot/open-golang/open" + "github.com/spf13/cobra" + "golang.org/x/oauth2" + "golang.org/x/term" +) + +func NewLoginCommand() *cobra.Command { + var ( + ctxName string + username string + password string + sso bool + ssoLaunchBrowser bool + ssoProt int + ) + loginCmd := &cobra.Command{ + + Use: "login SERVER", + Short: "Login into Microcks instance", + Long: "Login into Microcks instance", + Example: `microcks login http://locahost:8080 + +# Provide name to your logged in context (Defautl context name is server name) +microcks login http://localhost:8080 --name + +# Provide username and password as flags +microcks login http://localhost:8080 --username --password + +# Perform SSO login +microcks login http://localhost:8080 --sso + +# Change port callback server for SSO login +microcks login http://localhost:8080 --sso --sso-port + +# Get OAuth URI instead of getting redirect to browser for SSO login +microcks login http://localhost:8080 --sso --sso-launch-browser=false +`, + Run: func(cmd *cobra.Command, args []string) { + ctx := cmd.Context() + var server string + + //Chekc if server name is provided or not + if len(args) != 1 { + cmd.HelpFunc()(cmd, args) + os.Exit(1) + } + + server = args[0] + mc := connectors.NewMicrocksClient(server) + keycloakUrl, err := mc.GetKeycloakURL() + + if err != nil { + log.Fatal(err) + } + + if ctxName == "" { + ctxName = server + } + + var authToken = "" + var refreshToken = "" + + configFile, err := config.DefaultLocalConfigPath() + errors.CheckError(err) + localConfig, err := config.ReadLocalConfig(configFile) + errors.CheckError(err) + + if localConfig == nil { + localConfig = &config.LocalConfig{} + } + + if keycloakUrl == "null" { + localConfig.UpsertServer(config.Server{ + Server: server, + InsecureTLS: true, + KeycloackEnable: false, + }) + fmt.Print("No login required...\n") + } else { + if !sso { + //Chek for the enviroment variables + clientID := os.Getenv("MICROCKS_CLIENT_ID") + clientSecret := os.Getenv("MICROCKS_CLIENT_SECRET") + + if clientID == "" || clientSecret == "" { + fmt.Printf("Please Set 'MICROCKS_CLIENT_ID' & 'MICROCKS_CLIENT_SECRET' to perform password login\n") + os.Exit(1) + } + //Perform login and retrive tokens + authToken, refreshToken = passwordLogin(keycloakUrl, clientID, clientSecret, username, password) + } else { + httpClient := mc.HttpClient() + ctx = oidc.ClientContext(ctx, httpClient) + kc := connectors.NewKeycloakClient(keycloakUrl, "", "") + oauth2conf, err := kc.GetOIDCConfig() + errors.CheckError(err) + authToken, refreshToken = oauth2login(ctx, ssoProt, oauth2conf, ssoLaunchBrowser) + } + + parser := jwt.NewParser(jwt.WithoutClaimsValidation()) + claims := jwt.MapClaims{} + _, _, err = parser.ParseUnverified(authToken, &claims) + + if err != nil { + fmt.Println(err) + } + + em := StringField(claims, "preferred_username") + fmt.Printf("'%s' logged in successfully\n", em) + + localConfig.UpsertServer(config.Server{ + Server: server, + InsecureTLS: true, + KeycloackEnable: true, + }) + } + + localConfig.UpsertUser(config.User{ + Name: server, + AuthToken: authToken, + RefreshToken: refreshToken, + }) + + localConfig.CurrentContext = ctxName + localConfig.UpserContext(config.ContextRef{ + Name: ctxName, + Server: server, + User: server, + }) + + err = config.WriteLocalConfig(*localConfig, configFile) + errors.CheckError(err) + + fmt.Printf("Context '%s' updated\n", ctxName) + }, + } + + loginCmd.Flags().StringVar(&ctxName, "name", "", "Name to use for the context") + loginCmd.Flags().StringVar(&username, "username", "", "The username of an account to authenticate") + loginCmd.Flags().StringVar(&password, "password", "", "The password of an account to authenticate") + loginCmd.Flags().BoolVar(&sso, "sso", false, "Perform SSO login") + loginCmd.Flags().BoolVar(&ssoLaunchBrowser, "sso-launch-browser", true, "Automatically launch the system default browser when performing SSO login") + loginCmd.Flags().IntVar(&ssoProt, "sso-port", 8085, "Port to run local OAuth2 login application") + + return loginCmd +} + +func oauth2login( + ctx context.Context, + port int, + oauth2conf *oauth2.Config, + ssoLaunchBrowser bool, +) (string, string) { + oauth2conf.ClientID = "microcks-app-js" + oauth2conf.RedirectURL = fmt.Sprintf("http://localhost:%d/auth/callback", port) + + // 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, err := rand.String(24) + errors.CheckError(err) + var tokenString string + var refreshToken string + + handleErr := func(w http.ResponseWriter, errMsg string) { + http.Error(w, html.EscapeString(errMsg), http.StatusBadRequest) + completionChan <- errMsg + } + + // PKCE implementation of https://tools.ietf.org/html/rfc7636 + codeVerifier, err := rand.StringFromCharset( + 43, + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~", + ) + errors.CheckError(err) + codeChallengeHash := sha256.Sum256([]byte(codeVerifier)) + codeChallenge := base64.RawURLEncoding.EncodeToString(codeChallengeHash[:]) + + // Authorization redirect callback from OAuth2 auth flow. + // Handles both implicit and authorization code flow + callbackHandler := func(w http.ResponseWriter, r *http.Request) { + if formErr := r.FormValue("error"); formErr != "" { + handleErr(w, fmt.Sprintf("%s: %s", formErr, r.FormValue("error_description"))) + return + } + + handledRequests++ + if handledRequests > 2 { + // Since implicit flow will redirect back to ourselves, this counter ensures we do not + // fallinto a redirect loop (e.g. user visits the page by hand) + handleErr(w, "Unable to complete login flow: too many redirects") + return + } + + if state := r.FormValue("state"); state != stateNonce { + handleErr(w, "Unknown state nonce") + return + } + + tokenString = r.FormValue("id_token") + if tokenString == "" { + code := r.FormValue("code") + if code == "" { + handleErr(w, fmt.Sprintf("no code in request: %q", r.Form)) + return + } + opts := []oauth2.AuthCodeOption{oauth2.SetAuthURLParam("code_verifier", codeVerifier)} + tok, err := oauth2conf.Exchange(ctx, code, opts...) + if err != nil { + handleErr(w, err.Error()) + return + } + tokenString = tok.AccessToken + refreshToken = tok.RefreshToken + + } + successPage := ` +
Authentication successful!
+

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

+ + ` + fmt.Fprint(w, successPage) + completionChan <- "" + } + + srv := &http.Server{Addr: "localhost:" + strconv.Itoa(port)} + http.HandleFunc("/auth/callback", callbackHandler) + + var url string + opts := []oauth2.AuthCodeOption{} + opts = append(opts, oauth2.SetAuthURLParam("client_id", "microcks-app-js")) + opts = append(opts, oauth2.SetAuthURLParam("scope", "openid")) + opts = append(opts, oauth2.SetAuthURLParam("code_challenge", codeChallenge)) + opts = append(opts, oauth2.SetAuthURLParam("code_challenge_method", "S256")) + url = oauth2conf.AuthCodeURL(stateNonce, opts...) + + fmt.Printf("Performing %s flow login: %s\n", "authorization_code", url) + time.Sleep(1 * time.Second) + ssoAuthFlow(url, ssoLaunchBrowser) + go func() { + if err := srv.ListenAndServe(); err != http.ErrServerClosed { + log.Fatalf("Temporary HTTP server failed: %s", err) + } + }() + errMsg := <-completionChan + if errMsg != "" { + log.Fatal(errMsg) + } + + ctx, cancel := context.WithTimeout(ctx, 1*time.Second) + defer cancel() + _ = srv.Shutdown(ctx) + + return tokenString, refreshToken +} + +func ssoAuthFlow(url string, ssoLaunchBrowser bool) { + if ssoLaunchBrowser { + log.Printf("Opening system default browser for authentication\n") + err := open.Start(url) + errors.CheckError(err) + } else { + log.Printf("To authenticate, copy-and-paste the following URL into your preferred browser: %s\n", url) + } +} + +func passwordLogin(keycloakURL, clientId, clientSecret, Username, Password string) (string, string) { + kc := connectors.NewKeycloakClient(keycloakURL, clientId, clientSecret) + username, password := promptCredentials(Username, Password) + + authToken, refreshToken, err := kc.ConnectAndGetTokenAndRefreshToken(username, password) + + if err != nil { + panic(err) + } + + return authToken, refreshToken +} + +func promptCredentials(username, password string) (string, string) { + return promptUserName(username), promptPassword(password) +} + +func promptUserName(value string) string { + for value == "" { + reader := bufio.NewReader(os.Stdin) + fmt.Print("Username" + ": ") + valueRaw, err := reader.ReadString('\n') + if err != nil { + panic(err) + } + value = strings.TrimSpace(valueRaw) + } + return value +} + +func promptPassword(password string) string { + for password == "" { + fmt.Print("Password: ") + passwordRaw, err := term.ReadPassword(int(os.Stdin.Fd())) + if err != nil { + panic(err) + } + password = string(passwordRaw) + fmt.Print("\n") + } + return password +} + +func StringField(claims jwt.MapClaims, fieldName string) string { + if fieldIf, ok := claims[fieldName]; ok { + if field, ok := fieldIf.(string); ok { + return field + } + } + return "" +} diff --git a/go.mod b/go.mod index 74297de..bc9305d 100644 --- a/go.mod +++ b/go.mod @@ -5,10 +5,15 @@ go 1.23.0 toolchain go1.24.1 require ( + github.com/coreos/go-oidc/v3 v3.14.1 github.com/docker/docker v28.0.4+incompatible github.com/docker/go-connections v0.5.0 + github.com/golang-jwt/jwt/v4 v4.5.2 + github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 github.com/spf13/cobra v1.9.1 github.com/stretchr/testify v1.10.0 + golang.org/x/oauth2 v0.30.0 + golang.org/x/term v0.32.0 gopkg.in/yaml.v2 v2.4.0 ) @@ -19,6 +24,7 @@ require ( github.com/distribution/reference v0.6.0 // indirect github.com/docker/go-units v0.5.0 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/go-jose/go-jose/v4 v4.0.5 // indirect github.com/go-logr/logr v1.4.2 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/gogo/protobuf v1.3.2 // indirect @@ -37,7 +43,8 @@ require ( go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.35.0 // indirect go.opentelemetry.io/otel/metric v1.35.0 // indirect go.opentelemetry.io/otel/trace v1.35.0 // indirect - golang.org/x/sys v0.30.0 // indirect + golang.org/x/crypto v0.36.0 // indirect + golang.org/x/sys v0.33.0 // indirect golang.org/x/time v0.11.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect gotest.tools/v3 v3.5.2 // indirect diff --git a/go.sum b/go.sum index 7f44852..b607189 100644 --- a/go.sum +++ b/go.sum @@ -6,6 +6,8 @@ github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK3 github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= +github.com/coreos/go-oidc/v3 v3.14.1 h1:9ePWwfdwC4QKRlCXsJGou56adA/owXczOzwKdOumLqk= +github.com/coreos/go-oidc/v3 v3.14.1/go.mod h1:HaZ3szPaZ0e4r6ebqvsLWlk2Tn+aejfmrfah6hnSYEU= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -19,6 +21,8 @@ github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4 github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/go-jose/go-jose/v4 v4.0.5 h1:M6T8+mKZl/+fNNuFHvGIzDz7BTLQPIounk/b9dw3AaE= +github.com/go-jose/go-jose/v4 v4.0.5/go.mod h1:s3P1lRrkT8igV8D9OjyL4WRyHvjB6a4JSllnOrmmBOA= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= @@ -26,6 +30,8 @@ github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI= +github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= @@ -62,6 +68,8 @@ github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQD github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 h1:JIAuq3EEf9cgbU6AtGPK4CTG3Zf6CKMNqf0MHTggAUA= +github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966/go.mod h1:sUM3LWHvSMaG192sy56D9F7CNvL7jUJVXoqM1QKLnog= github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= @@ -95,14 +103,18 @@ go.opentelemetry.io/proto/otlp v1.5.0/go.mod h1:keN8WnHxOy8PG0rQZjJJ5A2ebUoafqWp golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= +golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8= -golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk= +golang.org/x/net v0.37.0 h1:1zLorHbz+LYj7MQlSf1+2tPIIgibq2eL5xkrGk6f+2c= +golang.org/x/net v0.37.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= +golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= +golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -111,12 +123,14 @@ golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5h golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= -golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= +golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg= +golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= -golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= +golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= +golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0= golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= diff --git a/pkg/connectors/keycloak_client.go b/pkg/connectors/keycloak_client.go index 0542dc5..da5a947 100644 --- a/pkg/connectors/keycloak_client.go +++ b/pkg/connectors/keycloak_client.go @@ -16,19 +16,24 @@ package connectors import ( + "bytes" "encoding/base64" "encoding/json" + "fmt" "io/ioutil" "net/http" "net/url" "strings" "github.com/microcks/microcks-cli/pkg/config" + "golang.org/x/oauth2" ) // KeycloakClient defines methods for cinteracting with Keycloak type KeycloakClient interface { ConnectAndGetToken() (string, error) + ConnectAndGetTokenAndRefreshToken(string, string) (string, string, error) + GetOIDCConfig() (*oauth2.Config, error) } type keycloakClient struct { @@ -103,3 +108,82 @@ func (c *keycloakClient) ConnectAndGetToken() (string, error) { accessToken := openIDResp["access_token"].(string) return accessToken, err } + +func (c *keycloakClient) GetOIDCConfig() (*oauth2.Config, error) { + rel := &url.URL{Path: ".well-known/openid-configuration"} + u := c.BaseURL.ResolveReference(rel) + + // Create HTTP request + req, err := http.NewRequest("GET", u.String(), nil) + if err != nil { + fmt.Println("Error creating request:", err) + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + body, err := ioutil.ReadAll(resp.Body) + if err != nil { + panic(err.Error()) + } + + var openIDResp map[string]interface{} + if err := json.Unmarshal(body, &openIDResp); err != nil { + panic(err) + } + + authURL := openIDResp["authorization_endpoint"].(string) + tokenURL := openIDResp["token_endpoint"].(string) + + return &oauth2.Config{ + Endpoint: oauth2.Endpoint{ + AuthURL: authURL, + TokenURL: tokenURL, + }, + }, nil +} + +func (c *keycloakClient) ConnectAndGetTokenAndRefreshToken(username, password string) (string, string, error) { + + rel := &url.URL{Path: "protocol/openid-connect/token"} + u := c.BaseURL.ResolveReference(rel) + + data := url.Values{} + data.Set("client_id", c.Username) + data.Set("client_secret", c.Password) + data.Set("username", username) + data.Set("password", password) + data.Set("grant_type", "password") + // Create HTTP request + req, err := http.NewRequest("POST", u.String(), bytes.NewBufferString(data.Encode())) + if err != nil { + fmt.Println("Error creating request:", err) + } + + // Set headers + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + resp, err := c.httpClient.Do(req) + if err != nil { + return "", "", err + } + defer resp.Body.Close() + + body, err := ioutil.ReadAll(resp.Body) + if err != nil { + panic(err.Error()) + } + + var openIDResp map[string]interface{} + if err := json.Unmarshal(body, &openIDResp); err != nil { + panic(err) + } + + authToken := openIDResp["access_token"].(string) + refershToken := openIDResp["refresh_token"].(string) + + return authToken, refershToken, nil +} diff --git a/pkg/connectors/microcks_client.go b/pkg/connectors/microcks_client.go index 1b0e861..20b61da 100644 --- a/pkg/connectors/microcks_client.go +++ b/pkg/connectors/microcks_client.go @@ -39,6 +39,7 @@ var ( // MicrocksClient allows interacting with Microcks APIs type MicrocksClient interface { + HttpClient() *http.Client GetKeycloakURL() (string, error) SetOAuthToken(oauthToken string) CreateTestResult(serviceID string, testEndpoint string, runnerType string, secretName string, timeout int64, filteredOperations string, operationsHeaders string, oAuth2Context string) (string, error) @@ -89,8 +90,8 @@ type microcksClient struct { func NewMicrocksClient(apiURL string) MicrocksClient { mc := microcksClient{} - if !strings.HasSuffix(apiURL, "/") { - apiURL += "/" + if !strings.HasSuffix(apiURL, "/api/") { + apiURL += "/api/" } u, err := url.Parse(apiURL) @@ -111,6 +112,10 @@ func NewMicrocksClient(apiURL string) MicrocksClient { return &mc } +func (mc *microcksClient) HttpClient() *http.Client { + return mc.httpClient +} + func (c *microcksClient) GetKeycloakURL() (string, error) { // Ensure we have a correct URL for retrieving Keycloal configuration. rel := &url.URL{Path: "keycloak/config"} diff --git a/pkg/util/rand/rand.go b/pkg/util/rand/rand.go new file mode 100644 index 0000000..1e748bf --- /dev/null +++ b/pkg/util/rand/rand.go @@ -0,0 +1,30 @@ +package rand + +import ( + "crypto/rand" + "fmt" + "math/big" +) + +const letterBytes = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" + +// String generates, from the set of capital and lowercase letters, a cryptographically-secure pseudo-random string of a given length. +func String(n int) (string, error) { + return StringFromCharset(n, letterBytes) +} + +// StringFromCharset generates, from a given charset, a cryptographically-secure pseudo-random string of a given length. +func StringFromCharset(n int, charset string) (string, error) { + b := make([]byte, n) + maxIdx := big.NewInt(int64(len(charset))) + for i := 0; i < n; i++ { + randIdx, err := rand.Int(rand.Reader, maxIdx) + if err != nil { + return "", fmt.Errorf("failed to generate random string: %w", err) + } + // randIdx is necessarily safe to convert to int, because the max came from an int. + randIdxInt := int(randIdx.Int64()) + b[i] = charset[randIdxInt] + } + return string(b), nil +} From 0343a6ed609a0f4b12831107f50ea2122033bf97 Mon Sep 17 00:00:00 2001 From: Harsh4902 Date: Thu, 15 May 2025 16:51:04 +0530 Subject: [PATCH 2/2] Adds suport for token refreshing mechanism on token expiry Signed-off-by: Harsh4902 --- pkg/config/localconfig.go | 38 ++++++ pkg/connectors/microcks_client.go | 195 +++++++++++++++++++++++++----- 2 files changed, 204 insertions(+), 29 deletions(-) diff --git a/pkg/config/localconfig.go b/pkg/config/localconfig.go index 1e21f9a..6289dae 100644 --- a/pkg/config/localconfig.go +++ b/pkg/config/localconfig.go @@ -14,6 +14,7 @@ type LocalConfig struct { Servers []Server `yaml:"servers"` Users []User `yaml:"users"` Instances []Instance `yaml:"instances"` + Auths []Auth `yaml:"auths"` } type ContextRef struct { @@ -53,6 +54,12 @@ type Instance struct { Driver string `yaml:"driver"` } +type Auth struct { + Server string + ClientId string + ClientSecret string +} + // ReadLocalConfig loads up the local configuration file. Returns nil if config does not exist func ReadLocalConfig(path string) (*LocalConfig, error) { var err error @@ -293,3 +300,34 @@ func (l *LocalConfig) RemoveInstance(instanceName string) bool { func (l *LocalConfig) IsEmpty() bool { return len(l.Servers) == 0 } + +func (l *LocalConfig) GetAuth(server string) (*Auth, error) { + for _, a := range l.Auths { + if a.Server == server { + return &a, nil + } + } + + return nil, fmt.Errorf("Auth for '%s' is undifined\n", server) +} + +func (l *LocalConfig) UpserAuth(auth Auth) { + for i, a := range l.Auths { + if a.Server == auth.Server { + l.Auths[i] = auth + return + } + } + + l.Auths = append(l.Auths, auth) +} + +func (l *LocalConfig) RemoveAuth(server string) bool { + for i, a := range l.Auths { + if a.Server == server { + l.Auths = append(l.Auths[:i], l.Auths[i+1:]...) + return true + } + } + return false +} diff --git a/pkg/connectors/microcks_client.go b/pkg/connectors/microcks_client.go index 20b61da..43593b6 100644 --- a/pkg/connectors/microcks_client.go +++ b/pkg/connectors/microcks_client.go @@ -17,11 +17,14 @@ package connectors import ( "bytes" + "context" + "crypto/tls" "encoding/json" - "errors" + errs "errors" "fmt" "io" "io/ioutil" + "log" "mime/multipart" "net/http" "net/url" @@ -30,7 +33,11 @@ import ( "strconv" "strings" + "github.com/coreos/go-oidc/v3/oidc" + "github.com/golang-jwt/jwt/v4" "github.com/microcks/microcks-cli/pkg/config" + "github.com/microcks/microcks-cli/pkg/errors" + "golang.org/x/oauth2" ) var ( @@ -79,41 +86,79 @@ type OAuth2ClientContext struct { Scopes string `json:"scopes"` } +type ClientOptions struct { + ServerAddr string + Context string + ConfigPath string + AuthToken string + InsecureTLS bool + Verbose bool + CaCertPaths string +} + type microcksClient struct { - APIURL *url.URL - OAuthToken string + ServerAddr string + APIURL *url.URL + AuthToken string + CertFile *tls.Certificate + InsecureTLS bool + RefreshToken string + Insecure bool + Verbose bool httpClient *http.Client } -// NewMicrocksClient build a new MicrocksClient implementation -func NewMicrocksClient(apiURL string) MicrocksClient { - mc := microcksClient{} +func NewClient(opts ClientOptions) (MicrocksClient, error) { + var c microcksClient + localCfg, err := config.ReadLocalConfig(opts.ConfigPath) + if err != nil { + return nil, err + } + var ctxName string - if !strings.HasSuffix(apiURL, "/api/") { - apiURL += "/api/" + if localCfg != nil { + configCtx, err := localCfg.ResolveContext(opts.Context) + if err != nil { + return nil, err + } + c.ServerAddr = configCtx.Server.Server + c.Insecure = configCtx.Server.KeycloackEnable + c.InsecureTLS = configCtx.Server.InsecureTLS + c.AuthToken = configCtx.User.AuthToken + c.RefreshToken = configCtx.User.RefreshToken + + apiurl := configCtx.Server.Server + + if !strings.HasSuffix(apiurl, "/api/") { + apiurl += "/api/" + } + + u, err := url.Parse(apiurl) + if err != nil { + panic(err) + } + c.APIURL = u + + ctxName = configCtx.Name } - u, err := url.Parse(apiURL) - if err != nil { - panic(err) + if opts.Verbose { + c.Verbose = opts.Verbose } - mc.APIURL = u - if config.InsecureTLS || len(config.CaCertPaths) > 0 { - tlsConfig := config.CreateTLSConfig() - tr := &http.Transport{ - TLSClientConfig: tlsConfig, + c.httpClient = &http.Client{} + if localCfg != nil { + err = c.refreshAuthToken(localCfg, ctxName, opts.ConfigPath) + if err != nil { + return nil, err } - mc.httpClient = &http.Client{Transport: tr} - } else { - mc.httpClient = http.DefaultClient } - return &mc + return &c, nil } -func (mc *microcksClient) HttpClient() *http.Client { - return mc.httpClient +func (c *microcksClient) HttpClient() *http.Client { + return c.httpClient } func (c *microcksClient) GetKeycloakURL() (string, error) { @@ -162,8 +207,100 @@ func (c *microcksClient) GetKeycloakURL() (string, error) { return "null", nil } +func (c *microcksClient) refreshAuthToken(localCfg *config.LocalConfig, ctxName, configPath string) error { + if c.RefreshToken == "" { + // If we have no refresh token, there's no point in doing anything + return nil + } + configCtx, err := localCfg.ResolveContext(ctxName) + if err != nil { + return err + } + parser := jwt.NewParser(jwt.WithoutClaimsValidation()) + var claims jwt.RegisteredClaims + _, _, err = parser.ParseUnverified(configCtx.User.AuthToken, &claims) + if err != nil { + return err + } + if claims.Valid() == nil { + // token is still valid + return nil + } + + log.Printf("Auth token no longer valid. Refreshing") + auth, err := localCfg.GetAuth(configCtx.Server.Server) + if err != nil { + return err + } + authToken, refreshToken, err := c.redeemRefreshToken(*auth) + if err != nil { + return err + } + c.AuthToken = authToken + c.RefreshToken = refreshToken + localCfg.UpsertUser(config.User{ + Name: ctxName, + AuthToken: authToken, + RefreshToken: refreshToken, + }) + err = config.WriteLocalConfig(*localCfg, configPath) + if err != nil { + return err + } + return nil +} + +func (c *microcksClient) redeemRefreshToken(auth config.Auth) (string, string, error) { + keyCloakUrl, err := c.GetKeycloakURL() + errors.CheckError(err) + kc := NewKeycloakClient(keyCloakUrl, "", "") + oauth2Conf, err := kc.GetOIDCConfig() + errors.CheckError(err) + oauth2Conf.ClientID = auth.ClientId + oauth2Conf.ClientSecret = auth.ClientSecret + + httpClient := c.httpClient + ctx := oidc.ClientContext(context.Background(), httpClient) + + t := &oauth2.Token{ + RefreshToken: c.RefreshToken, + } + token, err := oauth2Conf.TokenSource(ctx, t).Token() + if err != nil { + return "", "", err + } + + return token.AccessToken, token.RefreshToken, nil +} + +// NewMicrocksClient builds a new headless MicrocksClient without any authtoken and all for general purposes +func NewMicrocksClient(apiURL string) MicrocksClient { + mc := microcksClient{} + + if !strings.HasSuffix(apiURL, "/api/") { + apiURL += "/api/" + } + + u, err := url.Parse(apiURL) + if err != nil { + panic(err) + } + mc.APIURL = u + + if config.InsecureTLS || len(config.CaCertPaths) > 0 { + tlsConfig := config.CreateTLSConfig() + tr := &http.Transport{ + TLSClientConfig: tlsConfig, + } + mc.httpClient = &http.Client{Transport: tr} + } else { + mc.httpClient = http.DefaultClient + } + return &mc +} + func (c *microcksClient) SetOAuthToken(oauthToken string) { - c.OAuthToken = oauthToken + c.AuthToken = oauthToken } func (c *microcksClient) CreateTestResult(serviceID string, testEndpoint string, runnerType string, secretName string, timeout int64, filteredOperations string, operationsHeaders string, oAuth2Context string) (string, error) { @@ -199,7 +336,7 @@ func (c *microcksClient) CreateTestResult(serviceID string, testEndpoint string, req.Header.Set("Content-Type", "application/json; charset=utf-8") req.Header.Set("Accept", "application/json") - req.Header.Set("Authorization", "Bearer "+c.OAuthToken) + req.Header.Set("Authorization", "Bearer "+c.AuthToken) // Dump request if verbose required. config.DumpRequestIfRequired("Microcks for creating test", req, true) @@ -238,7 +375,7 @@ func (c *microcksClient) GetTestResult(testResultID string) (*TestResultSummary, } req.Header.Set("Accept", "application/json") - req.Header.Set("Authorization", "Bearer "+c.OAuthToken) + req.Header.Set("Authorization", "Bearer "+c.AuthToken) // Dump request if verbose required. config.DumpRequestIfRequired("Microcks for getting status", req, false) @@ -300,7 +437,7 @@ func (c *microcksClient) UploadArtifact(specificationFilePath string, mainArtifa return "", err } req.Header.Set("Content-Type", writer.FormDataContentType()) - req.Header.Set("Authorization", "Bearer "+c.OAuthToken) + req.Header.Set("Authorization", "Bearer "+c.AuthToken) // Dump request if verbose required. config.DumpRequestIfRequired("Microcks for uploading artifact", req, true) @@ -321,7 +458,7 @@ func (c *microcksClient) UploadArtifact(specificationFilePath string, mainArtifa // Raise exception if not created. if resp.StatusCode != 201 { - return "", errors.New(string(respBody)) + return "", errs.New(string(respBody)) } return string(respBody), err @@ -354,7 +491,7 @@ func (c *microcksClient) DownloadArtifact(artifactURL string, mainArtifact bool, return "", err } req.Header.Set("Content-Type", writer.FormDataContentType()) - req.Header.Set("Authorization", "Bearer "+c.OAuthToken) + req.Header.Set("Authorization", "Bearer "+c.AuthToken) // Dump request if verbose required. config.DumpRequestIfRequired("Microcks for uploading artifact", req, true) @@ -375,7 +512,7 @@ func (c *microcksClient) DownloadArtifact(artifactURL string, mainArtifact bool, // Raise exception if not created. if resp.StatusCode != 201 { - return "", errors.New(string(respBody)) + return "", errs.New(string(respBody)) } return string(respBody), err