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