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