diff --git a/cli/accounts.go b/cli/accounts.go index 1b25610d..8bc60e0e 100644 --- a/cli/accounts.go +++ b/cli/accounts.go @@ -38,6 +38,7 @@ var accountsCmd = &cobra.Command{ config.DumpAccounts(stdOut, loud) if loud { + // intentionally uses PrintErrf was a warning cmd.PrintErrf("--%s was specified - these results may be out of date, and you may not have access to accounts in this list.\n", FlagNoRefresh) } @@ -47,14 +48,14 @@ var accountsCmd = &cobra.Command{ serverAddr, _ := cmd.Flags().GetString(FlagServerAddress) serverAddrURI, err := url.Parse(serverAddr) if err != nil { - cmd.PrintErrf("--%s had an invalid value: %s\n", FlagServerAddress, err) - return nil + return genericError{ + ExitCode: ExitCodeValueError, + Message: fmt.Sprintf("--%s had an invalid value: %s\n", FlagServerAddress, err), + } } if HasTokenExpired(config.Tokens) { - cmd.PrintErrln("Your session has expired. Please run login again.") - config.SaveOAuthToken(nil) - return nil + return ErrTokensExpiredOrAbsent } tok := oauth2.Token{ @@ -66,9 +67,7 @@ var accountsCmd = &cobra.Command{ accounts, err := refreshAccounts(cmd.Context(), serverAddrURI, &tok) if err != nil { - cmd.PrintErrf("Error refreshing accounts: %s\n", err) - cmd.PrintErrln("If you don't need to refresh your accounts, consider adding the --no-refresh flag") - return nil + return fmt.Errorf("error refreshing accounts: %w", err) } config.UpdateAccounts(accounts) diff --git a/cli/error.go b/cli/error.go index 9ea4c842..c47dc9ab 100644 --- a/cli/error.go +++ b/cli/error.go @@ -5,34 +5,120 @@ import ( "strings" ) -func invalidValueError(value string, validValues []string) error { +const ( + ExitCodeTokensExpiredOrAbsent uint8 = 0x1 + ExitCodeUndisclosedOktaError = 0x2 + ExitCodeAuthenticationError = 0x3 + ExitCodeConnectivityError = 0x4 + ExitCodeValueError = 0x5 + ExitCodeAWSError = 0x6 + ExitCodeUnknownError = 0x7D +) + +var ( + ErrTokensExpiredOrAbsent = UsageError{ + ExitCode: ExitCodeTokensExpiredOrAbsent, + DebugMessage: "tokens expired or absent", + Description: "Your session has expired. Please login again.", + } +) + +type genericError struct { + Message string + ExitCode uint8 +} + +func (e genericError) Error() string { + return e.Message +} + +func (e genericError) Code() uint8 { + return e.ExitCode +} + +type codeError interface { + Error() string + Code() uint8 +} + +// UsageError indicates that the user used the program incorrectly +type UsageError struct { + ExitCode uint8 + Description string + DebugMessage string +} + +func (u UsageError) Error() string { + return u.Description +} + +func (u UsageError) Code() uint8 { + return u.ExitCode +} + +func UnknownRoleError(role, applicationID string) error { + return genericError{ + Message: fmt.Sprintf("You do not have access to the role %s on application %s", role, applicationID), + ExitCode: ExitCodeValueError, + } +} + +func UnknownAccountError(accountID, bypassCacheFlag string) error { + return genericError{ + Message: fmt.Sprintf("%q is not a known account name in your account cache. Your cache can be refreshed by entering executing `keyconjurer accounts`. If the value provided is an Okta application ID, you may provide --%s as an option to this command and try again.", accountID, bypassCacheFlag), + ExitCode: ExitCodeValueError, + } +} + +type ValueError struct { + Value string + ValidValues []string +} + +func (v ValueError) Error() string { var quoted []string - for _, v := range validValues { + for _, v := range v.ValidValues { quoted = append(quoted, fmt.Sprintf("%q", v)) } acceptable := strings.Join(quoted, ",") - help := fmt.Sprintf("provided value %s was not valid (accepted values: %s)", value, acceptable) - return &UsageError{ - ShortMessage: "invalid_value", - Help: help, - } + return fmt.Sprintf("provided value %s was not valid (accepted values: %s)", v.Value, acceptable) } -// UsageError indicates that the user used the program incorrectly -type UsageError struct { - // ShortMessage is not currently used, but is intended to be used when debugging. It is not displayed to users. - ShortMessage string - // Help is displayed to the user when the error message occurs over a tty. It should be one sentence and inform the user how to resolve the problem. - Help string +func (v ValueError) Code() uint8 { + return ExitCodeValueError +} + +type OktaError struct { + InnerError error + Message string +} + +func (o OktaError) Unwrap() error { + return o.InnerError +} + +func (o OktaError) Error() string { + return o.Message +} + +func (o OktaError) Code() uint8 { + return ExitCodeUndisclosedOktaError +} + +type AWSError struct { + InnerError error + Message string +} + +func (o AWSError) Unwrap() error { + return o.InnerError } -func (u *UsageError) Error() string { - return u.Help +func (o AWSError) Error() string { + return o.Message } -// ErrNoCredentials indicates the user attempted to use a command that requires credentials to be stored on disk but had not logged in beforehand. -var ErrNoCredentials error = &UsageError{ - ShortMessage: "no credentials", - Help: "You must log in using `keyconjurer login` before using this command", +func (o AWSError) Code() uint8 { + return ExitCodeAWSError } diff --git a/cli/get.go b/cli/get.go index b88c128d..3cbd4bc8 100644 --- a/cli/get.go +++ b/cli/get.go @@ -71,10 +71,8 @@ A role must be specified when using this command through the --role flag. You ma config := ConfigFromCommand(cmd) ctx := cmd.Context() if HasTokenExpired(config.Tokens) { - cmd.PrintErrln("Your session has expired. Please login again.") - return nil + return ErrTokensExpiredOrAbsent } - client := NewHTTPClient() ttl, _ := cmd.Flags().GetUint(FlagTimeToLive) timeRemaining, _ := cmd.Flags().GetUint(FlagTimeRemaining) @@ -88,11 +86,11 @@ A role must be specified when using this command through the --role flag. You ma tencentCliPath, _ := cmd.Flags().GetString(FlagTencentCLIPath) if !isMemberOfSlice(permittedOutputTypes, outputType) { - return invalidValueError(outputType, permittedOutputTypes) + return ValueError{Value: outputType, ValidValues: permittedOutputTypes} } if !isMemberOfSlice(permittedShellTypes, shellType) { - return invalidValueError(shellType, permittedShellTypes) + return ValueError{Value: shellType, ValidValues: permittedShellTypes} } // make sure we enforce limit @@ -113,8 +111,7 @@ A role must be specified when using this command through the --role flag. You ma bypassCache, _ := cmd.Flags().GetBool(FlagBypassCache) account, ok := resolveApplicationInfo(config, bypassCache, accountID) if !ok { - cmd.PrintErrf("%q is not a known account name in your account cache. Your cache can be refreshed by entering executing `keyconjurer accounts`. If the value provided is an Okta application ID, you may provide %s as an option to this command and try again.", accountID, FlagBypassCache) - return nil + return UnknownAccountError(args[0], FlagBypassCache) } if roleName == "" { @@ -140,35 +137,14 @@ A role must be specified when using this command through the --role flag. You ma return echoCredentials(accountID, accountID, credentials, outputType, shellType, awsCliPath, tencentCliPath) } - oauthCfg, err := DiscoverOAuth2Config(cmd.Context(), oidcDomain, clientID) + samlResponse, assertionStr, err := DiscoverConfigAndExchangeTokenForAssertion(cmd.Context(), NewHTTPClient(), config.Tokens, oidcDomain, clientID, account.ID) if err != nil { - cmd.PrintErrf("could not discover oauth2 config: %s\n", err) - return nil - } - - tok, err := ExchangeAccessTokenForWebSSOToken(cmd.Context(), client, oauthCfg, config.Tokens, account.ID) - if err != nil { - cmd.PrintErrf("error exchanging token: %s\n", err) - return nil - } - - assertion, err := ExchangeWebSSOTokenForSAMLAssertion(cmd.Context(), client, oidcDomain, tok) - if err != nil { - cmd.PrintErrf("failed to fetch SAML assertion: %s\n", err) - return nil - } - - assertionStr := string(assertion) - samlResponse, err := ParseBase64EncodedSAMLResponse(assertionStr) - if err != nil { - cmd.PrintErrf("could not parse assertion: %s\n", err) - return nil + return err } pair, ok := FindRoleInSAML(roleName, samlResponse) if !ok { - cmd.PrintErrf("you do not have access to the role %s on application %s\n", roleName, accountID) - return nil + return UnknownRoleError(roleName, args[0]) } if ttl == 1 && config.TTL != 0 { @@ -188,8 +164,7 @@ A role must be specified when using this command through the --role flag. You ma }) if err != nil { - cmd.PrintErrf("failed to exchange credentials: %s", err) - return nil + return AWSError{InnerError: err, Message: "failed to exchange credentials"} } credentials = CloudCredentials{ diff --git a/cli/main.go b/cli/main.go index 176fee82..ba011e36 100644 --- a/cli/main.go +++ b/cli/main.go @@ -1,6 +1,7 @@ package main import ( + "errors" "os" "strings" @@ -24,9 +25,13 @@ func main() { } rootCmd.SetArgs(args) - if err := rootCmd.Execute(); err == nil { - return + err := rootCmd.Execute() + var codeErr codeError + if errors.As(err, &codeErr) { + rootCmd.PrintErrf("keyconjurer: %s\n", codeErr.Error()) + os.Exit(int(codeErr.Code())) + } else if err != nil { + rootCmd.PrintErrf("keyconjurer: %s\n", err.Error()) + os.Exit(ExitCodeUnknownError) } - - os.Exit(1) } diff --git a/cli/oauth2.go b/cli/oauth2.go index e9d86249..701e9ef4 100644 --- a/cli/oauth2.go +++ b/cli/oauth2.go @@ -13,6 +13,7 @@ import ( "net/url" "strings" + "github.com/RobotsAndPencils/go-saml" "github.com/coreos/go-oidc" rootcerts "github.com/hashicorp/go-rootcerts" "golang.org/x/net/html" @@ -243,3 +244,27 @@ func ExchangeWebSSOTokenForSAMLAssertion(ctx context.Context, client *http.Clien return []byte(saml), nil } + +func DiscoverConfigAndExchangeTokenForAssertion(ctx context.Context, client *http.Client, toks *TokenSet, oidcDomain, clientID, applicationID string) (*saml.Response, string, error) { + oauthCfg, err := DiscoverOAuth2Config(ctx, oidcDomain, clientID) + if err != nil { + return nil, "", OktaError{Message: "could not discover oauth2 config", InnerError: err} + } + + tok, err := ExchangeAccessTokenForWebSSOToken(ctx, client, oauthCfg, toks, applicationID) + if err != nil { + return nil, "", OktaError{Message: "error exchanging token", InnerError: err} + } + + assertionBytes, err := ExchangeWebSSOTokenForSAMLAssertion(ctx, client, oidcDomain, tok) + if err != nil { + return nil, "", OktaError{Message: "failed to fetch SAML assertion", InnerError: err} + } + + response, err := ParseBase64EncodedSAMLResponse(string(assertionBytes)) + if err != nil { + return nil, "", OktaError{Message: "failed to parse SAML response", InnerError: err} + } + + return response, string(assertionBytes), nil +} diff --git a/cli/roles.go b/cli/roles.go index 59ea49e2..c614474e 100644 --- a/cli/roles.go +++ b/cli/roles.go @@ -11,10 +11,8 @@ var rolesCmd = cobra.Command{ RunE: func(cmd *cobra.Command, args []string) error { config := ConfigFromCommand(cmd) if HasTokenExpired(config.Tokens) { - cmd.PrintErrln("Your session has expired. Please login again.") - return nil + return ErrTokensExpiredOrAbsent } - client := NewHTTPClient() oidcDomain, _ := cmd.Flags().GetString(FlagOIDCDomain) clientID, _ := cmd.Flags().GetString(FlagClientID) @@ -25,28 +23,9 @@ var rolesCmd = cobra.Command{ applicationID = account.ID } - oauthCfg, err := DiscoverOAuth2Config(cmd.Context(), oidcDomain, clientID) + samlResponse, _, err := DiscoverConfigAndExchangeTokenForAssertion(cmd.Context(), NewHTTPClient(), config.Tokens, oidcDomain, clientID, applicationID) if err != nil { - cmd.PrintErrf("could not discover oauth2 config: %s\n", err) - return nil - } - - tok, err := ExchangeAccessTokenForWebSSOToken(cmd.Context(), client, oauthCfg, config.Tokens, applicationID) - if err != nil { - cmd.PrintErrf("error exchanging token: %s\n", err) - return nil - } - - assertionBytes, err := ExchangeWebSSOTokenForSAMLAssertion(cmd.Context(), client, oidcDomain, tok) - if err != nil { - cmd.PrintErrf("failed to fetch SAML assertion: %s\n", err) - return nil - } - - samlResponse, err := ParseBase64EncodedSAMLResponse(string(assertionBytes)) - if err != nil { - cmd.PrintErrf("could not parse assertion: %s\n", err) - return nil + return err } for _, name := range ListSAMLRoles(samlResponse) { diff --git a/cli/root.go b/cli/root.go index 11520603..14522f67 100644 --- a/cli/root.go +++ b/cli/root.go @@ -101,4 +101,5 @@ To get started run the following commands: defer file.Close() return config.Write(file) }, + SilenceErrors: true, } diff --git a/cli/switch.go b/cli/switch.go index 7e261111..1f817518 100644 --- a/cli/switch.go +++ b/cli/switch.go @@ -51,11 +51,11 @@ This command will fail if you do not have active Cloud credentials. cloudType, _ := cmd.Flags().GetString(FlagCloudType) awsCliPath, _ := cmd.Flags().GetString(FlagAWSCLIPath) if !isMemberOfSlice(permittedOutputTypes, outputType) { - return invalidValueError(outputType, permittedOutputTypes) + return ValueError{Value: outputType, ValidValues: permittedOutputTypes} } if !isMemberOfSlice(permittedShellTypes, shellType) { - return invalidValueError(shellType, permittedShellTypes) + return ValueError{Value: shellType, ValidValues: permittedShellTypes} } // We could read the environment variable for the assumed role ARN, but it might be expired which isn't very useful to the user.