diff --git a/cmd/root/root.go b/cmd/root/root.go index b5d871e7..156de086 100644 --- a/cmd/root/root.go +++ b/cmd/root/root.go @@ -20,6 +20,7 @@ import ( "github.com/sailpoint-oss/sailpoint-cli/cmd/ui_plugins" "github.com/sailpoint-oss/sailpoint-cli/cmd/va" "github.com/sailpoint-oss/sailpoint-cli/cmd/workflow" + "github.com/sailpoint-oss/sailpoint-cli/internal/config" "github.com/sailpoint-oss/sailpoint-cli/internal/terminal" "github.com/sailpoint-oss/sailpoint-cli/internal/util" "github.com/spf13/cobra" @@ -75,6 +76,7 @@ func NewRootCommand() *cobra.Command { root.PersistentFlags().StringVarP(&env, "env", "", "", "Environment to use for SailPoint CLI commands") root.PersistentFlags().BoolVarP(&debug, "debug", "", false, "Enable debug logging") + root.PersistentFlags().StringVar(config.RuntimeAccessTokenVar(), "access-token", "", "Pre-minted Identity Security Cloud access token to send as a bearer token") viper.BindPFlag("activeenvironment", root.PersistentFlags().Lookup("env")) viper.BindPFlag("debug", root.PersistentFlags().Lookup("debug")) diff --git a/cmd/root/root_test.go b/cmd/root/root_test.go index 121ca492..c2168562 100644 --- a/cmd/root/root_test.go +++ b/cmd/root/root_test.go @@ -4,11 +4,15 @@ package root import ( "bytes" + "encoding/base64" + "encoding/json" "io" "strings" "testing" + "time" "github.com/golang/mock/gomock" + "github.com/sailpoint-oss/sailpoint-cli/internal/config" ) // Expected number of subcommands to `sail` root command @@ -44,6 +48,39 @@ func TestNewRootCmd_noArgs(t *testing.T) { } } +func TestNewRootCmd_accessTokenFlag(t *testing.T) { + t.Setenv("SAIL_ACCESS_TOKEN", "") + t.Cleanup(func() { config.SetRuntimeAccessToken("") }) + + payload, err := json.Marshal(map[string]any{ + "org": "acme", + "exp": time.Now().Add(time.Hour).Unix(), + }) + if err != nil { + t.Fatalf("marshal claims: %v", err) + } + token := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"RS256"}`)) + "." + + base64.RawURLEncoding.EncodeToString(payload) + "." + + base64.RawURLEncoding.EncodeToString([]byte("sig")) + + cmd := NewRootCommand() + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + cmd.SetArgs([]string{"--access-token", "Bearer " + token}) + + if err := cmd.Execute(); err != nil { + t.Fatalf("Execute() error = %v", err) + } + + got, err := config.GetBearerToken() + if err != nil { + t.Fatalf("GetBearerToken() error = %v", err) + } + if got != token { + t.Fatalf("GetBearerToken() = %q, want the access token passed on the command line", got) + } +} + func TestNewRootCmd_completionDisabled(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() diff --git a/cmd/set/README.md b/cmd/set/README.md index 53ee19c0..6e728922 100644 --- a/cmd/set/README.md +++ b/cmd/set/README.md @@ -14,15 +14,50 @@ The `set` command makes it easy to update configuration values for the SailPoint ## Authentication -Run the following command to set the current authentication method for the CLI. +The CLI can authenticate to Identity Security Cloud with a personal access token, OAuth, or a bearer token that another system has already minted. -> :warning: **Currently only Personal Access Token Authentication is supported**: OAuth possibly coming in the future! +### Personal access token ```shell sail set auth pat +sail set pat ``` -This will set the currently active authentication method to PAT. +PAT authentication exchanges a client ID and client secret at the OAuth token endpoint. You can also set `SAIL_BASE_URL`, `SAIL_CLIENT_ID`, and `SAIL_CLIENT_SECRET`. + +### OAuth + +```shell +sail set auth oauth +``` + +OAuth signs in through the Identity Security Cloud web portal. + +### Bearer token + +Use bearer authentication when you receive an access token instead of a client ID and client secret. That token is the `access_token` returned after a client ID and client secret are presented to the Identity Security Cloud OAuth token endpoint. The CLI sends it as `Authorization: Bearer ` on every request and does not call the OAuth token endpoint again. + +For a deployment, or any other process that receives the token from another system: + +```shell +export SAIL_BASE_URL="https://.api.identitynow.com" +export SAIL_ACCESS_TOKEN="" +``` + +`SAIL_ACCESS_TOKEN` selects bearer authentication for that process, including when another authentication method is configured. You can also pass the token for a single invocation: + +```shell +sail --access-token "" +``` + +To store the token for the active environment: + +```shell +sail set auth bearer +sail set bearer --token "" +``` + +`sail set bearer` prompts for the token when `--token` is omitted. The value may be the raw access token or the full `Bearer ` header value. A stored token is kept in the system keyring, not in the config file, and is used for later commands until it expires. The CLI cannot refresh it. Mint a new token at the OAuth token endpoint and set it again. ## Debug diff --git a/cmd/set/auth.go b/cmd/set/auth.go index 7958d627..71a446b6 100644 --- a/cmd/set/auth.go +++ b/cmd/set/auth.go @@ -13,6 +13,7 @@ func PromptAuth() (string, error) { items := []tui.Choice{ {Title: "PAT", Description: "Person Access Token - Single User PAT Configuration"}, {Title: "OAuth", Description: "OAuth2.0 Authentication - Sign in via the Identity Security Cloud web portal"}, + {Title: "Bearer", Description: "Bearer Token - Use an access token already minted at the OAuth token endpoint"}, } choice, err := tui.PromptList(items, "Choose an authentication method to configure") @@ -26,9 +27,9 @@ func PromptAuth() (string, error) { func newAuthCommand() *cobra.Command { cmd := &cobra.Command{ Use: "auth", - Short: "Set the currently active authentication mode (PAT, OAuth)", - Long: "\nSet the currently active authentication mode\n\nSupported Authentication Methods:\nPAT\nOAuth", - Example: "sail set auth pat | sail set auth oauth", + Short: "Set the currently active authentication mode (PAT, OAuth, bearer)", + Long: "\nSet the currently active authentication mode\n\nSupported Authentication Methods:\nPAT\nOAuth\nBearer - an access token already minted at the Identity Security Cloud OAuth token endpoint", + Example: "sail set auth pat | sail set auth oauth | sail set auth bearer", Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { var selection string @@ -54,8 +55,14 @@ func newAuthCommand() *cobra.Command { config.SetAuthType("oauth") log.Info("Authentication method set to OAuth") + case "bearer": + + config.SetAuthType("bearer") + log.Info("Authentication method set to bearer") + log.Info("Provide the minted access token with `sail set bearer`, SAIL_ACCESS_TOKEN, or --access-token") + default: - log.Warn("No selection made") + log.Warn("Unsupported authentication method. Choose pat, oauth, or bearer", "selection", selection) } return nil diff --git a/cmd/set/bearer.go b/cmd/set/bearer.go new file mode 100644 index 00000000..e19e02fd --- /dev/null +++ b/cmd/set/bearer.go @@ -0,0 +1,51 @@ +// Copyright (c) 2026, SailPoint Technologies, Inc. All rights reserved. +package set + +import ( + "github.com/charmbracelet/log" + "github.com/sailpoint-oss/sailpoint-cli/internal/config" + "github.com/spf13/cobra" +) + +func newBearerCommand() *cobra.Command { + var token string + cmd := &cobra.Command{ + Use: "bearer", + Short: "Configure bearer token authentication for the currently active environment", + Long: "\nConfigure bearer token authentication for the CLI\n\n" + + "Use this when another system has already minted an Identity Security Cloud access token\n" + + "at the OAuth token endpoint. Pass that access_token instead of a client ID and client secret.\n" + + "The CLI sends it as a bearer token on every request and does not call the OAuth token endpoint.\n\n" + + "The token can also be supplied without storing it, for a deployment or a single command:\n\n" + + " export SAIL_ACCESS_TOKEN=\"\"\n" + + " sail --access-token \"\" \n\n" + + "SAIL_ACCESS_TOKEN and --access-token select bearer authentication for that process.\n" + + "A stored token cannot be refreshed, because the client ID and secret are not available.\n" + + "Mint a new token when this one expires.", + Example: "sail set bearer --token ", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + if token == "" { + entered, err := config.PromptForBearerToken() + if err != nil { + return err + } + token = entered + } + + if err := config.SetBearerToken(token); err != nil { + return err + } + + config.SetAuthType("bearer") + log.Info("Bearer token stored for the active environment") + log.Info("Authentication method set to bearer") + + return nil + }, + } + + cmd.Flags().StringVarP(&token, "token", "t", "", "Minted Identity Security Cloud access token to send as a bearer token") + + return cmd +} diff --git a/cmd/set/bearer_test.go b/cmd/set/bearer_test.go new file mode 100644 index 00000000..9ca7f1fe --- /dev/null +++ b/cmd/set/bearer_test.go @@ -0,0 +1,46 @@ +package set + +import ( + "io" + "strings" + "testing" + + "github.com/sailpoint-oss/sailpoint-cli/internal/config" + "github.com/spf13/viper" +) + +func TestAuthCommandSetsBearer(t *testing.T) { + viper.Reset() + t.Cleanup(viper.Reset) + + cmd := newAuthCommand() + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + cmd.SetArgs([]string{"Bearer"}) + + if err := cmd.Execute(); err != nil { + t.Fatalf("Execute() error = %v", err) + } + if got := config.GetAuthType(); got != "bearer" { + t.Fatalf("GetAuthType() = %q, want bearer", got) + } +} + +func TestBearerCommandRejectsUnmintedToken(t *testing.T) { + viper.Reset() + t.Cleanup(viper.Reset) + config.SetAuthType("pat") + + cmd := newBearerCommand() + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + cmd.SetArgs([]string{"--token", "not-a-token"}) + + err := cmd.Execute() + if err == nil || !strings.Contains(err.Error(), "not a signed Identity Security Cloud access token") { + t.Fatalf("Execute() error = %v, want a rejected token", err) + } + if got := config.GetAuthType(); got != "pat" { + t.Fatalf("GetAuthType() = %q, want pat after a rejected token", got) + } +} diff --git a/cmd/set/set.go b/cmd/set/set.go index 46009ca2..9903afa8 100644 --- a/cmd/set/set.go +++ b/cmd/set/set.go @@ -24,6 +24,7 @@ func NewSetCmd(term terminal.Terminal) *cobra.Command { newExportTemplateCommand(), newSearchTemplateCommand(), newPATCommand(term), + newBearerCommand(), ) return cmd diff --git a/internal/config/bearer.go b/internal/config/bearer.go new file mode 100644 index 00000000..23eb4254 --- /dev/null +++ b/internal/config/bearer.go @@ -0,0 +1,185 @@ +package config + +import ( + "errors" + "fmt" + "os" + "strings" + "time" + + "github.com/sailpoint-oss/sailpoint-cli/internal/terminal" + keyring "github.com/zalando/go-keyring" + "gopkg.in/square/go-jose.v2/jwt" +) + +// accessTokenEnvVar carries an Identity Security Cloud access token that was +// already minted at the OAuth token endpoint. When it is set, the CLI sends +// that token on every request and does not exchange a client ID and secret. +const accessTokenEnvVar = "SAIL_ACCESS_TOKEN" + +const bearerTokenService = "environments.bearer.accesstoken" + +// runtimeAccessToken is the --access-token value for this process. It is kept +// out of viper so SaveConfig does not write the token into the config file. +var runtimeAccessToken string + +// SetRuntimeAccessToken records the access token passed with --access-token. +func SetRuntimeAccessToken(token string) { + runtimeAccessToken = strings.TrimSpace(token) +} + +// RuntimeAccessTokenVar is the destination for the --access-token flag. +// The flag package writes it when the command line is parsed, so the token +// stays in memory and is not saved with the config file. +func RuntimeAccessTokenVar() *string { + return &runtimeAccessToken +} + +// authTypeInUse reports the authentication method for this process. +// An explicitly supplied access token selects bearer authentication so a +// deployment can pass the minted token without also storing a client ID and +// secret. +func authTypeInUse() string { + if _, ok := explicitBearerToken(); ok { + return "bearer" + } + return GetAuthType() +} + +// explicitBearerToken returns a token supplied for this process, rather than +// one stored in the keyring. +func explicitBearerToken() (string, bool) { + if env := strings.TrimSpace(os.Getenv(accessTokenEnvVar)); env != "" { + return env, true + } + if runtimeAccessToken != "" { + return runtimeAccessToken, true + } + return "", false +} + +// GetBearerToken returns the access token to send as a bearer token. +// SAIL_ACCESS_TOKEN and --access-token take precedence over a token stored +// for the active environment. The token is used as-is: this does not call +// the OAuth token endpoint. +func GetBearerToken() (string, error) { + if raw, ok := explicitBearerToken(); ok { + token, err := parseBearerToken(raw) + if err != nil { + return "", err + } + return token, nil + } + + env := GetActiveEnvironment() + value, err := keyring.Get(bearerTokenService, env) + if err != nil { + return "", missingBearerTokenError(env, err) + } + + token, err := parseBearerToken(value) + if err != nil { + return "", err + } + return token, nil +} + +// SetBearerToken stores an access token for the active environment. +func SetBearerToken(token string) error { + parsed, err := parseBearerToken(token) + if err != nil { + return err + } + + err = keyring.Set(bearerTokenService, GetActiveEnvironment(), parsed) + if err != nil { + return err + } + return nil +} + +// PromptForBearerToken prompts for an access token that was already minted at +// the Identity Security Cloud OAuth token endpoint. +func PromptForBearerToken() (string, error) { + const maxAttempts = 3 + + for attempt := 1; attempt <= maxAttempts; attempt++ { + token, err := terminal.PromptMasked("Access token:") + if err != nil { + return "", err + } + + parsed, err := parseBearerToken(token) + if err == nil { + return parsed, nil + } + + fmtBearerAttempt(err, attempt, maxAttempts) + } + + return "", fmt.Errorf("maximum attempts reached for entering the bearer token") +} + +// parseBearerToken accepts either the raw access token or a value prefixed +// with "Bearer ". It rejects tokens that are not signed JWTs or that have +// already expired. An expired token cannot be refreshed here, because the +// client ID and secret that minted it are not available. +func parseBearerToken(raw string) (string, error) { + token := normalizeBearerToken(raw) + if token == "" { + return "", fmt.Errorf("the bearer token is empty") + } + + parsed, err := jwt.ParseSigned(token) + if err != nil { + return "", fmt.Errorf("the bearer token is not a signed Identity Security Cloud access token: %w\n\n"+ + "Use the access_token returned by the OAuth token endpoint. The CLI sends that token on each request and does not exchange a client ID and secret", err) + } + + var claims jwt.Claims + if err := parsed.UnsafeClaimsWithoutVerification(&claims); err != nil { + return "", fmt.Errorf("the bearer token is not a signed Identity Security Cloud access token: %w", err) + } + + if claims.Expiry != nil && !claims.Expiry.Time().After(time.Now()) { + return "", expiredBearerTokenError(claims.Expiry.Time()) + } + + return token, nil +} + +func normalizeBearerToken(raw string) string { + token := strings.TrimSpace(raw) + if len(token) >= 2 { + if (token[0] == '"' && token[len(token)-1] == '"') || (token[0] == '\'' && token[len(token)-1] == '\'') { + token = strings.TrimSpace(token[1 : len(token)-1]) + } + } + if len(token) >= 7 && strings.EqualFold(token[:7], "bearer ") { + token = strings.TrimSpace(token[7:]) + } + return token +} + +func expiredBearerTokenError(expiry time.Time) error { + return fmt.Errorf("the bearer token expired at %s\n\n"+ + "Mint a new access token at the Identity Security Cloud OAuth token endpoint, then set %s, pass --access-token, or run `sail set bearer`", + expiry.UTC().Format(time.RFC3339), accessTokenEnvVar) +} + +func missingBearerTokenError(env string, err error) error { + guidance := fmt.Sprintf("Set %s, pass --access-token, or run `sail set bearer`", accessTokenEnvVar) + if errors.Is(err, keyring.ErrNotFound) { + return fmt.Errorf("no bearer token is stored for environment %q\n\n"+ + "Pass an access token that was already minted at the Identity Security Cloud OAuth token endpoint. %s", env, guidance) + } + return fmt.Errorf("failed to read the bearer token for environment %q from the keyring: %w\n\n%s", env, err, guidance) +} + +func fmtBearerAttempt(err error, attempt, maxAttempts int) { + message := err.Error() + if i := strings.IndexByte(message, '\n'); i >= 0 { + message = message[:i] + } + fmt.Printf("%s Attempt %d/%d.\n", message, attempt, maxAttempts) +} diff --git a/internal/config/bearer_test.go b/internal/config/bearer_test.go new file mode 100644 index 00000000..bd401363 --- /dev/null +++ b/internal/config/bearer_test.go @@ -0,0 +1,191 @@ +package config + +import ( + "encoding/base64" + "encoding/json" + "strings" + "testing" + "time" + + "github.com/spf13/viper" + keyring "github.com/zalando/go-keyring" +) + +func mintedToken(t *testing.T, claims map[string]any) string { + t.Helper() + + header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"RS256"}`)) + payload, err := json.Marshal(claims) + if err != nil { + t.Fatalf("marshal claims: %v", err) + } + + return header + "." + base64.RawURLEncoding.EncodeToString(payload) + "." + base64.RawURLEncoding.EncodeToString([]byte("sig")) +} + +func futureToken(t *testing.T) string { + t.Helper() + return mintedToken(t, map[string]any{ + "org": "acme", + "exp": time.Now().Add(time.Hour).Unix(), + }) +} + +func clearRuntimeToken(t *testing.T) { + t.Helper() + previous := runtimeAccessToken + runtimeAccessToken = "" + t.Cleanup(func() { runtimeAccessToken = previous }) +} + +func TestParseBearerTokenUsesMintedTokenAsIs(t *testing.T) { + clearRuntimeToken(t) + token := futureToken(t) + + for _, raw := range []string{token, "Bearer " + token, "bearer " + token, `"` + token + `"`, "'Bearer " + token + "'"} { + got, err := parseBearerToken(raw) + if err != nil { + t.Fatalf("parseBearerToken(%q) error = %v", raw, err) + } + if got != token { + t.Fatalf("parseBearerToken(%q) = %q, want the minted access token", raw, got) + } + } +} + +func TestParseBearerTokenRejectsExpiredAndMalformed(t *testing.T) { + expired := mintedToken(t, map[string]any{"exp": time.Now().Add(-time.Hour).Unix()}) + _, err := parseBearerToken(expired) + if err == nil || !strings.Contains(err.Error(), "expired at") || !strings.Contains(err.Error(), accessTokenEnvVar) || !strings.Contains(err.Error(), "sail set bearer") { + t.Fatalf("parseBearerToken(expired) error = %v, want expiry guidance", err) + } + + _, err = parseBearerToken("not-a-token") + if err == nil || !strings.Contains(err.Error(), "not a signed Identity Security Cloud access token") || !strings.Contains(err.Error(), "does not exchange a client ID and secret") { + t.Fatalf("parseBearerToken(malformed) error = %v", err) + } + + _, err = parseBearerToken(" ") + if err == nil || !strings.Contains(err.Error(), "empty") { + t.Fatalf("parseBearerToken(blank) error = %v", err) + } +} + +func TestParseBearerTokenAllowsTokenWithoutExpiry(t *testing.T) { + token := mintedToken(t, map[string]any{"org": "acme"}) + got, err := parseBearerToken(token) + if err != nil { + t.Fatalf("parseBearerToken() error = %v", err) + } + if got != token { + t.Fatalf("parseBearerToken() = %q, want %q", got, token) + } +} + +func TestGetBearerTokenFromEnvironment(t *testing.T) { + clearRuntimeToken(t) + token := futureToken(t) + t.Setenv(accessTokenEnvVar, "Bearer "+token) + SetRuntimeAccessToken("ignored") + t.Cleanup(func() { SetRuntimeAccessToken("") }) + + got, err := GetBearerToken() + if err != nil { + t.Fatalf("GetBearerToken() error = %v", err) + } + if got != token { + t.Fatalf("GetBearerToken() = %q, want the environment token", got) + } +} + +func TestGetBearerTokenFromFlag(t *testing.T) { + clearRuntimeToken(t) + t.Setenv(accessTokenEnvVar, "") + token := futureToken(t) + SetRuntimeAccessToken(token) + t.Cleanup(func() { SetRuntimeAccessToken("") }) + + got, err := GetBearerToken() + if err != nil { + t.Fatalf("GetBearerToken() error = %v", err) + } + if got != token { + t.Fatalf("GetBearerToken() = %q, want the flag token", got) + } +} + +func TestAuthTypeInUsePrefersExplicitBearerToken(t *testing.T) { + resetViper(t) + clearRuntimeToken(t) + SetAuthType("pat") + + if got := authTypeInUse(); got != "pat" { + t.Fatalf("authTypeInUse() = %q, want pat", got) + } + + t.Setenv(accessTokenEnvVar, futureToken(t)) + if got := authTypeInUse(); got != "bearer" { + t.Fatalf("authTypeInUse() = %q, want bearer when %s is set", got, accessTokenEnvVar) + } +} + +func TestMissingBearerTokenError(t *testing.T) { + err := missingBearerTokenError("alpha", keyring.ErrNotFound) + for _, want := range []string{`bearer token is stored for environment "alpha"`, "sail set bearer", accessTokenEnvVar, "--access-token", "OAuth token endpoint"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("missingBearerTokenError() = %q, want it to contain %q", err, want) + } + } +} + +func TestValidateBearerFromEnvironment(t *testing.T) { + resetViper(t) + clearRuntimeToken(t) + SetAuthType("pat") + t.Setenv("SAIL_BASE_URL", "https://acme.api.identitynow.com") + t.Setenv("SAIL_CLIENT_ID", "") + t.Setenv("SAIL_CLIENT_SECRET", "") + t.Setenv(accessTokenEnvVar, futureToken(t)) + + if err := Validate(); err != nil { + t.Fatalf("Validate() error = %v, want bearer token from the environment to skip the client ID and secret", err) + } +} + +func TestValidateBearerRejectsExpiredToken(t *testing.T) { + resetViper(t) + clearRuntimeToken(t) + SetAuthType("bearer") + t.Setenv("SAIL_BASE_URL", "https://acme.api.identitynow.com") + t.Setenv(accessTokenEnvVar, mintedToken(t, map[string]any{"exp": time.Now().Add(-time.Minute).Unix()})) + + err := Validate() + if err == nil || !strings.Contains(err.Error(), "expired at") { + t.Fatalf("Validate() error = %v, want an expired token error", err) + } +} + +func TestCheckEnvironmentMentionsBearerToken(t *testing.T) { + resetViper(t) + t.Setenv("SAIL_BASE_URL", "") + + err := CheckEnvironment() + if err == nil || !strings.Contains(err.Error(), accessTokenEnvVar) || !strings.Contains(err.Error(), "sail env create") { + t.Fatalf("CheckEnvironment() error = %v, want bearer token guidance", err) + } +} + +func TestValidateBearerRequiresBaseURL(t *testing.T) { + resetViper(t) + clearRuntimeToken(t) + viper.Set("environments.alpha.tenanturl", "https://alpha.identitynow.com") + SetActiveEnvironment("alpha") + SetAuthType("bearer") + t.Setenv("SAIL_BASE_URL", "") + t.Setenv(accessTokenEnvVar, futureToken(t)) + + err := Validate() + if err == nil || !strings.Contains(err.Error(), "configuration invalid") { + t.Fatalf("Validate() error = %v, want a missing BaseURL error", err) + } +} diff --git a/internal/config/config.go b/internal/config/config.go index f771d0de..5869e5be 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -259,7 +259,7 @@ func GetAuthToken() (string, error) { return "", err } - switch GetAuthType() { + switch authTypeInUse() { case "pat": @@ -337,6 +337,17 @@ func GetAuthToken() (string, error) { } + case "bearer": + + // The access token was already minted at the OAuth token endpoint. + // Send it on each request instead of exchanging a client ID and secret. + tempToken, err := GetBearerToken() + if err != nil { + return "", err + } + + token = tempToken + default: return token, fmt.Errorf("invalid authtype configured") } @@ -444,7 +455,7 @@ func CheckEnvironment() error { 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") + "Run `sail env create` to set up an environment, set SAIL_BASE_URL, SAIL_CLIENT_ID, and SAIL_CLIENT_SECRET for PAT authentication, or set SAIL_BASE_URL and SAIL_ACCESS_TOKEN to use a bearer token that was already minted at the Identity Security Cloud OAuth token endpoint") } active := GetActiveEnvironment() @@ -469,7 +480,7 @@ func CheckEnvironment() error { func Validate() error { var errors int - authType := GetAuthType() + authType := authTypeInUse() if err := CheckEnvironment(); err != nil { return err @@ -527,9 +538,24 @@ func Validate() error { errors++ } + case "bearer": + + if strings.TrimSpace(os.Getenv(accessTokenEnvVar)) == "" && runtimeAccessToken == "" && !supportsSecrets { + log.Warn("Secrets storage is not currently functional on this platform, bearer authentication will only work with SAIL_ACCESS_TOKEN or --access-token") + } + + if GetBaseUrl() == "" { + log.Error("configured environment is missing BaseURL, run `sail env update " + env + "` to set it") + errors++ + } + + if _, err := GetBearerToken(); err != nil { + return err + } + default: - log.Error("invalid authtype configured, run `sail set auth` to choose oauth or pat", "authtype", authType) + log.Error("invalid authtype configured, run `sail set auth` to choose oauth, pat, or bearer", "authtype", authType) errors++ } diff --git a/internal/util/util.go b/internal/util/util.go index 9503c705..93e1da32 100644 --- a/internal/util/util.go +++ b/internal/util/util.go @@ -119,7 +119,7 @@ func CreateOrUpdateEnvironment(environmentName string, update bool) error { tenantUrl, baseUrl = confirmTenantUrls(tenantUrl, baseUrl) - authType := terminal.InputPrompt("Authentication Type (oauth, pat):") + authType := strings.ToLower(strings.TrimSpace(terminal.InputPrompt("Authentication Type (oauth, pat, bearer):"))) if authType == "pat" { @@ -169,12 +169,39 @@ func CreateOrUpdateEnvironment(environmentName string, update bool) error { config.GetAuthToken() } + if authType == "bearer" { + + token, err := config.PromptForBearerToken() + if err != nil { + return err + } + + if environmentName != "" { + config.SetActiveEnvironment(environmentName) + } else { + config.SetActiveEnvironment(tenant) + } + + config.SetTenantUrl(tenantUrl) + config.SetBaseUrl(baseUrl) + config.SetAuthType(authType) + + err = config.SetBearerToken(token) + if err != nil { + return err + } + } + fmt.Print("\n\nEnvironment successfully created.\n\n") fmt.Print("You can change your authentication type at any time by running `sail set auth`.\n\n") if authType == "pat" { fmt.Print("You can change your client id and secret at any time by running `sail set pat`.\n\n") } + + if authType == "bearer" { + fmt.Print("You can change the bearer token at any time by running `sail set bearer`, or set SAIL_ACCESS_TOKEN.\n\n") + } } return nil }