From 3bcf5c876243967053032ebc7c35d128e8f32820 Mon Sep 17 00:00:00 2001 From: Tyler Mairose Date: Wed, 30 Sep 2026 10:51:53 -0400 Subject: [PATCH] Updates to the cli environment configuration --- cmd/environment/environment.md | 9 +++ internal/client/client.go | 7 +- internal/config/config.go | 51 ++++++++++-- internal/config/environment_test.go | 119 ++++++++++++++++++++++++++++ internal/config/oauth.go | 13 +++ internal/config/oauth_test.go | 59 ++++++++++++++ internal/config/pat.go | 55 ++++++++++--- internal/terminal/terminal.go | 90 +++++++++++++++++++++ internal/terminal/terminal_test.go | 56 +++++++++++++ internal/util/url.go | 54 +++++++++++++ internal/util/url_test.go | 40 ++++++++++ internal/util/util.go | 113 +++++++++++++++++++------- 12 files changed, 622 insertions(+), 44 deletions(-) create mode 100644 internal/config/environment_test.go create mode 100644 internal/terminal/terminal_test.go create mode 100644 internal/util/url_test.go diff --git a/cmd/environment/environment.md b/cmd/environment/environment.md index d23d95f1..02c3f4a2 100644 --- a/cmd/environment/environment.md +++ b/cmd/environment/environment.md @@ -19,6 +19,15 @@ If no environment is provided the CLI will use your tenant name as the name of t sail environment create ``` +When the CLI asks for the tenant name, you can also paste your tenant URL. This works for tenants that are not on the default `identitynow.com` domain. The CLI derives the environment name, the tenant URL, and the API URL from it. It then checks the API URL with the tenant. If the check passes, the CLI skips the URL confirmation prompts. + +```bash +sail environment create +Tenant name or URL (ie: acme, or https://acme.identitynow-demo.com): () https://devrel-ga-25031.identitynow-demo.com/ + +✔ Found tenant at https://devrel-ga-25031.identitynow-demo.com (API: https://devrel-ga-25031.api.identitynow-demo.com) +``` + ## Switching environments You can switch between environments by calling `use` and supplying the name of an existing environment you want to switch to. diff --git a/internal/client/client.go b/internal/client/client.go index c2c13838..b7d6aa73 100644 --- a/internal/client/client.go +++ b/internal/client/client.go @@ -289,7 +289,10 @@ func (c *SpClient) resolveUrl(rawURL string) (string, bool, error) { if u.Scheme == "" && u.Host == "" && u.Opaque == "" { base := strings.TrimSuffix(config.GetBaseUrl(), "/") if base == "" { - return "", false, fmt.Errorf("no tenant base url is configured, run \"sail configure\" or set SAIL_BASE_URL") + if err := config.CheckEnvironment(); err != nil { + return "", false, err + } + return "", false, fmt.Errorf("no tenant base url is configured, run \"sail env update\" or set SAIL_BASE_URL") } return base + trimmed, true, nil } @@ -311,7 +314,7 @@ func (c *SpClient) resolveUrl(rawURL string) (string, bool, error) { base, err := url.Parse(strings.TrimSuffix(config.GetBaseUrl(), "/")) if err != nil || base.Host == "" { - return "", false, fmt.Errorf("no valid tenant base url is configured, run \"sail configure\" or set SAIL_BASE_URL") + return "", false, fmt.Errorf("no valid tenant base url is configured, run \"sail env create\" or set SAIL_BASE_URL") } if !sameOrigin(base, u) { diff --git a/internal/config/config.go b/internal/config/config.go index 4e05e95b..f771d0de 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -4,6 +4,7 @@ import ( "fmt" "os" "path/filepath" + "sort" "strings" "time" @@ -159,7 +160,7 @@ func InitAPIClient(experimental bool) (*sailpoint.APIClient, error) { token, err := GetAuthToken() if err != nil { - log.Debug("unable to retrieve accesstoken", "error", err) + return apiClient, err } configuration := sailpoint.NewCLIConfiguration(sailpoint.ClientConfiguration{Token: token, BaseURL: GetBaseUrl()}) @@ -432,10 +433,50 @@ func TestSecretsStorage() bool { } } +// CheckEnvironment returns an error that tells the user what to do when no +// usable environment is configured. Setting SAIL_BASE_URL skips the check, +// because the CLI can then run from environment variables alone. +func CheckEnvironment() error { + if os.Getenv("SAIL_BASE_URL") != "" { + return nil + } + + environments := GetEnvironments() + if len(environments) == 0 { + return fmt.Errorf("no environment is configured\n\n" + + "Run `sail env create` to set up an environment, or set SAIL_BASE_URL, SAIL_CLIENT_ID, and SAIL_CLIENT_SECRET to use environment variables") + } + + active := GetActiveEnvironment() + if environments[active] == nil { + names := make([]string, 0, len(environments)) + for name := range environments { + names = append(names, name) + } + sort.Strings(names) + + problem := "no active environment is selected" + if strings.TrimSpace(active) != "" { + problem = fmt.Sprintf("the active environment %q does not exist", active) + } + + return fmt.Errorf("%s\n\nConfigured environments: %s\n\n"+ + "Run `sail env use ` to select one, or `sail env create` to add a new one", problem, strings.Join(names, ", ")) + } + + return nil +} + func Validate() error { var errors int authType := GetAuthType() + if err := CheckEnvironment(); err != nil { + return err + } + + env := GetActiveEnvironment() + supportsSecrets := TestSecretsStorage() switch authType { @@ -447,7 +488,7 @@ func Validate() error { } if GetBaseUrl() == "" { - log.Error("configured environment is missing BaseURL") + log.Error("configured environment is missing BaseURL, run `sail env update " + env + "` to set it") errors++ } @@ -477,18 +518,18 @@ func Validate() error { } if GetBaseUrl() == "" { - log.Error("configured environment is missing BaseURL") + log.Error("configured environment is missing BaseURL, run `sail env update " + env + "` to set it") errors++ } if GetTenantUrl() == "" { - log.Error("configured environment is missing TenantURL") + log.Error("configured environment is missing TenantURL, run `sail env update " + env + "` to set it") errors++ } default: - log.Error("invalid authtype '%s' configured", authType) + log.Error("invalid authtype configured, run `sail set auth` to choose oauth or pat", "authtype", authType) errors++ } diff --git a/internal/config/environment_test.go b/internal/config/environment_test.go new file mode 100644 index 00000000..fda015b7 --- /dev/null +++ b/internal/config/environment_test.go @@ -0,0 +1,119 @@ +package config + +import ( + "errors" + "strings" + "testing" + + "github.com/spf13/viper" + keyring "github.com/zalando/go-keyring" +) + +// resetViper clears all configuration for the test and again when it ends. +func resetViper(t *testing.T) { + t.Helper() + viper.Reset() + t.Cleanup(viper.Reset) +} + +func TestCheckEnvironmentNoEnvironments(t *testing.T) { + resetViper(t) + t.Setenv("SAIL_BASE_URL", "") + + err := CheckEnvironment() + if err == nil || !strings.Contains(err.Error(), "no environment is configured") || !strings.Contains(err.Error(), "sail env create") { + t.Fatalf("CheckEnvironment() error = %v, want no-environment guidance", err) + } +} + +func TestCheckEnvironmentUnknownActive(t *testing.T) { + resetViper(t) + t.Setenv("SAIL_BASE_URL", "") + viper.Set("environments.beta.baseurl", "https://beta.api.identitynow.com") + viper.Set("environments.alpha.baseurl", "https://alpha.api.identitynow.com") + SetActiveEnvironment("gone") + + err := CheckEnvironment() + if err == nil { + t.Fatal("CheckEnvironment() error = nil, want error") + } + for _, want := range []string{`"gone" does not exist`, "alpha, beta", "sail env use"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("CheckEnvironment() error = %q, want it to contain %q", err, want) + } + } +} + +func TestCheckEnvironmentNoActive(t *testing.T) { + resetViper(t) + t.Setenv("SAIL_BASE_URL", "") + viper.Set("environments.alpha.baseurl", "https://alpha.api.identitynow.com") + SetActiveEnvironment("") + + err := CheckEnvironment() + if err == nil || !strings.Contains(err.Error(), "no active environment is selected") { + t.Fatalf("CheckEnvironment() error = %v, want no-active guidance", err) + } +} + +func TestCheckEnvironmentValid(t *testing.T) { + resetViper(t) + t.Setenv("SAIL_BASE_URL", "") + viper.Set("environments.alpha.baseurl", "https://alpha.api.identitynow.com") + SetActiveEnvironment("Alpha") + + if err := CheckEnvironment(); err != nil { + t.Fatalf("CheckEnvironment() error = %v", err) + } +} + +func TestCheckEnvironmentEnvVars(t *testing.T) { + resetViper(t) + t.Setenv("SAIL_BASE_URL", "https://acme.api.identitynow.com") + + if err := CheckEnvironment(); err != nil { + t.Fatalf("CheckEnvironment() error = %v, want nil when SAIL_BASE_URL is set", err) + } +} + +func TestMissingSecretError(t *testing.T) { + err := missingSecretError("client ID", "SAIL_CLIENT_ID", "alpha", keyring.ErrNotFound) + for _, want := range []string{`client ID is stored for environment "alpha"`, "sail set pat", "SAIL_CLIENT_ID"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("missingSecretError() = %q, want it to contain %q", err, want) + } + } + + other := errors.New("keychain locked") + if err := missingSecretError("client ID", "SAIL_CLIENT_ID", "alpha", other); !errors.Is(err, other) { + t.Errorf("missingSecretError() = %v, want it to wrap the keyring error", err) + } +} + +func TestPatTokenErrorInvalidClient(t *testing.T) { + body := []byte(`{"error_description":"Full authentication is required to access this resource","error":"invalid_client"}`) + + err := patTokenError(400, body, "devrel", false) + for _, want := range []string{"rejected the PAT client ID or client secret", `"devrel"`, "sail set pat"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("patTokenError() = %q, want it to contain %q", err, want) + } + } + + err = patTokenError(400, body, "devrel", true) + if !strings.Contains(err.Error(), "SAIL_CLIENT_ID and SAIL_CLIENT_SECRET") { + t.Errorf("patTokenError() = %q, want environment variable guidance", err) + } +} + +func TestPatTokenErrorOther(t *testing.T) { + err := patTokenError(500, []byte(`{"error":"server_error","error_description":"try again later"}`), "devrel", false) + if !strings.Contains(err.Error(), "status 500") || !strings.Contains(err.Error(), "try again later") { + t.Errorf("patTokenError() = %q, want status and description", err) + } + + err = patTokenError(502, []byte("bad gateway"), "devrel", false) + if !strings.Contains(err.Error(), "bad gateway") { + t.Errorf("patTokenError() = %q, want raw body", err) + } +} diff --git a/internal/config/oauth.go b/internal/config/oauth.go index f7705757..55e83b6b 100644 --- a/internal/config/oauth.go +++ b/internal/config/oauth.go @@ -365,6 +365,19 @@ func discoverAuthorizeEndpoint(baseURL string) (string, error) { return info.AuthorizeEndpoint, nil } +// CheckTenantAPI reports whether baseURL answers {baseURL}/oauth/info like an +// Identity Security Cloud tenant. It is used to confirm a generated API URL +// before an environment is saved. +func CheckTenantAPI(baseURL string) error { + parsed, err := assertHTTPSURL(strings.TrimSuffix(baseURL, "/"), "tenant API URL") + if err != nil { + return err + } + + _, err = discoverAuthorizeEndpoint(parsed.Scheme + "://" + parsed.Host) + return err +} + // randomURLSafeString returns byteLength random bytes as a base64url string. func randomURLSafeString(byteLength int) (string, error) { buf := make([]byte, byteLength) diff --git a/internal/config/oauth_test.go b/internal/config/oauth_test.go index b0fb201f..4d8bfe2e 100644 --- a/internal/config/oauth_test.go +++ b/internal/config/oauth_test.go @@ -3,6 +3,8 @@ package config import ( "encoding/base64" "encoding/json" + "net/http" + "net/http/httptest" "strings" "testing" ) @@ -220,3 +222,60 @@ func TestPromptForPasteCodeTrimsInput(t *testing.T) { t.Fatalf("promptForPasteCode() = %q, want %q", got, "sp1.abc") } } + +// withTenantServer starts a TLS server that serves /oauth/info with the given +// status and body, and points oauthHTTPClient at it for the test. +func withTenantServer(t *testing.T, status int, body string) string { + t.Helper() + + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/oauth/info" { + http.NotFound(w, r) + return + } + w.WriteHeader(status) + _, _ = w.Write([]byte(body)) + })) + t.Cleanup(server.Close) + + previous := oauthHTTPClient + oauthHTTPClient = server.Client() + t.Cleanup(func() { oauthHTTPClient = previous }) + + return server.URL +} + +func TestCheckTenantAPIAcceptsTenant(t *testing.T) { + baseURL := withTenantServer(t, http.StatusOK, `{"authorizeEndpoint":"https://acme.example.com/oauth/authorize"}`) + + if err := CheckTenantAPI(baseURL + "/"); err != nil { + t.Fatalf("CheckTenantAPI() error = %v", err) + } +} + +func TestCheckTenantAPIRejectsNonTenant(t *testing.T) { + cases := map[string]struct { + status int + body string + }{ + "not found": {http.StatusNotFound, "not found"}, + "missing endpoint": {http.StatusOK, `{}`}, + "not json": {http.StatusOK, ""}, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + baseURL := withTenantServer(t, tc.status, tc.body) + + if err := CheckTenantAPI(baseURL); err == nil { + t.Fatal("CheckTenantAPI() error = nil, want error") + } + }) + } +} + +func TestCheckTenantAPIRejectsHTTP(t *testing.T) { + if err := CheckTenantAPI("http://acme.api.identitynow.com"); err == nil { + t.Fatal("CheckTenantAPI() error = nil, want error") + } +} diff --git a/internal/config/pat.go b/internal/config/pat.go index 2ac2be23..12c5b6c3 100644 --- a/internal/config/pat.go +++ b/internal/config/pat.go @@ -3,6 +3,7 @@ package config import ( "context" "encoding/json" + "errors" "fmt" "io" "net/http" @@ -141,11 +142,20 @@ func SetPatTokenExpiry(expiry time.Time) error { return nil } +// missingSecretError explains how to store a PAT credential that could not be +// read from the keyring. +func missingSecretError(label, envVar, env string, err error) error { + if errors.Is(err, keyring.ErrNotFound) { + return fmt.Errorf("no PAT %s is stored for environment %q\n\n"+ + "Run `sail set pat` to store your PAT credentials, or set %s", label, env, envVar) + } + return fmt.Errorf("failed to read the PAT %s for environment %q from the keyring: %w", label, env, err) +} + func GetClientID(env string) (string, error) { value, err := keyring.Get("environments.pat.clientid", env) if err != nil { - log.Error("issue retrieving clientID", "env", env) - return value, err + return value, missingSecretError("client ID", "SAIL_CLIENT_ID", env, err) } return value, nil } @@ -182,8 +192,7 @@ func DeletePatClientID(env string) error { func GetClientSecret(env string) (string, error) { value, err := keyring.Get("environments.pat.clientsecret", env) if err != nil { - log.Error("issue retrieving clientSecret", "env", env) - return value, err + return value, missingSecretError("client secret", "SAIL_CLIENT_SECRET", env, err) } return value, nil } @@ -276,15 +285,15 @@ func PATLogin() (PATSet, error) { _ = Body.Close() }(resp.Body) - if resp.StatusCode != http.StatusOK { - return set, fmt.Errorf("failed to retrieve access token. status %s", resp.Status) - } - raw, err := io.ReadAll(resp.Body) if err != nil { return set, err } + if resp.StatusCode != http.StatusOK { + return set, patTokenError(resp.StatusCode, raw, GetActiveEnvironment(), os.Getenv("SAIL_CLIENT_ID") != "") + } + var tResponse TokenResponse err = json.Unmarshal(raw, &tResponse) @@ -299,6 +308,32 @@ func PATLogin() (PATSet, error) { return set, nil } +// patTokenError turns a failed PAT token response into an error that says why +// the token request failed and how to fix it. usingEnvVars reports whether the +// credentials came from SAIL_CLIENT_ID and SAIL_CLIENT_SECRET. +func patTokenError(status int, body []byte, env string, usingEnvVars bool) error { + var tokenErr struct { + Error string `json:"error"` + Description string `json:"error_description"` + } + _ = json.Unmarshal(body, &tokenErr) + + if tokenErr.Error == "invalid_client" || status == http.StatusUnauthorized { + fix := "Run `sail set pat` to update the PAT credentials for this environment" + if usingEnvVars { + fix = "Check the values of SAIL_CLIENT_ID and SAIL_CLIENT_SECRET" + } + return fmt.Errorf("the tenant rejected the PAT client ID or client secret for environment %q\n\n"+ + "The PAT may be wrong, deleted, or created in a different tenant. %s", env, fix) + } + + detail := strings.TrimSpace(tokenErr.Description) + if detail == "" { + detail = strings.TrimSpace(string(body)) + } + return fmt.Errorf("failed to get an access token for environment %q (status %d): %s", env, status, detail) +} + func PromptForClientID() (string, error) { const maxAttempts = 3 var ClientID string @@ -306,7 +341,7 @@ func PromptForClientID() (string, error) { for attempt := 1; attempt <= maxAttempts; attempt++ { // Prompt for the Client ID - ClientID, err = terminal.PromptPassword("Personal Access Token Client ID:") + ClientID, err = terminal.PromptMasked("Personal Access Token Client ID:") if err != nil { return "", err } @@ -332,7 +367,7 @@ func PromptForClientSecret() (string, error) { for attempt := 1; attempt <= maxAttempts; attempt++ { // Prompt for the Client Secret - ClientSecret, err = terminal.PromptPassword("Personal Access Token Client Secret:") + ClientSecret, err = terminal.PromptMasked("Personal Access Token Client Secret:") if err != nil { return "", err } diff --git a/internal/terminal/terminal.go b/internal/terminal/terminal.go index 258b0b6b..1d04d4d0 100644 --- a/internal/terminal/terminal.go +++ b/internal/terminal/terminal.go @@ -3,10 +3,13 @@ package terminal import ( "bufio" + "errors" "fmt" + "io" "os" "strings" "syscall" + "unicode/utf8" "golang.org/x/term" ) @@ -52,3 +55,90 @@ func InputPrompt(label string) string { } return strings.TrimSpace(s) } + +// ErrInterrupted is returned by PromptMasked when the user presses ^C. +var ErrInterrupted = errors.New("input interrupted") + +// PromptMasked prompts for a secret and prints a * for each character received, +// so the user can see that typing or pasting worked. When stdin is not a +// terminal, it reads one line without echo handling. +func PromptMasked(promptMsg string) (string, error) { + fmt.Print(promptMsg + " ") + + fd := int(syscall.Stdin) + if !term.IsTerminal(fd) { + line, err := bufio.NewReader(os.Stdin).ReadString('\n') + if err != nil && line == "" { + return "", err + } + fmt.Println() + return strings.TrimSpace(line), nil + } + + state, err := term.MakeRaw(fd) + if err != nil { + return "", err + } + defer term.Restore(fd, state) + + value, err := readMasked(os.Stdin, os.Stdout) + fmt.Print("\r\n") + if err != nil { + return "", err + } + return strings.TrimSpace(value), nil +} + +// readMasked reads raw terminal input from r until Enter and writes one * to w +// for each character. Backspace removes the last character, ^C returns +// ErrInterrupted, and escape sequences such as arrow keys are ignored. +func readMasked(r io.Reader, w io.Writer) (string, error) { + var input []byte + buf := make([]byte, 256) + inEscape := false + + for { + n, err := r.Read(buf) + for _, b := range buf[:n] { + if inEscape { + // CSI sequences end with a byte in the range 0x40-0x7e. + if b != '[' && b >= 0x40 && b <= 0x7e { + inEscape = false + } + continue + } + + switch { + case b == '\r' || b == '\n': + return string(input), nil + case b == 0x03: + return "", ErrInterrupted + case b == 0x04 && len(input) == 0: + return "", io.EOF + case b == 0x7f || b == 0x08: + if len(input) > 0 { + _, size := utf8.DecodeLastRune(input) + input = input[:len(input)-size] + fmt.Fprint(w, "\b \b") + } + case b == 0x1b: + inEscape = true + case b < 0x20: + // Ignore other control characters. + default: + input = append(input, b) + // Print one * per character, not per byte of a multi-byte rune. + if b < 0x80 || b >= 0xc0 { + fmt.Fprint(w, "*") + } + } + } + + if err != nil { + if err == io.EOF && len(input) > 0 { + return string(input), nil + } + return "", err + } + } +} diff --git a/internal/terminal/terminal_test.go b/internal/terminal/terminal_test.go new file mode 100644 index 00000000..8774ae8b --- /dev/null +++ b/internal/terminal/terminal_test.go @@ -0,0 +1,56 @@ +package terminal + +import ( + "bytes" + "errors" + "io" + "strings" + "testing" +) + +func TestReadMasked(t *testing.T) { + tests := []struct { + name string + input string + want string + echo string + }{ + {"typed", "abc\r", "abc", "***"}, + {"pasted secret", strings.Repeat("x", 64) + "\r", strings.Repeat("x", 64), strings.Repeat("*", 64)}, + {"backspace", "abd\x7fc\r", "abc", "***\b \b*"}, + {"backspace on empty", "\x7fab\n", "ab", "**"}, + {"arrow keys ignored", "a\x1b[Db\r", "ab", "**"}, + {"multi-byte rune", "é\r", "é", "*"}, + {"eof after input", "abc", "abc", "***"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var out bytes.Buffer + got, err := readMasked(strings.NewReader(tt.input), &out) + if err != nil { + t.Fatalf("readMasked() error = %v", err) + } + if got != tt.want { + t.Errorf("readMasked() = %q, want %q", got, tt.want) + } + if out.String() != tt.echo { + t.Errorf("echo = %q, want %q", out.String(), tt.echo) + } + }) + } +} + +func TestReadMaskedInterrupt(t *testing.T) { + _, err := readMasked(strings.NewReader("ab\x03"), io.Discard) + if !errors.Is(err, ErrInterrupted) { + t.Fatalf("readMasked() error = %v, want ErrInterrupted", err) + } +} + +func TestReadMaskedEmptyEOF(t *testing.T) { + _, err := readMasked(strings.NewReader("\x04"), io.Discard) + if !errors.Is(err, io.EOF) { + t.Fatalf("readMasked() error = %v, want io.EOF", err) + } +} diff --git a/internal/util/url.go b/internal/util/url.go index f71bcc4e..4a6356be 100644 --- a/internal/util/url.go +++ b/internal/util/url.go @@ -1,11 +1,15 @@ package util import ( + "fmt" "log" "net/url" "path" + "strings" ) +const defaultTenantDomain = "identitynow.com" + func ResourceUrl(endpoint string, resourceParts ...string) string { u, err := url.Parse(endpoint) if err != nil { @@ -14,3 +18,53 @@ func ResourceUrl(endpoint string, resourceParts ...string) string { u.Path = path.Join(append([]string{u.Path}, resourceParts...)...) return u.String() } + +// ParseTenantInput turns a tenant name or URL into the environment name, the +// tenant URL and the API base URL. +// +// A plain name such as "acme" uses the default identitynow.com domain. A URL +// or host such as "https://acme.identitynow-demo.com/ui" keeps its domain, so +// the API URL becomes "https://acme.api.identitynow-demo.com". A pasted API +// URL ("acme.api.identitynow-demo.com") gives the same result. +func ParseTenantInput(input string) (string, string, string, error) { + input = strings.TrimSpace(input) + if input == "" { + return "", "", "", fmt.Errorf("tenant name or URL is empty") + } + + if !strings.Contains(input, ".") && !strings.Contains(input, "://") { + return input, "https://" + input + "." + defaultTenantDomain, "https://" + input + ".api." + defaultTenantDomain, nil + } + + if !strings.Contains(input, "://") { + input = "https://" + input + } + + parsed, err := url.Parse(input) + if err != nil { + return "", "", "", fmt.Errorf("tenant URL is not valid: %v", err) + } + if parsed.Scheme != "https" { + return "", "", "", fmt.Errorf("tenant URL must use HTTPS") + } + + host := strings.ToLower(parsed.Hostname()) + labels := strings.Split(host, ".") + if host == "" || len(labels) < 2 { + return "", "", "", fmt.Errorf("tenant URL has no valid host") + } + for _, label := range labels { + if label == "" { + return "", "", "", fmt.Errorf("tenant URL has no valid host") + } + } + + if len(labels) > 2 && labels[1] == "api" { + labels = append(labels[:1], labels[2:]...) + } + + name := labels[0] + domain := strings.Join(labels[1:], ".") + + return name, "https://" + name + "." + domain, "https://" + name + ".api." + domain, nil +} diff --git a/internal/util/url_test.go b/internal/util/url_test.go new file mode 100644 index 00000000..24d1e41d --- /dev/null +++ b/internal/util/url_test.go @@ -0,0 +1,40 @@ +package util + +import "testing" + +func TestParseTenantInput(t *testing.T) { + tests := []struct { + input string + name string + tenantURL string + baseURL string + }{ + {"acme", "acme", "https://acme.identitynow.com", "https://acme.api.identitynow.com"}, + {" acme ", "acme", "https://acme.identitynow.com", "https://acme.api.identitynow.com"}, + {"https://acme.identitynow.com", "acme", "https://acme.identitynow.com", "https://acme.api.identitynow.com"}, + {"https://devrel-ga-24331.identitynow-demo.com/", "devrel-ga-24331", "https://devrel-ga-24331.identitynow-demo.com", "https://devrel-ga-24331.api.identitynow-demo.com"}, + {"https://devrel-ga-24331.identitynow-demo.com/ui/d/dashboard?x=1", "devrel-ga-24331", "https://devrel-ga-24331.identitynow-demo.com", "https://devrel-ga-24331.api.identitynow-demo.com"}, + {"devrel-ga-24331.identitynow-demo.com", "devrel-ga-24331", "https://devrel-ga-24331.identitynow-demo.com", "https://devrel-ga-24331.api.identitynow-demo.com"}, + {"https://devrel-ga-24331.api.identitynow-demo.com", "devrel-ga-24331", "https://devrel-ga-24331.identitynow-demo.com", "https://devrel-ga-24331.api.identitynow-demo.com"}, + {"HTTPS://Acme.IdentityNow.com", "acme", "https://acme.identitynow.com", "https://acme.api.identitynow.com"}, + } + + for _, tt := range tests { + name, tenantURL, baseURL, err := ParseTenantInput(tt.input) + if err != nil { + t.Errorf("ParseTenantInput(%q) returned error: %v", tt.input, err) + continue + } + if name != tt.name || tenantURL != tt.tenantURL || baseURL != tt.baseURL { + t.Errorf("ParseTenantInput(%q) = (%q, %q, %q), want (%q, %q, %q)", tt.input, name, tenantURL, baseURL, tt.name, tt.tenantURL, tt.baseURL) + } + } +} + +func TestParseTenantInputRejectsBadInput(t *testing.T) { + for _, input := range []string{"", " ", "http://acme.identitynow.com", "https://", "https://.identitynow.com", "https://localhost"} { + if _, _, _, err := ParseTenantInput(input); err == nil { + t.Errorf("ParseTenantInput(%q) returned no error", input) + } + } +} diff --git a/internal/util/util.go b/internal/util/util.go index 4cf40741..9503c705 100644 --- a/internal/util/util.go +++ b/internal/util/util.go @@ -105,44 +105,22 @@ func CreateOrUpdateEnvironment(environmentName string, update bool) error { fmt.Print("Press ^C at any time to quit.\n\n") - tenant := "" - - if update && environmentName == "" { - tenant = terminal.InputPrompt("Tenant Name (ie: https://{tenant}.identitynow.com): (" + config.GetActiveEnvironment() + ")") - } else if update { - tenant = terminal.InputPrompt("Tenant Name (ie: https://{tenant}.identitynow.com): (" + getTextBetween(viper.GetString("environments."+environmentName+".tenanturl"), "//", ".") + ")") - } else { - tenant = terminal.InputPrompt("Tenant Name (ie: https://{tenant}.identitynow.com): (" + environmentName + ")") + tenant, tenantUrl, baseUrl, err := promptForTenant(environmentName, update) + if err != nil { + return err } if !update { if environments[tenant] != nil { - fmt.Print("Environment already exists\n\n To update the environment use `sail env update `" + tenant + ".\n\n") + fmt.Print("Environment already exists\n\n To update the environment use `sail env update " + tenant + "`.\n\n") return nil } } - if tenant == "" { - tenant = environmentName - } - - tenantUrl := "https://" + tenant + ".identitynow.com" - baseUrl := "https://" + tenant + ".api.identitynow.com" - - fmt.Print("\nThe following two prompts will allow you to set a custom base and tenant url if the generated URL\ndoes not apply. If the generated URL is correct simply press enter to proceed\n\n") - confirmTenantUrl := terminal.InputPrompt("Tenant URL (ie: https://{tenant}.identitynow.com): (" + tenantUrl + ")") - confirmBaseURL := terminal.InputPrompt("Base URL (ie: https://{tenant}.api.identitynow.com): (" + baseUrl + ")") + tenantUrl, baseUrl = confirmTenantUrls(tenantUrl, baseUrl) authType := terminal.InputPrompt("Authentication Type (oauth, pat):") - if confirmTenantUrl != "" { - tenantUrl = confirmTenantUrl - } - - if confirmBaseURL != "" { - baseUrl = confirmBaseURL - } - if authType == "pat" { clientID, err := config.PromptForClientID() @@ -201,6 +179,87 @@ func CreateOrUpdateEnvironment(environmentName string, update bool) error { return nil } +// promptForTenant asks for a tenant name or URL and returns the environment +// name, tenant URL and API base URL. On update, an empty answer keeps the URLs +// already stored for the environment. +func promptForTenant(environmentName string, update bool) (string, string, string, error) { + defaultName := environmentName + if update && defaultName == "" { + defaultName = config.GetActiveEnvironment() + } + + storedTenantUrl, storedBaseUrl := "", "" + if update { + storedTenantUrl = viper.GetString("environments." + defaultName + ".tenanturl") + storedBaseUrl = viper.GetString("environments." + defaultName + ".baseurl") + } + + promptDefault := defaultName + if storedTenantUrl != "" { + promptDefault = storedTenantUrl + } + + for attempt := 0; attempt < 3; attempt++ { + input := terminal.InputPrompt("Tenant name or URL (ie: acme, or https://acme.identitynow-demo.com): (" + promptDefault + ")") + + if input == "" && storedTenantUrl != "" && storedBaseUrl != "" { + return defaultName, storedTenantUrl, storedBaseUrl, nil + } + + if input == "" { + input = defaultName + } + + tenant, tenantUrl, baseUrl, err := ParseTenantInput(input) + if err != nil { + log.Warn("Invalid tenant name or URL", "error", err) + continue + } + + if environmentName != "" { + tenant = environmentName + } + + return tenant, tenantUrl, baseUrl, nil + } + + return "", "", "", fmt.Errorf("no valid tenant name or URL provided") +} + +// confirmTenantUrls checks the API URL against the tenant. When the check +// fails, the user can correct the URLs. The URLs are kept even if the check +// still fails, so tenants that are not reachable from this network still work. +func confirmTenantUrls(tenantUrl, baseUrl string) (string, string) { + err := config.CheckTenantAPI(baseUrl) + if err == nil { + fmt.Print("\n\u2714 Found tenant at " + tenantUrl + " (API: " + baseUrl + ")\n\n") + return tenantUrl, baseUrl + } + log.Warn("Could not confirm the tenant API URL", "url", baseUrl, "error", err) + + fmt.Print("\nThe following two prompts will allow you to set a custom base and tenant url if the generated URL\ndoes not apply. If the generated URL is correct simply press enter to proceed\n\n") + confirmTenantUrl := terminal.InputPrompt("Tenant URL (ie: https://{tenant}.identitynow.com): (" + tenantUrl + ")") + confirmBaseURL := terminal.InputPrompt("Base URL (ie: https://{tenant}.api.identitynow.com): (" + baseUrl + ")") + + if confirmTenantUrl != "" { + tenantUrl = confirmTenantUrl + } + + if confirmBaseURL != "" { + baseUrl = confirmBaseURL + } + + if confirmTenantUrl != "" || confirmBaseURL != "" { + if err := config.CheckTenantAPI(baseUrl); err != nil { + log.Warn("Could not confirm the tenant API URL, saving it anyway", "url", baseUrl, "error", err) + } else { + fmt.Print("\n\u2714 Found tenant at " + tenantUrl + " (API: " + baseUrl + ")\n\n") + } + } + + return tenantUrl, baseUrl +} + func SelectIdentity[T search.Identity](identities []search.Identity) (string, error) { var prompts []tui.Choice for i := 0; i < len(identities); i++ {