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}