Skip to content

Commit

Permalink
Exit codes (#104)
Browse files Browse the repository at this point in the history
* Handle common errors in main with well-defined error exit codes

* Refactor shared Okta error handling logic

* remove uses of printerrf in lieu of error messages

* Make UsageError a codeError

* Make the error experience a little better
  • Loading branch information
punmechanic authored Jan 24, 2024
1 parent f186716 commit 30b3fd3
Show file tree
Hide file tree
Showing 8 changed files with 160 additions and 90 deletions.
15 changes: 7 additions & 8 deletions cli/accounts.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand All @@ -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{
Expand All @@ -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)
Expand Down
124 changes: 105 additions & 19 deletions cli/error.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
41 changes: 8 additions & 33 deletions cli/get.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
Expand All @@ -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 == "" {
Expand All @@ -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 {
Expand All @@ -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{
Expand Down
13 changes: 9 additions & 4 deletions cli/main.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package main

import (
"errors"
"os"
"strings"

Expand All @@ -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)
}
25 changes: 25 additions & 0 deletions cli/oauth2.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
}
27 changes: 3 additions & 24 deletions cli/roles.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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) {
Expand Down
1 change: 1 addition & 0 deletions cli/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -101,4 +101,5 @@ To get started run the following commands:
defer file.Close()
return config.Write(file)
},
SilenceErrors: true,
}
4 changes: 2 additions & 2 deletions cli/switch.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down

0 comments on commit 30b3fd3

Please sign in to comment.