diff --git a/google/internal/externalaccount/basecredentials.go b/google/internal/externalaccount/basecredentials.go index 83ce9c2..4251c44 100644 --- a/google/internal/externalaccount/basecredentials.go +++ b/google/internal/externalaccount/basecredentials.go @@ -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..6fbb70e --- /dev/null +++ b/google/internal/externalaccount/executablecredsource.go @@ -0,0 +1,254 @@ +// 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" + "errors" + "fmt" + "os" + "os/exec" + "regexp" + "time" +) + +var serviceAccountImpersonationCompiler = 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 +) + +func missingFieldError(field string) error { + return fmt.Errorf("oauth2/google: response missing `%v` field", field) +} + +func jsonParsingError() error { + return errors.New("oauth2/google: unable to parse response JSON") +} + +func malformedFailureError() error { + return errors.New("oauth2/google: response must include `error` and `message` fields when unsuccessful") +} + +func userDefinedError(code, message string) error { + return fmt.Errorf("oauth2/google: executable returned unsuccessful response: (%v) %v", code, message) +} + +func unsupportedVersionError(version int) error { + return fmt.Errorf("oauth2/google: executable returned unsupported version: %v", version) +} + +func tokenExpiredError() error { + return errors.New("oauth2/google: the token returned by the executable is expired") +} + +func tokenTypeError() error { + return errors.New("oauth2/google: executable returned unsupported token type") +} + +func timeoutError() error { + return errors.New("oauth2/google: executable command timed out") +} + +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") +} + +// baseEnv is an alias of os.Environ used for testing +var baseEnv = os.Environ + +// runCommand is basically an alias of exec.CommandContext for testing. +var runCommand = func(ctx context.Context, command string, env []string) ([]byte, error) { + cmd := exec.CommandContext(ctx, command) + cmd.Env = env + + response, err := cmd.Output() + if err == nil { + return response, nil + } + + if err == context.DeadlineExceeded { + return nil, timeoutError() + } + + if exitError, ok := err.(*exec.ExitError); ok { + return nil, exitCodeError(exitError.ExitCode()) + } + + return nil, executableError(err) +} + +type executableCredentialSource struct { + Command string + Timeout time.Duration + OutputFile string + ctx context.Context + config *Config +} + +// CreateExecutableCredential creates an executableCredentialSource given an ExecutableConfig. +// It also performs defaulting and type conversions. +func CreateExecutableCredential(ctx context.Context, ec *ExecutableConfig, config *Config) (result executableCredentialSource, err error) { + if ec.Command == "" { + err = commandMissingError() + } + 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 { + err = timeoutRangeError() + return + } + } + result.OutputFile = ec.OutputFile + result.ctx = ctx + result.config = config + return +} + +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 parseSubjectToken(response []byte) (string, error) { + var result executableResponse + if err := json.Unmarshal(response, &result); err != nil { + return "", jsonParsingError() + } + + if result.Version == 0 { + return "", missingFieldError("version") + } + + if result.Success == nil { + return "", missingFieldError("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(result.Version) + } + + if result.ExpirationTime == 0 { + return "", missingFieldError("expiration_time") + } + + if result.TokenType == "" { + return "", missingFieldError("token_type") + } + + if result.ExpirationTime < now().Unix() { + 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("id_token") + } + return result.IdToken, nil + } + + if result.TokenType == "urn:ietf:params:oauth:token-type:saml2" { + if result.SamlResponse == "" { + return "", missingFieldError("saml_response") + } + return result.SamlResponse, nil + } + + return "", tokenTypeError() +} + +func (cs executableCredentialSource) subjectToken() (string, error) { + if token, ok := cs.getTokenFromOutputFile(); ok { + return token, nil + } + + return cs.getTokenFromExecutableCommand() +} + +func (cs executableCredentialSource) getTokenFromOutputFile() (string, bool) { + // TODO + return "", false +} + +func (cs executableCredentialSource) getEnvironment() []string { + result := baseEnv() + for k, v := range cs.getNewEnvironmentVariables() { + result = append(result, fmt.Sprintf("%v=%v", k, v)) + } + return result +} + +func (cs executableCredentialSource) getNewEnvironmentVariables() map[string]string { + result := map[string]string{ + "GOOGLE_EXTERNAL_ACCOUNT_AUDIENCE": cs.config.Audience, + "GOOGLE_EXTERNAL_ACCOUNT_TOKEN_TYPE": cs.config.SubjectTokenType, + "GOOGLE_EXTERNAL_ACCOUNT_INTERACTIVE": "0", + } + + if cs.config.ServiceAccountImpersonationURL != "" { + matches := serviceAccountImpersonationCompiler.FindStringSubmatch(cs.config.ServiceAccountImpersonationURL) + if matches != nil { + result["GOOGLE_EXTERNAL_ACCOUNT_IMPERSONATED_EMAIL"] = matches[1] + } + } + + if cs.OutputFile != "" { + result["GOOGLE_EXTERNAL_ACCOUNT_OUTPUT_FILE"] = 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 getenv("GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES") != "1" { + return "", executablesDisallowedError() + } + + ctx, cancel := context.WithDeadline(cs.ctx, now().Add(cs.Timeout)) + defer cancel() + + if output, err := runCommand(ctx, cs.Command, cs.getEnvironment()); err != nil { + return "", err + } else { + return parseSubjectToken(output) + } +} diff --git a/google/internal/externalaccount/executablecredsource_test.go b/google/internal/externalaccount/executablecredsource_test.go new file mode 100644 index 0000000..59f1e82 --- /dev/null +++ b/google/internal/externalaccount/executablecredsource_test.go @@ -0,0 +1,1105 @@ +// 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" + "testing" + "time" +) + +func Bool(b bool) *bool { + return &b +} + +func Int(i int) *int { + return &i +} + +func Int64(i int64) *int64 { + return &i +} + +func String(s string) *string { + return &s +} + +func TestCreateExecutableCredential(t *testing.T) { + ec := ExecutableConfig{ + Command: "blarg", + TimeoutMillis: Int(50000), + } + + ecs, err := CreateExecutableCredential(context.Background(), &ec, nil) + if err != nil { + t.Fatalf("creation failed %v", err) + } + if ecs.Command != "blarg" { + t.Errorf("ecs.Command got %v but want %v", ecs.Command, "blarg") + } + if ecs.Timeout != 50000*time.Millisecond { + t.Errorf("ecs.Timeout got %v but want %v", ecs.Timeout, 50000*time.Millisecond) + } +} + +func TestCreateExecutableCredential_WithoutTimeout(t *testing.T) { + ec := ExecutableConfig{ + Command: "blarg", + } + + ecs, err := CreateExecutableCredential(context.Background(), &ec, nil) + if err != nil { + t.Fatalf("creation failed %v", err) + } + if ecs.Command != "blarg" { + t.Errorf("ecs.Command got %v but want %v", ecs.Command, "blarg") + } + if ecs.Timeout != defaultTimeout { + t.Errorf("ecs.Timeout got %v but want %v", ecs.Timeout, 30000*time.Millisecond) + } +} + +func TestCreateExectuableCredential_WithoutCommand(t *testing.T) { + ec := ExecutableConfig{} + + _, err := CreateExecutableCredential(context.Background(), &ec, nil) + if err == nil { + t.Fatalf("Expected error but found none") + } + if got, want := err.Error(), commandMissingError().Error(); got != want { + t.Errorf("Incorrect error received.\nReceived: %s\nExpected: %s", got, want) + } +} + +func TestCreateExectuableCredential_TimeoutTooLow(t *testing.T) { + ec := ExecutableConfig{ + Command: "blarg", + TimeoutMillis: Int(4999), + } + + _, err := CreateExecutableCredential(context.Background(), &ec, nil) + if err == nil { + t.Fatalf("Expected error but found none") + } + if got, want := err.Error(), timeoutRangeError().Error(); got != want { + t.Errorf("Incorrect error received.\nReceived: %s\nExpected: %s", got, want) + } +} + +func TestCreateExectuableCredential_TimeoutLow(t *testing.T) { + ec := ExecutableConfig{ + Command: "blarg", + TimeoutMillis: Int(5000), + } + + _, err := CreateExecutableCredential(context.Background(), &ec, nil) + if err != nil { + t.Fatalf("creation failed %v", err) + } +} + +func TestCreateExectuableCredential_TimeoutHigh(t *testing.T) { + ec := ExecutableConfig{ + Command: "blarg", + TimeoutMillis: Int(120000), + } + + _, err := CreateExecutableCredential(context.Background(), &ec, nil) + if err != nil { + t.Fatalf("creation failed %v", err) + } +} + +func TestCreateExectuableCredential_TimeoutTooHigh(t *testing.T) { + ec := ExecutableConfig{ + Command: "blarg", + TimeoutMillis: Int(120001), + } + + _, err := CreateExecutableCredential(context.Background(), &ec, nil) + if err == nil { + t.Fatalf("Expected error but found none") + } + if got, want := err.Error(), timeoutRangeError().Error(); got != want { + t.Errorf("Incorrect error received.\nReceived: %s\nExpected: %s", got, want) + } +} + +func areSlicesEquivalent(a, b []string) bool { + if len(a) != len(b) { + return false + } + +OUTER: + for _, aa := range a { + for _, bb := range b { + if aa == bb { + continue OUTER + } + } + return false + } + return true +} + +func TestMinimalExecutableCredentialGetEnvironment(t *testing.T) { + 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", + }, + }, + } + + ecs, err := CreateExecutableCredential(context.Background(), config.CredentialSource.Executable, &config) + if err != nil { + t.Fatalf("creation failed %v", err) + } + + oldBaseEnv := baseEnv + defer func() { baseEnv = oldBaseEnv }() + baseEnv = func() []string { + return []string{"A=B"} + } + + expectedEnvironment := []string{ + "A=B", + fmt.Sprintf("GOOGLE_EXTERNAL_ACCOUNT_AUDIENCE=%v", config.Audience), + fmt.Sprintf("GOOGLE_EXTERNAL_ACCOUNT_TOKEN_TYPE=%v", config.SubjectTokenType), + "GOOGLE_EXTERNAL_ACCOUNT_INTERACTIVE=0", + } + + if got, want := ecs.getEnvironment(), expectedEnvironment; !areSlicesEquivalent(got, want) { + t.Errorf("Incorrect environment received.\nReceived: %s\nExpected: %s", got, want) + } +} + +func TestExectuableCredentialGetEnvironmentMalformedImpersonationUrl(t *testing.T) { + 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", + }, + }, + } + + ecs, err := CreateExecutableCredential(context.Background(), config.CredentialSource.Executable, &config) + if err != nil { + t.Fatalf("creation failed %v", err) + } + + oldBaseEnv := baseEnv + defer func() { baseEnv = oldBaseEnv }() + baseEnv = func() []string { + return []string{"A=B"} + } + + expectedEnvironment := []string{ + "A=B", + fmt.Sprintf("GOOGLE_EXTERNAL_ACCOUNT_AUDIENCE=%v", config.Audience), + fmt.Sprintf("GOOGLE_EXTERNAL_ACCOUNT_TOKEN_TYPE=%v", config.SubjectTokenType), + "GOOGLE_EXTERNAL_ACCOUNT_IMPERSONATED_EMAIL=test@project.iam.gserviceaccount.com", + "GOOGLE_EXTERNAL_ACCOUNT_INTERACTIVE=0", + fmt.Sprintf("GOOGLE_EXTERNAL_ACCOUNT_OUTPUT_FILE=%v", config.CredentialSource.Executable.OutputFile), + } + + if got, want := ecs.getEnvironment(), expectedEnvironment; !areSlicesEquivalent(got, want) { + t.Errorf("Incorrect environment received.\nReceived: %s\nExpected: %s", got, want) + } +} + +func TestExectuableCredentialGetEnvironment(t *testing.T) { + 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", + }, + }, + } + + ecs, err := CreateExecutableCredential(context.Background(), config.CredentialSource.Executable, &config) + if err != nil { + t.Fatalf("creation failed %v", err) + } + + oldBaseEnv := baseEnv + defer func() { baseEnv = oldBaseEnv }() + baseEnv = func() []string { + return []string{"A=B"} + } + + expectedEnvironment := []string{ + "A=B", + fmt.Sprintf("GOOGLE_EXTERNAL_ACCOUNT_AUDIENCE=%v", config.Audience), + fmt.Sprintf("GOOGLE_EXTERNAL_ACCOUNT_TOKEN_TYPE=%v", config.SubjectTokenType), + "GOOGLE_EXTERNAL_ACCOUNT_INTERACTIVE=0", + fmt.Sprintf("GOOGLE_EXTERNAL_ACCOUNT_OUTPUT_FILE=%v", config.CredentialSource.Executable.OutputFile), + } + + if got, want := ecs.getEnvironment(), expectedEnvironment; !areSlicesEquivalent(got, want) { + t.Errorf("Incorrect environment received.\nReceived: %s\nExpected: %s", got, want) + } +} + +func TestRetrieveExecutableSubjectTokenWithoutEnvironmentVariablesSet(t *testing.T) { + cs := CredentialSource{ + Executable: &ExecutableConfig{ + Command: "blarg", + TimeoutMillis: Int(5000), + }, + } + + tfc := testFileConfig + tfc.CredentialSource = cs + + oldGetenv := getenv + defer func() { getenv = oldGetenv }() + getenv = setEnvironment(map[string]string{}) + + base, err := tfc.parse(context.Background()) + if err != nil { + t.Fatalf("parse() failed %v", err) + } + + _, err = base.subjectToken() + if err == nil { + t.Fatalf("Expected error but found none") + } + if got, want := err.Error(), executablesDisallowedError().Error(); got != want { + t.Errorf("Incorrect error received.\nReceived: %s\nExpected: %s", got, want) + } +} + +func TestRetrieveExecutableSubjectTokenInvalidFormat(t *testing.T) { + cs := CredentialSource{ + Executable: &ExecutableConfig{ + Command: "blarg", + TimeoutMillis: Int(5000), + }, + } + + tfc := testFileConfig + tfc.CredentialSource = cs + + oldGetenv, oldNow, oldRunCommand := getenv, now, runCommand + defer func() { + getenv, now, runCommand = oldGetenv, oldNow, oldRunCommand + }() + + getenv = setEnvironment(map[string]string{"GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES": "1"}) + now = setTime(defaultTime) + deadline, deadlineSet := now(), false + runCommand = func(ctx context.Context, command string, env []string) ([]byte, error) { + deadline, deadlineSet = ctx.Deadline() + return []byte("tokentokentoken"), nil + } + + base, err := tfc.parse(context.Background()) + if err != nil { + t.Fatalf("parse() failed %v", err) + } + + _, err = base.subjectToken() + if err == nil { + t.Fatalf("Expected error but found none") + } + if got, want := err.Error(), jsonParsingError().Error(); got != want { + t.Errorf("Incorrect error received.\nReceived: %s\nExpected: %s", got, want) + } + + if !deadlineSet { + t.Errorf("Command run without a deadline") + } else if deadline != now().Add(5*time.Second) { + t.Errorf("Command run with incorrect deadline") + } +} + +func TestRetrieveExecutableSubjectTokenMissingVersion(t *testing.T) { + cs := CredentialSource{ + Executable: &ExecutableConfig{ + Command: "blarg", + TimeoutMillis: Int(5000), + }, + } + + tfc := testFileConfig + tfc.CredentialSource = cs + + oldGetenv, oldNow, oldRunCommand := getenv, now, runCommand + defer func() { + getenv, now, runCommand = oldGetenv, oldNow, oldRunCommand + }() + + getenv = setEnvironment(map[string]string{"GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES": "1"}) + now = setTime(defaultTime) + deadline, deadlineSet := now(), false + runCommand = func(ctx context.Context, command string, env []string) ([]byte, error) { + deadline, deadlineSet = ctx.Deadline() + return json.Marshal(executableResponse{ + Success: Bool(true), + }) + } + + base, err := tfc.parse(context.Background()) + if err != nil { + t.Fatalf("parse() failed %v", err) + } + + _, err = base.subjectToken() + if err == nil { + t.Fatalf("Expected error but found none") + } + if got, want := err.Error(), missingFieldError("version").Error(); got != want { + t.Errorf("Incorrect error received.\nReceived: %s\nExpected: %s", got, want) + } + + if !deadlineSet { + t.Errorf("Command run without a deadline") + } else if deadline != now().Add(5*time.Second) { + t.Errorf("Command run with incorrect deadline") + } +} + +func TestRetrieveExecutableSubjectTokenMissingSuccess(t *testing.T) { + cs := CredentialSource{ + Executable: &ExecutableConfig{ + Command: "blarg", + TimeoutMillis: Int(5000), + }, + } + + tfc := testFileConfig + tfc.CredentialSource = cs + + oldGetenv, oldNow, oldRunCommand := getenv, now, runCommand + defer func() { + getenv, now, runCommand = oldGetenv, oldNow, oldRunCommand + }() + + getenv = setEnvironment(map[string]string{"GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES": "1"}) + now = setTime(defaultTime) + deadline, deadlineSet := now(), false + runCommand = func(ctx context.Context, command string, env []string) ([]byte, error) { + deadline, deadlineSet = ctx.Deadline() + return json.Marshal(executableResponse{ + Version: 1, + }) + } + + base, err := tfc.parse(context.Background()) + if err != nil { + t.Fatalf("parse() failed %v", err) + } + + _, err = base.subjectToken() + if err == nil { + t.Fatalf("Expected error but found none") + } + if got, want := err.Error(), missingFieldError("success").Error(); got != want { + t.Errorf("Incorrect error received.\nReceived: %s\nExpected: %s", got, want) + } + + if !deadlineSet { + t.Errorf("Command run without a deadline") + } else if deadline != now().Add(5*time.Second) { + t.Errorf("Command run with incorrect deadline") + } +} + +func TestRetrieveExecutableSubjectTokenUnsuccessfulResponseWithFields(t *testing.T) { + cs := CredentialSource{ + Executable: &ExecutableConfig{ + Command: "blarg", + TimeoutMillis: Int(5000), + }, + } + + tfc := testFileConfig + tfc.CredentialSource = cs + + oldGetenv, oldNow, oldRunCommand := getenv, now, runCommand + defer func() { + getenv, now, runCommand = oldGetenv, oldNow, oldRunCommand + }() + + getenv = setEnvironment(map[string]string{"GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES": "1"}) + now = setTime(defaultTime) + deadline, deadlineSet := now(), false + runCommand = func(ctx context.Context, command string, env []string) ([]byte, error) { + deadline, deadlineSet = ctx.Deadline() + return json.Marshal(executableResponse{ + Success: Bool(false), + Version: 1, + Code: "404", + Message: "Token Not Found", + }) + } + + base, err := tfc.parse(context.Background()) + if err != nil { + t.Fatalf("parse() failed %v", err) + } + + _, err = base.subjectToken() + if err == nil { + t.Fatalf("Expected error but found none") + } + if got, want := err.Error(), userDefinedError("404", "Token Not Found").Error(); got != want { + t.Errorf("Incorrect error received.\nReceived: %s\nExpected: %s", got, want) + } + + if !deadlineSet { + t.Errorf("Command run without a deadline") + } else if deadline != now().Add(5*time.Second) { + t.Errorf("Command run with incorrect deadline") + } +} + +func TestRetrieveExecutableSubjectTokenUnsuccessfulResponseWithCode(t *testing.T) { + cs := CredentialSource{ + Executable: &ExecutableConfig{ + Command: "blarg", + TimeoutMillis: Int(5000), + }, + } + + tfc := testFileConfig + tfc.CredentialSource = cs + + oldGetenv, oldNow, oldRunCommand := getenv, now, runCommand + defer func() { + getenv, now, runCommand = oldGetenv, oldNow, oldRunCommand + }() + + getenv = setEnvironment(map[string]string{"GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES": "1"}) + now = setTime(defaultTime) + deadline, deadlineSet := now(), false + runCommand = func(ctx context.Context, command string, env []string) ([]byte, error) { + deadline, deadlineSet = ctx.Deadline() + return json.Marshal(executableResponse{ + Success: Bool(false), + Version: 1, + Code: "404", + }) + } + + base, err := tfc.parse(context.Background()) + if err != nil { + t.Fatalf("parse() failed %v", err) + } + + _, err = base.subjectToken() + if err == nil { + t.Fatalf("Expected error but found none") + } + if got, want := err.Error(), malformedFailureError().Error(); got != want { + t.Errorf("Incorrect error received.\nReceived: %s\nExpected: %s", got, want) + } + + if !deadlineSet { + t.Errorf("Command run without a deadline") + } else if deadline != now().Add(5*time.Second) { + t.Errorf("Command run with incorrect deadline") + } +} + +func TestRetrieveExecutableSubjectTokenUnsuccessfulResponseWithMessage(t *testing.T) { + cs := CredentialSource{ + Executable: &ExecutableConfig{ + Command: "blarg", + TimeoutMillis: Int(5000), + }, + } + + tfc := testFileConfig + tfc.CredentialSource = cs + + oldGetenv, oldNow, oldRunCommand := getenv, now, runCommand + defer func() { + getenv, now, runCommand = oldGetenv, oldNow, oldRunCommand + }() + + getenv = setEnvironment(map[string]string{"GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES": "1"}) + now = setTime(defaultTime) + deadline, deadlineSet := now(), false + runCommand = func(ctx context.Context, command string, env []string) ([]byte, error) { + deadline, deadlineSet = ctx.Deadline() + return json.Marshal(executableResponse{ + Success: Bool(false), + Version: 1, + Message: "Token Not Found", + }) + } + + base, err := tfc.parse(context.Background()) + if err != nil { + t.Fatalf("parse() failed %v", err) + } + + _, err = base.subjectToken() + if err == nil { + t.Fatalf("Expected error but found none") + } + if got, want := err.Error(), malformedFailureError().Error(); got != want { + t.Errorf("Incorrect error received.\nReceived: %s\nExpected: %s", got, want) + } + + if !deadlineSet { + t.Errorf("Command run without a deadline") + } else if deadline != now().Add(5*time.Second) { + t.Errorf("Command run with incorrect deadline") + } +} + +func TestRetrieveExecutableSubjectTokenUnsuccessfulResponseWithoutFields(t *testing.T) { + cs := CredentialSource{ + Executable: &ExecutableConfig{ + Command: "blarg", + TimeoutMillis: Int(5000), + }, + } + + tfc := testFileConfig + tfc.CredentialSource = cs + + oldGetenv, oldNow, oldRunCommand := getenv, now, runCommand + defer func() { + getenv, now, runCommand = oldGetenv, oldNow, oldRunCommand + }() + + getenv = setEnvironment(map[string]string{"GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES": "1"}) + now = setTime(defaultTime) + deadline, deadlineSet := now(), false + runCommand = func(ctx context.Context, command string, env []string) ([]byte, error) { + deadline, deadlineSet = ctx.Deadline() + return json.Marshal(executableResponse{ + Success: Bool(false), + Version: 1, + }) + } + + base, err := tfc.parse(context.Background()) + if err != nil { + t.Fatalf("parse() failed %v", err) + } + + _, err = base.subjectToken() + if err == nil { + t.Fatalf("Expected error but found none") + } + if got, want := err.Error(), malformedFailureError().Error(); got != want { + t.Errorf("Incorrect error received.\nReceived: %s\nExpected: %s", got, want) + } + + if !deadlineSet { + t.Errorf("Command run without a deadline") + } else if deadline != now().Add(5*time.Second) { + t.Errorf("Command run with incorrect deadline") + } +} + +func TestRetrieveExecutableSubjectTokenNewerVersion(t *testing.T) { + cs := CredentialSource{ + Executable: &ExecutableConfig{ + Command: "blarg", + TimeoutMillis: Int(5000), + }, + } + + tfc := testFileConfig + tfc.CredentialSource = cs + + oldGetenv, oldNow, oldRunCommand := getenv, now, runCommand + defer func() { + getenv, now, runCommand = oldGetenv, oldNow, oldRunCommand + }() + + getenv = setEnvironment(map[string]string{"GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES": "1"}) + now = setTime(defaultTime) + deadline, deadlineSet := now(), false + runCommand = func(ctx context.Context, command string, env []string) ([]byte, error) { + deadline, deadlineSet = ctx.Deadline() + return json.Marshal(executableResponse{ + Success: Bool(true), + Version: 2, + }) + } + + base, err := tfc.parse(context.Background()) + if err != nil { + t.Fatalf("parse() failed %v", err) + } + + _, err = base.subjectToken() + if err == nil { + t.Fatalf("Expected error but found none") + } + if got, want := err.Error(), unsupportedVersionError(2).Error(); got != want { + t.Errorf("Incorrect error received.\nReceived: %s\nExpected: %s", got, want) + } + + if !deadlineSet { + t.Errorf("Command run without a deadline") + } else if deadline != now().Add(5*time.Second) { + t.Errorf("Command run with incorrect deadline") + } +} + +func TestRetrieveExecutableSubjectTokenMissingExpiration(t *testing.T) { + cs := CredentialSource{ + Executable: &ExecutableConfig{ + Command: "blarg", + TimeoutMillis: Int(5000), + }, + } + + tfc := testFileConfig + tfc.CredentialSource = cs + + oldGetenv, oldNow, oldRunCommand := getenv, now, runCommand + defer func() { + getenv, now, runCommand = oldGetenv, oldNow, oldRunCommand + }() + + getenv = setEnvironment(map[string]string{"GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES": "1"}) + now = setTime(defaultTime) + deadline, deadlineSet := now(), false + runCommand = func(ctx context.Context, command string, env []string) ([]byte, error) { + deadline, deadlineSet = ctx.Deadline() + return json.Marshal(executableResponse{ + Success: Bool(true), + Version: 1, + TokenType: "urn:ietf:params:oauth:token-type:jwt", + }) + } + + base, err := tfc.parse(context.Background()) + if err != nil { + t.Fatalf("parse() failed %v", err) + } + + _, err = base.subjectToken() + if err == nil { + t.Fatalf("Expected error but found none") + } + if got, want := err.Error(), missingFieldError("expiration_time").Error(); got != want { + t.Errorf("Incorrect error received.\nReceived: %s\nExpected: %s", got, want) + } + + if !deadlineSet { + t.Errorf("Command run without a deadline") + } else if deadline != now().Add(5*time.Second) { + t.Errorf("Command run with incorrect deadline") + } +} + +func TestRetrieveExecutableSubjectTokenTokenTypeMissing(t *testing.T) { + cs := CredentialSource{ + Executable: &ExecutableConfig{ + Command: "blarg", + TimeoutMillis: Int(5000), + }, + } + + tfc := testFileConfig + tfc.CredentialSource = cs + + oldGetenv, oldNow, oldRunCommand := getenv, now, runCommand + defer func() { + getenv, now, runCommand = oldGetenv, oldNow, oldRunCommand + }() + + getenv = setEnvironment(map[string]string{"GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES": "1"}) + now = setTime(defaultTime) + deadline, deadlineSet := now(), false + runCommand = func(ctx context.Context, command string, env []string) ([]byte, error) { + deadline, deadlineSet = ctx.Deadline() + return json.Marshal(executableResponse{ + Success: Bool(true), + Version: 1, + ExpirationTime: now().Unix(), + }) + } + + base, err := tfc.parse(context.Background()) + if err != nil { + t.Fatalf("parse() failed %v", err) + } + + _, err = base.subjectToken() + if err == nil { + t.Fatalf("Expected error but found none") + } + if got, want := err.Error(), missingFieldError("token_type").Error(); got != want { + t.Errorf("Incorrect error received.\nReceived: %s\nExpected: %s", got, want) + } + + if !deadlineSet { + t.Errorf("Command run without a deadline") + } else if deadline != now().Add(5*time.Second) { + t.Errorf("Command run with incorrect deadline") + } +} + +func TestRetrieveExecutableSubjectTokenInvalidTokenType(t *testing.T) { + cs := CredentialSource{ + Executable: &ExecutableConfig{ + Command: "blarg", + TimeoutMillis: Int(5000), + }, + } + + tfc := testFileConfig + tfc.CredentialSource = cs + + oldGetenv, oldNow, oldRunCommand := getenv, now, runCommand + defer func() { + getenv, now, runCommand = oldGetenv, oldNow, oldRunCommand + }() + + getenv = setEnvironment(map[string]string{"GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES": "1"}) + now = setTime(defaultTime) + deadline, deadlineSet := now(), false + runCommand = func(ctx context.Context, command string, env []string) ([]byte, error) { + deadline, deadlineSet = ctx.Deadline() + return json.Marshal(executableResponse{ + Success: Bool(true), + Version: 1, + ExpirationTime: now().Unix(), + TokenType: "urn:ietf:params:oauth:token-type:invalid", + }) + } + + base, err := tfc.parse(context.Background()) + if err != nil { + t.Fatalf("parse() failed %v", err) + } + + _, err = base.subjectToken() + if err == nil { + t.Fatalf("Expected error but found none") + } + if got, want := err.Error(), tokenTypeError().Error(); got != want { + t.Errorf("Incorrect error received.\nReceived: %s\nExpected: %s", got, want) + } + + if !deadlineSet { + t.Errorf("Command run without a deadline") + } else if deadline != now().Add(5*time.Second) { + t.Errorf("Command run with incorrect deadline") + } +} + +func TestRetrieveExecutableSubjectTokenExpired(t *testing.T) { + cs := CredentialSource{ + Executable: &ExecutableConfig{ + Command: "blarg", + TimeoutMillis: Int(5000), + }, + } + + tfc := testFileConfig + tfc.CredentialSource = cs + + oldGetenv, oldNow, oldRunCommand := getenv, now, runCommand + defer func() { + getenv, now, runCommand = oldGetenv, oldNow, oldRunCommand + }() + + getenv = setEnvironment(map[string]string{"GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES": "1"}) + now = setTime(defaultTime) + deadline, deadlineSet := now(), false + runCommand = func(ctx context.Context, command string, env []string) ([]byte, error) { + deadline, deadlineSet = ctx.Deadline() + return json.Marshal(executableResponse{ + Success: Bool(true), + Version: 1, + ExpirationTime: now().Unix() - 1, + TokenType: "urn:ietf:params:oauth:token-type:jwt", + }) + } + + base, err := tfc.parse(context.Background()) + if err != nil { + t.Fatalf("parse() failed %v", err) + } + + _, err = base.subjectToken() + if err == nil { + t.Fatalf("Expected error but found none") + } + if got, want := err.Error(), tokenExpiredError().Error(); got != want { + t.Errorf("Incorrect error received.\nReceived: %s\nExpected: %s", got, want) + } + + if !deadlineSet { + t.Errorf("Command run without a deadline") + } else if deadline != now().Add(5*time.Second) { + t.Errorf("Command run with incorrect deadline") + } +} + +func TestRetrieveExecutableSubjectTokenJwt(t *testing.T) { + cs := CredentialSource{ + Executable: &ExecutableConfig{ + Command: "blarg", + TimeoutMillis: Int(5000), + }, + } + + tfc := testFileConfig + tfc.CredentialSource = cs + + oldGetenv, oldNow, oldRunCommand := getenv, now, runCommand + defer func() { + getenv, now, runCommand = oldGetenv, oldNow, oldRunCommand + }() + + getenv = setEnvironment(map[string]string{"GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES": "1"}) + now = setTime(defaultTime) + deadline, deadlineSet := now(), false + runCommand = func(ctx context.Context, command string, env []string) ([]byte, error) { + deadline, deadlineSet = ctx.Deadline() + return json.Marshal(executableResponse{ + Success: Bool(true), + Version: 1, + ExpirationTime: now().Unix() + 3600, + TokenType: "urn:ietf:params:oauth:token-type:jwt", + IdToken: "tokentokentoken", + }) + } + + base, err := tfc.parse(context.Background()) + if err != nil { + t.Fatalf("parse() failed %v", err) + } + + out, err := base.subjectToken() + if err != nil { + t.Fatalf("retrieveSubjectToken() failed: %v", err) + } + + if !deadlineSet { + t.Errorf("Command run without a deadline") + } else if deadline != now().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 TestRetrieveExecutableSubjectTokenJwtMissingIdToken(t *testing.T) { + cs := CredentialSource{ + Executable: &ExecutableConfig{ + Command: "blarg", + TimeoutMillis: Int(5000), + }, + } + + tfc := testFileConfig + tfc.CredentialSource = cs + + oldGetenv, oldNow, oldRunCommand := getenv, now, runCommand + defer func() { + getenv, now, runCommand = oldGetenv, oldNow, oldRunCommand + }() + + getenv = setEnvironment(map[string]string{"GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES": "1"}) + now = setTime(defaultTime) + deadline, deadlineSet := now(), false + runCommand = func(ctx context.Context, command string, env []string) ([]byte, error) { + deadline, deadlineSet = ctx.Deadline() + return json.Marshal(executableResponse{ + Success: Bool(true), + Version: 1, + ExpirationTime: now().Unix() + 3600, + TokenType: "urn:ietf:params:oauth:token-type:jwt", + }) + } + + base, err := tfc.parse(context.Background()) + if err != nil { + t.Fatalf("parse() failed %v", err) + } + + _, err = base.subjectToken() + if err == nil { + t.Fatalf("Expected error but found none") + } + if got, want := err.Error(), missingFieldError("id_token").Error(); got != want { + t.Errorf("Incorrect error received.\nReceived: %s\nExpected: %s", got, want) + } + + if !deadlineSet { + t.Errorf("Command run without a deadline") + } else if deadline != now().Add(5*time.Second) { + t.Errorf("Command run with incorrect deadline") + } +} + +func TestRetrieveExecutableSubjectTokenIdToken(t *testing.T) { + cs := CredentialSource{ + Executable: &ExecutableConfig{ + Command: "blarg", + TimeoutMillis: Int(5000), + }, + } + + tfc := testFileConfig + tfc.CredentialSource = cs + + oldGetenv, oldNow, oldRunCommand := getenv, now, runCommand + defer func() { + getenv, now, runCommand = oldGetenv, oldNow, oldRunCommand + }() + + getenv = setEnvironment(map[string]string{"GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES": "1"}) + now = setTime(defaultTime) + deadline, deadlineSet := now(), false + runCommand = func(ctx context.Context, command string, env []string) ([]byte, error) { + deadline, deadlineSet = ctx.Deadline() + return json.Marshal(executableResponse{ + Success: Bool(true), + Version: 1, + ExpirationTime: now().Unix() + 3600, + TokenType: "urn:ietf:params:oauth:token-type:id_token", + IdToken: "tokentokentoken", + }) + } + + base, err := tfc.parse(context.Background()) + if err != nil { + t.Fatalf("parse() failed %v", err) + } + + out, err := base.subjectToken() + if err != nil { + t.Fatalf("retrieveSubjectToken() failed: %v", err) + } + + if !deadlineSet { + t.Errorf("Command run without a deadline") + } else if deadline != now().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 TestRetrieveExecutableSubjectTokenSaml(t *testing.T) { + cs := CredentialSource{ + Executable: &ExecutableConfig{ + Command: "blarg", + TimeoutMillis: Int(5000), + }, + } + + tfc := testFileConfig + tfc.CredentialSource = cs + + oldGetenv, oldNow, oldRunCommand := getenv, now, runCommand + defer func() { + getenv, now, runCommand = oldGetenv, oldNow, oldRunCommand + }() + + getenv = setEnvironment(map[string]string{"GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES": "1"}) + now = setTime(defaultTime) + deadline, deadlineSet := now(), false + runCommand = func(ctx context.Context, command string, env []string) ([]byte, error) { + deadline, deadlineSet = ctx.Deadline() + return json.Marshal(executableResponse{ + Success: Bool(true), + Version: 1, + ExpirationTime: now().Unix() + 3600, + TokenType: "urn:ietf:params:oauth:token-type:saml2", + SamlResponse: "tokentokentoken", + }) + } + + base, err := tfc.parse(context.Background()) + if err != nil { + t.Fatalf("parse() failed %v", err) + } + + out, err := base.subjectToken() + if err != nil { + t.Fatalf("retrieveSubjectToken() failed: %v", err) + } + + if !deadlineSet { + t.Errorf("Command run without a deadline") + } else if deadline != now().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 TestRetrieveExecutableSubjectTokenSamlMissingResponse(t *testing.T) { + cs := CredentialSource{ + Executable: &ExecutableConfig{ + Command: "blarg", + TimeoutMillis: Int(5000), + }, + } + + tfc := testFileConfig + tfc.CredentialSource = cs + + oldGetenv, oldNow, oldRunCommand := getenv, now, runCommand + defer func() { + getenv, now, runCommand = oldGetenv, oldNow, oldRunCommand + }() + + getenv = setEnvironment(map[string]string{"GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES": "1"}) + now = setTime(defaultTime) + deadline, deadlineSet := now(), false + runCommand = func(ctx context.Context, command string, env []string) ([]byte, error) { + deadline, deadlineSet = ctx.Deadline() + return json.Marshal(executableResponse{ + Success: Bool(true), + Version: 1, + ExpirationTime: now().Unix() + 3600, + TokenType: "urn:ietf:params:oauth:token-type:saml2", + }) + } + + base, err := tfc.parse(context.Background()) + if err != nil { + t.Fatalf("parse() failed %v", err) + } + + _, err = base.subjectToken() + if err == nil { + t.Fatalf("Expected error but found none") + } + if got, want := err.Error(), missingFieldError("saml_response").Error(); got != want { + t.Errorf("Incorrect error received.\nReceived: %s\nExpected: %s", got, want) + } + + if !deadlineSet { + t.Errorf("Command run without a deadline") + } else if deadline != now().Add(5*time.Second) { + t.Errorf("Command run with incorrect deadline") + } +}