executable_provider.go raw

   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