Skip to content
This repository was archived by the owner on Jul 31, 2025. It is now read-only.
Closed
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,4 @@ awstesting/integration/smoke/_test/
/vendor/pkg/
/vendor/src/
/private/model/cli/gen-api/gen-api
.*.swp
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit this file probably shouldn't change

4 changes: 4 additions & 0 deletions aws/credentials/example.ini
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"}
228 changes: 228 additions & 0 deletions aws/credentials/process_provider.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,228 @@
package credentials
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

would you mind moving the process_provider.go and _test files to a processcreds package. Having the credential provider in their own package allow them to depend on the aws package if needed. the credentials package cannot use the aws package since aws imports `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())
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Loading the shared config file each time the credentials are retrieved have issues that I don't think is good for the SDK to perform. per #1993

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)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we need to look at exporting aws/session/shard_config.go or at least put it under the SDK's root internal package so it can be reused by the process provider. There are modifications to the shared config logic that will be important for this utility.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think ideally the ProcessProvider doesn't need to read/load the shared config file at all. The loading of the contents of the shared config's credential_process value should be passed in via an input parameter. Similar to the stscreds's credential provider.

If the shared_config.go logic is exported it would make it easier for the ProcessProvider to have access to the credential_provider value.

if err != nil {
return Value{ProviderName: ProcessProviderName}, awserr.New("ProcessProviderLoad", "failed to load shared config file", err)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For these error messages could you please create consts to make comparing against them easier.

e.g:

const (
     ErrCodeProcessProviderLoad = `ProcessProviderLoad`
)

}

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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can Expiration not be a time.Time?

}

func executeCredentialProcess(process string) ([]byte, error) {
processArgs := strings.Split(process, " ")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this split will have issue with quoted strings within the argument list of the credential_process. I don't think the following will be handled correctly:

credential_process=ls "my cool/path with/spaces"

will be split into:

[]string{"ls", "\"my", "cool/path", "with/spaces\""}

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) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is probably best to let exec.Command fail and capture any error with the command not being valid/executable.

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()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the exec.Command should already be pulling the application's environment. is this not the case, or is there an issue that requires it being set explicitly?

https://golang.org/pkg/os/exec/#Cmd.Env

out, err := cmd.Output()
if err != nil {
return nil, awserr.New("ProcessProviderLoad", "Error executing credential_process", err)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this capture stderr as well. I don't remember if the err returned by cmd.Output includes stderr or not. This might only include the exit code.

}
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 {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using a pointer for resp.Expiration would simplify receiving the time value. This is done for the SDK's endpoint credential provider

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) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This information should come from the SDK's session similar to stscreds There are several locations, and various env var names that can drive the config file to load within the SDK. Having loading the shared config file utility do this work, will help reduce duplication.

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")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The SDK does not use the AWS_DEFAULT_PROFILE by default. this was a purely CLI environment variable that still exists for backwards compatibility. The SDK's session and env_config. go provides the logic to determine which environment variable to use.

}
if p.Profile == "" {
p.Profile = "default"
}

return p.Profile
}
143 changes: 143 additions & 0 deletions aws/credentials/process_provider_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
package credentials

import (
"os"
"path/filepath"
"testing"

"github.com/stretchr/testify/assert"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The SDK is looking to move away from testify and use pure go testing. It would be great to the pure go testing package for these tests.

)

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)
}
}
}
Loading