...

Source file src/golang.org/x/oauth2/google/externalaccount/executablecredsource.go

Documentation: golang.org/x/oauth2/google/externalaccount

     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  }
   314  

View as plain text