google: base account credentials with file-sourcing

Implements the core functionality to allow 3rd party identities access to Google APIs.  Specifically, this PR implements the base account credential type and supports file-sourced credentials such as Kubernetes workloads.  Later updates will add support for URL-sourced credentials such as Microsoft Azure and support for AWS credentials.

Change-Id: I6e09a450f5221a1e06394b51374cff70ab3ab8a7
GitHub-Last-Rev: 3ab51622f8f7c6982a5e78ae9644675659318e7b
GitHub-Pull-Request: golang/oauth2#462
Reviewed-on: https://go-review.googlesource.com/c/oauth2/+/276312
Reviewed-by: Tyler Bui-Palsulich <tbp@google.com>
Trust: Tyler Bui-Palsulich <tbp@google.com>
Trust: Cody Oss <codyoss@google.com>
Run-TryBot: Tyler Bui-Palsulich <tbp@google.com>
TryBot-Result: Go Bot <gobot@golang.org>
diff --git a/google/google.go b/google/google.go
index 81de32b..e247491 100644
--- a/google/google.go
+++ b/google/google.go
@@ -15,6 +15,7 @@
 
 	"cloud.google.com/go/compute/metadata"
 	"golang.org/x/oauth2"
+	"golang.org/x/oauth2/google/internal/externalaccount"
 	"golang.org/x/oauth2/jwt"
 )
 
@@ -93,6 +94,7 @@
 const (
 	serviceAccountKey  = "service_account"
 	userCredentialsKey = "authorized_user"
+	externalAccountKey = "external_account"
 )
 
 // credentialsFile is the unmarshalled representation of a credentials file.
@@ -111,6 +113,16 @@
 	ClientSecret string `json:"client_secret"`
 	ClientID     string `json:"client_id"`
 	RefreshToken string `json:"refresh_token"`
+
+	// External Account fields
+	Audience string `json:"audience"`
+	SubjectTokenType string `json:"subject_token_type"`
+	TokenURLExternal string `json:"token_url"`
+	TokenInfoURL string `json:"token_info_url"`
+	ServiceAccountImpersonationURL string `json:"service_account_impersonation_url"`
+	CredentialSource externalaccount.CredentialSource `json:"credential_source"`
+	QuotaProjectID string `json:"quota_project_id"`
+
 }
 
 func (f *credentialsFile) jwtConfig(scopes []string) *jwt.Config {
@@ -141,6 +153,20 @@
 		}
 		tok := &oauth2.Token{RefreshToken: f.RefreshToken}
 		return cfg.TokenSource(ctx, tok), nil
+	case externalAccountKey:
+		cfg := &externalaccount.Config{
+			Audience:	f.Audience,
+			SubjectTokenType: f.SubjectTokenType,
+			TokenURL: f.TokenURLExternal,
+			TokenInfoURL: f.TokenInfoURL,
+			ServiceAccountImpersonationURL: f.ServiceAccountImpersonationURL,
+			ClientSecret: f.ClientSecret,
+			ClientID: f.ClientID,
+			CredentialSource: f.CredentialSource,
+			QuotaProjectID: f.QuotaProjectID,
+			Scopes: scopes,
+		}
+		return cfg.TokenSource(ctx), nil
 	case "":
 		return nil, errors.New("missing 'type' field in credentials")
 	default:
diff --git a/google/internal/externalaccount/basecredentials.go b/google/internal/externalaccount/basecredentials.go
new file mode 100644
index 0000000..3291d46
--- /dev/null
+++ b/google/internal/externalaccount/basecredentials.go
@@ -0,0 +1,133 @@
+// Copyright 2020 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"
+	"fmt"
+	"golang.org/x/oauth2"
+	"net/http"
+	"time"
+)
+
+// now aliases time.Now for testing
+var now = time.Now
+
+// Config stores the configuration for fetching tokens with external credentials.
+type Config struct {
+	Audience                       string
+	SubjectTokenType               string
+	TokenURL                       string
+	TokenInfoURL                   string
+	ServiceAccountImpersonationURL string
+	ClientSecret                   string
+	ClientID                       string
+	CredentialSource               CredentialSource
+	QuotaProjectID                 string
+	Scopes                         []string
+}
+
+// TokenSource Returns an external account TokenSource struct. This is to be called by package google to construct a google.Credentials.
+func (c *Config) TokenSource(ctx context.Context) oauth2.TokenSource {
+	ts := tokenSource{
+		ctx:  ctx,
+		conf: c,
+	}
+	return oauth2.ReuseTokenSource(nil, ts)
+}
+
+// Subject token file types.
+const (
+	fileTypeText = "text"
+	fileTypeJSON = "json"
+)
+
+type format struct {
+	// Type is either "text" or "json".  When not provided "text" type is assumed.
+	Type string `json:"type"`
+	// SubjectTokenFieldName is only required for JSON format.  This would be "access_token" for azure.
+	SubjectTokenFieldName string `json:"subject_token_field_name"`
+}
+
+// CredentialSource stores the information necessary to retrieve the credentials for the STS exchange.
+type CredentialSource struct {
+	File string `json:"file"`
+
+	URL     string            `json:"url"`
+	Headers map[string]string `json:"headers"`
+
+	EnvironmentID               string `json:"environment_id"`
+	RegionURL                   string `json:"region_url"`
+	RegionalCredVerificationURL string `json:"regional_cred_verification_url"`
+	CredVerificationURL         string `json:"cred_verification_url"`
+	Format                      format `json:"format"`
+}
+
+// parse determines the type of CredentialSource needed
+func (c *Config) parse() baseCredentialSource {
+	if c.CredentialSource.File != "" {
+		return fileCredentialSource{File: c.CredentialSource.File, Format: c.CredentialSource.Format}
+	}
+	return nil
+}
+
+type baseCredentialSource interface {
+	subjectToken() (string, error)
+}
+
+// tokenSource is the source that handles external credentials.
+type tokenSource struct {
+	ctx  context.Context
+	conf *Config
+}
+
+// Token allows tokenSource to conform to the oauth2.TokenSource interface.
+func (ts tokenSource) Token() (*oauth2.Token, error) {
+	conf := ts.conf
+
+	credSource := conf.parse()
+	if credSource == nil {
+		return nil, fmt.Errorf("oauth2/google: unable to parse credential source")
+	}
+	subjectToken, err := credSource.subjectToken()
+	if err != nil {
+		return nil, err
+	}
+	stsRequest := STSTokenExchangeRequest{
+		GrantType:          "urn:ietf:params:oauth:grant-type:token-exchange",
+		Audience:           conf.Audience,
+		Scope:              conf.Scopes,
+		RequestedTokenType: "urn:ietf:params:oauth:token-type:access_token",
+		SubjectToken:       subjectToken,
+		SubjectTokenType:   conf.SubjectTokenType,
+	}
+	header := make(http.Header)
+	header.Add("Content-Type", "application/x-www-form-urlencoded")
+	clientAuth := ClientAuthentication{
+		AuthStyle:    oauth2.AuthStyleInHeader,
+		ClientID:     conf.ClientID,
+		ClientSecret: conf.ClientSecret,
+	}
+	stsResp, err := ExchangeToken(ts.ctx, conf.TokenURL, &stsRequest, clientAuth, header, nil)
+	if err != nil {
+		return nil, err
+	}
+
+	accessToken := &oauth2.Token{
+		AccessToken: stsResp.AccessToken,
+		TokenType:   stsResp.TokenType,
+	}
+	if stsResp.ExpiresIn < 0 {
+		return nil, fmt.Errorf("oauth2/google: got invalid expiry from security token service")
+	} else if stsResp.ExpiresIn >= 0 {
+		accessToken.Expiry = now().Add(time.Duration(stsResp.ExpiresIn) * time.Second)
+	}
+
+	if stsResp.RefreshToken != "" {
+		accessToken.RefreshToken = stsResp.RefreshToken
+	}
+
+	return accessToken, nil
+}
diff --git a/google/internal/externalaccount/basecredentials_test.go b/google/internal/externalaccount/basecredentials_test.go
new file mode 100644
index 0000000..7ec12e4
--- /dev/null
+++ b/google/internal/externalaccount/basecredentials_test.go
@@ -0,0 +1,93 @@
+// Copyright 2020 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"
+	"io/ioutil"
+	"net/http"
+	"net/http/httptest"
+	"testing"
+	"time"
+)
+
+var testBaseCredSource = CredentialSource{
+	File:   "./testdata/3pi_cred.txt",
+	Format: format{Type: fileTypeText},
+}
+
+var testConfig = Config{
+	Audience:                       "32555940559.apps.googleusercontent.com",
+	SubjectTokenType:               "urn:ietf:params:oauth:token-type:jwt",
+	TokenInfoURL:                   "http://localhost:8080/v1/tokeninfo",
+	ServiceAccountImpersonationURL: "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/service-gcs-admin@$PROJECT_ID.iam.gserviceaccount.com:generateAccessToken",
+	ClientSecret:                   "notsosecret",
+	ClientID:                       "rbrgnognrhongo3bi4gb9ghg9g",
+	CredentialSource:               testBaseCredSource,
+	Scopes:                         []string{"https://www.googleapis.com/auth/devstorage.full_control"},
+}
+
+var (
+	baseCredsRequestBody        = "audience=32555940559.apps.googleusercontent.com&grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Atoken-exchange&options=null&requested_token_type=urn%3Aietf%3Aparams%3Aoauth%3Atoken-type%3Aaccess_token&scope=https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fdevstorage.full_control&subject_token=street123&subject_token_type=urn%3Aietf%3Aparams%3Aoauth%3Atoken-type%3Ajwt"
+	baseCredsResponseBody       = `{"access_token":"Sample.Access.Token","issued_token_type":"urn:ietf:params:oauth:token-type:access_token","token_type":"Bearer","expires_in":3600,"scope":"https://www.googleapis.com/auth/cloud-platform"}`
+	correctAT                   = "Sample.Access.Token"
+	expiry                int64 = 234852
+)
+var (
+	testNow = func() time.Time { return time.Unix(expiry, 0) }
+)
+
+func TestToken(t *testing.T) {
+
+	targetServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		if got, want := r.URL.String(), "/"; got != want {
+			t.Errorf("URL.String(): got %v but want %v", got, want)
+		}
+		headerAuth := r.Header.Get("Authorization")
+		if got, want := headerAuth, "Basic cmJyZ25vZ25yaG9uZ28zYmk0Z2I5Z2hnOWc6bm90c29zZWNyZXQ="; got != want {
+			t.Errorf("got %v but want %v", got, want)
+		}
+		headerContentType := r.Header.Get("Content-Type")
+		if got, want := headerContentType, "application/x-www-form-urlencoded"; got != want {
+			t.Errorf("got %v but want %v", got, want)
+		}
+		body, err := ioutil.ReadAll(r.Body)
+		if err != nil {
+			t.Errorf("Failed reading request body: %s.", err)
+		}
+		if got, want := string(body), baseCredsRequestBody; got != want {
+			t.Errorf("Unexpected exchange payload: got %v but want %v", got, want)
+		}
+		w.Header().Set("Content-Type", "application/json")
+		w.Write([]byte(baseCredsResponseBody))
+	}))
+	defer targetServer.Close()
+
+	testConfig.TokenURL = targetServer.URL
+	ourTS := tokenSource{
+		ctx:  context.Background(),
+		conf: &testConfig,
+	}
+
+	oldNow := now
+	defer func() { now = oldNow }()
+	now = testNow
+
+	tok, err := ourTS.Token()
+	if err != nil {
+		t.Fatalf("Unexpected error: %e", err)
+	}
+	if got, want := tok.AccessToken, correctAT; got != want {
+		t.Errorf("Unexpected access token: got %v, but wanted %v", got, want)
+	}
+	if got, want := tok.TokenType, "Bearer"; got != want {
+		t.Errorf("Unexpected TokenType: got %v, but wanted %v", got, want)
+	}
+
+	if got, want := tok.Expiry, now().Add(time.Duration(3600)*time.Second); got != want {
+		t.Errorf("Unexpected Expiry: got %v, but wanted %v", got, want)
+	}
+
+}
diff --git a/google/internal/externalaccount/filecredsource.go b/google/internal/externalaccount/filecredsource.go
new file mode 100644
index 0000000..e953ddb
--- /dev/null
+++ b/google/internal/externalaccount/filecredsource.go
@@ -0,0 +1,57 @@
+// Copyright 2020 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"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"io"
+	"io/ioutil"
+	"os"
+)
+
+type fileCredentialSource struct {
+	File   string
+	Format format
+}
+
+func (cs fileCredentialSource) subjectToken() (string, error) {
+	tokenFile, err := os.Open(cs.File)
+	if err != nil {
+		return "", fmt.Errorf("oauth2/google: failed to open credential file %q", cs.File)
+	}
+	defer tokenFile.Close()
+	tokenBytes, err := ioutil.ReadAll(io.LimitReader(tokenFile, 1<<20))
+	if err != nil {
+		return "", fmt.Errorf("oauth2/google: failed to read credential file: %v", err)
+	}
+	tokenBytes = bytes.TrimSpace(tokenBytes)
+	switch cs.Format.Type {
+	case "json":
+		jsonData := make(map[string]interface{})
+		err = json.Unmarshal(tokenBytes, &jsonData)
+		if err != nil {
+			return "", fmt.Errorf("oauth2/google: failed to unmarshal subject token file: %v", err)
+		}
+		val, ok := jsonData[cs.Format.SubjectTokenFieldName]
+		if !ok {
+			return "", errors.New("oauth2/google: provided subject_token_field_name not found in credentials")
+		}
+		token, ok := val.(string)
+		if !ok {
+			return "", errors.New("oauth2/google: improperly formatted subject token")
+		}
+		return token, nil
+	case "text":
+		return string(tokenBytes), nil
+	case "":
+		return string(tokenBytes), nil
+	default:
+		return "", errors.New("oauth2/google: invalid credential_source file format type")
+	}
+
+}
diff --git a/google/internal/externalaccount/filecredsource_test.go b/google/internal/externalaccount/filecredsource_test.go
new file mode 100644
index 0000000..0bc8048
--- /dev/null
+++ b/google/internal/externalaccount/filecredsource_test.go
@@ -0,0 +1,67 @@
+// Copyright 2020 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 (
+	"testing"
+)
+
+var testFileConfig = Config{
+	Audience:                       "32555940559.apps.googleusercontent.com",
+	SubjectTokenType:               "urn:ietf:params:oauth:token-type:jwt",
+	TokenURL:                       "http://localhost:8080/v1/token",
+	TokenInfoURL:                   "http://localhost:8080/v1/tokeninfo",
+	ServiceAccountImpersonationURL: "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/service-gcs-admin@$PROJECT_ID.iam.gserviceaccount.com:generateAccessToken",
+	ClientSecret:                   "notsosecret",
+	ClientID:                       "rbrgnognrhongo3bi4gb9ghg9g",
+}
+
+func TestRetrieveFileSubjectToken(t *testing.T) {
+	var fileSourceTests = []struct {
+		name string
+		cs   CredentialSource
+		want string
+	}{
+		{
+			name: "UntypedFileSource",
+			cs: CredentialSource{
+				File: "./testdata/3pi_cred.txt",
+			},
+			want: "street123",
+		},
+		{
+			name: "TextFileSource",
+			cs: CredentialSource{
+				File:   "./testdata/3pi_cred.txt",
+				Format: format{Type: fileTypeText},
+			},
+			want: "street123",
+		},
+		{
+			name: "JSONFileSource",
+			cs: CredentialSource{
+				File:   "./testdata/3pi_cred.json",
+				Format: format{Type: fileTypeJSON, SubjectTokenFieldName: "SubjToken"},
+			},
+			want: "321road",
+		},
+	}
+
+	for _, test := range fileSourceTests {
+		test := test
+		tfc := testFileConfig
+		tfc.CredentialSource = test.cs
+
+		t.Run(test.name, func(t *testing.T) {
+			out, err := tfc.parse().subjectToken()
+			if err != nil {
+				t.Errorf("Method subjectToken() errored.")
+			} else if test.want != out {
+				t.Errorf("got %v but want %v", out, test.want)
+			}
+
+		})
+	}
+}
diff --git a/google/internal/externalaccount/testdata/3pi_cred.json b/google/internal/externalaccount/testdata/3pi_cred.json
new file mode 100644
index 0000000..6a9cf7d
--- /dev/null
+++ b/google/internal/externalaccount/testdata/3pi_cred.json
@@ -0,0 +1,3 @@
+{
+	"SubjToken": "321road"
+}
diff --git a/google/internal/externalaccount/testdata/3pi_cred.txt b/google/internal/externalaccount/testdata/3pi_cred.txt
new file mode 100644
index 0000000..4e511cc
--- /dev/null
+++ b/google/internal/externalaccount/testdata/3pi_cred.txt
@@ -0,0 +1 @@
+street123