From f9ce19ea30139fe25e55e62b15cfa969d4267230 Mon Sep 17 00:00:00 2001 From: Patrick Jones Date: Tue, 26 Jan 2021 19:21:15 +0000 Subject: [PATCH] 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 TryBot-Result: Go Bot Trust: Cody Oss Trust: Tyler Bui-Palsulich Reviewed-by: Cody Oss --- .../externalaccount/basecredentials.go | 14 ++- .../externalaccount/basecredentials_test.go | 17 ++-- .../internal/externalaccount/impersonate.go | 83 ++++++++++++++++ .../externalaccount/impersonate_test.go | 95 +++++++++++++++++++ 4 files changed, 198 insertions(+), 11 deletions(-) create mode 100644 google/internal/externalaccount/impersonate.go create mode 100644 google/internal/externalaccount/impersonate_test.go 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 @@ func (c *Config) TokenSource(ctx context.Context) oauth2.TokenSource { 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 @@ func (ts tokenSource) Token() (*oauth2.Token, error) { 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 testBaseCredSource = CredentialSource{ } 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 @@ func TestToken(t *testing.T) { } 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) + } +}