executable_provider.go

  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
 15package externalaccount
 16
 17import (
 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
 33const (
 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
 47var (
 48	serviceAccountImpersonationRE = regexp.MustCompile(`https://iamcredentials..+/v1/projects/-/serviceAccounts/(.*@.*):generateAccessToken`)
 49)
 50
 51type nonCacheableError struct {
 52	message string
 53}
 54
 55func (nce nonCacheableError) Error() string {
 56	return nce.message
 57}
 58
 59// environment is a contract for testing
 60type 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
 67type runtimeEnvironment struct{}
 68
 69func (r runtimeEnvironment) existingEnv() []string {
 70	return os.Environ()
 71}
 72func (r runtimeEnvironment) getenv(key string) string {
 73	return os.Getenv(key)
 74}
 75func (r runtimeEnvironment) now() time.Time {
 76	return time.Now().UTC()
 77}
 78
 79func (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
105type executableSubjectProvider struct {
106	Command    string
107	Timeout    time.Duration
108	OutputFile string
109	client     *http.Client
110	opts       *Options
111	env        environment
112}
113
114type 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
125func (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
172func (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
179func (sp *executableSubjectProvider) providerType() string {
180	return executableProviderType
181}
182
183func (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
217func (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
234func (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
250func missingFieldError(source, field string) error {
251	return fmt.Errorf("credentials: %q missing %q field", source, field)
252}
253
254func jsonParsingError(source, data string) error {
255	return fmt.Errorf("credentials: unable to parse %q: %v", source, data)
256}
257
258func malformedFailureError() error {
259	return nonCacheableError{"credentials: response must include `error` and `message` fields when unsuccessful"}
260}
261
262func userDefinedError(code, message string) error {
263	return nonCacheableError{fmt.Sprintf("credentials: response contains unsuccessful response: (%v) %v", code, message)}
264}
265
266func unsupportedVersionError(source string, version int) error {
267	return fmt.Errorf("credentials: %v contains unsupported version: %v", source, version)
268}
269
270func tokenExpiredError() error {
271	return nonCacheableError{"credentials: the token returned by the executable is expired"}
272}
273
274func tokenTypeError(source string) error {
275	return fmt.Errorf("credentials: %v contains unsupported token type", source)
276}
277
278func exitCodeError(err *exec.ExitError) error {
279	return fmt.Errorf("credentials: executable command failed with exit code %v: %w", err.ExitCode(), err)
280}
281
282func executableError(err error) error {
283	return fmt.Errorf("credentials: executable command failed: %w", err)
284}