provider.go

  1package processcreds
  2
  3import (
  4	"bytes"
  5	"context"
  6	"encoding/json"
  7	"fmt"
  8	"io"
  9	"os"
 10	"os/exec"
 11	"runtime"
 12	"time"
 13
 14	"github.com/aws/aws-sdk-go-v2/aws"
 15	"github.com/aws/aws-sdk-go-v2/internal/sdkio"
 16)
 17
 18const (
 19	// ProviderName is the name this credentials provider will label any
 20	// returned credentials Value with.
 21	ProviderName = `ProcessProvider`
 22
 23	// DefaultTimeout default limit on time a process can run.
 24	DefaultTimeout = time.Duration(1) * time.Minute
 25)
 26
 27// ProviderError is an error indicating failure initializing or executing the
 28// process credentials provider
 29type ProviderError struct {
 30	Err error
 31}
 32
 33// Error returns the error message.
 34func (e *ProviderError) Error() string {
 35	return fmt.Sprintf("process provider error: %v", e.Err)
 36}
 37
 38// Unwrap returns the underlying error the provider error wraps.
 39func (e *ProviderError) Unwrap() error {
 40	return e.Err
 41}
 42
 43// Provider satisfies the credentials.Provider interface, and is a
 44// client to retrieve credentials from a process.
 45type Provider struct {
 46	// Provides a constructor for exec.Cmd that are invoked by the provider for
 47	// retrieving credentials. Use this to provide custom creation of exec.Cmd
 48	// with things like environment variables, or other configuration.
 49	//
 50	// The provider defaults to the DefaultNewCommand function.
 51	commandBuilder NewCommandBuilder
 52
 53	options Options
 54}
 55
 56// Options is the configuration options for configuring the Provider.
 57type Options struct {
 58	// Timeout limits the time a process can run.
 59	Timeout time.Duration
 60}
 61
 62// NewCommandBuilder provides the interface for specifying how command will be
 63// created that the Provider will use to retrieve credentials with.
 64type NewCommandBuilder interface {
 65	NewCommand(context.Context) (*exec.Cmd, error)
 66}
 67
 68// NewCommandBuilderFunc provides a wrapper type around a function pointer to
 69// satisfy the NewCommandBuilder interface.
 70type NewCommandBuilderFunc func(context.Context) (*exec.Cmd, error)
 71
 72// NewCommand calls the underlying function pointer the builder was initialized with.
 73func (fn NewCommandBuilderFunc) NewCommand(ctx context.Context) (*exec.Cmd, error) {
 74	return fn(ctx)
 75}
 76
 77// DefaultNewCommandBuilder provides the default NewCommandBuilder
 78// implementation used by the provider. It takes a command and arguments to
 79// invoke. The command will also be initialized with the current process
 80// environment variables, stderr, and stdin pipes.
 81type DefaultNewCommandBuilder struct {
 82	Args []string
 83}
 84
 85// NewCommand returns an initialized exec.Cmd with the builder's initialized
 86// Args. The command is also initialized current process environment variables,
 87// stderr, and stdin pipes.
 88func (b DefaultNewCommandBuilder) NewCommand(ctx context.Context) (*exec.Cmd, error) {
 89	var cmdArgs []string
 90	if runtime.GOOS == "windows" {
 91		cmdArgs = []string{"cmd.exe", "/C"}
 92	} else {
 93		cmdArgs = []string{"sh", "-c"}
 94	}
 95
 96	if len(b.Args) == 0 {
 97		return nil, &ProviderError{
 98			Err: fmt.Errorf("failed to prepare command: command must not be empty"),
 99		}
100	}
101
102	cmdArgs = append(cmdArgs, b.Args...)
103	cmd := exec.CommandContext(ctx, cmdArgs[0], cmdArgs[1:]...)
104	cmd.Env = os.Environ()
105
106	cmd.Stderr = os.Stderr // display stderr on console for MFA
107	cmd.Stdin = os.Stdin   // enable stdin for MFA
108
109	return cmd, nil
110}
111
112// NewProvider returns a pointer to a new Credentials object wrapping the
113// Provider.
114//
115// The provider defaults to the DefaultNewCommandBuilder for creating command
116// the Provider will use to retrieve credentials with.
117func NewProvider(command string, options ...func(*Options)) *Provider {
118	var args []string
119
120	// Ensure that the command arguments are not set if the provided command is
121	// empty. This will error out when the command is executed since no
122	// arguments are specified.
123	if len(command) > 0 {
124		args = []string{command}
125	}
126
127	commanBuilder := DefaultNewCommandBuilder{
128		Args: args,
129	}
130	return NewProviderCommand(commanBuilder, options...)
131}
132
133// NewProviderCommand returns a pointer to a new Credentials object with the
134// specified command, and default timeout duration. Use this to provide custom
135// creation of exec.Cmd for options like environment variables, or other
136// configuration.
137func NewProviderCommand(builder NewCommandBuilder, options ...func(*Options)) *Provider {
138	p := &Provider{
139		commandBuilder: builder,
140		options: Options{
141			Timeout: DefaultTimeout,
142		},
143	}
144
145	for _, option := range options {
146		option(&p.options)
147	}
148
149	return p
150}
151
152// A CredentialProcessResponse is the AWS credentials format that must be
153// returned when executing an external credential_process.
154type CredentialProcessResponse struct {
155	// As of this writing, the Version key must be set to 1. This might
156	// increment over time as the structure evolves.
157	Version int
158
159	// The access key ID that identifies the temporary security credentials.
160	AccessKeyID string `json:"AccessKeyId"`
161
162	// The secret access key that can be used to sign requests.
163	SecretAccessKey string
164
165	// The token that users must pass to the service API to use the temporary credentials.
166	SessionToken string
167
168	// The date on which the current credentials expire.
169	Expiration *time.Time
170
171	// The ID of the account for credentials
172	AccountID string `json:"AccountId"`
173}
174
175// Retrieve executes the credential process command and returns the
176// credentials, or error if the command fails.
177func (p *Provider) Retrieve(ctx context.Context) (aws.Credentials, error) {
178	out, err := p.executeCredentialProcess(ctx)
179	if err != nil {
180		return aws.Credentials{Source: ProviderName}, err
181	}
182
183	// Serialize and validate response
184	resp := &CredentialProcessResponse{}
185	if err = json.Unmarshal(out, resp); err != nil {
186		return aws.Credentials{Source: ProviderName}, &ProviderError{
187			Err: fmt.Errorf("parse failed of process output: %s, error: %w", out, err),
188		}
189	}
190
191	if resp.Version != 1 {
192		return aws.Credentials{Source: ProviderName}, &ProviderError{
193			Err: fmt.Errorf("wrong version in process output (not 1)"),
194		}
195	}
196
197	if len(resp.AccessKeyID) == 0 {
198		return aws.Credentials{Source: ProviderName}, &ProviderError{
199			Err: fmt.Errorf("missing AccessKeyId in process output"),
200		}
201	}
202
203	if len(resp.SecretAccessKey) == 0 {
204		return aws.Credentials{Source: ProviderName}, &ProviderError{
205			Err: fmt.Errorf("missing SecretAccessKey in process output"),
206		}
207	}
208
209	creds := aws.Credentials{
210		Source:          ProviderName,
211		AccessKeyID:     resp.AccessKeyID,
212		SecretAccessKey: resp.SecretAccessKey,
213		SessionToken:    resp.SessionToken,
214		AccountID:       resp.AccountID,
215	}
216
217	// Handle expiration
218	if resp.Expiration != nil {
219		creds.CanExpire = true
220		creds.Expires = *resp.Expiration
221	}
222
223	return creds, nil
224}
225
226// executeCredentialProcess starts the credential process on the OS and
227// returns the results or an error.
228func (p *Provider) executeCredentialProcess(ctx context.Context) ([]byte, error) {
229	if p.options.Timeout >= 0 {
230		var cancelFunc func()
231		ctx, cancelFunc = context.WithTimeout(ctx, p.options.Timeout)
232		defer cancelFunc()
233	}
234
235	cmd, err := p.commandBuilder.NewCommand(ctx)
236	if err != nil {
237		return nil, err
238	}
239
240	// get creds json on process's stdout
241	output := bytes.NewBuffer(make([]byte, 0, int(8*sdkio.KibiByte)))
242	if cmd.Stdout != nil {
243		cmd.Stdout = io.MultiWriter(cmd.Stdout, output)
244	} else {
245		cmd.Stdout = output
246	}
247
248	execCh := make(chan error, 1)
249	go executeCommand(cmd, execCh)
250
251	select {
252	case execError := <-execCh:
253		if execError == nil {
254			break
255		}
256		select {
257		case <-ctx.Done():
258			return output.Bytes(), &ProviderError{
259				Err: fmt.Errorf("credential process timed out: %w", execError),
260			}
261		default:
262			return output.Bytes(), &ProviderError{
263				Err: fmt.Errorf("error in credential_process: %w", execError),
264			}
265		}
266	}
267
268	out := output.Bytes()
269	if runtime.GOOS == "windows" {
270		// windows adds slashes to quotes
271		out = bytes.ReplaceAll(out, []byte(`\"`), []byte(`"`))
272	}
273
274	return out, nil
275}
276
277func executeCommand(cmd *exec.Cmd, exec chan error) {
278	// Start the command
279	err := cmd.Start()
280	if err == nil {
281		err = cmd.Wait()
282	}
283
284	exec <- err
285}