diff --git a/.gitignore b/.gitignore index fb11ceca011..86fb2b74362 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,4 @@ awstesting/integration/smoke/_test/ /vendor/pkg/ /vendor/src/ /private/model/cli/gen-api/gen-api +.*.swp diff --git a/aws/credentials/example.ini b/aws/credentials/example.ini index 7fc91d9d204..84a3536a4f0 100644 --- a/aws/credentials/example.ini +++ b/aws/credentials/example.ini @@ -2,6 +2,7 @@ aws_access_key_id = accessKey aws_secret_access_key = secret aws_session_token = token +credential_process = /bin/echo {"Version": 1, "AccessKeyId": "accessKey", "SecretAccessKey": "secret", "SessionToken": "tokenDefault", "Expiration": "2000-01-01T00:00:00-00:00"} [no_token] aws_access_key_id = accessKey @@ -10,3 +11,6 @@ aws_secret_access_key = secret [with_colon] aws_access_key_id: accessKey aws_secret_access_key: secret + +[profile process] +credential_process = /bin/echo {"Version": 1, "AccessKeyId": "accessKey", "SecretAccessKey": "secret", "SessionToken": "tokenProcess", "Expiration": "2000-01-01T00:00:00-00:00"} diff --git a/aws/credentials/process_provider.go b/aws/credentials/process_provider.go new file mode 100644 index 00000000000..2541cfe0eb9 --- /dev/null +++ b/aws/credentials/process_provider.go @@ -0,0 +1,228 @@ +package credentials + +import ( + "encoding/json" + "os" + "os/exec" + "strings" + "time" + + "github.com/aws/aws-sdk-go/aws/awserr" + "github.com/aws/aws-sdk-go/internal/shareddefaults" + "github.com/go-ini/ini" +) + +// ProcessProviderName provides a name of Process provider +const ProcessProviderName = "ProcessProvider" + +var ( + // ErrProcessProviderHomeNotFound is emitted when the user directory cannot be found. + ErrProcessProviderHomeNotFound = awserr.New("UserHomeNotFound", "user home directory not found.", nil) +) + +// A ProcessProvider retrieves credentials from a process specified as +// 'credential_process' in a profile, and keeps track if those credentials are +// expired. +// +// Profile ini file example: $HOME/.aws/config +type ProcessProvider struct { + Expiry + + // Path to the shared config file. + // + // If empty will look for "AWS_CONFIG_FILE" env variable. If the + // env value is empty will default to current user's home directory. + // Linux/OSX: "$HOME/.aws/config" + // Windows: "%USERPROFILE%\.aws\config" + Filename string + + // AWS Profile to retrieve the 'credential_process' from. If empty + // will default to environment variable "AWS_PROFILE" or "default" if + // environment variable is also not set. + Profile string + + // ExpiryWindow will allow the credentials to trigger refreshing prior to + // the credentials actually expiring. This is beneficial so race conditions + // with expiring credentials do not cause request to fail unexpectedly + // due to ExpiredTokenException exceptions. + // + // So a ExpiryWindow of 10s would cause calls to IsExpired() to return true + // 10 seconds before the credentials are actually expired. + // + // If ExpiryWindow is 0 or less it will be ignored. + ExpiryWindow time.Duration + + // A function to perform the credential_process execution. Can be replaced + // for testing + executionFunc func(process string) ([]byte, error) +} + +// NewProcessProvider returns a pointer to a new Credentials object +// wrapping the Profile file provider. +func NewProcessProvider(filename, profile string) *Credentials { + return NewCredentials(&ProcessProvider{ + Filename: filename, + Profile: profile, + executionFunc: executeCredentialProcess, + }) +} + +// Retrieve executes the 'credential_process' and returns the credentials +func (p *ProcessProvider) Retrieve() (Value, error) { + filename, err := p.filename() + if err != nil { + return Value{ProviderName: ProcessProviderName}, err + } + + creds, err := p.loadProfile(filename, p.profile()) + if err != nil { + return Value{ProviderName: ProcessProviderName}, err + } + + return creds, nil +} + +// loadProfiles executes the 'credential_process' from the profile of the given +// file, and returns the credentials retrieved. An error will be returned if it +// fails to read from the file, or the data is invalid. +func (p *ProcessProvider) loadProfile(filename, profile string) (Value, error) { + config, err := ini.Load(filename) + if err != nil { + return Value{ProviderName: ProcessProviderName}, awserr.New("ProcessProviderLoad", "failed to load shared config file", err) + } + + var iniProfile *ini.Section + if len(profile) == 0 || profile == "default" { + iniProfile, err = config.GetSection("default") + } else { + iniProfile, err = config.GetSection("profile " + profile) + } + if err != nil { + return Value{ProviderName: ProcessProviderName}, awserr.New("ProcessProviderLoad", "failed to get profile", err) + } + + process, err := iniProfile.GetKey("credential_process") + if err != nil { + return Value{ProviderName: ProcessProviderName}, awserr.New("ProcessProviderLoad", "No credential_process specified", err) + } + return p.credentialProcess(process.String()) +} + +type credentialProcessResponse struct { + Version int + AccessKeyID string `json:"AccessKeyId"` + SecretAccessKey string + SessionToken string + Expiration string +} + +func executeCredentialProcess(process string) ([]byte, error) { + processArgs := strings.Split(process, " ") + var command string + var cmdArgs []string + + if len(processArgs) == 1 { + command = processArgs[0] + } else if len(processArgs) > 1 { + command = processArgs[0] + cmdArgs = processArgs[1:] + } + + // TODO: Check for executability? + _, err := os.Stat(command) + if os.IsNotExist(err) { + return nil, awserr.New("ProcessProviderLoad", "credential_process "+command+" not found", err) + } else if err != nil { + return nil, awserr.New("ProcessProviderLoad", "failed to open credential_process "+command, err) + } + + // Execute command + cmd := exec.Command(command, cmdArgs...) + cmd.Env = os.Environ() + out, err := cmd.Output() + if err != nil { + return nil, awserr.New("ProcessProviderLoad", "Error executing credential_process", err) + } + return out, nil +} + +func (p *ProcessProvider) credentialProcess(process string) (Value, error) { + out, err := p.executionFunc(process) + if err != nil { + return Value{ProviderName: ProcessProviderName}, err + } + + // Serialize and validate response + resp := &credentialProcessResponse{} + err = json.Unmarshal(out, resp) + if err != nil { + return Value{ProviderName: ProcessProviderName}, awserr.New("ProcessProviderLoad", "Error parsing credential_process output: "+string(out), err) + } + if resp.Version != 1 { + return Value{ProviderName: ProcessProviderName}, awserr.New("ProcessProviderLoad", " Version in credential_process output is not 1", err) + } + if len(resp.AccessKeyID) == 0 { + return Value{ProviderName: ProcessProviderName}, awserr.New("ProcessProviderLoad", " Missing required AccessKeyId in credential_process output", err) + } + if len(resp.SecretAccessKey) == 0 { + return Value{ProviderName: ProcessProviderName}, awserr.New("ProcessProviderLoad", " Missing required SecretAccessKey in credential_process output", err) + } + + // Handle expiration + if len(resp.Expiration) > 0 { + expiry, err := time.Parse(time.RFC3339, resp.Expiration) + if err != nil { + return Value{ProviderName: ProcessProviderName}, awserr.New("ProcessProviderLoad", "Error parsing expiration of credential_process output: "+resp.Expiration, err) + } + p.SetExpiration(expiry, p.ExpiryWindow) + } + + return Value{ + ProviderName: ProcessProviderName, + AccessKeyID: resp.AccessKeyID, + SecretAccessKey: resp.SecretAccessKey, + SessionToken: resp.SessionToken, + }, nil + +} + +// filename returns the filename to use to read AWS shared credentials. +// +// Will return an error if the user's home directory path cannot be found. +func (p *ProcessProvider) filename() (string, error) { + if len(p.Filename) != 0 { + return p.Filename, nil + } + + if p.Filename = os.Getenv("AWS_CONFIG_FILE"); len(p.Filename) != 0 { + return p.Filename, nil + } + + if home := shareddefaults.UserHomeDir(); len(home) == 0 { + // Backwards compatibility of home directly not found error being returned. + // This error is too verbose, failure when opening the file would of been + // a better error to return. + return "", ErrProcessProviderHomeNotFound + } + + p.Filename = shareddefaults.SharedConfigFilename() + + return p.Filename, nil +} + +// profile returns the AWS shared config profile. If empty will read +// environment variable "AWS_PROFILE". If that is not set profile will +// return "default". +func (p *ProcessProvider) profile() string { + if p.Profile == "" { + p.Profile = os.Getenv("AWS_PROFILE") + } + if p.Profile == "" { + p.Profile = os.Getenv("AWS_DEFAULT_PROFILE") + } + if p.Profile == "" { + p.Profile = "default" + } + + return p.Profile +} diff --git a/aws/credentials/process_provider_test.go b/aws/credentials/process_provider_test.go new file mode 100644 index 00000000000..a273cb59b41 --- /dev/null +++ b/aws/credentials/process_provider_test.go @@ -0,0 +1,143 @@ +package credentials + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestProcessProvider(t *testing.T) { + os.Clearenv() + + p := ProcessProvider{Filename: "example.ini", Profile: "process", executionFunc: executeCredentialProcess} + creds, err := p.Retrieve() + assert.Nil(t, err, "Expect no error") + + assert.Equal(t, "accessKey", creds.AccessKeyID, "Expect access key ID to match") + assert.Equal(t, "secret", creds.SecretAccessKey, "Expect secret access key to match") + assert.Equal(t, "tokenProcess", creds.SessionToken, "Expect session token to match") +} + +func fakeExectuteCredsExpired(process string) ([]byte, error) { + return []byte(`{"Version": 1, "AccessKeyId": "accessKey", "SecretAccessKey": "secret", "SessionToken": "tokenDefault", "Expiration": "2000-01-01T00:00:00-00:00"}`), nil +} + +func TestProcessProviderIsExpired(t *testing.T) { + os.Clearenv() + + p := ProcessProvider{Filename: "example.ini", Profile: "process", executionFunc: fakeExectuteCredsExpired} + + assert.True(t, p.IsExpired(), "Expect creds to be expired before retrieve") +} + +func TestProcessProviderWithAWS_CONFIG_FILE(t *testing.T) { + os.Clearenv() + os.Setenv("AWS_CONFIG_FILE", "example.ini") + os.Setenv("AWS_DEFAULT_PROFILE", "process") + p := ProcessProvider{Filename: "", Profile: "", executionFunc: executeCredentialProcess} + creds, err := p.Retrieve() + + assert.Nil(t, err, "Expect no error") + + assert.Equal(t, "accessKey", creds.AccessKeyID, "Expect access key ID to match") + assert.Equal(t, "secret", creds.SecretAccessKey, "Expect secret access key to match") + assert.Equal(t, "tokenProcess", creds.SessionToken, "Expect session token to match") +} + +func TestProcessProviderWithAWS_CONFIG_FILEAbsPath(t *testing.T) { + os.Clearenv() + wd, err := os.Getwd() + assert.NoError(t, err) + os.Setenv("AWS_CONFIG_FILE", filepath.Join(wd, "example.ini")) + p := ProcessProvider{executionFunc: executeCredentialProcess} + creds, err := p.Retrieve() + assert.Nil(t, err, "Expect no error") + + assert.Equal(t, "accessKey", creds.AccessKeyID, "Expect access key ID to match") + assert.Equal(t, "secret", creds.SecretAccessKey, "Expect secret access key to match") + assert.Equal(t, "tokenDefault", creds.SessionToken, "Expect session token to match") +} + +func fakeExectuteCredsSuccess(process string) ([]byte, error) { + return []byte(`{"Version": 1, "AccessKeyId": "accessKey", "SecretAccessKey": "secret", "SessionToken": "tokenFake", "Expiration": "2000-01-01T00:00:00-00:00"}`), nil +} + +func TestProcessProviderWithAWS_PROFILE(t *testing.T) { + os.Clearenv() + os.Setenv("AWS_PROFILE", "process") + + p := ProcessProvider{Filename: "example.ini", Profile: "", executionFunc: fakeExectuteCredsSuccess} + creds, err := p.Retrieve() + assert.Nil(t, err, "Expect no error") + + assert.Equal(t, "accessKey", creds.AccessKeyID, "Expect access key ID to match") + assert.Equal(t, "secret", creds.SecretAccessKey, "Expect secret access key to match") + assert.Equal(t, "tokenFake", creds.SessionToken, "Expect token to match") +} + +func fakeExectuteCredsFailMalformed(process string) ([]byte, error) { + return []byte(`{"Version": 1, "AccessKeyId": "accessKey", "SecretAccessKey": "secret", "SessionToken": "tokenDefault", "Expiration": `), nil +} + +func TestProcessProviderMalformed(t *testing.T) { + os.Clearenv() + os.Setenv("AWS_PROFILE", "process") + + p := ProcessProvider{Filename: "example.ini", Profile: "", executionFunc: fakeExectuteCredsFailMalformed} + _, err := p.Retrieve() + assert.NotNil(t, err, "Expect an error") +} + +func fakeExectuteCredsNoToken(process string) ([]byte, error) { + return []byte(`{"Version": 1, "AccessKeyId": "accessKey", "SecretAccessKey": "secret"}`), nil +} + +func TestProcessProviderNoToken(t *testing.T) { + os.Clearenv() + + p := ProcessProvider{Filename: "example.ini", Profile: "process", executionFunc: fakeExectuteCredsNoToken} + creds, err := p.Retrieve() + assert.Nil(t, err, "Expect no error") + assert.Empty(t, creds.SessionToken, "Expect no token") +} + +func fakeExectuteCredsFailVersion(process string) ([]byte, error) { + return []byte(`{"Version": 2, "AccessKeyId": "accessKey", "SecretAccessKey": "secret", "SessionToken": "tokenDefault"}`), nil +} + +func TestProcessProviderWrongVersion(t *testing.T) { + os.Clearenv() + p := ProcessProvider{Filename: "example.ini", Profile: "process", executionFunc: fakeExectuteCredsFailVersion} + _, err := p.Retrieve() + assert.NotNil(t, err, "Expect an error") +} + +func fakeExectuteCredsFailExpiration(process string) ([]byte, error) { + return []byte(`{"Version": 1, "AccessKeyId": "accessKey", "SecretAccessKey": "secret", "SessionToken": "tokenDefault", "Expiration": "20222"}`), nil +} +func TestProcessProviderBadExpiry(t *testing.T) { + os.Clearenv() + p := ProcessProvider{Filename: "example.ini", Profile: "process", executionFunc: fakeExectuteCredsFailExpiration} + _, err := p.Retrieve() + assert.NotNil(t, err, "Expect an error") +} + +func BenchmarkProcessProvider(b *testing.B) { + os.Clearenv() + + p := ProcessProvider{Filename: "example.ini", Profile: "process", executionFunc: executeCredentialProcess} + _, err := p.Retrieve() + if err != nil { + b.Fatal(err) + } + + b.ResetTimer() + for i := 0; i < b.N; i++ { + _, err := p.Retrieve() + if err != nil { + b.Fatal(err) + } + } +} diff --git a/aws/defaults/defaults.go b/aws/defaults/defaults.go index 3cf1036b625..44bc7a648bb 100644 --- a/aws/defaults/defaults.go +++ b/aws/defaults/defaults.go @@ -95,6 +95,7 @@ func CredChain(cfg *aws.Config, handlers request.Handlers) *credentials.Credenti Providers: []credentials.Provider{ &credentials.EnvProvider{}, &credentials.SharedCredentialsProvider{Filename: "", Profile: ""}, + &credentials.ProcessProvider{Filename: "", Profile: ""}, RemoteCredProvider(*cfg, handlers), }, }) diff --git a/aws/session/session.go b/aws/session/session.go index 259b5c0fecc..66f2e8a01c0 100644 --- a/aws/session/session.go +++ b/aws/session/session.go @@ -453,6 +453,11 @@ func mergeConfigSrcs(cfg, userCfg *aws.Config, envCfg envConfig, sharedCfg share cfg.Credentials = credentials.NewStaticCredentialsFromCreds( sharedCfg.Creds, ) + } else if envCfg.EnableSharedConfig && len(sharedCfg.CredentialProcess) > 0 { + cfg.Credentials = credentials.NewProcessProvider( + envCfg.SharedConfigFile, + envCfg.Profile, + ) } else { // Fallback to default credentials provider, include mock errors // for the credential chain so user can identify why credentials diff --git a/aws/session/shared_config.go b/aws/session/shared_config.go index 09c8e5bc7ab..146e862cf9e 100644 --- a/aws/session/shared_config.go +++ b/aws/session/shared_config.go @@ -25,6 +25,9 @@ const ( // Additional Config fields regionKey = `region` + // External Credential Process + credentialProcessKey = `credential_process` + // DefaultSharedConfigProfile is the default profile to be used when // loading configuration from the config files if another profile name // is not provided. @@ -55,6 +58,9 @@ type sharedConfig struct { AssumeRole assumeRoleConfig AssumeRoleSource *sharedConfig + // An external process to request credentials + CredentialProcess string + // Region is the region the SDK should use for looking up AWS service endpoints // and signing requests. // @@ -205,6 +211,11 @@ func (cfg *sharedConfig) setFromIniFile(profile string, file sharedConfigFile) e } } + credentialProcess := section.Key(credentialProcessKey).String() + if len(credentialProcess) > 0 { + cfg.CredentialProcess = credentialProcess + } + // Region if v := section.Key(regionKey).String(); len(v) > 0 { cfg.Region = v