Skip to content

Commit f9ce19e

Browse files
gIthurielcodyoss
authored andcommitted
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: 9c21878 GitHub-Pull-Request: #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>
1 parent af13f52 commit f9ce19e

File tree

4 files changed

+198
-11
lines changed

4 files changed

+198
-11
lines changed

google/internal/externalaccount/basecredentials.go

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,18 @@ func (c *Config) TokenSource(ctx context.Context) oauth2.TokenSource {
3535
ctx: ctx,
3636
conf: c,
3737
}
38-
return oauth2.ReuseTokenSource(nil, ts)
38+
if c.ServiceAccountImpersonationURL == "" {
39+
return oauth2.ReuseTokenSource(nil, ts)
40+
}
41+
scopes := c.Scopes
42+
ts.conf.Scopes = []string{"https://www.googleapis.com/auth/cloud-platform"}
43+
imp := impersonateTokenSource{
44+
ctx: ctx,
45+
url: c.ServiceAccountImpersonationURL,
46+
scopes: scopes,
47+
ts: oauth2.ReuseTokenSource(nil, ts),
48+
}
49+
return oauth2.ReuseTokenSource(nil, imp)
3950
}
4051

4152
// Subject token file types.
@@ -130,6 +141,5 @@ func (ts tokenSource) Token() (*oauth2.Token, error) {
130141
if stsResp.RefreshToken != "" {
131142
accessToken.RefreshToken = stsResp.RefreshToken
132143
}
133-
134144
return accessToken, nil
135145
}

google/internal/externalaccount/basecredentials_test.go

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -19,14 +19,13 @@ var testBaseCredSource = CredentialSource{
1919
}
2020

2121
var testConfig = Config{
22-
Audience: "32555940559.apps.googleusercontent.com",
23-
SubjectTokenType: "urn:ietf:params:oauth:token-type:jwt",
24-
TokenInfoURL: "http://localhost:8080/v1/tokeninfo",
25-
ServiceAccountImpersonationURL: "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/service-gcs-admin@$PROJECT_ID.iam.gserviceaccount.com:generateAccessToken",
26-
ClientSecret: "notsosecret",
27-
ClientID: "rbrgnognrhongo3bi4gb9ghg9g",
28-
CredentialSource: testBaseCredSource,
29-
Scopes: []string{"https://www.googleapis.com/auth/devstorage.full_control"},
22+
Audience: "32555940559.apps.googleusercontent.com",
23+
SubjectTokenType: "urn:ietf:params:oauth:token-type:jwt",
24+
TokenInfoURL: "http://localhost:8080/v1/tokeninfo",
25+
ClientSecret: "notsosecret",
26+
ClientID: "rbrgnognrhongo3bi4gb9ghg9g",
27+
CredentialSource: testBaseCredSource,
28+
Scopes: []string{"https://www.googleapis.com/auth/devstorage.full_control"},
3029
}
3130

3231
var (
@@ -55,7 +54,7 @@ func TestToken(t *testing.T) {
5554
}
5655
body, err := ioutil.ReadAll(r.Body)
5756
if err != nil {
58-
t.Errorf("Failed reading request body: %s.", err)
57+
t.Fatalf("Failed reading request body: %s.", err)
5958
}
6059
if got, want := string(body), baseCredsRequestBody; got != want {
6160
t.Errorf("Unexpected exchange payload: got %v but want %v", got, want)
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
// Copyright 2021 The Go Authors. All rights reserved.
2+
// Use of this source code is governed by a BSD-style
3+
// license that can be found in the LICENSE file.
4+
5+
package externalaccount
6+
7+
import (
8+
"bytes"
9+
"context"
10+
"encoding/json"
11+
"fmt"
12+
"golang.org/x/oauth2"
13+
"io"
14+
"io/ioutil"
15+
"net/http"
16+
"time"
17+
)
18+
19+
// generateAccesstokenReq is used for service account impersonation
20+
type generateAccessTokenReq struct {
21+
Delegates []string `json:"delegates,omitempty"`
22+
Lifetime string `json:"lifetime,omitempty"`
23+
Scope []string `json:"scope,omitempty"`
24+
}
25+
26+
type impersonateTokenResponse struct {
27+
AccessToken string `json:"accessToken"`
28+
ExpireTime string `json:"expireTime"`
29+
}
30+
31+
type impersonateTokenSource struct {
32+
ctx context.Context
33+
ts oauth2.TokenSource
34+
35+
url string
36+
scopes []string
37+
}
38+
39+
// Token performs the exchange to get a temporary service account
40+
func (its impersonateTokenSource) Token() (*oauth2.Token, error) {
41+
reqBody := generateAccessTokenReq{
42+
Lifetime: "3600s",
43+
Scope: its.scopes,
44+
}
45+
b, err := json.Marshal(reqBody)
46+
if err != nil {
47+
return nil, fmt.Errorf("oauth2/google: unable to marshal request: %v", err)
48+
}
49+
client := oauth2.NewClient(its.ctx, its.ts)
50+
req, err := http.NewRequest("POST", its.url, bytes.NewReader(b))
51+
if err != nil {
52+
return nil, fmt.Errorf("oauth2/google: unable to create impersonation request: %v", err)
53+
}
54+
req = req.WithContext(its.ctx)
55+
req.Header.Set("Content-Type", "application/json")
56+
57+
resp, err := client.Do(req)
58+
if err != nil {
59+
return nil, fmt.Errorf("oauth2/google: unable to generate access token: %v", err)
60+
}
61+
defer resp.Body.Close()
62+
body, err := ioutil.ReadAll(io.LimitReader(resp.Body, 1<<20))
63+
if err != nil {
64+
return nil, fmt.Errorf("oauth2/google: unable to read body: %v", err)
65+
}
66+
if c := resp.StatusCode; c < 200 || c > 299 {
67+
return nil, fmt.Errorf("oauth2/google: status code %d: %s", c, body)
68+
}
69+
70+
var accessTokenResp impersonateTokenResponse
71+
if err := json.Unmarshal(body, &accessTokenResp); err != nil {
72+
return nil, fmt.Errorf("oauth2/google: unable to parse response: %v", err)
73+
}
74+
expiry, err := time.Parse(time.RFC3339, accessTokenResp.ExpireTime)
75+
if err != nil {
76+
return nil, fmt.Errorf("oauth2/google: unable to parse expiry: %v", err)
77+
}
78+
return &oauth2.Token{
79+
AccessToken: accessTokenResp.AccessToken,
80+
Expiry: expiry,
81+
TokenType: "Bearer",
82+
}, nil
83+
}
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
// Copyright 2021 The Go Authors. All rights reserved.
2+
// Use of this source code is governed by a BSD-style
3+
// license that can be found in the LICENSE file.
4+
5+
package externalaccount
6+
7+
import (
8+
"context"
9+
"io/ioutil"
10+
"net/http"
11+
"net/http/httptest"
12+
"testing"
13+
)
14+
15+
var testImpersonateConfig = Config{
16+
Audience: "32555940559.apps.googleusercontent.com",
17+
SubjectTokenType: "urn:ietf:params:oauth:token-type:jwt",
18+
TokenInfoURL: "http://localhost:8080/v1/tokeninfo",
19+
ClientSecret: "notsosecret",
20+
ClientID: "rbrgnognrhongo3bi4gb9ghg9g",
21+
CredentialSource: testBaseCredSource,
22+
Scopes: []string{"https://www.googleapis.com/auth/devstorage.full_control"},
23+
}
24+
25+
var (
26+
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"
27+
baseImpersonateCredsRespBody = `{"accessToken":"Second.Access.Token","expireTime":"2020-12-28T15:01:23Z"}`
28+
)
29+
30+
func TestImpersonation(t *testing.T) {
31+
impersonateServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
32+
if got, want := r.URL.String(), "/"; got != want {
33+
t.Errorf("URL.String(): got %v but want %v", got, want)
34+
}
35+
headerAuth := r.Header.Get("Authorization")
36+
if got, want := headerAuth, "Bearer Sample.Access.Token"; got != want {
37+
t.Errorf("got %v but want %v", got, want)
38+
}
39+
headerContentType := r.Header.Get("Content-Type")
40+
if got, want := headerContentType, "application/json"; got != want {
41+
t.Errorf("got %v but want %v", got, want)
42+
}
43+
body, err := ioutil.ReadAll(r.Body)
44+
if err != nil {
45+
t.Fatalf("Failed reading request body: %v.", err)
46+
}
47+
if got, want := string(body), "{\"lifetime\":\"3600s\",\"scope\":[\"https://www.googleapis.com/auth/devstorage.full_control\"]}"; got != want {
48+
t.Errorf("Unexpected impersonation payload: got %v but want %v", got, want)
49+
}
50+
w.Header().Set("Content-Type", "application/json")
51+
w.Write([]byte(baseImpersonateCredsRespBody))
52+
}))
53+
testImpersonateConfig.ServiceAccountImpersonationURL = impersonateServer.URL
54+
targetServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
55+
if got, want := r.URL.String(), "/"; got != want {
56+
t.Errorf("URL.String(): got %v but want %v", got, want)
57+
}
58+
headerAuth := r.Header.Get("Authorization")
59+
if got, want := headerAuth, "Basic cmJyZ25vZ25yaG9uZ28zYmk0Z2I5Z2hnOWc6bm90c29zZWNyZXQ="; got != want {
60+
t.Errorf("got %v but want %v", got, want)
61+
}
62+
headerContentType := r.Header.Get("Content-Type")
63+
if got, want := headerContentType, "application/x-www-form-urlencoded"; got != want {
64+
t.Errorf("got %v but want %v", got, want)
65+
}
66+
body, err := ioutil.ReadAll(r.Body)
67+
if err != nil {
68+
t.Fatalf("Failed reading request body: %v.", err)
69+
}
70+
if got, want := string(body), baseImpersonateCredsReqBody; got != want {
71+
t.Errorf("Unexpected exchange payload: got %v but want %v", got, want)
72+
}
73+
w.Header().Set("Content-Type", "application/json")
74+
w.Write([]byte(baseCredsResponseBody))
75+
}))
76+
defer targetServer.Close()
77+
78+
testImpersonateConfig.TokenURL = targetServer.URL
79+
ourTS := testImpersonateConfig.TokenSource(context.Background())
80+
81+
oldNow := now
82+
defer func() { now = oldNow }()
83+
now = testNow
84+
85+
tok, err := ourTS.Token()
86+
if err != nil {
87+
t.Fatalf("Unexpected error: %e", err)
88+
}
89+
if got, want := tok.AccessToken, "Second.Access.Token"; got != want {
90+
t.Errorf("Unexpected access token: got %v, but wanted %v", got, want)
91+
}
92+
if got, want := tok.TokenType, "Bearer"; got != want {
93+
t.Errorf("Unexpected TokenType: got %v, but wanted %v", got, want)
94+
}
95+
}

0 commit comments

Comments
 (0)