google: adding support for external account authorized user

To support a new type of credential: `ExternalAccountAuthorizedUser`

* Refactor the common dependency STS to a separate package.
* Adding the `externalaccountauthorizeduser` package.

Change-Id: I9b9624f912d216b67a0d31945a50f057f747710b
GitHub-Last-Rev: 6e2aaff345711d007f913a7c22dc6da750732938
GitHub-Pull-Request: golang/oauth2#671
Reviewed-on: https://go-review.googlesource.com/c/oauth2/+/531095
Reviewed-by: Leo Siracusa <[email protected]>
Reviewed-by: Alex Eitzman <[email protected]>
Run-TryBot: Cody Oss <[email protected]>
Reviewed-by: Cody Oss <[email protected]>
TryBot-Result: Gopher Robot <[email protected]>
diff --git a/google/google.go b/google/google.go
index 846683e..8bc6018 100644
--- a/google/google.go
+++ b/google/google.go
@@ -16,6 +16,7 @@
 	"cloud.google.com/go/compute/metadata"
 	"golang.org/x/oauth2"
 	"golang.org/x/oauth2/google/internal/externalaccount"
+	"golang.org/x/oauth2/google/internal/externalaccountauthorizeduser"
 	"golang.org/x/oauth2/jwt"
 )
 
@@ -96,10 +97,11 @@
 
 // JSON key file types.
 const (
-	serviceAccountKey          = "service_account"
-	userCredentialsKey         = "authorized_user"
-	externalAccountKey         = "external_account"
-	impersonatedServiceAccount = "impersonated_service_account"
+	serviceAccountKey                = "service_account"
+	userCredentialsKey               = "authorized_user"
+	externalAccountKey               = "external_account"
+	externalAccountAuthorizedUserKey = "external_account_authorized_user"
+	impersonatedServiceAccount       = "impersonated_service_account"
 )
 
 // credentialsFile is the unmarshalled representation of a credentials file.
@@ -132,6 +134,9 @@
 	QuotaProjectID                 string                           `json:"quota_project_id"`
 	WorkforcePoolUserProject       string                           `json:"workforce_pool_user_project"`
 
+	// External Account Authorized User fields
+	RevokeURL string `json:"revoke_url"`
+
 	// Service account impersonation
 	SourceCredentials *credentialsFile `json:"source_credentials"`
 }
@@ -200,6 +205,19 @@
 			WorkforcePoolUserProject: f.WorkforcePoolUserProject,
 		}
 		return cfg.TokenSource(ctx)
+	case externalAccountAuthorizedUserKey:
+		cfg := &externalaccountauthorizeduser.Config{
+			Audience:       f.Audience,
+			RefreshToken:   f.RefreshToken,
+			TokenURL:       f.TokenURLExternal,
+			TokenInfoURL:   f.TokenInfoURL,
+			ClientID:       f.ClientID,
+			ClientSecret:   f.ClientSecret,
+			RevokeURL:      f.RevokeURL,
+			QuotaProjectID: f.QuotaProjectID,
+			Scopes:         params.Scopes,
+		}
+		return cfg.TokenSource(ctx)
 	case impersonatedServiceAccount:
 		if f.ServiceAccountImpersonationURL == "" || f.SourceCredentials == nil {
 			return nil, errors.New("missing 'source_credentials' field or 'service_account_impersonation_url' in credentials")
diff --git a/google/internal/externalaccount/basecredentials.go b/google/internal/externalaccount/basecredentials.go
index f58fb25..33288d3 100644
--- a/google/internal/externalaccount/basecredentials.go
+++ b/google/internal/externalaccount/basecredentials.go
@@ -8,13 +8,12 @@
 	"context"
 	"fmt"
 	"net/http"
-	"net/url"
 	"regexp"
 	"strconv"
-	"strings"
 	"time"
 
 	"golang.org/x/oauth2"
+	"golang.org/x/oauth2/google/internal/stsexchange"
 )
 
 // now aliases time.Now for testing
@@ -63,31 +62,10 @@
 	WorkforcePoolUserProject string
 }
 
-// Each element consists of a list of patterns.  validateURLs checks for matches
-// that include all elements in a given list, in that order.
-
 var (
 	validWorkforceAudiencePattern *regexp.Regexp = regexp.MustCompile(`//iam\.googleapis\.com/locations/[^/]+/workforcePools/`)
 )
 
-func validateURL(input string, patterns []*regexp.Regexp, scheme string) bool {
-	parsed, err := url.Parse(input)
-	if err != nil {
-		return false
-	}
-	if !strings.EqualFold(parsed.Scheme, scheme) {
-		return false
-	}
-	toTest := parsed.Host
-
-	for _, pattern := range patterns {
-		if pattern.MatchString(toTest) {
-			return true
-		}
-	}
-	return false
-}
-
 func validateWorkforceAudience(input string) bool {
 	return validWorkforceAudiencePattern.MatchString(input)
 }
@@ -230,7 +208,7 @@
 	if err != nil {
 		return nil, err
 	}
-	stsRequest := stsTokenExchangeRequest{
+	stsRequest := stsexchange.TokenExchangeRequest{
 		GrantType:          "urn:ietf:params:oauth:grant-type:token-exchange",
 		Audience:           conf.Audience,
 		Scope:              conf.Scopes,
@@ -241,7 +219,7 @@
 	header := make(http.Header)
 	header.Add("Content-Type", "application/x-www-form-urlencoded")
 	header.Add("x-goog-api-client", getMetricsHeaderValue(conf, credSource))
-	clientAuth := clientAuthentication{
+	clientAuth := stsexchange.ClientAuthentication{
 		AuthStyle:    oauth2.AuthStyleInHeader,
 		ClientID:     conf.ClientID,
 		ClientSecret: conf.ClientSecret,
@@ -254,7 +232,7 @@
 			"userProject": conf.WorkforcePoolUserProject,
 		}
 	}
-	stsResp, err := exchangeToken(ts.ctx, conf.TokenURL, &stsRequest, clientAuth, header, options)
+	stsResp, err := stsexchange.ExchangeToken(ts.ctx, conf.TokenURL, &stsRequest, clientAuth, header, options)
 	if err != nil {
 		return nil, err
 	}
diff --git a/google/internal/externalaccount/sts_exchange_test.go b/google/internal/externalaccount/sts_exchange_test.go
deleted file mode 100644
index df4d5ff..0000000
--- a/google/internal/externalaccount/sts_exchange_test.go
+++ /dev/null
@@ -1,187 +0,0 @@
-// 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"
-	"encoding/json"
-	"io/ioutil"
-	"net/http"
-	"net/http/httptest"
-	"net/url"
-	"testing"
-
-	"golang.org/x/oauth2"
-)
-
-var auth = clientAuthentication{
-	AuthStyle:    oauth2.AuthStyleInHeader,
-	ClientID:     clientID,
-	ClientSecret: clientSecret,
-}
-
-var tokenRequest = stsTokenExchangeRequest{
-	ActingParty: struct {
-		ActorToken     string
-		ActorTokenType string
-	}{},
-	GrantType:          "urn:ietf:params:oauth:grant-type:token-exchange",
-	Resource:           "",
-	Audience:           "32555940559.apps.googleusercontent.com", //TODO: Make sure audience is correct in this test (might be mismatched)
-	Scope:              []string{"https://www.googleapis.com/auth/devstorage.full_control"},
-	RequestedTokenType: "urn:ietf:params:oauth:token-type:access_token",
-	SubjectToken:       "Sample.Subject.Token",
-	SubjectTokenType:   "urn:ietf:params:oauth:token-type:jwt",
-}
-
-var requestbody = "audience=32555940559.apps.googleusercontent.com&grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Atoken-exchange&requested_token_type=urn%3Aietf%3Aparams%3Aoauth%3Atoken-type%3Aaccess_token&scope=https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fdevstorage.full_control&subject_token=Sample.Subject.Token&subject_token_type=urn%3Aietf%3Aparams%3Aoauth%3Atoken-type%3Ajwt"
-var responseBody = `{"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"}`
-var expectedToken = stsTokenExchangeResponse{
-	AccessToken:     "Sample.Access.Token",
-	IssuedTokenType: "urn:ietf:params:oauth:token-type:access_token",
-	TokenType:       "Bearer",
-	ExpiresIn:       3600,
-	Scope:           "https://www.googleapis.com/auth/cloud-platform",
-	RefreshToken:    "",
-}
-
-func TestExchangeToken(t *testing.T) {
-	ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
-		if r.Method != "POST" {
-			t.Errorf("Unexpected request method, %v is found", r.Method)
-		}
-		if r.URL.String() != "/" {
-			t.Errorf("Unexpected request URL, %v is found", r.URL)
-		}
-		if got, want := r.Header.Get("Authorization"), "Basic cmJyZ25vZ25yaG9uZ28zYmk0Z2I5Z2hnOWc6bm90c29zZWNyZXQ="; got != want {
-			t.Errorf("Unexpected authorization header, got %v, want %v", got, want)
-		}
-		if got, want := r.Header.Get("Content-Type"), "application/x-www-form-urlencoded"; got != want {
-			t.Errorf("Unexpected Content-Type header, got %v, want %v", got, want)
-		}
-		body, err := ioutil.ReadAll(r.Body)
-		if err != nil {
-			t.Errorf("Failed reading request body: %v.", err)
-		}
-		if got, want := string(body), requestbody; got != want {
-			t.Errorf("Unexpected exchange payload, got %v but want %v", got, want)
-		}
-		w.Header().Set("Content-Type", "application/json")
-		w.Write([]byte(responseBody))
-	}))
-	defer ts.Close()
-
-	headers := http.Header{}
-	headers.Add("Content-Type", "application/x-www-form-urlencoded")
-
-	resp, err := exchangeToken(context.Background(), ts.URL, &tokenRequest, auth, headers, nil)
-	if err != nil {
-		t.Fatalf("exchangeToken failed with error: %v", err)
-	}
-
-	if expectedToken != *resp {
-		t.Errorf("mismatched messages received by mock server.  \nWant: \n%v\n\nGot:\n%v", expectedToken, *resp)
-	}
-
-}
-
-func TestExchangeToken_Err(t *testing.T) {
-	ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
-		w.Header().Set("Content-Type", "application/json")
-		w.Write([]byte("what's wrong with this response?"))
-	}))
-	defer ts.Close()
-
-	headers := http.Header{}
-	headers.Add("Content-Type", "application/x-www-form-urlencoded")
-	_, err := exchangeToken(context.Background(), ts.URL, &tokenRequest, auth, headers, nil)
-	if err == nil {
-		t.Errorf("Expected handled error; instead got nil.")
-	}
-}
-
-/* Lean test specifically for options, as the other features are tested earlier. */
-type testOpts struct {
-	First  string `json:"first"`
-	Second string `json:"second"`
-}
-
-var optsValues = [][]string{{"foo", "bar"}, {"cat", "pan"}}
-
-func TestExchangeToken_Opts(t *testing.T) {
-	ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
-		body, err := ioutil.ReadAll(r.Body)
-		if err != nil {
-			t.Fatalf("Failed reading request body: %v.", err)
-		}
-		data, err := url.ParseQuery(string(body))
-		if err != nil {
-			t.Fatalf("Failed to parse request body: %v", err)
-		}
-		strOpts, ok := data["options"]
-		if !ok {
-			t.Errorf("Server didn't recieve an \"options\" field.")
-		} else if len(strOpts) < 1 {
-			t.Errorf("\"options\" field has length 0.")
-		}
-		var opts map[string]interface{}
-		err = json.Unmarshal([]byte(strOpts[0]), &opts)
-		if err != nil {
-			t.Fatalf("Couldn't parse received \"options\" field.")
-		}
-		if len(opts) < 2 {
-			t.Errorf("Too few options received.")
-		}
-
-		val, ok := opts["one"]
-		if !ok {
-			t.Errorf("Couldn't find first option parameter.")
-		} else {
-			tOpts1, ok := val.(map[string]interface{})
-			if !ok {
-				t.Errorf("Failed to assert the first option parameter as type testOpts.")
-			} else {
-				if got, want := tOpts1["first"].(string), optsValues[0][0]; got != want {
-					t.Errorf("First value in first options field is incorrect; got %v but want %v", got, want)
-				}
-				if got, want := tOpts1["second"].(string), optsValues[0][1]; got != want {
-					t.Errorf("Second value in first options field is incorrect; got %v but want %v", got, want)
-				}
-			}
-		}
-
-		val2, ok := opts["two"]
-		if !ok {
-			t.Errorf("Couldn't find second option parameter.")
-		} else {
-			tOpts2, ok := val2.(map[string]interface{})
-			if !ok {
-				t.Errorf("Failed to assert the second option parameter as type testOpts.")
-			} else {
-				if got, want := tOpts2["first"].(string), optsValues[1][0]; got != want {
-					t.Errorf("First value in second options field is incorrect; got %v but want %v", got, want)
-				}
-				if got, want := tOpts2["second"].(string), optsValues[1][1]; got != want {
-					t.Errorf("Second value in second options field is incorrect; got %v but want %v", got, want)
-				}
-			}
-		}
-
-		// Send a proper reply so that no other errors crop up.
-		w.Header().Set("Content-Type", "application/json")
-		w.Write([]byte(responseBody))
-
-	}))
-	defer ts.Close()
-	headers := http.Header{}
-	headers.Add("Content-Type", "application/x-www-form-urlencoded")
-
-	firstOption := testOpts{optsValues[0][0], optsValues[0][1]}
-	secondOption := testOpts{optsValues[1][0], optsValues[1][1]}
-	inputOpts := make(map[string]interface{})
-	inputOpts["one"] = firstOption
-	inputOpts["two"] = secondOption
-	exchangeToken(context.Background(), ts.URL, &tokenRequest, auth, headers, inputOpts)
-}
diff --git a/google/internal/externalaccountauthorizeduser/externalaccountauthorizeduser.go b/google/internal/externalaccountauthorizeduser/externalaccountauthorizeduser.go
new file mode 100644
index 0000000..cb58207
--- /dev/null
+++ b/google/internal/externalaccountauthorizeduser/externalaccountauthorizeduser.go
@@ -0,0 +1,114 @@
+// Copyright 2023 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 externalaccountauthorizeduser
+
+import (
+	"context"
+	"errors"
+	"time"
+
+	"golang.org/x/oauth2"
+	"golang.org/x/oauth2/google/internal/stsexchange"
+)
+
+// now aliases time.Now for testing.
+var now = func() time.Time {
+	return time.Now().UTC()
+}
+
+var tokenValid = func(token oauth2.Token) bool {
+	return token.Valid()
+}
+
+type Config struct {
+	// Audience is the Secure Token Service (STS) audience which contains the resource name for the workforce pool and
+	// the provider identifier in that pool.
+	Audience string
+	// RefreshToken is the optional OAuth 2.0 refresh token. If specified, credentials can be refreshed.
+	RefreshToken string
+	// TokenURL is the optional STS token exchange endpoint for refresh. Must be specified for refresh, can be left as
+	// None if the token can not be refreshed.
+	TokenURL string
+	// TokenInfoURL is the optional STS endpoint URL for token introspection.
+	TokenInfoURL string
+	// ClientID is only required in conjunction with ClientSecret, as described above.
+	ClientID string
+	// ClientSecret is currently only required if token_info endpoint also needs to be called with the generated GCP
+	// access token. When provided, STS will be called with additional basic authentication using client_id as username
+	// and client_secret as password.
+	ClientSecret string
+	// Token is the OAuth2.0 access token. Can be nil if refresh information is provided.
+	Token string
+	// Expiry is the optional expiration datetime of the OAuth 2.0 access token.
+	Expiry time.Time
+	// RevokeURL is the optional STS endpoint URL for revoking tokens.
+	RevokeURL string
+	// QuotaProjectID is the optional project ID used for quota and billing. This project may be different from the
+	// project used to create the credentials.
+	QuotaProjectID string
+	Scopes         []string
+}
+
+func (c *Config) canRefresh() bool {
+	return c.ClientID != "" && c.ClientSecret != "" && c.RefreshToken != "" && c.TokenURL != ""
+}
+
+func (c *Config) TokenSource(ctx context.Context) (oauth2.TokenSource, error) {
+	var token oauth2.Token
+	if c.Token != "" && !c.Expiry.IsZero() {
+		token = oauth2.Token{
+			AccessToken: c.Token,
+			Expiry:      c.Expiry,
+			TokenType:   "Bearer",
+		}
+	}
+	if !tokenValid(token) && !c.canRefresh() {
+		return nil, errors.New("oauth2/google: Token should be created with fields to make it valid (`token` and `expiry`), or fields to allow it to refresh (`refresh_token`, `token_url`, `client_id`, `client_secret`).")
+	}
+
+	ts := tokenSource{
+		ctx:  ctx,
+		conf: c,
+	}
+
+	return oauth2.ReuseTokenSource(&token, ts), nil
+}
+
+type tokenSource struct {
+	ctx  context.Context
+	conf *Config
+}
+
+func (ts tokenSource) Token() (*oauth2.Token, error) {
+	conf := ts.conf
+	if !conf.canRefresh() {
+		return nil, errors.New("oauth2/google: The credentials do not contain the necessary fields need to refresh the access token. You must specify refresh_token, token_url, client_id, and client_secret.")
+	}
+
+	clientAuth := stsexchange.ClientAuthentication{
+		AuthStyle:    oauth2.AuthStyleInHeader,
+		ClientID:     conf.ClientID,
+		ClientSecret: conf.ClientSecret,
+	}
+
+	stsResponse, err := stsexchange.RefreshAccessToken(ts.ctx, conf.TokenURL, conf.RefreshToken, clientAuth, nil)
+	if err != nil {
+		return nil, err
+	}
+	if stsResponse.ExpiresIn < 0 {
+		return nil, errors.New("oauth2/google: got invalid expiry from security token service")
+	}
+
+	if stsResponse.RefreshToken != "" {
+		conf.RefreshToken = stsResponse.RefreshToken
+	}
+
+	token := &oauth2.Token{
+		AccessToken: stsResponse.AccessToken,
+		Expiry:      now().Add(time.Duration(stsResponse.ExpiresIn) * time.Second),
+		TokenType:   "Bearer",
+	}
+	return token, nil
+}
diff --git a/google/internal/externalaccountauthorizeduser/externalaccountauthorizeduser_test.go b/google/internal/externalaccountauthorizeduser/externalaccountauthorizeduser_test.go
new file mode 100644
index 0000000..94bfee3
--- /dev/null
+++ b/google/internal/externalaccountauthorizeduser/externalaccountauthorizeduser_test.go
@@ -0,0 +1,259 @@
+// Copyright 2023 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 externalaccountauthorizeduser
+
+import (
+	"context"
+	"encoding/json"
+	"errors"
+	"io/ioutil"
+	"net/http"
+	"net/http/httptest"
+	"testing"
+	"time"
+
+	"golang.org/x/oauth2"
+	"golang.org/x/oauth2/google/internal/stsexchange"
+)
+
+const expiryDelta = 10 * time.Second
+
+var (
+	expiry    = time.Unix(234852, 0)
+	testNow   = func() time.Time { return expiry }
+	testValid = func(t oauth2.Token) bool {
+		return t.AccessToken != "" && !t.Expiry.Round(0).Add(-expiryDelta).Before(testNow())
+	}
+)
+
+type testRefreshTokenServer struct {
+	URL             string
+	Authorization   string
+	ContentType     string
+	Body            string
+	ResponsePayload *stsexchange.Response
+	Response        string
+	server          *httptest.Server
+}
+
+func TestExernalAccountAuthorizedUser_JustToken(t *testing.T) {
+	config := &Config{
+		Token:  "AAAAAAA",
+		Expiry: now().Add(time.Hour),
+	}
+	ts, err := config.TokenSource(context.Background())
+	if err != nil {
+		t.Fatalf("Error getting token source: %v", err)
+	}
+
+	token, err := ts.Token()
+	if err != nil {
+		t.Fatalf("Error retrieving Token: %v", err)
+	}
+	if got, want := token.AccessToken, "AAAAAAA"; got != want {
+		t.Fatalf("Unexpected access token, got %v, want %v", got, want)
+	}
+}
+
+func TestExernalAccountAuthorizedUser_TokenRefreshWithRefreshTokenInRespondse(t *testing.T) {
+	server := &testRefreshTokenServer{
+		URL:           "/",
+		Authorization: "Basic Q0xJRU5UX0lEOkNMSUVOVF9TRUNSRVQ=",
+		ContentType:   "application/x-www-form-urlencoded",
+		Body:          "grant_type=refresh_token&refresh_token=BBBBBBBBB",
+		ResponsePayload: &stsexchange.Response{
+			ExpiresIn:    3600,
+			AccessToken:  "AAAAAAA",
+			RefreshToken: "CCCCCCC",
+		},
+	}
+
+	url, err := server.run(t)
+	if err != nil {
+		t.Fatalf("Error starting server")
+	}
+	defer server.close(t)
+
+	config := &Config{
+		RefreshToken: "BBBBBBBBB",
+		TokenURL:     url,
+		ClientID:     "CLIENT_ID",
+		ClientSecret: "CLIENT_SECRET",
+	}
+	ts, err := config.TokenSource(context.Background())
+	if err != nil {
+		t.Fatalf("Error getting token source: %v", err)
+	}
+
+	token, err := ts.Token()
+	if err != nil {
+		t.Fatalf("Error retrieving Token: %v", err)
+	}
+	if got, want := token.AccessToken, "AAAAAAA"; got != want {
+		t.Fatalf("Unexpected access token, got %v, want %v", got, want)
+	}
+	if config.RefreshToken != "CCCCCCC" {
+		t.Fatalf("Refresh token not updated")
+	}
+}
+
+func TestExernalAccountAuthorizedUser_MinimumFieldsRequiredForRefresh(t *testing.T) {
+	server := &testRefreshTokenServer{
+		URL:           "/",
+		Authorization: "Basic Q0xJRU5UX0lEOkNMSUVOVF9TRUNSRVQ=",
+		ContentType:   "application/x-www-form-urlencoded",
+		Body:          "grant_type=refresh_token&refresh_token=BBBBBBBBB",
+		ResponsePayload: &stsexchange.Response{
+			ExpiresIn:   3600,
+			AccessToken: "AAAAAAA",
+		},
+	}
+
+	url, err := server.run(t)
+	if err != nil {
+		t.Fatalf("Error starting server")
+	}
+	defer server.close(t)
+
+	config := &Config{
+		RefreshToken: "BBBBBBBBB",
+		TokenURL:     url,
+		ClientID:     "CLIENT_ID",
+		ClientSecret: "CLIENT_SECRET",
+	}
+	ts, err := config.TokenSource(context.Background())
+	if err != nil {
+		t.Fatalf("Error getting token source: %v", err)
+	}
+
+	token, err := ts.Token()
+	if err != nil {
+		t.Fatalf("Error retrieving Token: %v", err)
+	}
+	if got, want := token.AccessToken, "AAAAAAA"; got != want {
+		t.Fatalf("Unexpected access token, got %v, want %v", got, want)
+	}
+}
+
+func TestExternalAccountAuthorizedUser_MissingRefreshFields(t *testing.T) {
+	server := &testRefreshTokenServer{
+		URL:           "/",
+		Authorization: "Basic Q0xJRU5UX0lEOkNMSUVOVF9TRUNSRVQ=",
+		ContentType:   "application/x-www-form-urlencoded",
+		Body:          "grant_type=refresh_token&refresh_token=BBBBBBBBB",
+		ResponsePayload: &stsexchange.Response{
+			ExpiresIn:   3600,
+			AccessToken: "AAAAAAA",
+		},
+	}
+
+	url, err := server.run(t)
+	if err != nil {
+		t.Fatalf("Error starting server")
+	}
+	defer server.close(t)
+	testCases := []struct {
+		name   string
+		config Config
+	}{
+		{
+			name:   "empty config",
+			config: Config{},
+		},
+		{
+			name: "missing refresh token",
+			config: Config{
+				TokenURL:     url,
+				ClientID:     "CLIENT_ID",
+				ClientSecret: "CLIENT_SECRET",
+			},
+		},
+		{
+			name: "missing token url",
+			config: Config{
+				RefreshToken: "BBBBBBBBB",
+				ClientID:     "CLIENT_ID",
+				ClientSecret: "CLIENT_SECRET",
+			},
+		},
+		{
+			name: "missing client id",
+			config: Config{
+				RefreshToken: "BBBBBBBBB",
+				TokenURL:     url,
+				ClientSecret: "CLIENT_SECRET",
+			},
+		},
+		{
+			name: "missing client secrect",
+			config: Config{
+				RefreshToken: "BBBBBBBBB",
+				TokenURL:     url,
+				ClientID:     "CLIENT_ID",
+			},
+		},
+	}
+	for _, tc := range testCases {
+		t.Run(tc.name, func(t *testing.T) {
+
+			expectErrMsg := "oauth2/google: Token should be created with fields to make it valid (`token` and `expiry`), or fields to allow it to refresh (`refresh_token`, `token_url`, `client_id`, `client_secret`)."
+			_, err := tc.config.TokenSource((context.Background()))
+			if err == nil {
+				t.Fatalf("Expected error, but received none")
+			}
+			if got := err.Error(); got != expectErrMsg {
+				t.Fatalf("Unexpected error, got %v, want %v", got, expectErrMsg)
+			}
+		})
+	}
+}
+
+func (trts *testRefreshTokenServer) run(t *testing.T) (string, error) {
+	t.Helper()
+	if trts.server != nil {
+		return "", errors.New("Server is already running")
+	}
+	trts.server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		if got, want := r.URL.String(), trts.URL; got != want {
+			t.Errorf("URL.String(): got %v but want %v", got, want)
+		}
+		headerAuth := r.Header.Get("Authorization")
+		if got, want := headerAuth, trts.Authorization; got != want {
+			t.Errorf("got %v but want %v", got, want)
+		}
+		headerContentType := r.Header.Get("Content-Type")
+		if got, want := headerContentType, trts.ContentType; got != want {
+			t.Errorf("got %v but want %v", got, want)
+		}
+		body, err := ioutil.ReadAll(r.Body)
+		if err != nil {
+			t.Fatalf("Failed reading request body: %s.", err)
+		}
+		if got, want := string(body), trts.Body; got != want {
+			t.Errorf("Unexpected exchange payload: got %v but want %v", got, want)
+		}
+		w.Header().Set("Content-Type", "application/json")
+		if trts.ResponsePayload != nil {
+			content, err := json.Marshal(trts.ResponsePayload)
+			if err != nil {
+				t.Fatalf("unable to marshall response JSON")
+			}
+			w.Write(content)
+		} else {
+			w.Write([]byte(trts.Response))
+		}
+	}))
+	return trts.server.URL, nil
+}
+
+func (trts *testRefreshTokenServer) close(t *testing.T) error {
+	t.Helper()
+	if trts.server == nil {
+		return errors.New("No server is running")
+	}
+	trts.server.Close()
+	trts.server = nil
+	return nil
+}
diff --git a/google/internal/externalaccount/clientauth.go b/google/internal/stsexchange/clientauth.go
similarity index 88%
rename from google/internal/externalaccount/clientauth.go
rename to google/internal/stsexchange/clientauth.go
index 99987ce..ebd520e 100644
--- a/google/internal/externalaccount/clientauth.go
+++ b/google/internal/stsexchange/clientauth.go
@@ -2,7 +2,7 @@
 // Use of this source code is governed by a BSD-style
 // license that can be found in the LICENSE file.
 
-package externalaccount
+package stsexchange
 
 import (
 	"encoding/base64"
@@ -12,8 +12,8 @@
 	"golang.org/x/oauth2"
 )
 
-// clientAuthentication represents an OAuth client ID and secret and the mechanism for passing these credentials as stated in rfc6749#2.3.1.
-type clientAuthentication struct {
+// ClientAuthentication represents an OAuth client ID and secret and the mechanism for passing these credentials as stated in rfc6749#2.3.1.
+type ClientAuthentication struct {
 	// AuthStyle can be either basic or request-body
 	AuthStyle    oauth2.AuthStyle
 	ClientID     string
@@ -23,7 +23,7 @@
 // InjectAuthentication is used to add authentication to a Secure Token Service exchange
 // request.  It modifies either the passed url.Values or http.Header depending on the desired
 // authentication format.
-func (c *clientAuthentication) InjectAuthentication(values url.Values, headers http.Header) {
+func (c *ClientAuthentication) InjectAuthentication(values url.Values, headers http.Header) {
 	if c.ClientID == "" || c.ClientSecret == "" || values == nil || headers == nil {
 		return
 	}
diff --git a/google/internal/externalaccount/clientauth_test.go b/google/internal/stsexchange/clientauth_test.go
similarity index 97%
rename from google/internal/externalaccount/clientauth_test.go
rename to google/internal/stsexchange/clientauth_test.go
index bfb339d..53fa67c 100644
--- a/google/internal/externalaccount/clientauth_test.go
+++ b/google/internal/stsexchange/clientauth_test.go
@@ -2,7 +2,7 @@
 // Use of this source code is governed by a BSD-style
 // license that can be found in the LICENSE file.
 
-package externalaccount
+package stsexchange
 
 import (
 	"net/http"
@@ -38,7 +38,7 @@
 		"Content-Type": ContentType,
 	}
 
-	headerAuthentication := clientAuthentication{
+	headerAuthentication := ClientAuthentication{
 		AuthStyle:    oauth2.AuthStyleInHeader,
 		ClientID:     clientID,
 		ClientSecret: clientSecret,
@@ -80,7 +80,7 @@
 	headerP := http.Header{
 		"Content-Type": ContentType,
 	}
-	paramsAuthentication := clientAuthentication{
+	paramsAuthentication := ClientAuthentication{
 		AuthStyle:    oauth2.AuthStyleInParams,
 		ClientID:     clientID,
 		ClientSecret: clientSecret,
diff --git a/google/internal/externalaccount/sts_exchange.go b/google/internal/stsexchange/sts_exchange.go
similarity index 68%
rename from google/internal/externalaccount/sts_exchange.go
rename to google/internal/stsexchange/sts_exchange.go
index e6fcae5..1a0bebd 100644
--- a/google/internal/externalaccount/sts_exchange.go
+++ b/google/internal/stsexchange/sts_exchange.go
@@ -2,7 +2,7 @@
 // Use of this source code is governed by a BSD-style
 // license that can be found in the LICENSE file.
 
-package externalaccount
+package stsexchange
 
 import (
 	"context"
@@ -18,14 +18,17 @@
 	"golang.org/x/oauth2"
 )
 
-// exchangeToken performs an oauth2 token exchange with the provided endpoint.
+func defaultHeader() http.Header {
+	header := make(http.Header)
+	header.Add("Content-Type", "application/x-www-form-urlencoded")
+	return header
+}
+
+// ExchangeToken performs an oauth2 token exchange with the provided endpoint.
 // The first 4 fields are all mandatory.  headers can be used to pass additional
 // headers beyond the bare minimum required by the token exchange.  options can
 // be used to pass additional JSON-structured options to the remote server.
-func exchangeToken(ctx context.Context, endpoint string, request *stsTokenExchangeRequest, authentication clientAuthentication, headers http.Header, options map[string]interface{}) (*stsTokenExchangeResponse, error) {
-
-	client := oauth2.NewClient(ctx, nil)
-
+func ExchangeToken(ctx context.Context, endpoint string, request *TokenExchangeRequest, authentication ClientAuthentication, headers http.Header, options map[string]interface{}) (*Response, error) {
 	data := url.Values{}
 	data.Set("audience", request.Audience)
 	data.Set("grant_type", "urn:ietf:params:oauth:grant-type:token-exchange")
@@ -41,13 +44,28 @@
 		data.Set("options", string(opts))
 	}
 
+	return makeRequest(ctx, endpoint, data, authentication, headers)
+}
+
+func RefreshAccessToken(ctx context.Context, endpoint string, refreshToken string, authentication ClientAuthentication, headers http.Header) (*Response, error) {
+	data := url.Values{}
+	data.Set("grant_type", "refresh_token")
+	data.Set("refresh_token", refreshToken)
+
+	return makeRequest(ctx, endpoint, data, authentication, headers)
+}
+
+func makeRequest(ctx context.Context, endpoint string, data url.Values, authentication ClientAuthentication, headers http.Header) (*Response, error) {
+	if headers == nil {
+		headers = defaultHeader()
+	}
+	client := oauth2.NewClient(ctx, nil)
 	authentication.InjectAuthentication(data, headers)
 	encodedData := data.Encode()
 
 	req, err := http.NewRequest("POST", endpoint, strings.NewReader(encodedData))
 	if err != nil {
 		return nil, fmt.Errorf("oauth2/google: failed to properly build http request: %v", err)
-
 	}
 	req = req.WithContext(ctx)
 	for key, list := range headers {
@@ -71,7 +89,7 @@
 	if c := resp.StatusCode; c < 200 || c > 299 {
 		return nil, fmt.Errorf("oauth2/google: status code %d: %s", c, body)
 	}
-	var stsResp stsTokenExchangeResponse
+	var stsResp Response
 	err = json.Unmarshal(body, &stsResp)
 	if err != nil {
 		return nil, fmt.Errorf("oauth2/google: failed to unmarshal response body from Secure Token Server: %v", err)
@@ -81,8 +99,8 @@
 	return &stsResp, nil
 }
 
-// stsTokenExchangeRequest contains fields necessary to make an oauth2 token exchange.
-type stsTokenExchangeRequest struct {
+// TokenExchangeRequest contains fields necessary to make an oauth2 token exchange.
+type TokenExchangeRequest struct {
 	ActingParty struct {
 		ActorToken     string
 		ActorTokenType string
@@ -96,8 +114,8 @@
 	SubjectTokenType   string
 }
 
-// stsTokenExchangeResponse is used to decode the remote server response during an oauth2 token exchange.
-type stsTokenExchangeResponse struct {
+// Response is used to decode the remote server response during an oauth2 token exchange.
+type Response struct {
 	AccessToken     string `json:"access_token"`
 	IssuedTokenType string `json:"issued_token_type"`
 	TokenType       string `json:"token_type"`
diff --git a/google/internal/stsexchange/sts_exchange_test.go b/google/internal/stsexchange/sts_exchange_test.go
new file mode 100644
index 0000000..895b9bc
--- /dev/null
+++ b/google/internal/stsexchange/sts_exchange_test.go
@@ -0,0 +1,271 @@
+// 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 stsexchange
+
+import (
+	"context"
+	"encoding/json"
+	"io/ioutil"
+	"net/http"
+	"net/http/httptest"
+	"net/url"
+	"testing"
+
+	"golang.org/x/oauth2"
+)
+
+var auth = ClientAuthentication{
+	AuthStyle:    oauth2.AuthStyleInHeader,
+	ClientID:     clientID,
+	ClientSecret: clientSecret,
+}
+
+var exchangeTokenRequest = TokenExchangeRequest{
+	ActingParty: struct {
+		ActorToken     string
+		ActorTokenType string
+	}{},
+	GrantType:          "urn:ietf:params:oauth:grant-type:token-exchange",
+	Resource:           "",
+	Audience:           "32555940559.apps.googleusercontent.com", //TODO: Make sure audience is correct in this test (might be mismatched)
+	Scope:              []string{"https://www.googleapis.com/auth/devstorage.full_control"},
+	RequestedTokenType: "urn:ietf:params:oauth:token-type:access_token",
+	SubjectToken:       "Sample.Subject.Token",
+	SubjectTokenType:   "urn:ietf:params:oauth:token-type:jwt",
+}
+
+var exchangeRequestBody = "audience=32555940559.apps.googleusercontent.com&grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Atoken-exchange&requested_token_type=urn%3Aietf%3Aparams%3Aoauth%3Atoken-type%3Aaccess_token&scope=https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fdevstorage.full_control&subject_token=Sample.Subject.Token&subject_token_type=urn%3Aietf%3Aparams%3Aoauth%3Atoken-type%3Ajwt"
+var exchangeResponseBody = `{"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"}`
+var expectedExchangeToken = Response{
+	AccessToken:     "Sample.Access.Token",
+	IssuedTokenType: "urn:ietf:params:oauth:token-type:access_token",
+	TokenType:       "Bearer",
+	ExpiresIn:       3600,
+	Scope:           "https://www.googleapis.com/auth/cloud-platform",
+	RefreshToken:    "",
+}
+
+var refreshToken = "ReFrEsHtOkEn"
+var refreshRequestBody = "grant_type=refresh_token&refresh_token=" + refreshToken
+var refreshResponseBody = `{"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","refresh_token":"REFRESHED_REFRESH"}`
+var expectedRefreshResponse = Response{
+	AccessToken:     "Sample.Access.Token",
+	IssuedTokenType: "urn:ietf:params:oauth:token-type:access_token",
+	TokenType:       "Bearer",
+	ExpiresIn:       3600,
+	Scope:           "https://www.googleapis.com/auth/cloud-platform",
+	RefreshToken:    "REFRESHED_REFRESH",
+}
+
+func TestExchangeToken(t *testing.T) {
+	ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		if r.Method != "POST" {
+			t.Errorf("Unexpected request method, %v is found", r.Method)
+		}
+		if r.URL.String() != "/" {
+			t.Errorf("Unexpected request URL, %v is found", r.URL)
+		}
+		if got, want := r.Header.Get("Authorization"), "Basic cmJyZ25vZ25yaG9uZ28zYmk0Z2I5Z2hnOWc6bm90c29zZWNyZXQ="; got != want {
+			t.Errorf("Unexpected authorization header, got %v, want %v", got, want)
+		}
+		if got, want := r.Header.Get("Content-Type"), "application/x-www-form-urlencoded"; got != want {
+			t.Errorf("Unexpected Content-Type header, got %v, want %v", got, want)
+		}
+		body, err := ioutil.ReadAll(r.Body)
+		if err != nil {
+			t.Errorf("Failed reading request body: %v.", err)
+		}
+		if got, want := string(body), exchangeRequestBody; got != want {
+			t.Errorf("Unexpected exchange payload, got %v but want %v", got, want)
+		}
+		w.Header().Set("Content-Type", "application/json")
+		w.Write([]byte(exchangeResponseBody))
+	}))
+	defer ts.Close()
+
+	headers := http.Header{}
+	headers.Add("Content-Type", "application/x-www-form-urlencoded")
+
+	resp, err := ExchangeToken(context.Background(), ts.URL, &exchangeTokenRequest, auth, headers, nil)
+	if err != nil {
+		t.Fatalf("exchangeToken failed with error: %v", err)
+	}
+
+	if expectedExchangeToken != *resp {
+		t.Errorf("mismatched messages received by mock server.  \nWant: \n%v\n\nGot:\n%v", expectedExchangeToken, *resp)
+	}
+
+	resp, err = ExchangeToken(context.Background(), ts.URL, &exchangeTokenRequest, auth, nil, nil)
+	if err != nil {
+		t.Fatalf("exchangeToken failed with error: %v", err)
+	}
+
+	if expectedExchangeToken != *resp {
+		t.Errorf("mismatched messages received by mock server.  \nWant: \n%v\n\nGot:\n%v", expectedExchangeToken, *resp)
+	}
+}
+
+func TestExchangeToken_Err(t *testing.T) {
+	ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		w.Header().Set("Content-Type", "application/json")
+		w.Write([]byte("what's wrong with this response?"))
+	}))
+	defer ts.Close()
+
+	headers := http.Header{}
+	headers.Add("Content-Type", "application/x-www-form-urlencoded")
+	_, err := ExchangeToken(context.Background(), ts.URL, &exchangeTokenRequest, auth, headers, nil)
+	if err == nil {
+		t.Errorf("Expected handled error; instead got nil.")
+	}
+}
+
+/* Lean test specifically for options, as the other features are tested earlier. */
+type testOpts struct {
+	First  string `json:"first"`
+	Second string `json:"second"`
+}
+
+var optsValues = [][]string{{"foo", "bar"}, {"cat", "pan"}}
+
+func TestExchangeToken_Opts(t *testing.T) {
+	ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		body, err := ioutil.ReadAll(r.Body)
+		if err != nil {
+			t.Fatalf("Failed reading request body: %v.", err)
+		}
+		data, err := url.ParseQuery(string(body))
+		if err != nil {
+			t.Fatalf("Failed to parse request body: %v", err)
+		}
+		strOpts, ok := data["options"]
+		if !ok {
+			t.Errorf("Server didn't recieve an \"options\" field.")
+		} else if len(strOpts) < 1 {
+			t.Errorf("\"options\" field has length 0.")
+		}
+		var opts map[string]interface{}
+		err = json.Unmarshal([]byte(strOpts[0]), &opts)
+		if err != nil {
+			t.Fatalf("Couldn't parse received \"options\" field.")
+		}
+		if len(opts) < 2 {
+			t.Errorf("Too few options received.")
+		}
+
+		val, ok := opts["one"]
+		if !ok {
+			t.Errorf("Couldn't find first option parameter.")
+		} else {
+			tOpts1, ok := val.(map[string]interface{})
+			if !ok {
+				t.Errorf("Failed to assert the first option parameter as type testOpts.")
+			} else {
+				if got, want := tOpts1["first"].(string), optsValues[0][0]; got != want {
+					t.Errorf("First value in first options field is incorrect; got %v but want %v", got, want)
+				}
+				if got, want := tOpts1["second"].(string), optsValues[0][1]; got != want {
+					t.Errorf("Second value in first options field is incorrect; got %v but want %v", got, want)
+				}
+			}
+		}
+
+		val2, ok := opts["two"]
+		if !ok {
+			t.Errorf("Couldn't find second option parameter.")
+		} else {
+			tOpts2, ok := val2.(map[string]interface{})
+			if !ok {
+				t.Errorf("Failed to assert the second option parameter as type testOpts.")
+			} else {
+				if got, want := tOpts2["first"].(string), optsValues[1][0]; got != want {
+					t.Errorf("First value in second options field is incorrect; got %v but want %v", got, want)
+				}
+				if got, want := tOpts2["second"].(string), optsValues[1][1]; got != want {
+					t.Errorf("Second value in second options field is incorrect; got %v but want %v", got, want)
+				}
+			}
+		}
+
+		// Send a proper reply so that no other errors crop up.
+		w.Header().Set("Content-Type", "application/json")
+		w.Write([]byte(exchangeResponseBody))
+
+	}))
+	defer ts.Close()
+	headers := http.Header{}
+	headers.Add("Content-Type", "application/x-www-form-urlencoded")
+
+	firstOption := testOpts{optsValues[0][0], optsValues[0][1]}
+	secondOption := testOpts{optsValues[1][0], optsValues[1][1]}
+	inputOpts := make(map[string]interface{})
+	inputOpts["one"] = firstOption
+	inputOpts["two"] = secondOption
+	ExchangeToken(context.Background(), ts.URL, &exchangeTokenRequest, auth, headers, inputOpts)
+}
+
+func TestRefreshToken(t *testing.T) {
+	ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		if r.Method != "POST" {
+			t.Errorf("Unexpected request method, %v is found", r.Method)
+		}
+		if r.URL.String() != "/" {
+			t.Errorf("Unexpected request URL, %v is found", r.URL)
+		}
+		if got, want := r.Header.Get("Authorization"), "Basic cmJyZ25vZ25yaG9uZ28zYmk0Z2I5Z2hnOWc6bm90c29zZWNyZXQ="; got != want {
+			t.Errorf("Unexpected authorization header, got %v, want %v", got, want)
+		}
+		if got, want := r.Header.Get("Content-Type"), "application/x-www-form-urlencoded"; got != want {
+			t.Errorf("Unexpected Content-Type header, got %v, want %v", got, want)
+		}
+		body, err := ioutil.ReadAll(r.Body)
+		if err != nil {
+			t.Errorf("Failed reading request body: %v.", err)
+		}
+		if got, want := string(body), refreshRequestBody; got != want {
+			t.Errorf("Unexpected exchange payload, got %v but want %v", got, want)
+		}
+		w.Header().Set("Content-Type", "application/json")
+		w.Write([]byte(refreshResponseBody))
+	}))
+	defer ts.Close()
+
+	headers := http.Header{}
+	headers.Add("Content-Type", "application/x-www-form-urlencoded")
+
+	resp, err := RefreshAccessToken(context.Background(), ts.URL, refreshToken, auth, headers)
+	if err != nil {
+		t.Fatalf("exchangeToken failed with error: %v", err)
+	}
+
+	if expectedRefreshResponse != *resp {
+		t.Errorf("mismatched messages received by mock server.  \nWant: \n%v\n\nGot:\n%v", expectedRefreshResponse, *resp)
+	}
+
+	resp, err = RefreshAccessToken(context.Background(), ts.URL, refreshToken, auth, nil)
+	if err != nil {
+		t.Fatalf("exchangeToken failed with error: %v", err)
+	}
+
+	if expectedRefreshResponse != *resp {
+		t.Errorf("mismatched messages received by mock server.  \nWant: \n%v\n\nGot:\n%v", expectedRefreshResponse, *resp)
+	}
+}
+
+func TestRefreshToken_Err(t *testing.T) {
+	ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		w.Header().Set("Content-Type", "application/json")
+		w.Write([]byte("what's wrong with this response?"))
+	}))
+	defer ts.Close()
+
+	headers := http.Header{}
+	headers.Add("Content-Type", "application/x-www-form-urlencoded")
+
+	_, err := RefreshAccessToken(context.Background(), ts.URL, refreshToken, auth, headers)
+	if err == nil {
+		t.Errorf("Expected handled error; instead got nil.")
+	}
+}
OSZAR »