Skip to content

Commit 02e64fa

Browse files
ScruffyProdigycodyoss
authored andcommitted
google/internal/externalaccount: create executable credentials
This changeset would allow users to specify a command to be run which will return a token Change-Id: If84cce97c273cdd08ef2010a1693cd813d053ed3 GitHub-Last-Rev: 98f3787 GitHub-Pull-Request: #563 Reviewed-on: https://go-review.googlesource.com/c/oauth2/+/404114 Reviewed-by: Tyler Bui-Palsulich <tbp@google.com> TryBot-Result: Gopher Robot <gobot@golang.org> Run-TryBot: Cody Oss <codyoss@google.com> Reviewed-by: Cody Oss <codyoss@google.com>
1 parent fd043fe commit 02e64fa

File tree

3 files changed

+1340
-2
lines changed

3 files changed

+1340
-2
lines changed

‎google/internal/externalaccount/basecredentials.go

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -163,14 +163,16 @@ type format struct {
163163
}
164164

165165
// CredentialSource stores the information necessary to retrieve the credentials for the STS exchange.
166-
// Either the File or the URL field should be filled, depending on the kind of credential in question.
166+
// One field amongst File, URL, and Executable should be filled, depending on the kind of credential in question.
167167
// The EnvironmentID should start with AWS if being used for an AWS credential.
168168
type CredentialSource struct {
169169
File string `json:"file"`
170170

171171
URL string `json:"url"`
172172
Headers map[string]string `json:"headers"`
173173

174+
Executable *ExecutableConfig `json:"executable"`
175+
174176
EnvironmentID string `json:"environment_id"`
175177
RegionURL string `json:"region_url"`
176178
RegionalCredVerificationURL string `json:"regional_cred_verification_url"`
@@ -179,7 +181,13 @@ type CredentialSource struct {
179181
Format format `json:"format"`
180182
}
181183

182-
// parse determines the type of CredentialSource needed
184+
type ExecutableConfig struct {
185+
Command string `json:"command"`
186+
TimeoutMillis *int `json:"timeout_millis"`
187+
OutputFile string `json:"output_file"`
188+
}
189+
190+
// parse determines the type of CredentialSource needed.
183191
func (c *Config) parse(ctx context.Context) (baseCredentialSource, error) {
184192
if len(c.CredentialSource.EnvironmentID) > 3 && c.CredentialSource.EnvironmentID[:3] == "aws" {
185193
if awsVersion, err := strconv.Atoi(c.CredentialSource.EnvironmentID[3:]); err == nil {
@@ -205,6 +213,8 @@ func (c *Config) parse(ctx context.Context) (baseCredentialSource, error) {
205213
return fileCredentialSource{File: c.CredentialSource.File, Format: c.CredentialSource.Format}, nil
206214
} else if c.CredentialSource.URL != "" {
207215
return urlCredentialSource{URL: c.CredentialSource.URL, Headers: c.CredentialSource.Headers, Format: c.CredentialSource.Format, ctx: ctx}, nil
216+
} else if c.CredentialSource.Executable != nil {
217+
return CreateExecutableCredential(ctx, c.CredentialSource.Executable, c)
208218
}
209219
return nil, fmt.Errorf("oauth2/google: unable to parse credential source")
210220
}
Lines changed: 308 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,308 @@
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.googleapis.com/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: %v missing `%q` field", source, field)
42+
}
43+
44+
func jsonParsingError(source, data string) error {
45+
return fmt.Errorf("oauth2/google: unable to parse %v\nResponse: %v", source, data)
46+
}
47+
48+
func malformedFailureError() error {
49+
return nonCacheableError{"oauth2/google: 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: response contains unsuccessful response: (%v) %v", code, message)}
54+
}
55+
56+
func unsupportedVersionError(source string, version int) error {
57+
return fmt.Errorf("oauth2/google: %v contains unsupported version: %v", source, version)
58+
}
59+
60+
func tokenExpiredError() error {
61+
return nonCacheableError{"oauth2/google: the token returned by the executable is expired"}
62+
}
63+
64+
func tokenTypeError(source string) error {
65+
return fmt.Errorf("oauth2/google: %v contains unsupported token type", source)
66+
}
67+
68+
func exitCodeError(exitCode int) error {
69+
return fmt.Errorf("oauth2/google: executable command failed with exit code %v", exitCode)
70+
}
71+
72+
func executableError(err error) error {
73+
return fmt.Errorf("oauth2/google: executable command failed: %v", err)
74+
}
75+
76+
func executablesDisallowedError() error {
77+
return errors.New("oauth2/google: 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: 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: 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 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 {
207+
return "", missingFieldError(source, "expiration_time")
208+
}
209+
210+
if result.TokenType == "" {
211+
return "", missingFieldError(source, "token_type")
212+
}
213+
214+
if 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) subjectToken() (string, error) {
236+
if token, err := cs.getTokenFromOutputFile(); token != "" || err != nil {
237+
return token, err
238+
}
239+
240+
return cs.getTokenFromExecutableCommand()
241+
}
242+
243+
func (cs executableCredentialSource) getTokenFromOutputFile() (token string, err error) {
244+
if cs.OutputFile == "" {
245+
// This ExecutableCredentialSource doesn't use an OutputFile.
246+
return "", nil
247+
}
248+
249+
file, err := os.Open(cs.OutputFile)
250+
if err != nil {
251+
// No OutputFile found. Hasn't been created yet, so skip it.
252+
return "", nil
253+
}
254+
defer file.Close()
255+
256+
data, err := io.ReadAll(io.LimitReader(file, 1<<20))
257+
if err != nil || len(data) == 0 {
258+
// Cachefile exists, but no data found. Get new credential.
259+
return "", nil
260+
}
261+
262+
token, err = parseSubjectTokenFromSource(data, outputFileSource, cs.env.now().Unix())
263+
if err != nil {
264+
if _, ok := err.(nonCacheableError); ok {
265+
// If the cached token is expired we need a new token,
266+
// and if the cache contains a failure, we need to try again.
267+
return "", nil
268+
}
269+
270+
// There was an error in the cached token, and the developer should be aware of it.
271+
return "", err
272+
}
273+
// Token parsing succeeded. Use found token.
274+
return token, nil
275+
}
276+
277+
func (cs executableCredentialSource) executableEnvironment() []string {
278+
result := cs.env.existingEnv()
279+
result = append(result, fmt.Sprintf("GOOGLE_EXTERNAL_ACCOUNT_AUDIENCE=%v", cs.config.Audience))
280+
result = append(result, fmt.Sprintf("GOOGLE_EXTERNAL_ACCOUNT_TOKEN_TYPE=%v", cs.config.SubjectTokenType))
281+
result = append(result, "GOOGLE_EXTERNAL_ACCOUNT_INTERACTIVE=0")
282+
if cs.config.ServiceAccountImpersonationURL != "" {
283+
matches := serviceAccountImpersonationRE.FindStringSubmatch(cs.config.ServiceAccountImpersonationURL)
284+
if matches != nil {
285+
result = append(result, fmt.Sprintf("GOOGLE_EXTERNAL_ACCOUNT_IMPERSONATED_EMAIL=%v", matches[1]))
286+
}
287+
}
288+
if cs.OutputFile != "" {
289+
result = append(result, fmt.Sprintf("GOOGLE_EXTERNAL_ACCOUNT_OUTPUT_FILE=%v", cs.OutputFile))
290+
}
291+
return result
292+
}
293+
294+
func (cs executableCredentialSource) getTokenFromExecutableCommand() (string, error) {
295+
// For security reasons, we need our consumers to set this environment variable to allow executables to be run.
296+
if cs.env.getenv("GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES") != "1" {
297+
return "", executablesDisallowedError()
298+
}
299+
300+
ctx, cancel := context.WithDeadline(cs.ctx, cs.env.now().Add(cs.Timeout))
301+
defer cancel()
302+
303+
output, err := cs.env.run(ctx, cs.Command, cs.executableEnvironment())
304+
if err != nil {
305+
return "", err
306+
}
307+
return parseSubjectTokenFromSource(output, executableSource, cs.env.now().Unix())
308+
}

0 commit comments

Comments
 (0)