executablecredsource.go raw
1 // Copyright 2022 The Go Authors. All rights reserved.
2 // Use of this source code is governed by a BSD-style
3 // license that can be found in the LICENSE file.
4
5 package externalaccount
6
7 import (
8 "bytes"
9 "context"
10 "encoding/json"
11 "errors"
12 "fmt"
13 "io"
14 "os"
15 "os/exec"
16 "regexp"
17 "strings"
18 "time"
19 )
20
21 var serviceAccountImpersonationRE = regexp.MustCompile("https://iamcredentials\\..+/v1/projects/-/serviceAccounts/(.*@.*):generateAccessToken")
22
23 const (
24 executableSupportedMaxVersion = 1
25 defaultTimeout = 30 * time.Second
26 timeoutMinimum = 5 * time.Second
27 timeoutMaximum = 120 * time.Second
28 executableSource = "response"
29 outputFileSource = "output file"
30 )
31
32 type nonCacheableError struct {
33 message string
34 }
35
36 func (nce nonCacheableError) Error() string {
37 return nce.message
38 }
39
40 func missingFieldError(source, field string) error {
41 return fmt.Errorf("oauth2/google/externalaccount: %v missing `%q` field", source, field)
42 }
43
44 func jsonParsingError(source, data string) error {
45 return fmt.Errorf("oauth2/google/externalaccount: unable to parse %v\nResponse: %v", source, data)
46 }
47
48 func malformedFailureError() error {
49 return nonCacheableError{"oauth2/google/externalaccount: response must include `error` and `message` fields when unsuccessful"}
50 }
51
52 func userDefinedError(code, message string) error {
53 return nonCacheableError{fmt.Sprintf("oauth2/google/externalaccount: response contains unsuccessful response: (%v) %v", code, message)}
54 }
55
56 func unsupportedVersionError(source string, version int) error {
57 return fmt.Errorf("oauth2/google/externalaccount: %v contains unsupported version: %v", source, version)
58 }
59
60 func tokenExpiredError() error {
61 return nonCacheableError{"oauth2/google/externalaccount: the token returned by the executable is expired"}
62 }
63
64 func tokenTypeError(source string) error {
65 return fmt.Errorf("oauth2/google/externalaccount: %v contains unsupported token type", source)
66 }
67
68 func exitCodeError(exitCode int) error {
69 return fmt.Errorf("oauth2/google/externalaccount: executable command failed with exit code %v", exitCode)
70 }
71
72 func executableError(err error) error {
73 return fmt.Errorf("oauth2/google/externalaccount: executable command failed: %v", err)
74 }
75
76 func executablesDisallowedError() error {
77 return errors.New("oauth2/google/externalaccount: executables need to be explicitly allowed (set GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES to '1') to run")
78 }
79
80 func timeoutRangeError() error {
81 return errors.New("oauth2/google/externalaccount: invalid `timeout_millis` field — executable timeout must be between 5 and 120 seconds")
82 }
83
84 func commandMissingError() error {
85 return errors.New("oauth2/google/externalaccount: missing `command` field — executable command must be provided")
86 }
87
88 type environment interface {
89 existingEnv() []string
90 getenv(string) string
91 run(ctx context.Context, command string, env []string) ([]byte, error)
92 now() time.Time
93 }
94
95 type runtimeEnvironment struct{}
96
97 func (r runtimeEnvironment) existingEnv() []string {
98 return os.Environ()
99 }
100
101 func (r runtimeEnvironment) getenv(key string) string {
102 return os.Getenv(key)
103 }
104
105 func (r runtimeEnvironment) now() time.Time {
106 return time.Now().UTC()
107 }
108
109 func (r runtimeEnvironment) run(ctx context.Context, command string, env []string) ([]byte, error) {
110 splitCommand := strings.Fields(command)
111 cmd := exec.CommandContext(ctx, splitCommand[0], splitCommand[1:]...)
112 cmd.Env = env
113
114 var stdout, stderr bytes.Buffer
115 cmd.Stdout = &stdout
116 cmd.Stderr = &stderr
117
118 if err := cmd.Run(); err != nil {
119 if ctx.Err() == context.DeadlineExceeded {
120 return nil, context.DeadlineExceeded
121 }
122
123 if exitError, ok := err.(*exec.ExitError); ok {
124 return nil, exitCodeError(exitError.ExitCode())
125 }
126
127 return nil, executableError(err)
128 }
129
130 bytesStdout := bytes.TrimSpace(stdout.Bytes())
131 if len(bytesStdout) > 0 {
132 return bytesStdout, nil
133 }
134 return bytes.TrimSpace(stderr.Bytes()), nil
135 }
136
137 type executableCredentialSource struct {
138 Command string
139 Timeout time.Duration
140 OutputFile string
141 ctx context.Context
142 config *Config
143 env environment
144 }
145
146 // CreateExecutableCredential creates an executableCredentialSource given an ExecutableConfig.
147 // It also performs defaulting and type conversions.
148 func createExecutableCredential(ctx context.Context, ec *ExecutableConfig, config *Config) (executableCredentialSource, error) {
149 if ec.Command == "" {
150 return executableCredentialSource{}, commandMissingError()
151 }
152
153 result := executableCredentialSource{}
154 result.Command = ec.Command
155 if ec.TimeoutMillis == nil {
156 result.Timeout = defaultTimeout
157 } else {
158 result.Timeout = time.Duration(*ec.TimeoutMillis) * time.Millisecond
159 if result.Timeout < timeoutMinimum || result.Timeout > timeoutMaximum {
160 return executableCredentialSource{}, timeoutRangeError()
161 }
162 }
163 result.OutputFile = ec.OutputFile
164 result.ctx = ctx
165 result.config = config
166 result.env = runtimeEnvironment{}
167 return result, nil
168 }
169
170 type executableResponse struct {
171 Version int `json:"version,omitempty"`
172 Success *bool `json:"success,omitempty"`
173 TokenType string `json:"token_type,omitempty"`
174 ExpirationTime int64 `json:"expiration_time,omitempty"`
175 IdToken string `json:"id_token,omitempty"`
176 SamlResponse string `json:"saml_response,omitempty"`
177 Code string `json:"code,omitempty"`
178 Message string `json:"message,omitempty"`
179 }
180
181 func (cs executableCredentialSource) parseSubjectTokenFromSource(response []byte, source string, now int64) (string, error) {
182 var result executableResponse
183 if err := json.Unmarshal(response, &result); err != nil {
184 return "", jsonParsingError(source, string(response))
185 }
186
187 if result.Version == 0 {
188 return "", missingFieldError(source, "version")
189 }
190
191 if result.Success == nil {
192 return "", missingFieldError(source, "success")
193 }
194
195 if !*result.Success {
196 if result.Code == "" || result.Message == "" {
197 return "", malformedFailureError()
198 }
199 return "", userDefinedError(result.Code, result.Message)
200 }
201
202 if result.Version > executableSupportedMaxVersion || result.Version < 0 {
203 return "", unsupportedVersionError(source, result.Version)
204 }
205
206 if result.ExpirationTime == 0 && cs.OutputFile != "" {
207 return "", missingFieldError(source, "expiration_time")
208 }
209
210 if result.TokenType == "" {
211 return "", missingFieldError(source, "token_type")
212 }
213
214 if result.ExpirationTime != 0 && result.ExpirationTime < now {
215 return "", tokenExpiredError()
216 }
217
218 if result.TokenType == "urn:ietf:params:oauth:token-type:jwt" || result.TokenType == "urn:ietf:params:oauth:token-type:id_token" {
219 if result.IdToken == "" {
220 return "", missingFieldError(source, "id_token")
221 }
222 return result.IdToken, nil
223 }
224
225 if result.TokenType == "urn:ietf:params:oauth:token-type:saml2" {
226 if result.SamlResponse == "" {
227 return "", missingFieldError(source, "saml_response")
228 }
229 return result.SamlResponse, nil
230 }
231
232 return "", tokenTypeError(source)
233 }
234
235 func (cs executableCredentialSource) credentialSourceType() string {
236 return "executable"
237 }
238
239 func (cs executableCredentialSource) subjectToken() (string, error) {
240 if token, err := cs.getTokenFromOutputFile(); token != "" || err != nil {
241 return token, err
242 }
243
244 return cs.getTokenFromExecutableCommand()
245 }
246
247 func (cs executableCredentialSource) getTokenFromOutputFile() (token string, err error) {
248 if cs.OutputFile == "" {
249 // This ExecutableCredentialSource doesn't use an OutputFile.
250 return "", nil
251 }
252
253 file, err := os.Open(cs.OutputFile)
254 if err != nil {
255 // No OutputFile found. Hasn't been created yet, so skip it.
256 return "", nil
257 }
258 defer file.Close()
259
260 data, err := io.ReadAll(io.LimitReader(file, 1<<20))
261 if err != nil || len(data) == 0 {
262 // Cachefile exists, but no data found. Get new credential.
263 return "", nil
264 }
265
266 token, err = cs.parseSubjectTokenFromSource(data, outputFileSource, cs.env.now().Unix())
267 if err != nil {
268 if _, ok := err.(nonCacheableError); ok {
269 // If the cached token is expired we need a new token,
270 // and if the cache contains a failure, we need to try again.
271 return "", nil
272 }
273
274 // There was an error in the cached token, and the developer should be aware of it.
275 return "", err
276 }
277 // Token parsing succeeded. Use found token.
278 return token, nil
279 }
280
281 func (cs executableCredentialSource) executableEnvironment() []string {
282 result := cs.env.existingEnv()
283 result = append(result, fmt.Sprintf("GOOGLE_EXTERNAL_ACCOUNT_AUDIENCE=%v", cs.config.Audience))
284 result = append(result, fmt.Sprintf("GOOGLE_EXTERNAL_ACCOUNT_TOKEN_TYPE=%v", cs.config.SubjectTokenType))
285 result = append(result, "GOOGLE_EXTERNAL_ACCOUNT_INTERACTIVE=0")
286 if cs.config.ServiceAccountImpersonationURL != "" {
287 matches := serviceAccountImpersonationRE.FindStringSubmatch(cs.config.ServiceAccountImpersonationURL)
288 if matches != nil {
289 result = append(result, fmt.Sprintf("GOOGLE_EXTERNAL_ACCOUNT_IMPERSONATED_EMAIL=%v", matches[1]))
290 }
291 }
292 if cs.OutputFile != "" {
293 result = append(result, fmt.Sprintf("GOOGLE_EXTERNAL_ACCOUNT_OUTPUT_FILE=%v", cs.OutputFile))
294 }
295 return result
296 }
297
298 func (cs executableCredentialSource) getTokenFromExecutableCommand() (string, error) {
299 // For security reasons, we need our consumers to set this environment variable to allow executables to be run.
300 if cs.env.getenv("GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES") != "1" {
301 return "", executablesDisallowedError()
302 }
303
304 ctx, cancel := context.WithDeadline(cs.ctx, cs.env.now().Add(cs.Timeout))
305 defer cancel()
306
307 output, err := cs.env.run(ctx, cs.Command, cs.executableEnvironment())
308 if err != nil {
309 return "", err
310 }
311 return cs.parseSubjectTokenFromSource(output, executableSource, cs.env.now().Unix())
312 }
313