Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Exit codes #104

Merged
merged 6 commits into from
Jan 24, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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