diff --git a/cmd/login.go b/cmd/login.go index a969d1f..34eee5f 100644 --- a/cmd/login.go +++ b/cmd/login.go @@ -12,6 +12,7 @@ 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" @@ -252,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) }, } @@ -614,12 +584,12 @@ var loginAccountManagement = &cobra.Command{ }, } +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 StartOIDCServer(8080) + return oidc.StartOIDCServer(OidcPort) }, } diff --git a/cmd/root.go b/cmd/root.go index 3770629..9ce3fba 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -146,6 +146,7 @@ func InitializeCmd() { 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/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/oidc/callback.go b/external/oidc/callback.go new file mode 100644 index 0000000..6831829 --- /dev/null +++ b/external/oidc/callback.go @@ -0,0 +1,96 @@ +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 + AccountTokenStructBase64 []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:", 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) + } + + 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_state", state.Value, + "oauth_code_verifier", verifier.Value, + }, false, "", true) + + if err != nil { + return nil, fmt.Errorf("could not get account tokens: %w", err) + } + + cpi := CallbackPageInfo{ + LoginType: "AM", + } + + 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) + } + + base64.URLEncoding.EncodeToString(str) + + cpi.AccountTokenStructBase64 = append(cpi.AccountTokenStructBase64, base64.URLEncoding.EncodeToString(str)) + } + + return &cpi, 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..db32650 --- /dev/null +++ b/external/oidc/get_token.go @@ -0,0 +1,74 @@ +package oidc + +import ( + "context" + "encoding/base64" + gojson "encoding/json" + "fmt" + "github.com/elasticpath/epcc-cli/external/authentication" + 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 + + } + + return nil, fmt.Errorf("invalid login type") +} diff --git a/cmd/oidc.go b/external/oidc/index.go similarity index 58% rename from cmd/oidc.go rename to external/oidc/index.go index 7298080..7f5a86a 100644 --- a/cmd/oidc.go +++ b/external/oidc/index.go @@ -1,141 +1,165 @@ -package cmd +package oidc import ( "context" "crypto/rand" "crypto/sha256" "encoding/base64" - gojson "encoding/json" "fmt" - "github.com/elasticpath/epcc-cli/external/authentication" - "github.com/elasticpath/epcc-cli/external/browser" "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/rest" "github.com/google/uuid" "github.com/mitchellh/mapstructure" log "github.com/sirupsen/logrus" - "net/http" - "strings" - "text/template" - "time" ) -func StartOIDCServer(port int) error { +type LoginPageInfo struct { + CustomerProfiles []OidcProfileInfo + CustomerClientId string + AccountProfiles []OidcProfileInfo + AccountClientId string + RedirectUriUnencoded string + RedirectUriEncoded string + State string + CodeVerifier string + CodeChallenge string +} - http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { - ctx := context.Background() +func GetIndexData(ctx context.Context, port uint16) (*LoginPageInfo, error) { - if r.URL.Path != "/" { - http.NotFound(w, r) - return - } + overrides := &httpclient.HttpParameterOverrides{ + QueryParameters: nil, + OverrideUrlPath: "", + } - log.Infof("Handling request %s %s", r.Method, r.URL.Path) - 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) - // 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) + } - if err != nil { - log.Errorf("Could not retrieve customer authentication settings") - w.WriteHeader(500) - return + 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) + } - accountAuthSettings, err := rest.GetInternal(ctx, overrides, []string{"account-authentication-settings"}, false) + customerRealmId, err := json.RunJQOnStringAndGetString(".data.relationships[\"authentication-realm\"].data.id", customerAuthSettings) - if err != nil { - log.Errorf("Could not retrieve account authentication settings") - w.WriteHeader(500) - return + if err != nil { + return nil, fmt.Errorf("could not determine customer realm id: %w", err) + } - } + customerClientId, err := json.RunJQOnStringAndGetString(".data.meta.client_id", customerAuthSettings) - customerRealmId, err := json.RunJQOnStringAndGetString(".data.relationships[\"authentication-realm\"].data.id", customerAuthSettings) + if err != nil { + return nil, fmt.Errorf("could not determine customer client id: %w", err) + } - if err != nil { - log.Errorf("Could not determine customer realm id") - w.WriteHeader(500) - return - } - log.Infof("Customer Auth Settings: %v", customerRealmId) + accountRealmId, err := json.RunJQOnStringAndGetString(".data.relationships.authentication_realm.data.id", accountAuthSettings) - customerClientId, err := json.RunJQOnStringAndGetString(".data.meta.client_id", customerAuthSettings) + if err != nil { + return nil, fmt.Errorf("could not determine account realm id: %w", err) + } - if err != nil { - log.Errorf("Could not determine customer client id") - w.WriteHeader(500) - return - } + accountClientId, err := json.RunJQOnStringAndGetString(".data.meta.client_id", accountAuthSettings) - accountRealmId, err := json.RunJQOnStringAndGetString(".data.relationships.authentication_realm.data.id", accountAuthSettings) + if err != nil { + return nil, fmt.Errorf("could not determine account client id: %w", err) + } - if err != nil { - log.Errorf("Could not determine account realm id") - w.WriteHeader(500) - return - } + customerProfiles, err := getOidcProfilesForRealm(ctx, overrides, customerRealmId) - accountClientId, err := json.RunJQOnStringAndGetString(".data.meta.client_id", accountAuthSettings) + if err != nil { + return nil, fmt.Errorf("could not get oidc profiles for customers: %w", err) + } - if err != nil { - log.Errorf("Could not determine customer client id") - w.WriteHeader(500) - return - } + accountProfiles, err := getOidcProfilesForRealm(ctx, overrides, accountRealmId) - log.Infof("Account Auth Settings: %v", accountRealmId) + if err != nil { + return nil, fmt.Errorf("could not get oidc profiles for customers: %w", err) + } - customerProfiles, err := getOidcProfilesForRealm(ctx, overrides, customerRealmId) + verifier, err := GenerateCodeVerifier() - if err != nil { - log.Errorf("Could not determine customer OIDC Profiles") - return - } + if err != nil { + return nil, fmt.Errorf("could not get code verifier: %w", err) + } - accountProfiles, err := getOidcProfilesForRealm(ctx, overrides, accountRealmId) + challenge, err := GenerateCodeChallenge(verifier) - if err != nil { - log.Errorf("Could not determine customer OIDC Profiles: %v", err) - return - } + if err != nil { + return nil, fmt.Errorf("could not get code challenge: %w", err) + } - verifier, err := GenerateCodeVerifier() + 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, + } - if err != nil { - log.Errorf("Could not get verifier %v", err) - return - } + return &profiles, nil +} - challenge, err := GenerateCodeChallenge(verifier) +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 { - log.Errorf("Could not get challenge %v", err) - return - } + if err != nil { + return nil, 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, - } + resObj, err := json.RunJQOnString(".data | map({name: .name, authorization_link: .links[\"authorization-endpoint\"], idp: .meta.issuer})", res) - if err != nil { - log.Errorf("Could not determine customer OIDC Profiles") - return - } + 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 +} + +/* tmpl, err := template.New("index").Parse(oidc.Index) @@ -300,113 +324,4 @@ func StartOIDCServer(port int) error { return }) - - go func() { - log.Warnf("Waiting for server to start") - time.Sleep(1 * time.Second) - browser.OpenUrl(fmt.Sprintf("http://localhost:%d", port)) - - }() - - log.Infof("Starting server on port %d", port) - - err := http.ListenAndServe(fmt.Sprintf(":%d", port), nil) - - return err - -} - -type LoginPageInfo struct { - CustomerProfiles []OidcProfileInfo - CustomerClientId string - AccountProfiles []OidcProfileInfo - AccountClientId string - RedirectUriUnencoded string - RedirectUriEncoded string - State string - CodeVerifier string - CodeChallenge string -} - -type SuccessPageInfo struct { - LoginType string - AccountTokenResponse *authentication.AccountManagementAuthenticationTokenResponse - AccountTokenStructBase64 []string -} - -type OidcProfileInfo struct { - Name string `mapstructure:"name"` - AuthorizationLink string `mapstructure:"authorization_link"` - Idp string -} - -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 -} - -func RenderErrorPage(w http.ResponseWriter, title string, description string) { - tmpl, err := template.New("index").Parse(oidc.ErrorPage) - - if err != nil { - log.Errorf("Could not parse index template") - w.WriteHeader(500) - return - } - - data := map[string]string{ - "error": title, - "error_description": description, - } - err = tmpl.Execute(w, data) - - if err != nil { - log.Warnf("Error handling request %v\n%v", err) - return - } -} +*/ diff --git a/external/oidc/server.go b/external/oidc/server.go index 731b06d..a3cac22 100644 --- a/external/oidc/server.go +++ b/external/oidc/server.go @@ -1,20 +1,365 @@ package oidc import ( - _ "embed" + "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 style.css -var Css string +//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 +} + +/* + 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 { + log.Errorf("Could not retrieve customer authentication settings") + w.WriteHeader(500) + return + + } + + accountAuthSettings, err := rest.GetInternal(ctx, overrides, []string{"account-authentication-settings"}, false) + + if err != nil { + log.Errorf("Could not retrieve account authentication settings") + w.WriteHeader(500) + return + + } + + customerRealmId, err := json.RunJQOnStringAndGetString(".data.relationships[\"authentication-realm\"].data.id", customerAuthSettings) + + if err != nil { + log.Errorf("Could not determine customer realm id") + w.WriteHeader(500) + return + } + log.Infof("Customer Auth Settings: %v", customerRealmId) + + customerClientId, err := json.RunJQOnStringAndGetString(".data.meta.client_id", customerAuthSettings) + + if err != nil { + log.Errorf("Could not determine customer client id") + w.WriteHeader(500) + return + } + + accountRealmId, err := json.RunJQOnStringAndGetString(".data.relationships.authentication_realm.data.id", accountAuthSettings) + + if err != nil { + log.Errorf("Could not determine account realm id") + w.WriteHeader(500) + return + } + + accountClientId, err := json.RunJQOnStringAndGetString(".data.meta.client_id", accountAuthSettings) + + if err != nil { + log.Errorf("Could not determine customer client id") + w.WriteHeader(500) + return + } + + log.Infof("Account Auth Settings: %v", accountRealmId) + + customerProfiles, err := getOidcProfilesForRealm(ctx, overrides, customerRealmId) + + if err != nil { + log.Errorf("Could not determine customer OIDC Profiles") + return + } + + accountProfiles, err := getOidcProfilesForRealm(ctx, overrides, accountRealmId) + + if err != nil { + log.Errorf("Could not determine customer OIDC Profiles: %v", err) + return + } + + verifier, err := GenerateCodeVerifier() + + if err != nil { + log.Errorf("Could not get verifier %v", err) + return + } + + challenge, err := GenerateCodeChallenge(verifier) + + if err != nil { + log.Errorf("Could not get challenge %v", err) + return + } + + 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, + } + + if err != nil { + log.Errorf("Could not determine customer OIDC Profiles") + return + } + + tmpl, err := template.New("index").Parse(oidc.Index) + + if err != nil { + log.Errorf("Could not parse index template") + w.WriteHeader(500) + return + } + + err = tmpl.Execute(w, profiles) + + if err != nil { + log.Warnf("Error handling request %v\n%v", r, err) + return + } + }) + + http.HandleFunc("/callback", func(w http.ResponseWriter, r *http.Request) { + // 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:", port) + + if data["code"] != "" { + + state, err := r.Cookie("state") + + if err != nil { + log.Errorf("Could not get state cookie") + w.WriteHeader(500) + return + } + + verifier, err := r.Cookie("code_verifier") + + if err != nil { + log.Errorf("Could not get verifier cookie") + w.WriteHeader(500) + return + } + + 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_state", state.Value, + "oauth_code_verifier", verifier.Value, + }, false, "", true) + + if err != nil { + RenderErrorPage(w, "Could Not Get Account Tokens", err.Error()) + return + } + + spi := SuccessPageInfo{ + LoginType: "AM", + } + + err = gojson.Unmarshal([]byte(result), &spi.AccountTokenResponse) + + if err != nil { + log.Errorf("Could not unmarshal account: %v", err) + } + + for _, v := range spi.AccountTokenResponse.Data { + + str, err := gojson.Marshal(v) + + if err != nil { + log.Errorf("Could not encode token %v", err) + } + + base64.URLEncoding.EncodeToString(str) + + spi.AccountTokenStructBase64 = append(spi.AccountTokenStructBase64, base64.URLEncoding.EncodeToString(str)) + } + + log.Infof("Result: %v, SPI: %v, State: %s, Verifier: %s", result, spi, state.String(), verifier.String()) + + tmpl, err := template.New("index").Parse(oidc.SuccessPage) + + if err != nil { + log.Errorf("Could not parse index template") + w.WriteHeader(500) + return + } + + err = tmpl.Execute(w, spi) + + if err != nil { + log.Warnf("Error handling request %v\n%v", r, err) + return + } + + return + } else if data["error"] == "" { + RenderErrorPage(w, "bad_response", "Invalid response from IdP, no code or error query parameter") + } else { + RenderErrorPage(w, data["error"], data["error_description"]) + } + + }) + + http.HandleFunc("/style.css", func(w http.ResponseWriter, r *http.Request) { + + w.Header().Set("Content-Type", "text/css") + + _, err := fmt.Fprintf(w, oidc.Css) + if err != nil { + log.Warnf("Error handling request %v\n%v", r, err) + return + } + }) + + +*/ + +/* -//go:embed index.gohtml -var Index string +func RenderErrorPage(w http.ResponseWriter, title string, description string) { + tmpl, err := template.New("index").Parse(oidc.ErrorPage) -//go:embed error.gohtml -var ErrorPage string + if err != nil { + log.Errorf("Could not parse index template") + w.WriteHeader(500) + return + } -//go:embed success.gohtml -var SuccessPage string + data := map[string]string{ + "error": title, + "error_description": description, + } + err = tmpl.Execute(w, data) -//go:embed done.gohtml -var DonePage string + if err != nil { + log.Warnf("Error handling request %v\n%v", err) + return + } +} +*/ diff --git a/external/oidc/site/callback.gohtml b/external/oidc/site/callback.gohtml new file mode 100644 index 0000000..aaea694 --- /dev/null +++ b/external/oidc/site/callback.gohtml @@ -0,0 +1,39 @@ +{{ template "header" }} + {{ if ne .ErrorTitle "" }} +

An error has occurred

+

Error Type: {{ .ErrorTitle }}

+

Error Description: {{ .ErrorDescription }}

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

Authentication 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 }}
+ + {{ end }} +
+
+
+
+
+
+ To return to start Click here. +{{ template "footer" }} \ No newline at end of file diff --git a/external/oidc/done.gohtml b/external/oidc/site/done.gohtml similarity index 100% rename from external/oidc/done.gohtml rename to external/oidc/site/done.gohtml diff --git a/external/oidc/error.gohtml b/external/oidc/site/error.gohtml similarity index 100% rename from external/oidc/error.gohtml rename to external/oidc/site/error.gohtml 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..61f84ea --- /dev/null +++ b/external/oidc/site/get_token.gohtml @@ -0,0 +1,17 @@ +{{ template "header" }} + {{ if ne .ErrorTitle "" }} +

An error has occurred

+

Error Type: {{ .ErrorTitle }}

+

Error Description: {{ .ErrorDescription }}

+ {{ else if eq .LoginType "AM" }} + + You have successfully logged in as: +
+
+ Account Name: {{ .Name }}
+ Account ID: {{ .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..04a1aad --- /dev/null +++ b/external/oidc/site/header.gohtml @@ -0,0 +1,17 @@ +{{define "header"}} + + + + + + Top Banner + + + + +
+ +

EPCC CLI OIDC Login

+
+
+{{ end }} \ No newline at end of file diff --git a/external/oidc/index.gohtml b/external/oidc/site/index.gohtml similarity index 74% rename from external/oidc/index.gohtml rename to external/oidc/site/index.gohtml index 52b7375..7d558f4 100644 --- a/external/oidc/index.gohtml +++ b/external/oidc/site/index.gohtml @@ -1,43 +1,30 @@ - - - - - - Top Banner - - - - - -
-

OIDC Tester

-
-
+ } +

Welcome

This utility allows you to test Single Sign-On (SSO) with Elastic Path Commerce Cloud. @@ -95,7 +82,4 @@

{{ $item.Name }}

{{ end }} - -
- - \ No newline at end of file +{{ template "footer" }} \ No newline at end of file diff --git a/external/oidc/style.css b/external/oidc/site/static/style.css similarity index 50% rename from external/oidc/style.css rename to external/oidc/site/static/style.css index 4fa20d8..0160a1e 100644 --- a/external/oidc/style.css +++ b/external/oidc/site/static/style.css @@ -4,6 +4,7 @@ body { font-family: Arial, sans-serif; } + /* Top Banner */ .top-banner { background-color: #61DEA6; @@ -13,6 +14,16 @@ body { 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 */ @@ -45,4 +56,17 @@ main { .url-link { width: 800px; -} \ No newline at end of file +} + +table { + border-collapse: collapse; + + border: 2px solid black; /* Adds a bold border around the table */ +} + +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/oidc/success.gohtml b/external/oidc/success.gohtml deleted file mode 100644 index 3e71ae9..0000000 --- a/external/oidc/success.gohtml +++ /dev/null @@ -1,41 +0,0 @@ - - - - - - OIDC Login Successful - - - -
-

OIDC Tester

-
-
- {{ if eq .LoginType "AM" }} -

Authentication 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 }} - {{ $item.AccountName }} ({{ $item.AccountId }}) - {{ end }} - - - - {{ end }} - -
-
-
-
-
-
- To return to start Click here. -
- - \ No newline at end of file diff --git a/go.mod b/go.mod index 4559e79..87332da 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 a916553..9e13302 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=