From ca1a2673701534001a87dcf152a5e19fec27031f Mon Sep 17 00:00:00 2001 From: Steve Ramage <49958178+steve-r-west@users.noreply.github.com> Date: Thu, 21 Nov 2024 14:32:44 -0800 Subject: [PATCH] Resolves #193 - Add support for authentication via oidc (#479) --- cmd/create.go | 132 +----------------- cmd/delete.go | 116 +--------------- cmd/get.go | 122 +---------------- cmd/login.go | 57 +++----- cmd/reset-store.go | 11 +- cmd/root.go | 2 + cmd/update.go | 94 +------------ external/authentication/internal_login.go | 43 ++++++ external/json/to_json.go | 25 ++++ external/oidc/callback.go | 138 +++++++++++++++++++ external/oidc/get_token.go | 155 +++++++++++++++++++++ external/oidc/index.go | 160 ++++++++++++++++++++++ external/oidc/server.go | 114 +++++++++++++++ external/oidc/site/callback.gohtml | 52 +++++++ external/oidc/site/favicon.ico | Bin 0 -> 1150 bytes external/oidc/site/footer.gohtml | 5 + external/oidc/site/get_token.gohtml | 37 +++++ external/oidc/site/header.gohtml | 18 +++ external/oidc/site/index.gohtml | 123 +++++++++++++++++ external/oidc/site/static/light.svg | 71 ++++++++++ external/oidc/site/static/style.css | 76 ++++++++++ external/rest/create.go | 141 +++++++++++++++++++ external/rest/delete.go | 125 +++++++++++++++++ external/rest/get.go | 130 ++++++++++++++++++ external/rest/update.go | 100 ++++++++++++++ go.mod | 1 + go.sum | 1 + 27 files changed, 1551 insertions(+), 498 deletions(-) create mode 100644 external/authentication/internal_login.go create mode 100644 external/oidc/callback.go create mode 100644 external/oidc/get_token.go create mode 100644 external/oidc/index.go create mode 100644 external/oidc/server.go create mode 100644 external/oidc/site/callback.gohtml create mode 100644 external/oidc/site/favicon.ico create mode 100644 external/oidc/site/footer.gohtml create mode 100644 external/oidc/site/get_token.gohtml create mode 100644 external/oidc/site/header.gohtml create mode 100644 external/oidc/site/index.gohtml create mode 100644 external/oidc/site/static/light.svg create mode 100644 external/oidc/site/static/style.css create mode 100644 external/rest/create.go create mode 100644 external/rest/delete.go create mode 100644 external/rest/get.go create mode 100644 external/rest/update.go diff --git a/cmd/create.go b/cmd/create.go index 3d404fa..7a9baf5 100644 --- a/cmd/create.go +++ b/cmd/create.go @@ -5,18 +5,13 @@ import ( gojson "encoding/json" "fmt" "github.com/elasticpath/epcc-cli/external/aliases" - "github.com/elasticpath/epcc-cli/external/autofill" "github.com/elasticpath/epcc-cli/external/completion" - "github.com/elasticpath/epcc-cli/external/encoding" "github.com/elasticpath/epcc-cli/external/httpclient" "github.com/elasticpath/epcc-cli/external/json" "github.com/elasticpath/epcc-cli/external/resources" - "github.com/elasticpath/epcc-cli/external/shutdown" + "github.com/elasticpath/epcc-cli/external/rest" log "github.com/sirupsen/logrus" "github.com/spf13/cobra" - "io" - "net/http" - "net/url" "strings" ) @@ -105,7 +100,7 @@ func NewCreateCommand(parentCmd *cobra.Command) func() { } } - body, err := createInternal(context.Background(), overrides, append([]string{resourceName}, args...), autoFillOnCreate, setAlias, skipAliases) + body, err := rest.CreateInternal(context.Background(), overrides, append([]string{resourceName}, args...), autoFillOnCreate, setAlias, skipAliases) if err != nil { return err @@ -241,126 +236,3 @@ func NewCreateCommand(parentCmd *cobra.Command) func() { return resetFunc } - -func createInternal(ctx context.Context, overrides *httpclient.HttpParameterOverrides, args []string, autoFillOnCreate bool, aliasName string, skipAliases bool) (string, error) { - shutdown.OutstandingOpCounter.Add(1) - defer shutdown.OutstandingOpCounter.Done() - - // Find Resource - resource, ok := resources.GetResourceByName(args[0]) - if !ok { - return "", fmt.Errorf("could not find resource %s", args[0]) - } - - if resource.CreateEntityInfo == nil { - return "", fmt.Errorf("resource %s doesn't support CREATE", args[0]) - } - - // Count ids in CreateEntity - resourceURL := resource.CreateEntityInfo.Url - - idCount, err := resources.GetNumberOfVariablesNeeded(resourceURL) - - if err != nil { - return "", err - } - - // Replace ids with args in resourceURL - resourceURL, err = resources.GenerateUrl(resource.CreateEntityInfo, args[1:], true) - - if overrides.OverrideUrlPath != "" { - log.Warnf("Overriding URL Path from %s to %s", resourceURL, overrides.OverrideUrlPath) - resourceURL = overrides.OverrideUrlPath - } - - if err != nil { - return "", err - } - - var resp *http.Response = nil - var resBody []byte - - if resource.CreateEntityInfo.ContentType == "multipart/form-data" { - - byteBuf, contentType, err := encoding.ToMultiPartEncoding(args[(idCount+1):], resource.NoWrapping, resource.JsonApiFormat == "complaint", resource.Attributes) - if err != nil { - return "", err - } - - // Submit request - resp, err = httpclient.DoFileRequest(ctx, resourceURL, byteBuf, contentType) - - } else { - // Assume it's application/json - - params := url.Values{} - - for _, v := range overrides.QueryParameters { - keyAndValue := strings.SplitN(v, "=", 2) - if len(keyAndValue) != 2 { - return "", fmt.Errorf("Could not parse query parameter %v, all query parameters should be a key and value format", keyAndValue) - } - params.Add(keyAndValue[0], keyAndValue[1]) - } - - if !resource.NoWrapping { - args = append(args, "type", resource.JsonApiType) - } - // Create the body from remaining args - - jsonArgs := args[(idCount + 1):] - if autoFillOnCreate { - autofilledData := autofill.GetJsonArrayForResource(&resource) - - jsonArgs = append(autofilledData, jsonArgs...) - } - - body, err := json.ToJson(jsonArgs, resource.NoWrapping, resource.JsonApiFormat == "compliant", resource.Attributes, true) - - if err != nil { - return "", err - } - - // Submit request - resp, err = httpclient.DoRequest(ctx, "POST", resourceURL, params.Encode(), strings.NewReader(body)) - - } - - if err != nil { - return "", fmt.Errorf("got error %s", err.Error()) - } else if resp == nil { - return "", fmt.Errorf("got nil response with request: %s", resourceURL) - } - - if resp.Body != nil { - defer resp.Body.Close() - - // Print the body - resBody, err = io.ReadAll(resp.Body) - if err != nil { - log.Fatal(err) - } - - // Check if error response - if resp.StatusCode >= 400 && resp.StatusCode <= 600 { - json.PrintJson(string(resBody)) - return "", fmt.Errorf(resp.Status) - } - - // 204 is no content, so we will skip it. - if resp.StatusCode != 204 { - if !skipAliases { - aliases.SaveAliasesForResources(string(resBody)) - } - } - - if aliasName != "" { - aliases.SetAliasForResource(string(resBody), aliasName) - } - - return string(resBody), nil - } else { - return "", nil - } - -} diff --git a/cmd/delete.go b/cmd/delete.go index eaafd5e..d42aced 100644 --- a/cmd/delete.go +++ b/cmd/delete.go @@ -8,13 +8,9 @@ import ( "github.com/elasticpath/epcc-cli/external/httpclient" "github.com/elasticpath/epcc-cli/external/json" "github.com/elasticpath/epcc-cli/external/resources" - "github.com/elasticpath/epcc-cli/external/shutdown" + "github.com/elasticpath/epcc-cli/external/rest" log "github.com/sirupsen/logrus" "github.com/spf13/cobra" - "io" - "net/http" - "net/url" - "strings" ) func NewDeleteCommand(parentCmd *cobra.Command) func() { @@ -94,7 +90,7 @@ func NewDeleteCommand(parentCmd *cobra.Command) func() { } } - body, err := deleteInternal(context.Background(), overrides, allow404, append([]string{resourceName}, args...)) + body, err := rest.DeleteInternal(context.Background(), overrides, allow404, append([]string{resourceName}, args...)) if err != nil { if body != "" { @@ -193,111 +189,3 @@ func NewDeleteCommand(parentCmd *cobra.Command) func() { return resetFunc } -func deleteInternal(ctx context.Context, overrides *httpclient.HttpParameterOverrides, allow404 bool, args []string) (string, error) { - shutdown.OutstandingOpCounter.Add(1) - defer shutdown.OutstandingOpCounter.Done() - - resource, ok := resources.GetResourceByName(args[0]) - if !ok { - return "", fmt.Errorf("could not find resource %s", args[0]) - } - - resp, err := deleteResource(ctx, overrides, args) - if err != nil { - return "", err - } - - if resp == nil { - return "", fmt.Errorf("got nil response") - } - - if resp.StatusCode < 400 { - idToDelete := aliases.ResolveAliasValuesOrReturnIdentity(resource.JsonApiType, resource.AlternateJsonApiTypesForAliases, args[len(args)-1], "id") - aliases.DeleteAliasesById(idToDelete, resource.JsonApiType) - } - if resp.Body != nil { - - defer resp.Body.Close() - - // Print the body - body, err := io.ReadAll(resp.Body) - - if err != nil { - log.Fatal(err) - } - - // Check if error response - if resp.StatusCode >= 400 && resp.StatusCode <= 600 { - if resp.StatusCode != 404 || !allow404 { - return string(body), fmt.Errorf(resp.Status) - } - } - - return string(body), nil - } else { - return "", nil - } - -} - -func deleteResource(ctx context.Context, overrides *httpclient.HttpParameterOverrides, args []string) (*http.Response, error) { - // Find Resource - resource, ok := resources.GetResourceByName(args[0]) - if !ok { - return nil, fmt.Errorf("could not find resource %s", args[0]) - } - - if resource.DeleteEntityInfo == nil { - return nil, fmt.Errorf("resource %s doesn't support DELETE", args[0]) - } - - // Replace ids with args in resourceURL - resourceURL, err := resources.GenerateUrl(resource.DeleteEntityInfo, args[1:], true) - - if err != nil { - return nil, err - } - - if overrides.OverrideUrlPath != "" { - log.Warnf("Overriding URL Path from %s to %s", resourceURL, overrides.OverrideUrlPath) - resourceURL = overrides.OverrideUrlPath - } - - params := url.Values{} - - for _, v := range overrides.QueryParameters { - keyAndValue := strings.SplitN(v, "=", 2) - if len(keyAndValue) != 2 { - return nil, fmt.Errorf("Could not parse query parameter %v, all query parameters should be a key and value format", keyAndValue) - } - params.Add(keyAndValue[0], keyAndValue[1]) - } - - idCount, err := resources.GetNumberOfVariablesNeeded(resource.DeleteEntityInfo.Url) - - if !resource.NoWrapping { - args = append(args, "type", resource.JsonApiType) - } - // Create the body from remaining args - - jsonArgs := args[(idCount + 1):] - - var payload io.Reader = nil - if len(jsonArgs) > 0 { - body, err := json.ToJson(jsonArgs, resource.NoWrapping, resource.JsonApiFormat == "compliant", resource.Attributes, true) - - if err != nil { - return nil, err - } - - payload = strings.NewReader(body) - } - - // Submit request - resp, err := httpclient.DoRequest(ctx, "DELETE", resourceURL, params.Encode(), payload) - if err != nil { - return nil, fmt.Errorf("got error %s", err.Error()) - } - - return resp, nil -} diff --git a/cmd/get.go b/cmd/get.go index d39d9e8..73e50fc 100644 --- a/cmd/get.go +++ b/cmd/get.go @@ -9,13 +9,9 @@ import ( "github.com/elasticpath/epcc-cli/external/httpclient" "github.com/elasticpath/epcc-cli/external/json" "github.com/elasticpath/epcc-cli/external/resources" - "github.com/elasticpath/epcc-cli/external/shutdown" + "github.com/elasticpath/epcc-cli/external/rest" log "github.com/sirupsen/logrus" "github.com/spf13/cobra" - "io" - "net/http" - "net/url" - "strings" "time" ) @@ -149,7 +145,7 @@ func NewGetCommand(parentCmd *cobra.Command) func() { retriesFailedError := fmt.Errorf("Maximum number of retries hit %d and condition [%s] always true", retryWhileJQMaxAttempts, retryWhileJQ) for attempt := uint16(0); attempt < retryWhileJQMaxAttempts; attempt++ { - body, err = getInternal(context.Background(), overrides, append([]string{resourceName}, args...), skipAliases) + body, err = rest.GetInternal(context.Background(), overrides, append([]string{resourceName}, args...), skipAliases) if retryWhileJQ == "" { retriesFailedError = nil break @@ -305,117 +301,3 @@ func NewGetCommand(parentCmd *cobra.Command) func() { return resetFunc } - -func getInternal(ctx context.Context, overrides *httpclient.HttpParameterOverrides, args []string, skipAliases bool) (string, error) { - resp, err := getResource(ctx, overrides, args) - - if err != nil { - return "", err - } else if resp == nil { - return "", fmt.Errorf("got nil response") - } - - if resp.Body != nil { - defer resp.Body.Close() - - // Print the body - body, err := io.ReadAll(resp.Body) - if err != nil { - log.Fatal(err) - } - - // Check if error response - if resp.StatusCode >= 400 && resp.StatusCode <= 600 { - json.PrintJson(string(body)) - return "", fmt.Errorf(resp.Status) - } - - if !skipAliases { - aliases.SaveAliasesForResources(string(body)) - } - - return string(body), nil - } else { - return "", nil - } -} - -func getUrl(resource resources.Resource, args []string) (*resources.CrudEntityInfo, error) { - - if resource.GetCollectionInfo == nil && resource.GetEntityInfo == nil { - return nil, fmt.Errorf("resource %s doesn't support GET", args[0]) - } else if resource.GetCollectionInfo != nil && resource.GetEntityInfo == nil { - return resource.GetCollectionInfo, nil - } else if resource.GetCollectionInfo == nil && resource.GetEntityInfo != nil { - return resource.GetEntityInfo, nil - } else { - if _, ok := resources.GetPluralResources()[args[0]]; ok { - return resource.GetCollectionInfo, nil - } else { - return resource.GetEntityInfo, nil - } - } -} - -func getResource(ctx context.Context, overrides *httpclient.HttpParameterOverrides, args []string) (*http.Response, error) { - shutdown.OutstandingOpCounter.Add(1) - defer shutdown.OutstandingOpCounter.Done() - - // Find Resource - resource, ok := resources.GetResourceByName(args[0]) - if !ok { - return nil, fmt.Errorf("could not find resource %s", args[0]) - } - - var idCount int - - resourceUrlInfo, err2 := getUrl(resource, args) - if err2 != nil { - return nil, err2 - } - - idCount, err := resources.GetNumberOfVariablesNeeded(resourceUrlInfo.Url) - - if err != nil { - return nil, err - } - - // Replace ids with args in resourceURL - resourceURL, err := resources.GenerateUrl(resourceUrlInfo, args[1:], true) - - if err != nil { - return nil, err - } - - if overrides.OverrideUrlPath != "" { - log.Warnf("Overriding URL Path from %s to %s", resourceURL, overrides.OverrideUrlPath) - resourceURL = overrides.OverrideUrlPath - } - - // Add remaining args as query params - params := url.Values{} - for i := idCount + 1; i+1 < len(args); i = i + 2 { - params.Add(args[i], args[i+1]) - } - - if (idCount-len(args)+1)%2 != 0 { - log.Warnf("Extra argument at the end of the command %s", args[len(args)-1]) - } - - for _, v := range overrides.QueryParameters { - keyAndValue := strings.SplitN(v, "=", 2) - if len(keyAndValue) != 2 { - return nil, fmt.Errorf("Could not parse query parameter %v, all query parameters should be a key and value format", keyAndValue) - } - params.Add(keyAndValue[0], keyAndValue[1]) - } - - // Submit request - resp, err := httpclient.DoRequest(ctx, "GET", resourceURL, params.Encode(), nil) - - if err != nil { - return nil, fmt.Errorf("got error %s", err.Error()) - } - - return resp, nil -} diff --git a/cmd/login.go b/cmd/login.go index 4dd30f4..34eee5f 100644 --- a/cmd/login.go +++ b/cmd/login.go @@ -12,7 +12,9 @@ import ( "github.com/elasticpath/epcc-cli/external/headergroups" "github.com/elasticpath/epcc-cli/external/httpclient" "github.com/elasticpath/epcc-cli/external/json" + "github.com/elasticpath/epcc-cli/external/oidc" "github.com/elasticpath/epcc-cli/external/resources" + "github.com/elasticpath/epcc-cli/external/rest" log "github.com/sirupsen/logrus" "github.com/spf13/cobra" "net/url" @@ -251,38 +253,7 @@ var loginImplicit = &cobra.Command{ }, RunE: func(cmd *cobra.Command, args []string) error { - - values := url.Values{} - values.Set("grant_type", "implicit") - - env := config.GetEnv() - if len(args) == 0 { - log.Debug("Arguments have been passed, not using profile EPCC_CLIENT_ID") - values.Set("client_id", env.EPCC_CLIENT_ID) - } - - if len(args)%2 != 0 { - return fmt.Errorf("invalid number of arguments supplied to login command, must be multiple of 2, not %v", len(args)) - } - - for i := 0; i < len(args); i += 2 { - k := args[i] - values.Set(k, args[i+1]) - } - - token, err := authentication.GetAuthenticationToken(false, &values, true) - - if err != nil { - return err - } - - if token != nil { - log.Infof("Successfully authenticated with implicit token, session expires %s", time.Unix(token.Expires, 0).Format(time.RFC1123Z)) - } else { - log.Warn("Did not successfully authenticate against the API") - } - - return nil + return authentication.InternalImplicitAuthentication(args) }, } @@ -330,7 +301,7 @@ var loginCustomer = &cobra.Command{ newArgs = append(newArgs, "customer-token") newArgs = append(newArgs, args...) - body, err := createInternal(ctx, overrides, newArgs, false, "", false) + body, err := rest.CreateInternal(ctx, overrides, newArgs, false, "", false) if err != nil { log.Warnf("Login not completed successfully") @@ -360,7 +331,7 @@ var loginCustomer = &cobra.Command{ if customerTokenResponse != nil { // Get the customer so we have aliases where we need the id. - getCustomerBody, err := getInternal(ctx, overrides, []string{"customer", customerTokenResponse.Data.CustomerId}, false) + getCustomerBody, err := rest.GetInternal(ctx, overrides, []string{"customer", customerTokenResponse.Data.CustomerId}, false) if err != nil { log.Warnf("Could not retrieve customer") @@ -460,7 +431,7 @@ var loginAccountManagement = &cobra.Command{ } // Populate an alias to get the authentication_realm. - _, err := getInternal(ctx, overrides, []string{"account-authentication-settings"}, false) + _, err := rest.GetInternal(ctx, overrides, []string{"account-authentication-settings"}, false) if err != nil { return fmt.Errorf("couldn't determine authentication realm: %w", err) @@ -485,7 +456,7 @@ var loginAccountManagement = &cobra.Command{ // Try and auto-detect the password profile id if passwordAuthentication { - resp, err := getInternal(ctx, overrides, []string{"password-profiles", "related_authentication_realm_for_account_authentication_settings_last_read=entity"}, false) + resp, err := rest.GetInternal(ctx, overrides, []string{"password-profiles", "related_authentication_realm_for_account_authentication_settings_last_read=entity"}, false) if err != nil { return fmt.Errorf("couldn't determine password profile: %w", err) @@ -540,7 +511,7 @@ var loginAccountManagement = &cobra.Command{ } // Do the login and get back a list of accounts - body, err := createInternal(ctx, overrides, loginArgs, false, "", false) + body, err := rest.CreateInternal(ctx, overrides, loginArgs, false, "", false) if err != nil { log.Warnf("Login not completed successfully") @@ -600,7 +571,7 @@ var loginAccountManagement = &cobra.Command{ authentication.SaveAccountManagementAuthenticationToken(*selectedAccount) - accountMembers, err := getInternal(ctx, overrides, []string{"account-members"}, false) + accountMembers, err := rest.GetInternal(ctx, overrides, []string{"account-members"}, false) if err == nil { accountMemberId, _ := json.RunJQOnString(".data[0].id", accountMembers) @@ -612,3 +583,13 @@ var loginAccountManagement = &cobra.Command{ return json.PrintJson(string(jsonBody)) }, } + +var OidcPort uint16 = 8080 +var loginOidc = &cobra.Command{ + Use: "oidc", + Short: "Starts a local webserver to facilitate OIDC login flows", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + return oidc.StartOIDCServer(OidcPort) + }, +} diff --git a/cmd/reset-store.go b/cmd/reset-store.go index 1cfcecf..cac506e 100644 --- a/cmd/reset-store.go +++ b/cmd/reset-store.go @@ -9,6 +9,7 @@ import ( "github.com/elasticpath/epcc-cli/external/httpclient" "github.com/elasticpath/epcc-cli/external/json" "github.com/elasticpath/epcc-cli/external/resources" + "github.com/elasticpath/epcc-cli/external/rest" log "github.com/sirupsen/logrus" "github.com/spf13/cobra" "io" @@ -70,25 +71,25 @@ var ResetStore = &cobra.Command{ // We would also need locking to go faster. // Get customer and account authentication settings to populate the aliases - _, err = getInternal(ctx, overrides, []string{"customer-authentication-settings"}, false) + _, err = rest.GetInternal(ctx, overrides, []string{"customer-authentication-settings"}, false) if err != nil { errors = append(errors, err.Error()) } - _, err = getInternal(ctx, overrides, []string{"account-authentication-settings"}, false) + _, err = rest.GetInternal(ctx, overrides, []string{"account-authentication-settings"}, false) if err != nil { errors = append(errors, err.Error()) } - _, err = getInternal(ctx, overrides, []string{"merchant-realm-mappings"}, false) + _, err = rest.GetInternal(ctx, overrides, []string{"merchant-realm-mappings"}, false) if err != nil { errors = append(errors, err.Error()) } - _, err = getInternal(ctx, overrides, []string{"authentication-realms"}, false) + _, err = rest.GetInternal(ctx, overrides, []string{"authentication-realms"}, false) if err != nil { errors = append(errors, err.Error()) @@ -200,7 +201,7 @@ func resetResourcesUndeletableResources(ctx context.Context, overrides *httpclie errors := make([]string, 0) for _, resetCmd := range resetCmds { - body, err := updateInternal(ctx, overrides, false, resetCmd) + body, err := rest.UpdateInternal(ctx, overrides, false, resetCmd) if err != nil { errors = append(errors, fmt.Errorf("error resetting %s: %v", resetCmd[0], err).Error()) diff --git a/cmd/root.go b/cmd/root.go index e029948..9ce3fba 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -144,7 +144,9 @@ func InitializeCmd() { LoginCmd.AddCommand(loginDocs) LoginCmd.AddCommand(loginCustomer) LoginCmd.AddCommand(loginAccountManagement) + LoginCmd.AddCommand(loginOidc) + loginOidc.PersistentFlags().Uint16VarP(&OidcPort, "port", "p", 8080, "The port to listen on for the OIDC callback") logoutCmd.AddCommand(logoutBearer) logoutCmd.AddCommand(logoutCustomer) logoutCmd.AddCommand(logoutAccountManagement) diff --git a/cmd/update.go b/cmd/update.go index f09e6ca..e617983 100644 --- a/cmd/update.go +++ b/cmd/update.go @@ -9,12 +9,9 @@ import ( "github.com/elasticpath/epcc-cli/external/httpclient" "github.com/elasticpath/epcc-cli/external/json" "github.com/elasticpath/epcc-cli/external/resources" - "github.com/elasticpath/epcc-cli/external/shutdown" + "github.com/elasticpath/epcc-cli/external/rest" log "github.com/sirupsen/logrus" "github.com/spf13/cobra" - "io" - "net/url" - "strings" ) func NewUpdateCommand(parentCmd *cobra.Command) func() { @@ -89,13 +86,13 @@ func NewUpdateCommand(parentCmd *cobra.Command) func() { aliasId := aliases.ResolveAliasValuesOrReturnIdentity(resource.JsonApiType, resource.AlternateJsonApiTypesForAliases, ifAliasDoesNotExist, "id") if aliasId != ifAliasDoesNotExist { - // If the aliasId is different than the request then it does exist. + // If the aliasId is different from the request then it does exist. log.Infof("Alias [%s] does exist (value: %s), not continuing run", ifAliasDoesNotExist, aliasId) return nil } } - body, err := updateInternal(context.Background(), overrides, skipAliases, append([]string{resourceName}, args...)) + body, err := rest.UpdateInternal(context.Background(), overrides, skipAliases, append([]string{resourceName}, args...)) if err != nil { return err @@ -213,88 +210,3 @@ func NewUpdateCommand(parentCmd *cobra.Command) func() { return resetFunc } - -func updateInternal(ctx context.Context, overrides *httpclient.HttpParameterOverrides, skipAliases bool, args []string) (string, error) { - shutdown.OutstandingOpCounter.Add(1) - defer shutdown.OutstandingOpCounter.Done() - - // Find Resource - resource, ok := resources.GetResourceByName(args[0]) - if !ok { - return "", fmt.Errorf("could not find resource %s", args[0]) - } - - if resource.UpdateEntityInfo == nil { - return "", fmt.Errorf("resource %s doesn't support UPDATE", args[0]) - } - - // Count ids in UpdateEntity - resourceUrlInfo := resource.UpdateEntityInfo - idCount, err := resources.GetNumberOfVariablesNeeded(resourceUrlInfo.Url) - if err != nil { - return "", err - } - - // Replace ids with args in resourceURL - resourceURL, err := resources.GenerateUrl(resourceUrlInfo, args[1:], true) - if err != nil { - return "", err - } - - if overrides.OverrideUrlPath != "" { - log.Warnf("Overriding URL Path from %s to %s", resourceURL, overrides.OverrideUrlPath) - resourceURL = overrides.OverrideUrlPath - } - - args = append(args, "type", resource.JsonApiType) - // Create the body from remaining args - body, err := json.ToJson(args[(idCount+1):], resource.NoWrapping, resource.JsonApiFormat == "compliant", resource.Attributes, true) - if err != nil { - return "", err - } - - params := url.Values{} - - for _, v := range overrides.QueryParameters { - keyAndValue := strings.SplitN(v, "=", 2) - if len(keyAndValue) != 2 { - return "", fmt.Errorf("Could not parse query parameter %v, all query parameters should be a key and value format", keyAndValue) - } - params.Add(keyAndValue[0], keyAndValue[1]) - } - - // Submit request - resp, err := httpclient.DoRequest(ctx, "PUT", resourceURL, params.Encode(), strings.NewReader(body)) - if err != nil { - return "", fmt.Errorf("got error %s", err.Error()) - } else if resp == nil { - return "", fmt.Errorf("got nil response") - } - - if resp.Body != nil { - defer resp.Body.Close() - - // Print the body - resBody, err := io.ReadAll(resp.Body) - if err != nil { - log.Fatal(err) - } - - // Check if error response - if resp.StatusCode >= 400 && resp.StatusCode <= 600 { - json.PrintJson(string(resBody)) - return "", fmt.Errorf(resp.Status) - } - - // 204 is no content, so we will skip it. - if resp.StatusCode != 204 { - if !skipAliases { - aliases.SaveAliasesForResources(string(resBody)) - } - } - - return string(resBody), nil - } else { - return "", nil - } -} diff --git a/external/authentication/internal_login.go b/external/authentication/internal_login.go new file mode 100644 index 0000000..7ea805e --- /dev/null +++ b/external/authentication/internal_login.go @@ -0,0 +1,43 @@ +package authentication + +import ( + "fmt" + "github.com/elasticpath/epcc-cli/config" + log "github.com/sirupsen/logrus" + "net/url" + "time" +) + +func InternalImplicitAuthentication(args []string) error { + values := url.Values{} + values.Set("grant_type", "implicit") + + env := config.GetEnv() + if len(args) == 0 { + log.Debug("Arguments have been passed, not using profile EPCC_CLIENT_ID") + values.Set("client_id", env.EPCC_CLIENT_ID) + } + + if len(args)%2 != 0 { + return fmt.Errorf("invalid number of arguments supplied to login command, must be multiple of 2, not %v", len(args)) + } + + for i := 0; i < len(args); i += 2 { + k := args[i] + values.Set(k, args[i+1]) + } + + token, err := GetAuthenticationToken(false, &values, true) + + if err != nil { + return err + } + + if token != nil { + log.Infof("Successfully authenticated with implicit token, session expires %s", time.Unix(token.Expires, 0).Format(time.RFC1123Z)) + } else { + log.Warn("Did not successfully authenticate against the API") + } + + return nil +} diff --git a/external/json/to_json.go b/external/json/to_json.go index 41422bf..cf28fcb 100644 --- a/external/json/to_json.go +++ b/external/json/to_json.go @@ -7,6 +7,7 @@ import ( "github.com/elasticpath/epcc-cli/external/resources" "github.com/elasticpath/epcc-cli/external/templates" "github.com/itchyny/gojq" + "github.com/mitchellh/mapstructure" log "github.com/sirupsen/logrus" "regexp" "strings" @@ -192,6 +193,30 @@ func RunJQOnString(queryStr string, json string) (interface{}, error) { return RunJQ(queryStr, obj) } +func RunJQOnStringAndGetString(queryStr string, json string) (string, error) { + result, err := RunJQOnString(queryStr, json) + + if err != nil { + return "", err + } + + if result, ok := result.(string); ok { + return result, nil + } + + return "", fmt.Errorf("could not convert %T into string", result) +} + +func RunJQOnStringAndMarshalResponse(queryStr string, json string, obj any) error { + result, err := RunJQOnString(queryStr, json) + + if err != nil { + return err + } + + return mapstructure.Decode(result, &obj) +} + func RunJQ(queryStr string, result interface{}) (interface{}, error) { query, err := gojq.Parse(queryStr) diff --git a/external/oidc/callback.go b/external/oidc/callback.go new file mode 100644 index 0000000..d0d9bdc --- /dev/null +++ b/external/oidc/callback.go @@ -0,0 +1,138 @@ +package oidc + +import ( + "context" + "encoding/base64" + gojson "encoding/json" + "fmt" + "github.com/elasticpath/epcc-cli/external/authentication" + "github.com/elasticpath/epcc-cli/external/httpclient" + "github.com/elasticpath/epcc-cli/external/rest" + "net/http" +) + +type CallbackPageInfo struct { + LoginType string + ErrorTitle string + ErrorDescription string + AccountTokenResponse *authentication.AccountManagementAuthenticationTokenResponse + CustomerTokenResponse *authentication.CustomerTokenResponse + AccountTokenStructBase64 []string + CustomerTokenStructBase64 string +} + +func GetCallbackData(ctx context.Context, port uint16, r *http.Request) (*CallbackPageInfo, error) { + // Parse the query parameters + queryParams := r.URL.Query() + + // Convert query parameters to a map + data := make(map[string]string) + for key, values := range queryParams { + data[key] = values[0] // Use the first value if multiple are provided + } + + data["uri"] = fmt.Sprintf("http://localhost:%d", port) + + if data["code"] != "" { + + state, err := r.Cookie("state") + + if err != nil { + return nil, fmt.Errorf("could not get state cookie: %w", err) + } + + verifier, err := r.Cookie("code_verifier") + + if err != nil { + return nil, fmt.Errorf("could not get verifier cookie: %w", err) + } + + login_type, err := r.Cookie("login_type") + + if err != nil { + return nil, fmt.Errorf("could not get login_type cookie: %w", err) + } + + if data["state"] != state.Value { + return &CallbackPageInfo{ + ErrorTitle: "State Mismatch", + ErrorDescription: "State mismatch between locally stored value and value from IdP", + LoginType: login_type.Value, + }, nil + } + + cpi := CallbackPageInfo{ + LoginType: login_type.Value, + } + + if login_type.Value == "AM" { + result, err := rest.CreateInternal(context.Background(), &httpclient.HttpParameterOverrides{}, []string{"account-management-authentication-token", + "authentication_mechanism", "oidc", + "oauth_authorization_code", data["code"], + "oauth_redirect_uri", fmt.Sprintf("http://localhost:%d/callback", port), + "oauth_code_verifier", verifier.Value, + }, false, "", true) + + if err != nil { + return nil, fmt.Errorf("could not get account tokens: %w", err) + } + + err = gojson.Unmarshal([]byte(result), &cpi.AccountTokenResponse) + + if err != nil { + return nil, fmt.Errorf("could not unmarshal response: %w", err) + } + + for _, v := range cpi.AccountTokenResponse.Data { + + str, err := gojson.Marshal(v) + + if err != nil { + return nil, fmt.Errorf("could not encode token: %w", err) + } + + cpi.AccountTokenStructBase64 = append(cpi.AccountTokenStructBase64, base64.URLEncoding.EncodeToString(str)) + } + + return &cpi, nil + } else if login_type.Value == "Customers" { + result, err := rest.CreateInternal(context.Background(), &httpclient.HttpParameterOverrides{}, []string{"customer-token", + "authentication_mechanism", "oidc", + "oauth_authorization_code", data["code"], + "oauth_redirect_uri", fmt.Sprintf("http://localhost:%d/callback", port), + "oauth_code_verifier", verifier.Value, + }, false, "", true) + + if err != nil { + return nil, fmt.Errorf("could not get customer tokens: %w", err) + } + + err = gojson.Unmarshal([]byte(result), &cpi.CustomerTokenResponse) + + str, err := gojson.Marshal(cpi.CustomerTokenResponse.Data) + + if err != nil { + return nil, fmt.Errorf("could not encode token: %w", err) + } + + cpi.CustomerTokenStructBase64 = base64.URLEncoding.EncodeToString(str) + return &cpi, nil + } else { + return &CallbackPageInfo{ + ErrorTitle: "Unknown Login Type", + ErrorDescription: fmt.Sprintf("Unsupported login type used: %v", login_type.Value), + }, nil + } + } else if data["error"] == "" { + + return &CallbackPageInfo{ + ErrorTitle: "Bad Response", + ErrorDescription: "Invalid response from IdP, no code or error query parameter", + }, nil + } else { + return &CallbackPageInfo{ + ErrorTitle: data["error"], + ErrorDescription: data["error_description"], + }, nil + } +} diff --git a/external/oidc/get_token.go b/external/oidc/get_token.go new file mode 100644 index 0000000..f91a085 --- /dev/null +++ b/external/oidc/get_token.go @@ -0,0 +1,155 @@ +package oidc + +import ( + "context" + "encoding/base64" + gojson "encoding/json" + "fmt" + "github.com/elasticpath/epcc-cli/external/authentication" + "github.com/elasticpath/epcc-cli/external/httpclient" + "github.com/elasticpath/epcc-cli/external/json" + "github.com/elasticpath/epcc-cli/external/rest" + log "github.com/sirupsen/logrus" + "net/http" + "os" + "time" +) + +type TokenPageInfo struct { + LoginType string + ErrorTitle string + ErrorDescription string + Name string + Id string +} + +func GetTokenData(ctx context.Context, port uint16, r *http.Request) (*TokenPageInfo, error) { + // Parse the query parameters + queryParams := r.URL.Query() + + // Convert query parameters to a map + data := make(map[string]string) + for key, values := range queryParams { + data[key] = values[0] // Use the first value if multiple are provided + } + + if data["login_type"] == "AM" { + token := data["token"] + amTokenJson, err := base64.URLEncoding.DecodeString(token) + + if err != nil { + return nil, fmt.Errorf("could not get decode am token: %w", err) + } + + amToken := authentication.AccountManagementAuthenticationTokenStruct{} + + err = gojson.Unmarshal(amTokenJson, &amToken) + + if err != nil { + return nil, fmt.Errorf("could not get unmarshal am token: %w", err) + } + + authentication.SaveAccountManagementAuthenticationToken(amToken) + + apiToken := authentication.GetApiToken() + + if apiToken != nil { + if apiToken.Identifier == "client_credentials" { + log.Warnf("You are currently logged in with client_credentials, please switch to implicit with `epcc login implicit` to use the account management token correctly. Mixing client_credentials and the account management token can lead to unintended results.") + } + } + + go func() { + time.Sleep(2 * time.Second) + log.Infof("Authentication complete, shutting down") + os.Exit(0) + }() + + return &TokenPageInfo{ + LoginType: "AM", + Name: amToken.AccountName, + Id: amToken.AccountId, + }, nil + + } else if data["login_type"] == "Customers" { + token := data["token"] + custToken, err := base64.URLEncoding.DecodeString(token) + + if err != nil { + return nil, fmt.Errorf("could not get decode am token: %w", err) + } + + custTokenStruct := authentication.CustomerTokenStruct{} + + err = gojson.Unmarshal(custToken, &custTokenStruct) + + if err != nil { + return nil, fmt.Errorf("could not get unmarshal am token: %w", err) + } + + ctr := authentication.CustomerTokenResponse{ + Data: custTokenStruct, + AdditionalInfo: authentication.CustomerTokenEpccCliAdditionalInfo{}, + } + + authentication.SaveCustomerToken(ctr) + + apiToken := authentication.GetApiToken() + + if apiToken != nil { + if apiToken.Identifier == "client_credentials" { + log.Warnf("You are currently logged in with client_credentials, please switch to implicit with `epcc login implicit` to use the customer token correctly. Mixing client_credentials and the customer token can lead to unintended results.") + } + } + + if authentication.IsAccountManagementAuthenticationTokenSet() { + log.Warnf("Logging out of Account Management") + authentication.ClearAccountManagementAuthenticationToken() + } + + result, err := rest.GetInternal(context.Background(), &httpclient.HttpParameterOverrides{}, []string{"customer", custTokenStruct.CustomerId}, false) + + customerName := "Unknown" + customerEmail := "Unkwown" + + if err == nil { + customerName, err = json.RunJQOnStringAndGetString(".data.name", result) + + if err != nil { + log.Warnf("Could not get customer name from response %s, %v", result, err) + } + + customerEmail, err = json.RunJQOnStringAndGetString(".data.email", result) + + if err != nil { + log.Warnf("Could not get customer email from response %s, %v", result, err) + } + + ctr := authentication.CustomerTokenResponse{ + Data: custTokenStruct, + AdditionalInfo: authentication.CustomerTokenEpccCliAdditionalInfo{ + CustomerName: customerName, + CustomerEmail: customerEmail, + }, + } + + log.Infof("Saving customer token with %s,%s, %v", customerName, customerEmail, result) + authentication.SaveCustomerToken(ctr) + } + + go func() { + time.Sleep(2 * time.Second) + log.Infof("Authentication complete, shutting down") + os.Exit(0) + }() + + return &TokenPageInfo{ + LoginType: "Customers", + Name: customerName, + Id: custTokenStruct.CustomerId, + }, nil + + } + + return nil, fmt.Errorf("invalid login type") +} diff --git a/external/oidc/index.go b/external/oidc/index.go new file mode 100644 index 0000000..4551f21 --- /dev/null +++ b/external/oidc/index.go @@ -0,0 +1,160 @@ +package oidc + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "fmt" + "github.com/elasticpath/epcc-cli/external/httpclient" + "github.com/elasticpath/epcc-cli/external/json" + "github.com/elasticpath/epcc-cli/external/rest" + "github.com/google/uuid" + "github.com/mitchellh/mapstructure" + log "github.com/sirupsen/logrus" +) + +type LoginPageInfo struct { + CustomerProfiles []OidcProfileInfo + CustomerClientId string + AccountProfiles []OidcProfileInfo + AccountClientId string + RedirectUriUnencoded string + RedirectUriEncoded string + State string + CodeVerifier string + CodeChallenge string +} + +func GetIndexData(ctx context.Context, port uint16) (*LoginPageInfo, error) { + + overrides := &httpclient.HttpParameterOverrides{ + QueryParameters: nil, + OverrideUrlPath: "", + } + + // Get customer and account authentication settings to populate the aliases + customerAuthSettings, err := rest.GetInternal(ctx, overrides, []string{"customer-authentication-settings"}, false) + + if err != nil { + return nil, fmt.Errorf("could not retrieve customer authentication settings: %w", err) + } + + accountAuthSettings, err := rest.GetInternal(ctx, overrides, []string{"account-authentication-settings"}, false) + + if err != nil { + return nil, fmt.Errorf("could not retrieve account authentication settings: %w", err) + } + + customerRealmId, err := json.RunJQOnStringAndGetString(".data.relationships[\"authentication-realm\"].data.id", customerAuthSettings) + + if err != nil { + return nil, fmt.Errorf("could not determine customer realm id: %w", err) + } + + customerClientId, err := json.RunJQOnStringAndGetString(".data.meta.client_id", customerAuthSettings) + + if err != nil { + return nil, fmt.Errorf("could not determine customer client id: %w", err) + } + + accountRealmId, err := json.RunJQOnStringAndGetString(".data.relationships.authentication_realm.data.id", accountAuthSettings) + + if err != nil { + return nil, fmt.Errorf("could not determine account realm id: %w", err) + } + + accountClientId, err := json.RunJQOnStringAndGetString(".data.meta.client_id", accountAuthSettings) + + if err != nil { + return nil, fmt.Errorf("could not determine account client id: %w", err) + } + + customerProfiles, err := getOidcProfilesForRealm(ctx, overrides, customerRealmId) + + if err != nil { + return nil, fmt.Errorf("could not get oidc profiles for customers: %w", err) + } + + accountProfiles, err := getOidcProfilesForRealm(ctx, overrides, accountRealmId) + + if err != nil { + return nil, fmt.Errorf("could not get oidc profiles for customers: %w", err) + } + + verifier, err := GenerateCodeVerifier() + + if err != nil { + return nil, fmt.Errorf("could not get code verifier: %w", err) + } + + challenge, err := GenerateCodeChallenge(verifier) + + if err != nil { + return nil, fmt.Errorf("could not get code challenge: %w", err) + } + + profiles := LoginPageInfo{ + CustomerProfiles: customerProfiles, + CustomerClientId: customerClientId, + AccountProfiles: accountProfiles, + AccountClientId: accountClientId, + State: uuid.New().String(), + RedirectUriEncoded: fmt.Sprintf("%s%d%s", "http%3A%2F%2Flocalhost%3A", port, "/callback"), + RedirectUriUnencoded: fmt.Sprintf("%s%d%s", "http://localhost:", port, "/callback"), + CodeVerifier: verifier, + CodeChallenge: challenge, + } + + return &profiles, nil +} + +func getOidcProfilesForRealm(ctx context.Context, overrides *httpclient.HttpParameterOverrides, realmId string) ([]OidcProfileInfo, error) { + res, err := rest.GetInternal(ctx, overrides, []string{"oidc-profiles", realmId}, false) + + if err != nil { + return nil, err + } + + resObj, err := json.RunJQOnString(".data | map({name: .name, authorization_link: .links[\"authorization-endpoint\"], idp: .meta.issuer})", res) + + if err != nil { + log.Errorf("Couldn't get oidc profile information: %v", err) + return nil, err + } + + var result []OidcProfileInfo + + err = mapstructure.Decode(resObj, &result) + + if err != nil { + log.Errorf("Couldn't convert into map: %v", err) + return nil, err + } + + return result, nil + +} + +// GenerateCodeVerifier generates a securely random code verifier. +func GenerateCodeVerifier() (string, error) { + // PKCE requires a code verifier between 43 and 128 characters. + // We'll generate 32 bytes of randomness, which Base64 encodes to 43 characters. + verifier := make([]byte, 32) + _, err := rand.Read(verifier) + if err != nil { + return "", fmt.Errorf("failed to generate random verifier: %w", err) + } + + // Base64 URL encode the random bytes + return base64.RawURLEncoding.EncodeToString(verifier), nil +} + +// GenerateCodeChallenge generates the S256 code challenge from the verifier. +func GenerateCodeChallenge(verifier string) (string, error) { + // Compute the SHA256 hash of the verifier + hash := sha256.Sum256([]byte(verifier)) + + // Base64 URL encode the hash + return base64.RawURLEncoding.EncodeToString(hash[:]), nil +} diff --git a/external/oidc/server.go b/external/oidc/server.go new file mode 100644 index 0000000..785d5fa --- /dev/null +++ b/external/oidc/server.go @@ -0,0 +1,114 @@ +package oidc + +import ( + "bytes" + "context" + "embed" + "fmt" + "github.com/elasticpath/epcc-cli/external/browser" + "path/filepath" + "time" + + log "github.com/sirupsen/logrus" + "net/http" + "strings" + "text/template" +) + +//go:embed site/* +var EmbedFS embed.FS + +func StartOIDCServer(port uint16) error { + + // Parse all templates at startup + var err error + templates, err := template.ParseFS(EmbedFS, "site/*.gohtml") + if err != nil { + panic(fmt.Sprintf("Error parsing templates: %v", err)) + } + + http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + + ctx := context.Background() + log.Tracef("Handling request %s %s", r.Method, r.URL.Path) + + // Remove leading "/" and sanitize the path + requestPath := strings.TrimPrefix(r.URL.Path, "/") + if requestPath == "" { + requestPath = "index" + } + + // Check if it's a .html request for template rendering + if filepath.Ext(requestPath) == "" { + + templateName := requestPath + ".gohtml" + + var data any + var error error + + switch requestPath { + case "index": + data, error = GetIndexData(ctx, port) + case "callback": + data, error = GetCallbackData(ctx, port, r) + case "get_token": + data, error = GetTokenData(ctx, port, r) + } + + if error != nil { + log.Errorf("Request %s %s => %s", r.Method, r.URL.Path, "500") + http.Error(w, fmt.Sprintf("Error getting data: %v", error), http.StatusInternalServerError) + return + } + + // Render the template by its base name + if tmpl := templates.Lookup(templateName); tmpl != nil { + if err := tmpl.Execute(w, data); err != nil { + log.Errorf("Request %s %s => %s", r.Method, r.URL.Path, "500") + http.Error(w, fmt.Sprintf("Error rendering template: %v", err), http.StatusInternalServerError) + } + + log.Warnf("Request %s %s => %s", r.Method, r.URL.Path, "200") + return + } + + log.Infof("Request %s %s => %s", r.Method, r.URL.Path, "404") + http.NotFound(w, r) + return + } + + file, err := EmbedFS.ReadFile("site/" + requestPath) + if err != nil { + log.Warnf("Request %s %s => %s", r.Method, r.URL.Path, "404") + http.NotFound(w, r) + return + } + + if filepath.Ext(requestPath) == ".css" { + w.Header().Set("Content-Type", "text/css") + } + + http.ServeContent(w, r, requestPath, time.Now(), bytes.NewReader(file)) + log.Infof("Request %s %s => %s", r.Method, r.URL.Path, "200") + }) + + log.Infof("Starting server on port %d", port) + + go func() { + log.Warnf("Waiting for server to start") + time.Sleep(1 * time.Second) + browser.OpenUrl(fmt.Sprintf("http://localhost:%d", port)) + + }() + + err = http.ListenAndServe(fmt.Sprintf(":%d", port), nil) + + return err + +} + +type OidcProfileInfo struct { + Name string `mapstructure:"name"` + AuthorizationLink string `mapstructure:"authorization_link"` + Idp string +} diff --git a/external/oidc/site/callback.gohtml b/external/oidc/site/callback.gohtml new file mode 100644 index 0000000..a698642 --- /dev/null +++ b/external/oidc/site/callback.gohtml @@ -0,0 +1,52 @@ +{{ template "header" }} + {{ if ne .ErrorTitle "" }} +

errorAn Error has Occurred

+

Error Type: {{ .ErrorTitle }}

+

Error Description: {{ .ErrorDescription }}

+ {{ else if eq .LoginType "AM" }} +

check_circle Authentication With Identity Provider Successful

+ + To finish the authentication process, you must select an account. If you see no accounts make sure that auto_create_account_for_account_members is enabled or manually create an account. + +
+ epcc update account-authentication-setting auto_create_account_for_account_members true +
+
+

Please Select An Account

+ + + + + + + + {{ range $i, $item := .AccountTokenResponse.Data }} + + + + + + {{ end }}
Account NameAccount IDAction
{{ $item.AccountName }}{{ $item.AccountId }}
+ {{ else if eq .LoginType "Customers" }} +

check_circle Authentication With Identity Provider Successful

+ To continue, press GO next to the Customer. + + + + + + + + + + +
Customer IDAction
{{ .CustomerTokenResponse.Data.Id }}
+ {{ end }} +
+
+
+
+
+
+ To return to start Click here. +{{ template "footer" }} \ No newline at end of file diff --git a/external/oidc/site/favicon.ico b/external/oidc/site/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..78d8c64fabf10ef91d606470b6464c9ecbd7f6fe GIT binary patch literal 1150 zcmdUuK@Ng25JkU6Np~IauZ#87~2~e>P++#(pAe&$n>Xw<~42F z0cP@N8D!kyItOw9dkL|``T8P& literal 0 HcmV?d00001 diff --git a/external/oidc/site/footer.gohtml b/external/oidc/site/footer.gohtml new file mode 100644 index 0000000..4c82922 --- /dev/null +++ b/external/oidc/site/footer.gohtml @@ -0,0 +1,5 @@ +{{ define "footer" }} + + + +{{ end }} \ No newline at end of file diff --git a/external/oidc/site/get_token.gohtml b/external/oidc/site/get_token.gohtml new file mode 100644 index 0000000..a4b67b9 --- /dev/null +++ b/external/oidc/site/get_token.gohtml @@ -0,0 +1,37 @@ +{{ template "header" }} + {{ if ne .ErrorTitle "" }} +

errorAn Error has Occurred

+

Error Type: {{ .ErrorTitle }}

+

Error Description: {{ .ErrorDescription }}

+ {{ else if eq .LoginType "AM" }} + +

check_circle Authentication Complete

+ You are now authenticated as: + + + + + + + + + +
Account NameAccount ID
{{ .Name }}{{ .Id }}
+

You may now close this window.

+ {{ else if eq .LoginType "Customers" }} + +

check_circle Authentication Complete

+ You are now authenticated as: + + + + + + + + + +
Customer NameCustomer ID
{{ .Name }}{{ .Id }}
+

You may now close this window.

+ {{ end }} +{{ template "footer" }} \ No newline at end of file diff --git a/external/oidc/site/header.gohtml b/external/oidc/site/header.gohtml new file mode 100644 index 0000000..3449908 --- /dev/null +++ b/external/oidc/site/header.gohtml @@ -0,0 +1,18 @@ +{{define "header"}} + + + + + + Top Banner + + + + + +
+ +

EPCC CLI OIDC Login

+
+
+{{ end }} \ No newline at end of file diff --git a/external/oidc/site/index.gohtml b/external/oidc/site/index.gohtml new file mode 100644 index 0000000..c8f8363 --- /dev/null +++ b/external/oidc/site/index.gohtml @@ -0,0 +1,123 @@ +{{ template "header" }} + +

Welcome

+ This utility allows you to test Single Sign-On (SSO) with Elastic Path Commerce Cloud. + + You can authenticate two ways: + + + +

Account Management

+ + In order to successfully authenticate with Account Management following the HOWTO Guide, you will need to + make the following changes to the store: +
    +
  1. Add {{ $.RedirectUriUnencoded }} to the Account Management Authentication Realm allowed redirect URIs. + + +
    +
    + epcc get account-authentication-settings
    + epcc get authentication-realm related_authentication_realm_for_account_authentication_settings_last_read=entity
    +
    +
    + After inspecting the list of redirect_uris, you can add a new one with the following syntax: + +
    +
    + epcc update authentication-realm related_authentication_realm_for_account_authentication_settings_last_read=entity redirect_uris[0] {{ $.RedirectUriUnencoded }}
    +
    +
    +
  2. +
  3. Add an OpenID Connect Profile that connects to the Identity Provider: + +
    +
    + epcc create oidc-profile related_authentication_realm_for_account_authentication_settings_last_read=entity name "EPCC CLI Test OIDC Profile" client_id my_client_id client_secret my_client_secret discovery_url my_discovery_url +
    +
    +
    +
  4. Refresh this page, you should then see each OpenID Connect Profile and can authenticate, by clicking the Login button after perhaps editing the URL. The URL will contain all the necessarily arguments.
  5. +
+

OpenID Connect Profiles

+ {{ range $i, $item := .AccountProfiles }} +

Profile: {{ $item.Name }} ( {{ $item.Idp }} )

+
+
+ {{ end }} + + +

Customers

+ + In order to successfully authenticate with Customers similar to the HOWTO Guide for Account Management, you will need to + make the following changes to the store: +
    +
  1. Add {{ $.RedirectUriUnencoded }} to the Buyer Organization allowed redirect URIs. + + +
    +
    + epcc get customer-authentication-settings
    + epcc get authentication-realm related_authentication-realm_for_customer-authentication-settings_last_read=entity
    +
    +
    + After inspecting the list of redirect_uris, you can add a new one with the following syntax: + +
    +
    + epcc update authentication-realm related_authentication-realm_for_customer-authentication-settings_last_read=entity redirect_uris[0] {{ $.RedirectUriUnencoded }}
    +
    +
    +
  2. +
  3. Add an OpenID Connect Profile that connects to the Identity Provider: + +
    +
    + epcc create oidc-profile related_authentication-realm_for_customer-authentication-settings_last_read=entity=entity name "EPCC CLI Test OIDC Profile" client_id my_client_id client_secret my_client_secret discovery_url my_discovery_url +
    +
    +
    +
  4. Refresh this page, you should then see each OpenID Connect Profile and can authenticate, by clicking the Login button after perhaps editing the URL. The URL will contain all the necessarily arguments.
  5. +
+ +

OpenID Connect Profiles

+ {{ range $i, $item := .CustomerProfiles }} +

Profile: {{ $item.Name }} ( {{ $item.Idp }} )

+
+
+ {{ end }} +{{ template "footer" }} \ No newline at end of file diff --git a/external/oidc/site/static/light.svg b/external/oidc/site/static/light.svg new file mode 100644 index 0000000..4552a0b --- /dev/null +++ b/external/oidc/site/static/light.svg @@ -0,0 +1,71 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/external/oidc/site/static/style.css b/external/oidc/site/static/style.css new file mode 100644 index 0000000..8fca85a --- /dev/null +++ b/external/oidc/site/static/style.css @@ -0,0 +1,76 @@ +/* General Reset */ +body { + margin: 0; + font-family: Arial, sans-serif; +} + + +/* Top Banner */ +.top-banner { + background-color: #61DEA6; + color: black; + height: 80px; + display: flex; + align-items: center; + justify-content: center; + text-align: center; + position: relative; + width: 100%; +} + + +.logo { + position: absolute; /* Keeps the image pinned to the left */ + left: 50px; /* Always stays 50px from the left edge of the page */ + top: 50%; /* Vertically centers relative to the header */ + transform: translateY(-50%); /* Adjusts for the height of the image */ +} + +/* Top Banner */ +.error-banner { + background-color: red; + color: black; + height: 80px; + display: flex; + align-items: center; + justify-content: center; + text-align: center; +} + +/* Top Banner */ +.success-banner { + background-color: greenyellow; + color: black; + height: 80px; + display: flex; + align-items: center; + justify-content: center; + text-align: center; +} + + +/* Main Content */ +main { + padding: 20px; +} + +.url-link { + width: 800px; +} + +table { + border-collapse: collapse; + + border: 2px solid black; /* Adds a bold border around the table */ +} + +.success { color: mediumseagreen; } + +.error { color: crimson; } + +table td, table th { + border: 2px solid black; /* Adds borders to each cell */ + min-width: 100px; /* Ensures a minimum width for all cells */ + padding: 5px; /* Optional, adds some padding inside cells */ + text-align: left; /* Optional, left-aligns text in cells */ +} diff --git a/external/rest/create.go b/external/rest/create.go new file mode 100644 index 0000000..576b65a --- /dev/null +++ b/external/rest/create.go @@ -0,0 +1,141 @@ +package rest + +import ( + "context" + "fmt" + "github.com/elasticpath/epcc-cli/external/aliases" + "github.com/elasticpath/epcc-cli/external/autofill" + "github.com/elasticpath/epcc-cli/external/encoding" + "github.com/elasticpath/epcc-cli/external/httpclient" + "github.com/elasticpath/epcc-cli/external/json" + "github.com/elasticpath/epcc-cli/external/resources" + "github.com/elasticpath/epcc-cli/external/shutdown" + log "github.com/sirupsen/logrus" + "io" + "net/http" + "net/url" + "strings" +) + +func CreateInternal(ctx context.Context, overrides *httpclient.HttpParameterOverrides, args []string, autoFillOnCreate bool, aliasName string, skipAliases bool) (string, error) { + shutdown.OutstandingOpCounter.Add(1) + defer shutdown.OutstandingOpCounter.Done() + + // Find Resource + resource, ok := resources.GetResourceByName(args[0]) + if !ok { + return "", fmt.Errorf("could not find resource %s", args[0]) + } + + if resource.CreateEntityInfo == nil { + return "", fmt.Errorf("resource %s doesn't support CREATE", args[0]) + } + + // Count ids in CreateEntity + resourceURL := resource.CreateEntityInfo.Url + + idCount, err := resources.GetNumberOfVariablesNeeded(resourceURL) + + if err != nil { + return "", err + } + + // Replace ids with args in resourceURL + resourceURL, err = resources.GenerateUrl(resource.CreateEntityInfo, args[1:], true) + + if overrides.OverrideUrlPath != "" { + log.Warnf("Overriding URL Path from %s to %s", resourceURL, overrides.OverrideUrlPath) + resourceURL = overrides.OverrideUrlPath + } + + if err != nil { + return "", err + } + + var resp *http.Response = nil + var resBody []byte + + if resource.CreateEntityInfo.ContentType == "multipart/form-data" { + + byteBuf, contentType, err := encoding.ToMultiPartEncoding(args[(idCount+1):], resource.NoWrapping, resource.JsonApiFormat == "complaint", resource.Attributes) + if err != nil { + return "", err + } + + // Submit request + resp, err = httpclient.DoFileRequest(ctx, resourceURL, byteBuf, contentType) + + } else { + // Assume it's application/json + + params := url.Values{} + + for _, v := range overrides.QueryParameters { + keyAndValue := strings.SplitN(v, "=", 2) + if len(keyAndValue) != 2 { + return "", fmt.Errorf("Could not parse query parameter %v, all query parameters should be a key and value format", keyAndValue) + } + params.Add(keyAndValue[0], keyAndValue[1]) + } + + if !resource.NoWrapping { + args = append(args, "type", resource.JsonApiType) + } + // Create the body from remaining args + + jsonArgs := args[(idCount + 1):] + if autoFillOnCreate { + autofilledData := autofill.GetJsonArrayForResource(&resource) + + jsonArgs = append(autofilledData, jsonArgs...) + } + + body, err := json.ToJson(jsonArgs, resource.NoWrapping, resource.JsonApiFormat == "compliant", resource.Attributes, true) + + if err != nil { + return "", err + } + + // Submit request + resp, err = httpclient.DoRequest(ctx, "POST", resourceURL, params.Encode(), strings.NewReader(body)) + + } + + if err != nil { + return "", fmt.Errorf("got error %s", err.Error()) + } else if resp == nil { + return "", fmt.Errorf("got nil response with request: %s", resourceURL) + } + + if resp.Body != nil { + defer resp.Body.Close() + + // Print the body + resBody, err = io.ReadAll(resp.Body) + if err != nil { + log.Fatal(err) + } + + // Check if error response + if resp.StatusCode >= 400 && resp.StatusCode <= 600 { + json.PrintJson(string(resBody)) + return "", fmt.Errorf(resp.Status) + } + + // 204 is no content, so we will skip it. + if resp.StatusCode != 204 { + if !skipAliases { + aliases.SaveAliasesForResources(string(resBody)) + } + } + + if aliasName != "" { + aliases.SetAliasForResource(string(resBody), aliasName) + } + + return string(resBody), nil + } else { + return "", nil + } + +} diff --git a/external/rest/delete.go b/external/rest/delete.go new file mode 100644 index 0000000..07fedbb --- /dev/null +++ b/external/rest/delete.go @@ -0,0 +1,125 @@ +package rest + +import ( + "context" + "fmt" + "github.com/elasticpath/epcc-cli/external/aliases" + "github.com/elasticpath/epcc-cli/external/httpclient" + "github.com/elasticpath/epcc-cli/external/json" + "github.com/elasticpath/epcc-cli/external/resources" + "github.com/elasticpath/epcc-cli/external/shutdown" + log "github.com/sirupsen/logrus" + "io" + "net/http" + "net/url" + "strings" +) + +func DeleteResource(ctx context.Context, overrides *httpclient.HttpParameterOverrides, args []string) (*http.Response, error) { + // Find Resource + resource, ok := resources.GetResourceByName(args[0]) + if !ok { + return nil, fmt.Errorf("could not find resource %s", args[0]) + } + + if resource.DeleteEntityInfo == nil { + return nil, fmt.Errorf("resource %s doesn't support DELETE", args[0]) + } + + // Replace ids with args in resourceURL + resourceURL, err := resources.GenerateUrl(resource.DeleteEntityInfo, args[1:], true) + + if err != nil { + return nil, err + } + + if overrides.OverrideUrlPath != "" { + log.Warnf("Overriding URL Path from %s to %s", resourceURL, overrides.OverrideUrlPath) + resourceURL = overrides.OverrideUrlPath + } + + params := url.Values{} + + for _, v := range overrides.QueryParameters { + keyAndValue := strings.SplitN(v, "=", 2) + if len(keyAndValue) != 2 { + return nil, fmt.Errorf("Could not parse query parameter %v, all query parameters should be a key and value format", keyAndValue) + } + params.Add(keyAndValue[0], keyAndValue[1]) + } + + idCount, err := resources.GetNumberOfVariablesNeeded(resource.DeleteEntityInfo.Url) + + if !resource.NoWrapping { + args = append(args, "type", resource.JsonApiType) + } + // Create the body from remaining args + + jsonArgs := args[(idCount + 1):] + + var payload io.Reader = nil + if len(jsonArgs) > 0 { + body, err := json.ToJson(jsonArgs, resource.NoWrapping, resource.JsonApiFormat == "compliant", resource.Attributes, true) + + if err != nil { + return nil, err + } + + payload = strings.NewReader(body) + } + + // Submit request + resp, err := httpclient.DoRequest(ctx, "DELETE", resourceURL, params.Encode(), payload) + if err != nil { + return nil, fmt.Errorf("got error %s", err.Error()) + } + + return resp, nil +} + +func DeleteInternal(ctx context.Context, overrides *httpclient.HttpParameterOverrides, allow404 bool, args []string) (string, error) { + shutdown.OutstandingOpCounter.Add(1) + defer shutdown.OutstandingOpCounter.Done() + + resource, ok := resources.GetResourceByName(args[0]) + if !ok { + return "", fmt.Errorf("could not find resource %s", args[0]) + } + + resp, err := DeleteResource(ctx, overrides, args) + if err != nil { + return "", err + } + + if resp == nil { + return "", fmt.Errorf("got nil response") + } + + if resp.StatusCode < 400 { + idToDelete := aliases.ResolveAliasValuesOrReturnIdentity(resource.JsonApiType, resource.AlternateJsonApiTypesForAliases, args[len(args)-1], "id") + aliases.DeleteAliasesById(idToDelete, resource.JsonApiType) + } + if resp.Body != nil { + + defer resp.Body.Close() + + // Print the body + body, err := io.ReadAll(resp.Body) + + if err != nil { + log.Fatal(err) + } + + // Check if error response + if resp.StatusCode >= 400 && resp.StatusCode <= 600 { + if resp.StatusCode != 404 || !allow404 { + return string(body), fmt.Errorf(resp.Status) + } + } + + return string(body), nil + } else { + return "", nil + } + +} diff --git a/external/rest/get.go b/external/rest/get.go new file mode 100644 index 0000000..48a20c5 --- /dev/null +++ b/external/rest/get.go @@ -0,0 +1,130 @@ +package rest + +import ( + "context" + "fmt" + "github.com/elasticpath/epcc-cli/external/aliases" + "github.com/elasticpath/epcc-cli/external/httpclient" + "github.com/elasticpath/epcc-cli/external/json" + "github.com/elasticpath/epcc-cli/external/resources" + "github.com/elasticpath/epcc-cli/external/shutdown" + log "github.com/sirupsen/logrus" + "io" + "net/http" + "net/url" + "strings" +) + +func GetInternal(ctx context.Context, overrides *httpclient.HttpParameterOverrides, args []string, skipAliases bool) (string, error) { + resp, err := GetResource(ctx, overrides, args) + + if err != nil { + return "", err + } else if resp == nil { + return "", fmt.Errorf("got nil response") + } + + if resp.Body != nil { + defer resp.Body.Close() + + // Print the body + body, err := io.ReadAll(resp.Body) + if err != nil { + log.Fatal(err) + } + + // Check if error response + if resp.StatusCode >= 400 && resp.StatusCode <= 600 { + json.PrintJson(string(body)) + return "", fmt.Errorf(resp.Status) + } + + if !skipAliases { + aliases.SaveAliasesForResources(string(body)) + } + + return string(body), nil + } else { + return "", nil + } +} + +func GetUrl(resource resources.Resource, args []string) (*resources.CrudEntityInfo, error) { + + if resource.GetCollectionInfo == nil && resource.GetEntityInfo == nil { + return nil, fmt.Errorf("resource %s doesn't support GET", args[0]) + } else if resource.GetCollectionInfo != nil && resource.GetEntityInfo == nil { + return resource.GetCollectionInfo, nil + } else if resource.GetCollectionInfo == nil && resource.GetEntityInfo != nil { + return resource.GetEntityInfo, nil + } else { + if _, ok := resources.GetPluralResources()[args[0]]; ok { + return resource.GetCollectionInfo, nil + } else { + return resource.GetEntityInfo, nil + } + } +} + +func GetResource(ctx context.Context, overrides *httpclient.HttpParameterOverrides, args []string) (*http.Response, error) { + shutdown.OutstandingOpCounter.Add(1) + defer shutdown.OutstandingOpCounter.Done() + + // Find Resource + resource, ok := resources.GetResourceByName(args[0]) + if !ok { + return nil, fmt.Errorf("could not find resource %s", args[0]) + } + + var idCount int + + resourceUrlInfo, err2 := GetUrl(resource, args) + if err2 != nil { + return nil, err2 + } + + idCount, err := resources.GetNumberOfVariablesNeeded(resourceUrlInfo.Url) + + if err != nil { + return nil, err + } + + // Replace ids with args in resourceURL + resourceURL, err := resources.GenerateUrl(resourceUrlInfo, args[1:], true) + + if err != nil { + return nil, err + } + + if overrides.OverrideUrlPath != "" { + log.Warnf("Overriding URL Path from %s to %s", resourceURL, overrides.OverrideUrlPath) + resourceURL = overrides.OverrideUrlPath + } + + // Add remaining args as query params + params := url.Values{} + for i := idCount + 1; i+1 < len(args); i = i + 2 { + params.Add(args[i], args[i+1]) + } + + if (idCount-len(args)+1)%2 != 0 { + log.Warnf("Extra argument at the end of the command %s", args[len(args)-1]) + } + + for _, v := range overrides.QueryParameters { + keyAndValue := strings.SplitN(v, "=", 2) + if len(keyAndValue) != 2 { + return nil, fmt.Errorf("Could not parse query parameter %v, all query parameters should be a key and value format", keyAndValue) + } + params.Add(keyAndValue[0], keyAndValue[1]) + } + + // Submit request + resp, err := httpclient.DoRequest(ctx, "GET", resourceURL, params.Encode(), nil) + + if err != nil { + return nil, fmt.Errorf("got error %s", err.Error()) + } + + return resp, nil +} diff --git a/external/rest/update.go b/external/rest/update.go new file mode 100644 index 0000000..a8e83bb --- /dev/null +++ b/external/rest/update.go @@ -0,0 +1,100 @@ +package rest + +import ( + "context" + "fmt" + "github.com/elasticpath/epcc-cli/external/aliases" + "github.com/elasticpath/epcc-cli/external/httpclient" + "github.com/elasticpath/epcc-cli/external/json" + "github.com/elasticpath/epcc-cli/external/resources" + "github.com/elasticpath/epcc-cli/external/shutdown" + log "github.com/sirupsen/logrus" + "io" + "net/url" + "strings" +) + +func UpdateInternal(ctx context.Context, overrides *httpclient.HttpParameterOverrides, skipAliases bool, args []string) (string, error) { + shutdown.OutstandingOpCounter.Add(1) + defer shutdown.OutstandingOpCounter.Done() + + // Find Resource + resource, ok := resources.GetResourceByName(args[0]) + if !ok { + return "", fmt.Errorf("could not find resource %s", args[0]) + } + + if resource.UpdateEntityInfo == nil { + return "", fmt.Errorf("resource %s doesn't support UPDATE", args[0]) + } + + // Count ids in UpdateEntity + resourceUrlInfo := resource.UpdateEntityInfo + idCount, err := resources.GetNumberOfVariablesNeeded(resourceUrlInfo.Url) + if err != nil { + return "", err + } + + // Replace ids with args in resourceURL + resourceURL, err := resources.GenerateUrl(resourceUrlInfo, args[1:], true) + if err != nil { + return "", err + } + + if overrides.OverrideUrlPath != "" { + log.Warnf("Overriding URL Path from %s to %s", resourceURL, overrides.OverrideUrlPath) + resourceURL = overrides.OverrideUrlPath + } + + args = append(args, "type", resource.JsonApiType) + // Create the body from remaining args + body, err := json.ToJson(args[(idCount+1):], resource.NoWrapping, resource.JsonApiFormat == "compliant", resource.Attributes, true) + if err != nil { + return "", err + } + + params := url.Values{} + + for _, v := range overrides.QueryParameters { + keyAndValue := strings.SplitN(v, "=", 2) + if len(keyAndValue) != 2 { + return "", fmt.Errorf("Could not parse query parameter %v, all query parameters should be a key and value format", keyAndValue) + } + params.Add(keyAndValue[0], keyAndValue[1]) + } + + // Submit request + resp, err := httpclient.DoRequest(ctx, "PUT", resourceURL, params.Encode(), strings.NewReader(body)) + if err != nil { + return "", fmt.Errorf("got error %s", err.Error()) + } else if resp == nil { + return "", fmt.Errorf("got nil response") + } + + if resp.Body != nil { + defer resp.Body.Close() + + // Print the body + resBody, err := io.ReadAll(resp.Body) + if err != nil { + log.Fatal(err) + } + + // Check if error response + if resp.StatusCode >= 400 && resp.StatusCode <= 600 { + json.PrintJson(string(resBody)) + return "", fmt.Errorf(resp.Status) + } + + // 204 is no content, so we will skip it. + if resp.StatusCode != 204 { + if !skipAliases { + aliases.SaveAliasesForResources(string(resBody)) + } + } + + return string(resBody), nil + } else { + return "", nil + } +} diff --git a/go.mod b/go.mod index f9e9799..525c7a4 100644 --- a/go.mod +++ b/go.mod @@ -29,6 +29,7 @@ require github.com/quasilyte/regex/syntax v0.0.0-20210819130434-b3f0c404a727 require ( github.com/iancoleman/strcase v0.2.0 github.com/jolestar/go-commons-pool/v2 v2.1.2 + github.com/mitchellh/mapstructure v1.1.2 ) require dario.cat/mergo v1.0.1 // indirect diff --git a/go.sum b/go.sum index 598677d..089272d 100644 --- a/go.sum +++ b/go.sum @@ -99,6 +99,7 @@ github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5 github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= +github.com/mitchellh/mapstructure v1.1.2 h1:fmNYVwqnSfB9mZU6OS2O6GsXM+wcskZDuKQzvN1EDeE= github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw=