google: support service account impersonation

Adds support for service account impersonation when a URL for service account impersonation is provided.

Change-Id: I9f3bbd6926212cecb13938fc5dac358ba56855b8
GitHub-Last-Rev: 9c218789db45e9b80bb8bec5c69539dd386d9668
GitHub-Pull-Request: golang/oauth2#468
Reviewed-on: https://go-review.googlesource.com/c/oauth2/+/285012
Run-TryBot: Cody Oss <codyoss@google.com>
TryBot-Result: Go Bot <gobot@golang.org>
Trust: Cody Oss <codyoss@google.com>
Trust: Tyler Bui-Palsulich <tbp@google.com>
Reviewed-by: Cody Oss <codyoss@google.com>
diff --git a/google/internal/externalaccount/basecredentials.go b/google/internal/externalaccount/basecredentials.go
index dff0881..deb9deb 100644
--- a/google/internal/externalaccount/basecredentials.go
+++ b/google/internal/externalaccount/basecredentials.go
@@ -35,7 +35,18 @@
 		ctx:  ctx,
 		conf: c,
 	}
-	return oauth2.ReuseTokenSource(nil, ts)
+	if c.ServiceAccountImpersonationURL == "" {
+		return oauth2.ReuseTokenSource(nil, ts)
+	}
+	scopes := c.Scopes
+	ts.conf.Scopes = []string{"https://www.googleapis.com/auth/cloud-platform"}
+	imp := impersonateTokenSource{
+		ctx:    ctx,
+		url:    c.ServiceAccountImpersonationURL,
+		scopes: scopes,
+		ts: oauth2.ReuseTokenSource(nil, ts),
+	}
+	return oauth2.ReuseTokenSource(nil, imp)
 }
 
 // Subject token file types.
@@ -130,6 +141,5 @@
 	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
index 7ec12e4..eb60899 100644
--- a/google/internal/externalaccount/basecredentials_test.go
+++ b/google/internal/externalaccount/basecredentials_test.go
@@ -19,14 +19,13 @@
 }
 
 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"},
+	Audience:         "32555940559.apps.googleusercontent.com",
+	SubjectTokenType: "urn:ietf:params:oauth:token-type:jwt",
+	TokenInfoURL:     "http://localhost:8080/v1/tokeninfo",
+	ClientSecret:     "notsosecret",
+	ClientID:         "rbrgnognrhongo3bi4gb9ghg9g",
+	CredentialSource: testBaseCredSource,
+	Scopes:           []string{"https://www.googleapis.com/auth/devstorage.full_control"},
 }
 
 var (
@@ -55,7 +54,7 @@
 		}
 		body, err := ioutil.ReadAll(r.Body)
 		if err != nil {
-			t.Errorf("Failed reading request body: %s.", err)
+			t.Fatalf("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)
diff --git a/google/internal/externalaccount/impersonate.go b/google/internal/externalaccount/impersonate.go
new file mode 100644
index 0000000..1d29c46
--- /dev/null
+++ b/google/internal/externalaccount/impersonate.go
@@ -0,0 +1,83 @@
+// Copyright 2021 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"
+	"fmt"
+	"golang.org/x/oauth2"
+	"io"
+	"io/ioutil"
+	"net/http"
+	"time"
+)
+
+// generateAccesstokenReq is used for service account impersonation
+type generateAccessTokenReq struct {
+	Delegates []string `json:"delegates,omitempty"`
+	Lifetime  string   `json:"lifetime,omitempty"`
+	Scope     []string `json:"scope,omitempty"`
+}
+
+type impersonateTokenResponse struct {
+	AccessToken string `json:"accessToken"`
+	ExpireTime  string `json:"expireTime"`
+}
+
+type impersonateTokenSource struct {
+	ctx context.Context
+	ts  oauth2.TokenSource
+
+	url    string
+	scopes []string
+}
+
+// Token performs the exchange to get a temporary service account
+func (its impersonateTokenSource) Token() (*oauth2.Token, error) {
+	reqBody := generateAccessTokenReq{
+		Lifetime: "3600s",
+		Scope:    its.scopes,
+	}
+	b, err := json.Marshal(reqBody)
+	if err != nil {
+		return nil, fmt.Errorf("oauth2/google: unable to marshal request: %v", err)
+	}
+	client := oauth2.NewClient(its.ctx, its.ts)
+	req, err := http.NewRequest("POST", its.url, bytes.NewReader(b))
+	if err != nil {
+		return nil, fmt.Errorf("oauth2/google: unable to create impersonation request: %v", err)
+	}
+	req = req.WithContext(its.ctx)
+	req.Header.Set("Content-Type", "application/json")
+
+	resp, err := client.Do(req)
+	if err != nil {
+		return nil, fmt.Errorf("oauth2/google: unable to generate access token: %v", err)
+	}
+	defer resp.Body.Close()
+	body, err := ioutil.ReadAll(io.LimitReader(resp.Body, 1<<20))
+	if err != nil {
+		return nil, fmt.Errorf("oauth2/google: unable to read body: %v", err)
+	}
+	if c := resp.StatusCode; c < 200 || c > 299 {
+		return nil, fmt.Errorf("oauth2/google: status code %d: %s", c, body)
+	}
+
+	var accessTokenResp impersonateTokenResponse
+	if err := json.Unmarshal(body, &accessTokenResp); err != nil {
+		return nil, fmt.Errorf("oauth2/google: unable to parse response: %v", err)
+	}
+	expiry, err := time.Parse(time.RFC3339, accessTokenResp.ExpireTime)
+	if err != nil {
+		return nil, fmt.Errorf("oauth2/google: unable to parse expiry: %v", err)
+	}
+	return &oauth2.Token{
+		AccessToken: accessTokenResp.AccessToken,
+		Expiry:      expiry,
+		TokenType:   "Bearer",
+	}, nil
+}
diff --git a/google/internal/externalaccount/impersonate_test.go b/google/internal/externalaccount/impersonate_test.go
new file mode 100644
index 0000000..a2d8978
--- /dev/null
+++ b/google/internal/externalaccount/impersonate_test.go
@@ -0,0 +1,95 @@
+// Copyright 2021 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"
+)
+
+var testImpersonateConfig = Config{
+	Audience:         "32555940559.apps.googleusercontent.com",
+	SubjectTokenType: "urn:ietf:params:oauth:token-type:jwt",
+	TokenInfoURL:     "http://localhost:8080/v1/tokeninfo",
+	ClientSecret:     "notsosecret",
+	ClientID:         "rbrgnognrhongo3bi4gb9ghg9g",
+	CredentialSource: testBaseCredSource,
+	Scopes:           []string{"https://www.googleapis.com/auth/devstorage.full_control"},
+}
+
+var (
+	baseImpersonateCredsReqBody  = "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%2Fcloud-platform&subject_token=street123&subject_token_type=urn%3Aietf%3Aparams%3Aoauth%3Atoken-type%3Ajwt"
+	baseImpersonateCredsRespBody = `{"accessToken":"Second.Access.Token","expireTime":"2020-12-28T15:01:23Z"}`
+)
+
+func TestImpersonation(t *testing.T) {
+	impersonateServer := 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, "Bearer Sample.Access.Token"; got != want {
+			t.Errorf("got %v but want %v", got, want)
+		}
+		headerContentType := r.Header.Get("Content-Type")
+		if got, want := headerContentType, "application/json"; 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: %v.", err)
+		}
+		if got, want := string(body), "{\"lifetime\":\"3600s\",\"scope\":[\"https://www.googleapis.com/auth/devstorage.full_control\"]}"; got != want {
+			t.Errorf("Unexpected impersonation payload: got %v but want %v", got, want)
+		}
+		w.Header().Set("Content-Type", "application/json")
+		w.Write([]byte(baseImpersonateCredsRespBody))
+	}))
+	testImpersonateConfig.ServiceAccountImpersonationURL = impersonateServer.URL
+	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.Fatalf("Failed reading request body: %v.", err)
+		}
+		if got, want := string(body), baseImpersonateCredsReqBody; 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()
+
+	testImpersonateConfig.TokenURL = targetServer.URL
+	ourTS := testImpersonateConfig.TokenSource(context.Background())
+
+	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, "Second.Access.Token"; 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)
+	}
+}