golang.org/x/oauth2@v0.18.0/google/externalaccount/executablecredsource.go (about) 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 "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 // CreateExecutableCredential creates an executableCredentialSource given an ExecutableConfig. 148 // It also performs defaulting and type conversions. 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 // This ExecutableCredentialSource doesn't use an OutputFile. 251 return "", nil 252 } 253 254 file, err := os.Open(cs.OutputFile) 255 if err != nil { 256 // No OutputFile found. Hasn't been created yet, so skip it. 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 // Cachefile exists, but no data found. Get new credential. 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 // If the cached token is expired we need a new token, 271 // and if the cache contains a failure, we need to try again. 272 return "", nil 273 } 274 275 // There was an error in the cached token, and the developer should be aware of it. 276 return "", err 277 } 278 // Token parsing succeeded. Use found token. 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 // For security reasons, we need our consumers to set this environment variable to allow executables to be run. 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 }