diff --git a/google/internal/externalaccount/basecredentials.go b/google/internal/externalaccount/basecredentials.go index 83ce9c2..b3d5fe2 100644 --- a/google/internal/externalaccount/basecredentials.go +++ b/google/internal/externalaccount/basecredentials.go @@ -163,7 +163,7 @@ type format struct { } // CredentialSource stores the information necessary to retrieve the credentials for the STS exchange. -// Either the File or the URL field should be filled, depending on the kind of credential in question. +// One field amongst File, URL, and Executable should be filled, depending on the kind of credential in question. // The EnvironmentID should start with AWS if being used for an AWS credential. type CredentialSource struct { File string `json:"file"` @@ -171,6 +171,8 @@ type CredentialSource struct { URL string `json:"url"` Headers map[string]string `json:"headers"` + Executable *ExecutableConfig `json:"executable"` + EnvironmentID string `json:"environment_id"` RegionURL string `json:"region_url"` RegionalCredVerificationURL string `json:"regional_cred_verification_url"` @@ -179,7 +181,13 @@ type CredentialSource struct { Format format `json:"format"` } -// parse determines the type of CredentialSource needed +type ExecutableConfig struct { + Command string `json:"command"` + TimeoutMillis *int `json:"timeout_millis"` + OutputFile string `json:"output_file"` +} + +// parse determines the type of CredentialSource needed. func (c *Config) parse(ctx context.Context) (baseCredentialSource, error) { if len(c.CredentialSource.EnvironmentID) > 3 && c.CredentialSource.EnvironmentID[:3] == "aws" { if awsVersion, err := strconv.Atoi(c.CredentialSource.EnvironmentID[3:]); err == nil { @@ -205,6 +213,8 @@ func (c *Config) parse(ctx context.Context) (baseCredentialSource, error) { return fileCredentialSource{File: c.CredentialSource.File, Format: c.CredentialSource.Format}, nil } else if c.CredentialSource.URL != "" { return urlCredentialSource{URL: c.CredentialSource.URL, Headers: c.CredentialSource.Headers, Format: c.CredentialSource.Format, ctx: ctx}, nil + } else if c.CredentialSource.Executable != nil { + return CreateExecutableCredential(ctx, c.CredentialSource.Executable, c) } return nil, fmt.Errorf("oauth2/google: unable to parse credential source") } diff --git a/google/internal/externalaccount/executablecredsource.go b/google/internal/externalaccount/executablecredsource.go new file mode 100644 index 0000000..6ecbe6e --- /dev/null +++ b/google/internal/externalaccount/executablecredsource.go @@ -0,0 +1,308 @@ +// Copyright 2022 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package externalaccount + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "os/exec" + "regexp" + "strings" + "time" +) + +var serviceAccountImpersonationRE = regexp.MustCompile("https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/(.*@.*):generateAccessToken") + +const ( + executableSupportedMaxVersion = 1 + defaultTimeout = 30 * time.Second + timeoutMinimum = 5 * time.Second + timeoutMaximum = 120 * time.Second + executableSource = "response" + outputFileSource = "output file" +) + +type nonCacheableError struct { + message string +} + +func (nce nonCacheableError) Error() string { + return nce.message +} + +func missingFieldError(source, field string) error { + return fmt.Errorf("oauth2/google: %v missing `%q` field", source, field) +} + +func jsonParsingError(source, data string) error { + return fmt.Errorf("oauth2/google: unable to parse %v\nResponse: %v", source, data) +} + +func malformedFailureError() error { + return nonCacheableError{"oauth2/google: response must include `error` and `message` fields when unsuccessful"} +} + +func userDefinedError(code, message string) error { + return nonCacheableError{fmt.Sprintf("oauth2/google: response contains unsuccessful response: (%v) %v", code, message)} +} + +func unsupportedVersionError(source string, version int) error { + return fmt.Errorf("oauth2/google: %v contains unsupported version: %v", source, version) +} + +func tokenExpiredError() error { + return nonCacheableError{"oauth2/google: the token returned by the executable is expired"} +} + +func tokenTypeError(source string) error { + return fmt.Errorf("oauth2/google: %v contains unsupported token type", source) +} + +func exitCodeError(exitCode int) error { + return fmt.Errorf("oauth2/google: executable command failed with exit code %v", exitCode) +} + +func executableError(err error) error { + return fmt.Errorf("oauth2/google: executable command failed: %v", err) +} + +func executablesDisallowedError() error { + return errors.New("oauth2/google: executables need to be explicitly allowed (set GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES to '1') to run") +} + +func timeoutRangeError() error { + return errors.New("oauth2/google: invalid `timeout_millis` field — executable timeout must be between 5 and 120 seconds") +} + +func commandMissingError() error { + return errors.New("oauth2/google: missing `command` field — executable command must be provided") +} + +type environment interface { + existingEnv() []string + getenv(string) string + run(ctx context.Context, command string, env []string) ([]byte, error) + now() time.Time +} + +type runtimeEnvironment struct{} + +func (r runtimeEnvironment) existingEnv() []string { + return os.Environ() +} + +func (r runtimeEnvironment) getenv(key string) string { + return os.Getenv(key) +} + +func (r runtimeEnvironment) now() time.Time { + return time.Now().UTC() +} + +func (r runtimeEnvironment) run(ctx context.Context, command string, env []string) ([]byte, error) { + splitCommand := strings.Fields(command) + cmd := exec.CommandContext(ctx, splitCommand[0], splitCommand[1:]...) + cmd.Env = env + + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + if err := cmd.Run(); err != nil { + if ctx.Err() == context.DeadlineExceeded { + return nil, context.DeadlineExceeded + } + + if exitError, ok := err.(*exec.ExitError); ok { + return nil, exitCodeError(exitError.ExitCode()) + } + + return nil, executableError(err) + } + + bytesStdout := bytes.TrimSpace(stdout.Bytes()) + if len(bytesStdout) > 0 { + return bytesStdout, nil + } + return bytes.TrimSpace(stderr.Bytes()), nil +} + +type executableCredentialSource struct { + Command string + Timeout time.Duration + OutputFile string + ctx context.Context + config *Config + env environment +} + +// CreateExecutableCredential creates an executableCredentialSource given an ExecutableConfig. +// It also performs defaulting and type conversions. +func CreateExecutableCredential(ctx context.Context, ec *ExecutableConfig, config *Config) (executableCredentialSource, error) { + if ec.Command == "" { + return executableCredentialSource{}, commandMissingError() + } + + result := executableCredentialSource{} + result.Command = ec.Command + if ec.TimeoutMillis == nil { + result.Timeout = defaultTimeout + } else { + result.Timeout = time.Duration(*ec.TimeoutMillis) * time.Millisecond + if result.Timeout < timeoutMinimum || result.Timeout > timeoutMaximum { + return executableCredentialSource{}, timeoutRangeError() + } + } + result.OutputFile = ec.OutputFile + result.ctx = ctx + result.config = config + result.env = runtimeEnvironment{} + return result, nil +} + +type executableResponse struct { + Version int `json:"version,omitempty"` + Success *bool `json:"success,omitempty"` + TokenType string `json:"token_type,omitempty"` + ExpirationTime int64 `json:"expiration_time,omitempty"` + IdToken string `json:"id_token,omitempty"` + SamlResponse string `json:"saml_response,omitempty"` + Code string `json:"code,omitempty"` + Message string `json:"message,omitempty"` +} + +func parseSubjectTokenFromSource(response []byte, source string, now int64) (string, error) { + var result executableResponse + if err := json.Unmarshal(response, &result); err != nil { + return "", jsonParsingError(source, string(response)) + } + + if result.Version == 0 { + return "", missingFieldError(source, "version") + } + + if result.Success == nil { + return "", missingFieldError(source, "success") + } + + if !*result.Success { + if result.Code == "" || result.Message == "" { + return "", malformedFailureError() + } + return "", userDefinedError(result.Code, result.Message) + } + + if result.Version > executableSupportedMaxVersion || result.Version < 0 { + return "", unsupportedVersionError(source, result.Version) + } + + if result.ExpirationTime == 0 { + return "", missingFieldError(source, "expiration_time") + } + + if result.TokenType == "" { + return "", missingFieldError(source, "token_type") + } + + if result.ExpirationTime < now { + return "", tokenExpiredError() + } + + if result.TokenType == "urn:ietf:params:oauth:token-type:jwt" || result.TokenType == "urn:ietf:params:oauth:token-type:id_token" { + if result.IdToken == "" { + return "", missingFieldError(source, "id_token") + } + return result.IdToken, nil + } + + if result.TokenType == "urn:ietf:params:oauth:token-type:saml2" { + if result.SamlResponse == "" { + return "", missingFieldError(source, "saml_response") + } + return result.SamlResponse, nil + } + + return "", tokenTypeError(source) +} + +func (cs executableCredentialSource) subjectToken() (string, error) { + if token, err := cs.getTokenFromOutputFile(); token != "" || err != nil { + return token, err + } + + return cs.getTokenFromExecutableCommand() +} + +func (cs executableCredentialSource) getTokenFromOutputFile() (token string, err error) { + if cs.OutputFile == "" { + // This ExecutableCredentialSource doesn't use an OutputFile. + return "", nil + } + + file, err := os.Open(cs.OutputFile) + if err != nil { + // No OutputFile found. Hasn't been created yet, so skip it. + return "", nil + } + defer file.Close() + + data, err := io.ReadAll(io.LimitReader(file, 1<<20)) + if err != nil || len(data) == 0 { + // Cachefile exists, but no data found. Get new credential. + return "", nil + } + + token, err = parseSubjectTokenFromSource(data, outputFileSource, cs.env.now().Unix()) + if err != nil { + if _, ok := err.(nonCacheableError); ok { + // If the cached token is expired we need a new token, + // and if the cache contains a failure, we need to try again. + return "", nil + } + + // There was an error in the cached token, and the developer should be aware of it. + return "", err + } + // Token parsing succeeded. Use found token. + return token, nil +} + +func (cs executableCredentialSource) executableEnvironment() []string { + result := cs.env.existingEnv() + result = append(result, fmt.Sprintf("GOOGLE_EXTERNAL_ACCOUNT_AUDIENCE=%v", cs.config.Audience)) + result = append(result, fmt.Sprintf("GOOGLE_EXTERNAL_ACCOUNT_TOKEN_TYPE=%v", cs.config.SubjectTokenType)) + result = append(result, "GOOGLE_EXTERNAL_ACCOUNT_INTERACTIVE=0") + if cs.config.ServiceAccountImpersonationURL != "" { + matches := serviceAccountImpersonationRE.FindStringSubmatch(cs.config.ServiceAccountImpersonationURL) + if matches != nil { + result = append(result, fmt.Sprintf("GOOGLE_EXTERNAL_ACCOUNT_IMPERSONATED_EMAIL=%v", matches[1])) + } + } + if cs.OutputFile != "" { + result = append(result, fmt.Sprintf("GOOGLE_EXTERNAL_ACCOUNT_OUTPUT_FILE=%v", cs.OutputFile)) + } + return result +} + +func (cs executableCredentialSource) getTokenFromExecutableCommand() (string, error) { + // For security reasons, we need our consumers to set this environment variable to allow executables to be run. + if cs.env.getenv("GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES") != "1" { + return "", executablesDisallowedError() + } + + ctx, cancel := context.WithDeadline(cs.ctx, cs.env.now().Add(cs.Timeout)) + defer cancel() + + output, err := cs.env.run(ctx, cs.Command, cs.executableEnvironment()) + if err != nil { + return "", err + } + return parseSubjectTokenFromSource(output, executableSource, cs.env.now().Unix()) +} diff --git a/google/internal/externalaccount/executablecredsource_test.go b/google/internal/externalaccount/executablecredsource_test.go new file mode 100644 index 0000000..f115b29 --- /dev/null +++ b/google/internal/externalaccount/executablecredsource_test.go @@ -0,0 +1,1020 @@ +// Copyright 2022 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package externalaccount + +import ( + "context" + "encoding/json" + "fmt" + "io/ioutil" + "os" + "sort" + "testing" + "time" + + "github.com/google/go-cmp/cmp" +) + +type testEnvironment struct { + envVars map[string]string + deadline time.Time + deadlineSet bool + byteResponse []byte + jsonResponse *executableResponse +} + +var executablesAllowed = map[string]string{ + "GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES": "1", +} + +func (t *testEnvironment) existingEnv() []string { + result := []string{} + for k, v := range t.envVars { + result = append(result, fmt.Sprintf("%v=%v", k, v)) + } + return result +} + +func (t *testEnvironment) getenv(key string) string { + return t.envVars[key] +} + +func (t *testEnvironment) run(ctx context.Context, command string, env []string) ([]byte, error) { + t.deadline, t.deadlineSet = ctx.Deadline() + if t.jsonResponse != nil { + return json.Marshal(t.jsonResponse) + } + return t.byteResponse, nil +} + +func (t *testEnvironment) getDeadline() (time.Time, bool) { + return t.deadline, t.deadlineSet +} + +func (t *testEnvironment) now() time.Time { + return defaultTime +} + +func Bool(b bool) *bool { + return &b +} + +func Int(i int) *int { + return &i +} + +var creationTests = []struct { + name string + executableConfig ExecutableConfig + expectedErr error + expectedTimeout time.Duration +}{ + { + name: "Basic Creation", + executableConfig: ExecutableConfig{ + Command: "blarg", + TimeoutMillis: Int(50000), + }, + expectedTimeout: 50000 * time.Millisecond, + }, + { + name: "Without Timeout", + executableConfig: ExecutableConfig{ + Command: "blarg", + }, + expectedTimeout: 30000 * time.Millisecond, + }, + { + name: "Without Command", + executableConfig: ExecutableConfig{}, + expectedErr: commandMissingError(), + }, + { + name: "Timeout Too Low", + executableConfig: ExecutableConfig{ + Command: "blarg", + TimeoutMillis: Int(4999), + }, + expectedErr: timeoutRangeError(), + }, + { + name: "Timeout Lower Bound", + executableConfig: ExecutableConfig{ + Command: "blarg", + TimeoutMillis: Int(5000), + }, + expectedTimeout: 5000 * time.Millisecond, + }, + { + name: "Timeout Upper Bound", + executableConfig: ExecutableConfig{ + Command: "blarg", + TimeoutMillis: Int(120000), + }, + expectedTimeout: 120000 * time.Millisecond, + }, + { + name: "Timeout Too High", + executableConfig: ExecutableConfig{ + Command: "blarg", + TimeoutMillis: Int(120001), + }, + expectedErr: timeoutRangeError(), + }, +} + +func TestCreateExecutableCredential(t *testing.T) { + for _, tt := range creationTests { + t.Run(tt.name, func(t *testing.T) { + ecs, err := CreateExecutableCredential(context.Background(), &tt.executableConfig, nil) + if tt.expectedErr != nil { + if err == nil { + t.Fatalf("Expected error but found none") + } + if got, want := err.Error(), tt.expectedErr.Error(); got != want { + t.Errorf("Incorrect error received.\nReceived: %s\nExpected: %s", got, want) + } + } else if err != nil { + ecJson := "{???}" + if ecBytes, err2 := json.Marshal(tt.executableConfig); err2 != nil { + ecJson = string(ecBytes) + } + + t.Fatalf("CreateExecutableCredential with %v returned error: %v", ecJson, err) + } else { + if ecs.Command != "blarg" { + t.Errorf("ecs.Command got %v but want %v", ecs.Command, "blarg") + } + if ecs.Timeout != tt.expectedTimeout { + t.Errorf("ecs.Timeout got %v but want %v", ecs.Timeout, tt.expectedTimeout) + } + } + }) + } +} + +var getEnvironmentTests = []struct { + name string + config Config + environment testEnvironment + expectedEnvironment []string +}{ + { + name: "Minimal Executable Config", + config: Config{ + Audience: "//iam.googleapis.com/projects/123/locations/global/workloadIdentityPools/pool/providers/oidc", + SubjectTokenType: "urn:ietf:params:oauth:token-type:jwt", + CredentialSource: CredentialSource{ + Executable: &ExecutableConfig{ + Command: "blarg", + }, + }, + }, + environment: testEnvironment{ + envVars: map[string]string{ + "A": "B", + }, + }, + expectedEnvironment: []string{ + "A=B", + "GOOGLE_EXTERNAL_ACCOUNT_AUDIENCE=//iam.googleapis.com/projects/123/locations/global/workloadIdentityPools/pool/providers/oidc", + "GOOGLE_EXTERNAL_ACCOUNT_TOKEN_TYPE=urn:ietf:params:oauth:token-type:jwt", + "GOOGLE_EXTERNAL_ACCOUNT_INTERACTIVE=0", + }, + }, + { + name: "Full Impersonation URL", + config: Config{ + Audience: "//iam.googleapis.com/projects/123/locations/global/workloadIdentityPools/pool/providers/oidc", + ServiceAccountImpersonationURL: "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/test@project.iam.gserviceaccount.com:generateAccessToken", + SubjectTokenType: "urn:ietf:params:oauth:token-type:jwt", + CredentialSource: CredentialSource{ + Executable: &ExecutableConfig{ + Command: "blarg", + OutputFile: "/path/to/generated/cached/credentials", + }, + }, + }, + environment: testEnvironment{ + envVars: map[string]string{ + "A": "B", + }, + }, + expectedEnvironment: []string{ + "A=B", + "GOOGLE_EXTERNAL_ACCOUNT_AUDIENCE=//iam.googleapis.com/projects/123/locations/global/workloadIdentityPools/pool/providers/oidc", + "GOOGLE_EXTERNAL_ACCOUNT_TOKEN_TYPE=urn:ietf:params:oauth:token-type:jwt", + "GOOGLE_EXTERNAL_ACCOUNT_IMPERSONATED_EMAIL=test@project.iam.gserviceaccount.com", + "GOOGLE_EXTERNAL_ACCOUNT_INTERACTIVE=0", + "GOOGLE_EXTERNAL_ACCOUNT_OUTPUT_FILE=/path/to/generated/cached/credentials", + }, + }, + { + name: "Impersonation Email", + config: Config{ + Audience: "//iam.googleapis.com/projects/123/locations/global/workloadIdentityPools/pool/providers/oidc", + ServiceAccountImpersonationURL: "test@project.iam.gserviceaccount.com", + SubjectTokenType: "urn:ietf:params:oauth:token-type:jwt", + CredentialSource: CredentialSource{ + Executable: &ExecutableConfig{ + Command: "blarg", + OutputFile: "/path/to/generated/cached/credentials", + }, + }, + }, + environment: testEnvironment{ + envVars: map[string]string{ + "A": "B", + }, + }, + expectedEnvironment: []string{ + "A=B", + "GOOGLE_EXTERNAL_ACCOUNT_AUDIENCE=//iam.googleapis.com/projects/123/locations/global/workloadIdentityPools/pool/providers/oidc", + "GOOGLE_EXTERNAL_ACCOUNT_TOKEN_TYPE=urn:ietf:params:oauth:token-type:jwt", + "GOOGLE_EXTERNAL_ACCOUNT_INTERACTIVE=0", + "GOOGLE_EXTERNAL_ACCOUNT_OUTPUT_FILE=/path/to/generated/cached/credentials", + }, + }, +} + +func TestExecutableCredentialGetEnvironment(t *testing.T) { + for _, tt := range getEnvironmentTests { + t.Run(tt.name, func(t *testing.T) { + config := tt.config + + ecs, err := CreateExecutableCredential(context.Background(), config.CredentialSource.Executable, &config) + if err != nil { + t.Fatalf("creation failed %v", err) + } + + ecs.env = &tt.environment + + // This Transformer sorts a []string. + sorter := cmp.Transformer("Sort", func(in []string) []string { + out := append([]string(nil), in...) // Copy input to avoid mutating it + sort.Strings(out) + return out + }) + + if got, want := ecs.executableEnvironment(), tt.expectedEnvironment; !cmp.Equal(got, want, sorter) { + t.Errorf("Incorrect environment received.\nReceived: %s\nExpected: %s", got, want) + } + }) + } +} + +var failureTests = []struct { + name string + testEnvironment testEnvironment + noExecution bool + expectedErr error +}{ + { + name: "Environment Variable Not Set", + testEnvironment: testEnvironment{ + byteResponse: []byte{}, + }, + noExecution: true, + expectedErr: executablesDisallowedError(), + }, + + { + name: "Invalid Token", + testEnvironment: testEnvironment{ + envVars: executablesAllowed, + byteResponse: []byte("tokentokentoken"), + }, + expectedErr: jsonParsingError(executableSource, "tokentokentoken"), + }, + + { + name: "Version Field Missing", + testEnvironment: testEnvironment{ + envVars: executablesAllowed, + jsonResponse: &executableResponse{ + Success: Bool(true), + }, + }, + expectedErr: missingFieldError(executableSource, "version"), + }, + + { + name: "Success Field Missing", + testEnvironment: testEnvironment{ + envVars: executablesAllowed, + jsonResponse: &executableResponse{ + Version: 1, + }, + }, + expectedErr: missingFieldError(executableSource, "success"), + }, + + { + name: "User defined error", + testEnvironment: testEnvironment{ + envVars: executablesAllowed, + jsonResponse: &executableResponse{ + Success: Bool(false), + Version: 1, + Code: "404", + Message: "Token Not Found", + }, + }, + expectedErr: userDefinedError("404", "Token Not Found"), + }, + + { + name: "User defined error without code", + testEnvironment: testEnvironment{ + envVars: executablesAllowed, + jsonResponse: &executableResponse{ + Success: Bool(false), + Version: 1, + Message: "Token Not Found", + }, + }, + expectedErr: malformedFailureError(), + }, + + { + name: "User defined error without message", + testEnvironment: testEnvironment{ + envVars: executablesAllowed, + jsonResponse: &executableResponse{ + Success: Bool(false), + Version: 1, + Code: "404", + }, + }, + expectedErr: malformedFailureError(), + }, + + { + name: "User defined error without fields", + testEnvironment: testEnvironment{ + envVars: executablesAllowed, + jsonResponse: &executableResponse{ + Success: Bool(false), + Version: 1, + }, + }, + expectedErr: malformedFailureError(), + }, + + { + name: "Newer Version", + testEnvironment: testEnvironment{ + envVars: executablesAllowed, + jsonResponse: &executableResponse{ + Success: Bool(true), + Version: 2, + }, + }, + expectedErr: unsupportedVersionError(executableSource, 2), + }, + + { + name: "Missing Token Type", + testEnvironment: testEnvironment{ + envVars: executablesAllowed, + jsonResponse: &executableResponse{ + Success: Bool(true), + Version: 1, + ExpirationTime: defaultTime.Unix(), + }, + }, + expectedErr: missingFieldError(executableSource, "token_type"), + }, + + { + name: "Missing Expiration", + testEnvironment: testEnvironment{ + envVars: executablesAllowed, + jsonResponse: &executableResponse{ + Success: Bool(true), + Version: 1, + TokenType: "urn:ietf:params:oauth:token-type:jwt", + }, + }, + expectedErr: missingFieldError(executableSource, "expiration_time"), + }, + + { + name: "Token Expired", + testEnvironment: testEnvironment{ + envVars: executablesAllowed, + jsonResponse: &executableResponse{ + Success: Bool(true), + Version: 1, + ExpirationTime: defaultTime.Unix() - 1, + TokenType: "urn:ietf:params:oauth:token-type:jwt", + }, + }, + expectedErr: tokenExpiredError(), + }, + + { + name: "Invalid Token Type", + testEnvironment: testEnvironment{ + envVars: executablesAllowed, + jsonResponse: &executableResponse{ + Success: Bool(true), + Version: 1, + ExpirationTime: defaultTime.Unix(), + TokenType: "urn:ietf:params:oauth:token-type:invalid", + }, + }, + expectedErr: tokenTypeError(executableSource), + }, + + { + name: "Missing JWT", + testEnvironment: testEnvironment{ + envVars: executablesAllowed, + jsonResponse: &executableResponse{ + Success: Bool(true), + Version: 1, + ExpirationTime: defaultTime.Unix(), + TokenType: "urn:ietf:params:oauth:token-type:jwt", + }, + }, + expectedErr: missingFieldError(executableSource, "id_token"), + }, + + { + name: "Missing ID Token", + testEnvironment: testEnvironment{ + envVars: executablesAllowed, + jsonResponse: &executableResponse{ + Success: Bool(true), + Version: 1, + ExpirationTime: defaultTime.Unix(), + TokenType: "urn:ietf:params:oauth:token-type:id_token", + }, + }, + expectedErr: missingFieldError(executableSource, "id_token"), + }, + + { + name: "Missing SAML Token", + testEnvironment: testEnvironment{ + envVars: executablesAllowed, + jsonResponse: &executableResponse{ + Success: Bool(true), + Version: 1, + ExpirationTime: defaultTime.Unix(), + TokenType: "urn:ietf:params:oauth:token-type:saml2", + }, + }, + expectedErr: missingFieldError(executableSource, "saml_response"), + }, +} + +func TestRetrieveExecutableSubjectTokenExecutableErrors(t *testing.T) { + cs := CredentialSource{ + Executable: &ExecutableConfig{ + Command: "blarg", + TimeoutMillis: Int(5000), + }, + } + + tfc := testFileConfig + tfc.CredentialSource = cs + + base, err := tfc.parse(context.Background()) + if err != nil { + t.Fatalf("parse() failed %v", err) + } + + ecs, ok := base.(executableCredentialSource) + if !ok { + t.Fatalf("Wrong credential type created.") + } + + for _, tt := range failureTests { + t.Run(tt.name, func(t *testing.T) { + ecs.env = &tt.testEnvironment + + if _, err = ecs.subjectToken(); err == nil { + t.Fatalf("Expected error but found none") + } else if got, want := err.Error(), tt.expectedErr.Error(); got != want { + t.Errorf("Incorrect error received.\nReceived: %s\nExpected: %s", got, want) + } + + deadline, deadlineSet := tt.testEnvironment.getDeadline() + if tt.noExecution { + if deadlineSet { + t.Errorf("Executable called when it should not have been") + } + } else { + if !deadlineSet { + t.Errorf("Command run without a deadline") + } else if deadline != defaultTime.Add(5*time.Second) { + t.Errorf("Command run with incorrect deadline") + } + } + }) + } +} + +var successTests = []struct { + name string + testEnvironment testEnvironment +}{ + { + name: "JWT", + testEnvironment: testEnvironment{ + envVars: executablesAllowed, + jsonResponse: &executableResponse{ + Success: Bool(true), + Version: 1, + ExpirationTime: defaultTime.Unix() + 3600, + TokenType: "urn:ietf:params:oauth:token-type:jwt", + IdToken: "tokentokentoken", + }, + }, + }, + + { + name: "ID Token", + testEnvironment: testEnvironment{ + envVars: executablesAllowed, + jsonResponse: &executableResponse{ + Success: Bool(true), + Version: 1, + ExpirationTime: defaultTime.Unix() + 3600, + TokenType: "urn:ietf:params:oauth:token-type:id_token", + IdToken: "tokentokentoken", + }, + }, + }, + + { + name: "SAML", + testEnvironment: testEnvironment{ + envVars: executablesAllowed, + jsonResponse: &executableResponse{ + Success: Bool(true), + Version: 1, + ExpirationTime: defaultTime.Unix() + 3600, + TokenType: "urn:ietf:params:oauth:token-type:saml2", + SamlResponse: "tokentokentoken", + }, + }, + }, +} + +func TestRetrieveExecutableSubjectTokenSuccesses(t *testing.T) { + cs := CredentialSource{ + Executable: &ExecutableConfig{ + Command: "blarg", + TimeoutMillis: Int(5000), + }, + } + + tfc := testFileConfig + tfc.CredentialSource = cs + + base, err := tfc.parse(context.Background()) + if err != nil { + t.Fatalf("parse() failed %v", err) + } + + ecs, ok := base.(executableCredentialSource) + if !ok { + t.Fatalf("Wrong credential type created.") + } + + for _, tt := range successTests { + t.Run(tt.name, func(t *testing.T) { + ecs.env = &tt.testEnvironment + + out, err := ecs.subjectToken() + if err != nil { + t.Fatalf("retrieveSubjectToken() failed: %v", err) + } + + deadline, deadlineSet := tt.testEnvironment.getDeadline() + if !deadlineSet { + t.Errorf("Command run without a deadline") + } else if deadline != defaultTime.Add(5*time.Second) { + t.Errorf("Command run with incorrect deadline") + } + + if got, want := out, "tokentokentoken"; got != want { + t.Errorf("Incorrect token received.\nReceived: %s\nExpected: %s", got, want) + } + }) + } +} + +func TestRetrieveOutputFileSubjectTokenNotJSON(t *testing.T) { + outputFile, err := ioutil.TempFile("testdata", "result.*.json") + if err != nil { + t.Fatalf("Tempfile failed: %v", err) + } + defer os.Remove(outputFile.Name()) + + cs := CredentialSource{ + Executable: &ExecutableConfig{ + Command: "blarg", + TimeoutMillis: Int(5000), + OutputFile: outputFile.Name(), + }, + } + + tfc := testFileConfig + tfc.CredentialSource = cs + + base, err := tfc.parse(context.Background()) + if err != nil { + t.Fatalf("parse() failed %v", err) + } + + ecs, ok := base.(executableCredentialSource) + if !ok { + t.Fatalf("Wrong credential type created.") + } + + if _, err = outputFile.Write([]byte("tokentokentoken")); err != nil { + t.Fatalf("error writing to file: %v", err) + } + + te := testEnvironment{ + envVars: executablesAllowed, + byteResponse: []byte{}, + } + ecs.env = &te + + if _, err = base.subjectToken(); err == nil { + t.Fatalf("Expected error but found none") + } else if got, want := err.Error(), jsonParsingError(outputFileSource, "tokentokentoken").Error(); got != want { + t.Errorf("Incorrect error received.\nExpected: %s\nRecieved: %s", want, got) + } + + _, deadlineSet := te.getDeadline() + if deadlineSet { + t.Errorf("Executable called when it should not have been") + } +} + +// These are errors in the output file that should be reported to the user. +// Most of these will help the developers debug their code. +var cacheFailureTests = []struct { + name string + outputFileContents executableResponse + expectedErr error +}{ + { + name: "Missing Version", + outputFileContents: executableResponse{ + Success: Bool(true), + }, + expectedErr: missingFieldError(outputFileSource, "version"), + }, + + { + name: "Missing Success", + outputFileContents: executableResponse{ + Version: 1, + }, + expectedErr: missingFieldError(outputFileSource, "success"), + }, + + { + name: "Newer Version", + outputFileContents: executableResponse{ + Success: Bool(true), + Version: 2, + }, + expectedErr: unsupportedVersionError(outputFileSource, 2), + }, + + { + name: "Missing Token Type", + outputFileContents: executableResponse{ + Success: Bool(true), + Version: 1, + ExpirationTime: defaultTime.Unix(), + }, + expectedErr: missingFieldError(outputFileSource, "token_type"), + }, + + { + name: "Missing Expiration", + outputFileContents: executableResponse{ + Success: Bool(true), + Version: 1, + TokenType: "urn:ietf:params:oauth:token-type:jwt", + }, + expectedErr: missingFieldError(outputFileSource, "expiration_time"), + }, + + { + name: "Invalid Token Type", + outputFileContents: executableResponse{ + Success: Bool(true), + Version: 1, + ExpirationTime: defaultTime.Unix(), + TokenType: "urn:ietf:params:oauth:token-type:invalid", + }, + expectedErr: tokenTypeError(outputFileSource), + }, + + { + name: "Missing JWT", + outputFileContents: executableResponse{ + Success: Bool(true), + Version: 1, + ExpirationTime: defaultTime.Unix() + 3600, + TokenType: "urn:ietf:params:oauth:token-type:jwt", + }, + expectedErr: missingFieldError(outputFileSource, "id_token"), + }, + + { + name: "Missing ID Token", + outputFileContents: executableResponse{ + Success: Bool(true), + Version: 1, + ExpirationTime: defaultTime.Unix() + 3600, + TokenType: "urn:ietf:params:oauth:token-type:id_token", + }, + expectedErr: missingFieldError(outputFileSource, "id_token"), + }, + + { + name: "Missing SAML", + outputFileContents: executableResponse{ + Success: Bool(true), + Version: 1, + ExpirationTime: defaultTime.Unix() + 3600, + TokenType: "urn:ietf:params:oauth:token-type:jwt", + }, + expectedErr: missingFieldError(outputFileSource, "id_token"), + }, +} + +func TestRetrieveOutputFileSubjectTokenFailureTests(t *testing.T) { + for _, tt := range cacheFailureTests { + t.Run(tt.name, func(t *testing.T) { + outputFile, err := ioutil.TempFile("testdata", "result.*.json") + if err != nil { + t.Fatalf("Tempfile failed: %v", err) + } + defer os.Remove(outputFile.Name()) + + cs := CredentialSource{ + Executable: &ExecutableConfig{ + Command: "blarg", + TimeoutMillis: Int(5000), + OutputFile: outputFile.Name(), + }, + } + + tfc := testFileConfig + tfc.CredentialSource = cs + + base, err := tfc.parse(context.Background()) + if err != nil { + t.Fatalf("parse() failed %v", err) + } + + ecs, ok := base.(executableCredentialSource) + if !ok { + t.Fatalf("Wrong credential type created.") + } + te := testEnvironment{ + envVars: executablesAllowed, + byteResponse: []byte{}, + } + ecs.env = &te + if err = json.NewEncoder(outputFile).Encode(tt.outputFileContents); err != nil { + t.Errorf("Error encoding to file: %v", err) + return + } + if _, err = ecs.subjectToken(); err == nil { + t.Errorf("Expected error but found none") + } else if got, want := err.Error(), tt.expectedErr.Error(); got != want { + t.Errorf("Incorrect error received.\nExpected: %s\nRecieved: %s", want, got) + } + + if _, deadlineSet := te.getDeadline(); deadlineSet { + t.Errorf("Executable called when it should not have been") + } + }) + } +} + +// These tests should ignore the error in the output file, and check the executable. +var invalidCacheTests = []struct { + name string + outputFileContents executableResponse +}{ + { + name: "User Defined Error", + outputFileContents: executableResponse{ + Success: Bool(false), + Version: 1, + Code: "404", + Message: "Token Not Found", + }, + }, + + { + name: "User Defined Error without Code", + outputFileContents: executableResponse{ + Success: Bool(false), + Version: 1, + Message: "Token Not Found", + }, + }, + + { + name: "User Defined Error without Message", + outputFileContents: executableResponse{ + Success: Bool(false), + Version: 1, + Code: "404", + }, + }, + + { + name: "User Defined Error without Fields", + outputFileContents: executableResponse{ + Success: Bool(false), + Version: 1, + }, + }, + + { + name: "Expired Token", + outputFileContents: executableResponse{ + Success: Bool(true), + Version: 1, + ExpirationTime: defaultTime.Unix() - 1, + TokenType: "urn:ietf:params:oauth:token-type:jwt", + }, + }, +} + +func TestRetrieveOutputFileSubjectTokenInvalidCache(t *testing.T) { + for _, tt := range invalidCacheTests { + t.Run(tt.name, func(t *testing.T) { + outputFile, err := ioutil.TempFile("testdata", "result.*.json") + if err != nil { + t.Fatalf("Tempfile failed: %v", err) + } + defer os.Remove(outputFile.Name()) + + cs := CredentialSource{ + Executable: &ExecutableConfig{ + Command: "blarg", + TimeoutMillis: Int(5000), + OutputFile: outputFile.Name(), + }, + } + + tfc := testFileConfig + tfc.CredentialSource = cs + + base, err := tfc.parse(context.Background()) + if err != nil { + t.Fatalf("parse() failed %v", err) + } + + te := testEnvironment{ + envVars: executablesAllowed, + jsonResponse: &executableResponse{ + Success: Bool(true), + Version: 1, + ExpirationTime: defaultTime.Unix() + 3600, + TokenType: "urn:ietf:params:oauth:token-type:jwt", + IdToken: "tokentokentoken", + }, + } + + ecs, ok := base.(executableCredentialSource) + if !ok { + t.Fatalf("Wrong credential type created.") + } + ecs.env = &te + + if err = json.NewEncoder(outputFile).Encode(tt.outputFileContents); err != nil { + t.Errorf("Error encoding to file: %v", err) + return + } + + out, err := ecs.subjectToken() + if err != nil { + t.Errorf("retrieveSubjectToken() failed: %v", err) + return + } + + if deadline, deadlineSet := te.getDeadline(); !deadlineSet { + t.Errorf("Command run without a deadline") + } else if deadline != defaultTime.Add(5*time.Second) { + t.Errorf("Command run with incorrect deadline") + } + + if got, want := out, "tokentokentoken"; got != want { + t.Errorf("Incorrect token received.\nExpected: %s\nRecieved: %s", want, got) + } + }) + } +} + +var cacheSuccessTests = []struct { + name string + outputFileContents executableResponse +}{ + { + name: "JWT", + outputFileContents: executableResponse{ + Success: Bool(true), + Version: 1, + ExpirationTime: defaultTime.Unix() + 3600, + TokenType: "urn:ietf:params:oauth:token-type:jwt", + IdToken: "tokentokentoken", + }, + }, + + { + name: "Id Token", + outputFileContents: executableResponse{ + Success: Bool(true), + Version: 1, + ExpirationTime: defaultTime.Unix() + 3600, + TokenType: "urn:ietf:params:oauth:token-type:id_token", + IdToken: "tokentokentoken", + }, + }, + + { + name: "SAML", + outputFileContents: executableResponse{ + Success: Bool(true), + Version: 1, + ExpirationTime: defaultTime.Unix() + 3600, + TokenType: "urn:ietf:params:oauth:token-type:saml2", + SamlResponse: "tokentokentoken", + }, + }, +} + +func TestRetrieveOutputFileSubjectTokenJwt(t *testing.T) { + for _, tt := range cacheSuccessTests { + t.Run(tt.name, func(t *testing.T) { + + outputFile, err := ioutil.TempFile("testdata", "result.*.json") + if err != nil { + t.Fatalf("Tempfile failed: %v", err) + } + defer os.Remove(outputFile.Name()) + + cs := CredentialSource{ + Executable: &ExecutableConfig{ + Command: "blarg", + TimeoutMillis: Int(5000), + OutputFile: outputFile.Name(), + }, + } + + tfc := testFileConfig + tfc.CredentialSource = cs + + base, err := tfc.parse(context.Background()) + if err != nil { + t.Fatalf("parse() failed %v", err) + } + + te := testEnvironment{ + envVars: executablesAllowed, + byteResponse: []byte{}, + } + + ecs, ok := base.(executableCredentialSource) + if !ok { + t.Fatalf("Wrong credential type created.") + } + ecs.env = &te + + if err = json.NewEncoder(outputFile).Encode(tt.outputFileContents); err != nil { + t.Errorf("Error encoding to file: %v", err) + return + } + + if out, err := ecs.subjectToken(); err != nil { + t.Errorf("retrieveSubjectToken() failed: %v", err) + } else if got, want := out, "tokentokentoken"; got != want { + t.Errorf("Incorrect token received.\nExpected: %s\nRecieved: %s", want, got) + } + + if _, deadlineSet := te.getDeadline(); deadlineSet { + t.Errorf("Executable called when it should not have been") + } + }) + } +}