From 99c7f352e2716ef18eb9367c97574f774df08008 Mon Sep 17 00:00:00 2001 From: Steve Ramage Date: Tue, 19 Nov 2024 16:18:54 -0800 Subject: [PATCH] WIP #193 - OIDC Login --- cmd/login.go | 10 + cmd/oidc.go | 408 +++++++++++++++++++++++++++++++++++ cmd/root.go | 1 + external/json/to_json.go | 25 +++ external/oidc/done.gohtml | 27 +++ external/oidc/error.gohtml | 20 ++ external/oidc/index.gohtml | 64 ++++++ external/oidc/server.go | 20 ++ external/oidc/style.css | 48 +++++ external/oidc/success.gohtml | 22 ++ 10 files changed, 645 insertions(+) create mode 100644 cmd/oidc.go create mode 100644 external/oidc/done.gohtml create mode 100644 external/oidc/error.gohtml create mode 100644 external/oidc/index.gohtml create mode 100644 external/oidc/server.go create mode 100644 external/oidc/style.css create mode 100644 external/oidc/success.gohtml diff --git a/cmd/login.go b/cmd/login.go index 4dd30f4..73e3355 100644 --- a/cmd/login.go +++ b/cmd/login.go @@ -612,3 +612,13 @@ var loginAccountManagement = &cobra.Command{ return json.PrintJson(string(jsonBody)) }, } + +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) + }, +} diff --git a/cmd/oidc.go b/cmd/oidc.go new file mode 100644 index 0000000..7d08324 --- /dev/null +++ b/cmd/oidc.go @@ -0,0 +1,408 @@ +package cmd + +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/google/uuid" + "github.com/mitchellh/mapstructure" + log "github.com/sirupsen/logrus" + "net/http" + "strings" + "text/template" + "time" +) + +func StartOIDCServer(port int) error { + + http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + ctx := context.Background() + + if r.URL.Path != "/" { + http.NotFound(w, r) + return + } + + 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 := 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 := 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(), + RedirectUri: fmt.Sprintf("%s%d%s", "http%3A%2F%2Flocalhost%3A", port, "%2Fcallback"), + 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 := 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 + } + }) + + http.HandleFunc("/set-am-token/", func(w http.ResponseWriter, r *http.Request) { + token := strings.Replace(r.URL.Path, "/set-am-token/", "", 1) + + log.Infof("Setting Token to %s", token) + + amTokenJson, err := base64.URLEncoding.DecodeString(token) + + if err != nil { + log.Errorf("Could not base64 decode token %v", err) + w.WriteHeader(500) + return + } + + amToken := authentication.AccountManagementAuthenticationTokenStruct{} + + err = gojson.Unmarshal(amTokenJson, &amToken) + + if err != nil { + log.Errorf("Could not unmarshal token %v", err) + w.WriteHeader(500) + return + } + + authentication.SaveAccountManagementAuthenticationToken(amToken) + + tmpl, err := template.New("index").Parse(oidc.DonePage) + + if err != nil { + log.Errorf("Could not parse index template") + w.WriteHeader(500) + return + } + + err = tmpl.Execute(w, amToken) + + if err != nil { + log.Warnf("Error handling request %v\n%v", r, err) + return + } + + return + + 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 + RedirectUri 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"` +} + +func getOidcProfilesForRealm(ctx context.Context, overrides *httpclient.HttpParameterOverrides, realmId string) ([]OidcProfileInfo, error) { + res, err := 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\"]})", 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/cmd/root.go b/cmd/root.go index e029948..3770629 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -144,6 +144,7 @@ func InitializeCmd() { LoginCmd.AddCommand(loginDocs) LoginCmd.AddCommand(loginCustomer) LoginCmd.AddCommand(loginAccountManagement) + LoginCmd.AddCommand(loginOidc) logoutCmd.AddCommand(logoutBearer) logoutCmd.AddCommand(logoutCustomer) 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/done.gohtml b/external/oidc/done.gohtml new file mode 100644 index 0000000..d46f233 --- /dev/null +++ b/external/oidc/done.gohtml @@ -0,0 +1,27 @@ + + + + + + OIDC Signin Complete + + + + +
+

OIDC Login Complete

+
+
+

You are now logged in as Account {{ .AccountName }} ({{ .AccountId }})

+ Click here to close this window +
+ + \ No newline at end of file diff --git a/external/oidc/error.gohtml b/external/oidc/error.gohtml new file mode 100644 index 0000000..440ef69 --- /dev/null +++ b/external/oidc/error.gohtml @@ -0,0 +1,20 @@ + + + + + + OIDC Error + + + +
+

OIDC Test Failed

+
+
+ +

Error Type: {{ .error }}

+

Error Description: {{ .error_description }}

+ Go Back To Start +
+ + \ No newline at end of file diff --git a/external/oidc/index.gohtml b/external/oidc/index.gohtml new file mode 100644 index 0000000..89eaab3 --- /dev/null +++ b/external/oidc/index.gohtml @@ -0,0 +1,64 @@ + + + + + + Top Banner + + + + + +
+

OIDC Tester

+
+
+

Account Management OIDC Profiles

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

{{ $item.Name }}

+
+
+ + {{ end }} + + To add a new redirect URI, use: + + epcc update + + +

Customer OIDC Profiles

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

{{ $item.Name }}

+ + {{ end }} + +
+ + \ No newline at end of file diff --git a/external/oidc/server.go b/external/oidc/server.go new file mode 100644 index 0000000..731b06d --- /dev/null +++ b/external/oidc/server.go @@ -0,0 +1,20 @@ +package oidc + +import ( + _ "embed" +) + +//go:embed style.css +var Css string + +//go:embed index.gohtml +var Index string + +//go:embed error.gohtml +var ErrorPage string + +//go:embed success.gohtml +var SuccessPage string + +//go:embed done.gohtml +var DonePage string diff --git a/external/oidc/style.css b/external/oidc/style.css new file mode 100644 index 0000000..4fa20d8 --- /dev/null +++ b/external/oidc/style.css @@ -0,0 +1,48 @@ +/* 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; +} + +/* 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; +} \ No newline at end of file diff --git a/external/oidc/success.gohtml b/external/oidc/success.gohtml new file mode 100644 index 0000000..4ac4605 --- /dev/null +++ b/external/oidc/success.gohtml @@ -0,0 +1,22 @@ + + + + + + OIDC Login Successful + + + +
+

OIDC Test Success

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

Please Select An Account

+ {{ range $i, $item := .AccountTokenResponse.Data }} + {{ $item.AccountName }} ({{ $item.AccountId }}) + {{ end }} + {{ end }} +
+ + \ No newline at end of file