...

Source file src/cloud.google.com/go/auth/credentials/internal/externalaccount/executable_provider.go

Documentation: cloud.google.com/go/auth/credentials/internal/externalaccount

     1  // Copyright 2023 Google LLC
     2  //
     3  // Licensed under the Apache License, Version 2.0 (the "License");
     4  // you may not use this file except in compliance with the License.
     5  // You may obtain a copy of the License at
     6  //
     7  //      http://www.apache.org/licenses/LICENSE-2.0
     8  //
     9  // Unless required by applicable law or agreed to in writing, software
    10  // distributed under the License is distributed on an "AS IS" BASIS,
    11  // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    12  // See the License for the specific language governing permissions and
    13  // limitations under the License.
    14  
    15  package externalaccount
    16  
    17  import (
    18  	"bytes"
    19  	"context"
    20  	"encoding/json"
    21  	"errors"
    22  	"fmt"
    23  	"net/http"
    24  	"os"
    25  	"os/exec"
    26  	"regexp"
    27  	"strings"
    28  	"time"
    29  
    30  	"cloud.google.com/go/auth/internal"
    31  )
    32  
    33  const (
    34  	executableSupportedMaxVersion = 1
    35  	executableDefaultTimeout      = 30 * time.Second
    36  	executableSource              = "response"
    37  	executableProviderType        = "executable"
    38  	outputFileSource              = "output file"
    39  
    40  	allowExecutablesEnvVar = "GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES"
    41  
    42  	jwtTokenType   = "urn:ietf:params:oauth:token-type:jwt"
    43  	idTokenType    = "urn:ietf:params:oauth:token-type:id_token"
    44  	saml2TokenType = "urn:ietf:params:oauth:token-type:saml2"
    45  )
    46  
    47  var (
    48  	serviceAccountImpersonationRE = regexp.MustCompile(`https://iamcredentials..+/v1/projects/-/serviceAccounts/(.*@.*):generateAccessToken`)
    49  )
    50  
    51  type nonCacheableError struct {
    52  	message string
    53  }
    54  
    55  func (nce nonCacheableError) Error() string {
    56  	return nce.message
    57  }
    58  
    59  // environment is a contract for testing
    60  type environment interface {
    61  	existingEnv() []string
    62  	getenv(string) string
    63  	run(ctx context.Context, command string, env []string) ([]byte, error)
    64  	now() time.Time
    65  }
    66  
    67  type runtimeEnvironment struct{}
    68  
    69  func (r runtimeEnvironment) existingEnv() []string {
    70  	return os.Environ()
    71  }
    72  func (r runtimeEnvironment) getenv(key string) string {
    73  	return os.Getenv(key)
    74  }
    75  func (r runtimeEnvironment) now() time.Time {
    76  	return time.Now().UTC()
    77  }
    78  
    79  func (r runtimeEnvironment) run(ctx context.Context, command string, env []string) ([]byte, error) {
    80  	splitCommand := strings.Fields(command)
    81  	cmd := exec.CommandContext(ctx, splitCommand[0], splitCommand[1:]...)
    82  	cmd.Env = env
    83  
    84  	var stdout, stderr bytes.Buffer
    85  	cmd.Stdout = &stdout
    86  	cmd.Stderr = &stderr
    87  
    88  	if err := cmd.Run(); err != nil {
    89  		if ctx.Err() == context.DeadlineExceeded {
    90  			return nil, context.DeadlineExceeded
    91  		}
    92  		if exitError, ok := err.(*exec.ExitError); ok {
    93  			return nil, exitCodeError(exitError)
    94  		}
    95  		return nil, executableError(err)
    96  	}
    97  
    98  	bytesStdout := bytes.TrimSpace(stdout.Bytes())
    99  	if len(bytesStdout) > 0 {
   100  		return bytesStdout, nil
   101  	}
   102  	return bytes.TrimSpace(stderr.Bytes()), nil
   103  }
   104  
   105  type executableSubjectProvider struct {
   106  	Command    string
   107  	Timeout    time.Duration
   108  	OutputFile string
   109  	client     *http.Client
   110  	opts       *Options
   111  	env        environment
   112  }
   113  
   114  type executableResponse struct {
   115  	Version        int    `json:"version,omitempty"`
   116  	Success        *bool  `json:"success,omitempty"`
   117  	TokenType      string `json:"token_type,omitempty"`
   118  	ExpirationTime int64  `json:"expiration_time,omitempty"`
   119  	IDToken        string `json:"id_token,omitempty"`
   120  	SamlResponse   string `json:"saml_response,omitempty"`
   121  	Code           string `json:"code,omitempty"`
   122  	Message        string `json:"message,omitempty"`
   123  }
   124  
   125  func (sp *executableSubjectProvider) parseSubjectTokenFromSource(response []byte, source string, now int64) (string, error) {
   126  	var result executableResponse
   127  	if err := json.Unmarshal(response, &result); err != nil {
   128  		return "", jsonParsingError(source, string(response))
   129  	}
   130  	// Validate
   131  	if result.Version == 0 {
   132  		return "", missingFieldError(source, "version")
   133  	}
   134  	if result.Success == nil {
   135  		return "", missingFieldError(source, "success")
   136  	}
   137  	if !*result.Success {
   138  		if result.Code == "" || result.Message == "" {
   139  			return "", malformedFailureError()
   140  		}
   141  		return "", userDefinedError(result.Code, result.Message)
   142  	}
   143  	if result.Version > executableSupportedMaxVersion || result.Version < 0 {
   144  		return "", unsupportedVersionError(source, result.Version)
   145  	}
   146  	if result.ExpirationTime == 0 && sp.OutputFile != "" {
   147  		return "", missingFieldError(source, "expiration_time")
   148  	}
   149  	if result.TokenType == "" {
   150  		return "", missingFieldError(source, "token_type")
   151  	}
   152  	if result.ExpirationTime != 0 && result.ExpirationTime < now {
   153  		return "", tokenExpiredError()
   154  	}
   155  
   156  	switch result.TokenType {
   157  	case jwtTokenType, idTokenType:
   158  		if result.IDToken == "" {
   159  			return "", missingFieldError(source, "id_token")
   160  		}
   161  		return result.IDToken, nil
   162  	case saml2TokenType:
   163  		if result.SamlResponse == "" {
   164  			return "", missingFieldError(source, "saml_response")
   165  		}
   166  		return result.SamlResponse, nil
   167  	default:
   168  		return "", tokenTypeError(source)
   169  	}
   170  }
   171  
   172  func (sp *executableSubjectProvider) subjectToken(ctx context.Context) (string, error) {
   173  	if token, err := sp.getTokenFromOutputFile(); token != "" || err != nil {
   174  		return token, err
   175  	}
   176  	return sp.getTokenFromExecutableCommand(ctx)
   177  }
   178  
   179  func (sp *executableSubjectProvider) providerType() string {
   180  	return executableProviderType
   181  }
   182  
   183  func (sp *executableSubjectProvider) getTokenFromOutputFile() (token string, err error) {
   184  	if sp.OutputFile == "" {
   185  		// This ExecutableCredentialSource doesn't use an OutputFile.
   186  		return "", nil
   187  	}
   188  
   189  	file, err := os.Open(sp.OutputFile)
   190  	if err != nil {
   191  		// No OutputFile found. Hasn't been created yet, so skip it.
   192  		return "", nil
   193  	}
   194  	defer file.Close()
   195  
   196  	data, err := internal.ReadAll(file)
   197  	if err != nil || len(data) == 0 {
   198  		// Cachefile exists, but no data found. Get new credential.
   199  		return "", nil
   200  	}
   201  
   202  	token, err = sp.parseSubjectTokenFromSource(data, outputFileSource, sp.env.now().Unix())
   203  	if err != nil {
   204  		if _, ok := err.(nonCacheableError); ok {
   205  			// If the cached token is expired we need a new token,
   206  			// and if the cache contains a failure, we need to try again.
   207  			return "", nil
   208  		}
   209  
   210  		// There was an error in the cached token, and the developer should be aware of it.
   211  		return "", err
   212  	}
   213  	// Token parsing succeeded.  Use found token.
   214  	return token, nil
   215  }
   216  
   217  func (sp *executableSubjectProvider) executableEnvironment() []string {
   218  	result := sp.env.existingEnv()
   219  	result = append(result, fmt.Sprintf("GOOGLE_EXTERNAL_ACCOUNT_AUDIENCE=%v", sp.opts.Audience))
   220  	result = append(result, fmt.Sprintf("GOOGLE_EXTERNAL_ACCOUNT_TOKEN_TYPE=%v", sp.opts.SubjectTokenType))
   221  	result = append(result, "GOOGLE_EXTERNAL_ACCOUNT_INTERACTIVE=0")
   222  	if sp.opts.ServiceAccountImpersonationURL != "" {
   223  		matches := serviceAccountImpersonationRE.FindStringSubmatch(sp.opts.ServiceAccountImpersonationURL)
   224  		if matches != nil {
   225  			result = append(result, fmt.Sprintf("GOOGLE_EXTERNAL_ACCOUNT_IMPERSONATED_EMAIL=%v", matches[1]))
   226  		}
   227  	}
   228  	if sp.OutputFile != "" {
   229  		result = append(result, fmt.Sprintf("GOOGLE_EXTERNAL_ACCOUNT_OUTPUT_FILE=%v", sp.OutputFile))
   230  	}
   231  	return result
   232  }
   233  
   234  func (sp *executableSubjectProvider) getTokenFromExecutableCommand(ctx context.Context) (string, error) {
   235  	// For security reasons, we need our consumers to set this environment variable to allow executables to be run.
   236  	if sp.env.getenv(allowExecutablesEnvVar) != "1" {
   237  		return "", errors.New("credentials: executables need to be explicitly allowed (set GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES to '1') to run")
   238  	}
   239  
   240  	ctx, cancel := context.WithDeadline(ctx, sp.env.now().Add(sp.Timeout))
   241  	defer cancel()
   242  
   243  	output, err := sp.env.run(ctx, sp.Command, sp.executableEnvironment())
   244  	if err != nil {
   245  		return "", err
   246  	}
   247  	return sp.parseSubjectTokenFromSource(output, executableSource, sp.env.now().Unix())
   248  }
   249  
   250  func missingFieldError(source, field string) error {
   251  	return fmt.Errorf("credentials: %q missing %q field", source, field)
   252  }
   253  
   254  func jsonParsingError(source, data string) error {
   255  	return fmt.Errorf("credentials: unable to parse %q: %v", source, data)
   256  }
   257  
   258  func malformedFailureError() error {
   259  	return nonCacheableError{"credentials: response must include `error` and `message` fields when unsuccessful"}
   260  }
   261  
   262  func userDefinedError(code, message string) error {
   263  	return nonCacheableError{fmt.Sprintf("credentials: response contains unsuccessful response: (%v) %v", code, message)}
   264  }
   265  
   266  func unsupportedVersionError(source string, version int) error {
   267  	return fmt.Errorf("credentials: %v contains unsupported version: %v", source, version)
   268  }
   269  
   270  func tokenExpiredError() error {
   271  	return nonCacheableError{"credentials: the token returned by the executable is expired"}
   272  }
   273  
   274  func tokenTypeError(source string) error {
   275  	return fmt.Errorf("credentials: %v contains unsupported token type", source)
   276  }
   277  
   278  func exitCodeError(err *exec.ExitError) error {
   279  	return fmt.Errorf("credentials: executable command failed with exit code %v: %w", err.ExitCode(), err)
   280  }
   281  
   282  func executableError(err error) error {
   283  	return fmt.Errorf("credentials: executable command failed: %w", err)
   284  }
   285  

View as plain text