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: 98f37871caf9f21b5d47f197ef7447f6961f5b47
GitHub-Pull-Request: golang/oauth2#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>
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 @@
}
// 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 @@
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 @@
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 @@
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")
+ }
+ })
+ }
+}