Skip to content

Commit 6b3c2da

Browse files
ScruffyProdigycodyoss
authored andcommitted
google/externalaccount: add support for workforce pool credentials
Workforce pools (external account credentials for non-Google users) are organization-level resources which means that issued workforce pool tokens will not have any client project ID on token exchange as currently designed. "To use a Google API, the client must identify the application to the server. If the API requires authentication, the client must also identify the principal running the application." The application here is the client project. The token will identify the user principal but not the application. This will result in APIs rejecting requests authenticated with these tokens. Note that passing a x-goog-user-project override header on API request is still not sufficient. The token is still expected to have a client project. As a result, we have extended the spec to support an additional workforce_pool_user_project for these credentials (workforce pools) which will be passed when exchanging an external token for a Google Access token. After the exchange, the issued access token will use the supplied project as the client project. The underlying principal must still have serviceusage.services.use IAM permission to use the project for billing/quota. This field is not needed for flows with basic client authentication (e.g. client ID is supplied). The client ID is sufficient to determine the client project and any additionally supplied workforce_pool_user_project value will be ignored. Note that this feature is not usable yet publicly. Change-Id: I8311d7783e4048c260cbb68e90d3565df864d7e0 GitHub-Last-Rev: a6dc5eb GitHub-Pull-Request: #520 Reviewed-on: https://go-review.googlesource.com/c/oauth2/+/353393 Reviewed-by: Cody Oss <[email protected]> Reviewed-by: Bassam Ojeil <[email protected]> Trust: Cody Oss <[email protected]> Trust: Tyler Bui-Palsulich <[email protected]> Run-TryBot: Cody Oss <[email protected]> TryBot-Result: Go Bot <[email protected]>
1 parent 2bc19b1 commit 6b3c2da

File tree

3 files changed

+200
-25
lines changed

3 files changed

+200
-25
lines changed

google/google.go

+2
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,7 @@ type credentialsFile struct {
123123
ServiceAccountImpersonationURL string `json:"service_account_impersonation_url"`
124124
CredentialSource externalaccount.CredentialSource `json:"credential_source"`
125125
QuotaProjectID string `json:"quota_project_id"`
126+
WorkforcePoolUserProject string `json:"workforce_pool_user_project"`
126127
}
127128

128129
func (f *credentialsFile) jwtConfig(scopes []string, subject string) *jwt.Config {
@@ -176,6 +177,7 @@ func (f *credentialsFile) tokenSource(ctx context.Context, params CredentialsPar
176177
CredentialSource: f.CredentialSource,
177178
QuotaProjectID: f.QuotaProjectID,
178179
Scopes: params.Scopes,
180+
WorkforcePoolUserProject: f.WorkforcePoolUserProject,
179181
}
180182
return cfg.TokenSource(ctx)
181183
case "":

google/internal/externalaccount/basecredentials.go

+27-3
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,11 @@ type Config struct {
5353
QuotaProjectID string
5454
// Scopes contains the desired scopes for the returned access token.
5555
Scopes []string
56+
// The optional workforce pool user project number when the credential
57+
// corresponds to a workforce pool and not a workload identity pool.
58+
// The underlying principal must still have serviceusage.services.use IAM
59+
// permission to use the project for billing/quota.
60+
WorkforcePoolUserProject string
5661
}
5762

5863
// Each element consists of a list of patterns. validateURLs checks for matches
@@ -73,6 +78,7 @@ var (
7378
regexp.MustCompile(`^iamcredentials\.[^\.\s\/\\]+\.googleapis\.com$`),
7479
regexp.MustCompile(`^[^\.\s\/\\]+-iamcredentials\.googleapis\.com$`),
7580
}
81+
validWorkforceAudiencePattern *regexp.Regexp = regexp.MustCompile(`//iam\.googleapis\.com/locations/[^/]+/workforcePools/`)
7682
)
7783

7884
func validateURL(input string, patterns []*regexp.Regexp, scheme string) bool {
@@ -86,14 +92,17 @@ func validateURL(input string, patterns []*regexp.Regexp, scheme string) bool {
8692
toTest := parsed.Host
8793

8894
for _, pattern := range patterns {
89-
90-
if valid := pattern.MatchString(toTest); valid {
95+
if pattern.MatchString(toTest) {
9196
return true
9297
}
9398
}
9499
return false
95100
}
96101

102+
func validateWorkforceAudience(input string) bool {
103+
return validWorkforceAudiencePattern.MatchString(input)
104+
}
105+
97106
// TokenSource Returns an external account TokenSource struct. This is to be called by package google to construct a google.Credentials.
98107
func (c *Config) TokenSource(ctx context.Context) (oauth2.TokenSource, error) {
99108
return c.tokenSource(ctx, validTokenURLPatterns, validImpersonateURLPatterns, "https")
@@ -115,6 +124,13 @@ func (c *Config) tokenSource(ctx context.Context, tokenURLValidPats []*regexp.Re
115124
}
116125
}
117126

127+
if c.WorkforcePoolUserProject != "" {
128+
valid := validateWorkforceAudience(c.Audience)
129+
if !valid {
130+
return nil, fmt.Errorf("oauth2/google: workforce_pool_user_project should not be set for non-workforce pool credentials")
131+
}
132+
}
133+
118134
ts := tokenSource{
119135
ctx: ctx,
120136
conf: c,
@@ -224,7 +240,15 @@ func (ts tokenSource) Token() (*oauth2.Token, error) {
224240
ClientID: conf.ClientID,
225241
ClientSecret: conf.ClientSecret,
226242
}
227-
stsResp, err := exchangeToken(ts.ctx, conf.TokenURL, &stsRequest, clientAuth, header, nil)
243+
var options map[string]interface{}
244+
// Do not pass workforce_pool_user_project when client authentication is used.
245+
// The client ID is sufficient for determining the user project.
246+
if conf.WorkforcePoolUserProject != "" && conf.ClientID == "" {
247+
options = map[string]interface{}{
248+
"userProject": conf.WorkforcePoolUserProject,
249+
}
250+
}
251+
stsResp, err := exchangeToken(ts.ctx, conf.TokenURL, &stsRequest, clientAuth, header, options)
228252
if err != nil {
229253
return nil, err
230254
}

google/internal/externalaccount/basecredentials_test.go

+171-22
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ import (
1212
"strings"
1313
"testing"
1414
"time"
15+
16+
"golang.org/x/oauth2"
1517
)
1618

1719
const (
@@ -35,66 +37,175 @@ var testConfig = Config{
3537
}
3638

3739
var (
38-
baseCredsRequestBody = "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=street123&subject_token_type=urn%3Aietf%3Aparams%3Aoauth%3Atoken-type%3Ajwt"
39-
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"}`
40-
correctAT = "Sample.Access.Token"
41-
expiry int64 = 234852
40+
baseCredsRequestBody = "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=street123&subject_token_type=urn%3Aietf%3Aparams%3Aoauth%3Atoken-type%3Aid_token"
41+
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"}`
42+
workforcePoolRequestBodyWithClientId = "audience=%2F%2Fiam.googleapis.com%2Flocations%2Feu%2FworkforcePools%2Fpool-id%2Fproviders%2Fprovider-id&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=street123&subject_token_type=urn%3Aietf%3Aparams%3Aoauth%3Atoken-type%3Aid_token"
43+
workforcePoolRequestBodyWithoutClientId = "audience=%2F%2Fiam.googleapis.com%2Flocations%2Feu%2FworkforcePools%2Fpool-id%2Fproviders%2Fprovider-id&grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Atoken-exchange&options=%7B%22userProject%22%3A%22myProject%22%7D&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%3Aid_token"
44+
correctAT = "Sample.Access.Token"
45+
expiry int64 = 234852
4246
)
4347
var (
4448
testNow = func() time.Time { return time.Unix(expiry, 0) }
4549
)
4650

47-
func TestToken(t *testing.T) {
51+
type testExchangeTokenServer struct {
52+
url string
53+
authorization string
54+
contentType string
55+
body string
56+
response string
57+
}
4858

49-
targetServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
50-
if got, want := r.URL.String(), "/"; got != want {
59+
func run(t *testing.T, config *Config, tets *testExchangeTokenServer) (*oauth2.Token, error) {
60+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
61+
if got, want := r.URL.String(), tets.url; got != want {
5162
t.Errorf("URL.String(): got %v but want %v", got, want)
5263
}
5364
headerAuth := r.Header.Get("Authorization")
54-
if got, want := headerAuth, "Basic cmJyZ25vZ25yaG9uZ28zYmk0Z2I5Z2hnOWc6bm90c29zZWNyZXQ="; got != want {
65+
if got, want := headerAuth, tets.authorization; got != want {
5566
t.Errorf("got %v but want %v", got, want)
5667
}
5768
headerContentType := r.Header.Get("Content-Type")
58-
if got, want := headerContentType, "application/x-www-form-urlencoded"; got != want {
69+
if got, want := headerContentType, tets.contentType; got != want {
5970
t.Errorf("got %v but want %v", got, want)
6071
}
6172
body, err := ioutil.ReadAll(r.Body)
6273
if err != nil {
6374
t.Fatalf("Failed reading request body: %s.", err)
6475
}
65-
if got, want := string(body), baseCredsRequestBody; got != want {
76+
if got, want := string(body), tets.body; got != want {
6677
t.Errorf("Unexpected exchange payload: got %v but want %v", got, want)
6778
}
6879
w.Header().Set("Content-Type", "application/json")
69-
w.Write([]byte(baseCredsResponseBody))
80+
w.Write([]byte(tets.response))
7081
}))
71-
defer targetServer.Close()
72-
73-
testConfig.TokenURL = targetServer.URL
74-
ourTS := tokenSource{
75-
ctx: context.Background(),
76-
conf: &testConfig,
77-
}
82+
defer server.Close()
83+
config.TokenURL = server.URL
7884

7985
oldNow := now
8086
defer func() { now = oldNow }()
8187
now = testNow
8288

83-
tok, err := ourTS.Token()
84-
if err != nil {
85-
t.Fatalf("Unexpected error: %e", err)
89+
ts := tokenSource{
90+
ctx: context.Background(),
91+
conf: config,
8692
}
93+
94+
return ts.Token()
95+
}
96+
97+
func validateToken(t *testing.T, tok *oauth2.Token) {
8798
if got, want := tok.AccessToken, correctAT; got != want {
8899
t.Errorf("Unexpected access token: got %v, but wanted %v", got, want)
89100
}
90101
if got, want := tok.TokenType, "Bearer"; got != want {
91102
t.Errorf("Unexpected TokenType: got %v, but wanted %v", got, want)
92103
}
93104

94-
if got, want := tok.Expiry, now().Add(time.Duration(3600)*time.Second); got != want {
105+
if got, want := tok.Expiry, testNow().Add(time.Duration(3600)*time.Second); got != want {
95106
t.Errorf("Unexpected Expiry: got %v, but wanted %v", got, want)
96107
}
108+
}
109+
110+
func TestToken(t *testing.T) {
111+
config := Config{
112+
Audience: "32555940559.apps.googleusercontent.com",
113+
SubjectTokenType: "urn:ietf:params:oauth:token-type:id_token",
114+
ClientSecret: "notsosecret",
115+
ClientID: "rbrgnognrhongo3bi4gb9ghg9g",
116+
CredentialSource: testBaseCredSource,
117+
Scopes: []string{"https://www.googleapis.com/auth/devstorage.full_control"},
118+
}
119+
120+
server := testExchangeTokenServer{
121+
url: "/",
122+
authorization: "Basic cmJyZ25vZ25yaG9uZ28zYmk0Z2I5Z2hnOWc6bm90c29zZWNyZXQ=",
123+
contentType: "application/x-www-form-urlencoded",
124+
body: baseCredsRequestBody,
125+
response: baseCredsResponseBody,
126+
}
127+
128+
tok, err := run(t, &config, &server)
97129

130+
if err != nil {
131+
t.Fatalf("Unexpected error: %e", err)
132+
}
133+
validateToken(t, tok)
134+
}
135+
136+
func TestWorkforcePoolTokenWithClientID(t *testing.T) {
137+
config := Config{
138+
Audience: "//iam.googleapis.com/locations/eu/workforcePools/pool-id/providers/provider-id",
139+
SubjectTokenType: "urn:ietf:params:oauth:token-type:id_token",
140+
ClientSecret: "notsosecret",
141+
ClientID: "rbrgnognrhongo3bi4gb9ghg9g",
142+
CredentialSource: testBaseCredSource,
143+
Scopes: []string{"https://www.googleapis.com/auth/devstorage.full_control"},
144+
WorkforcePoolUserProject: "myProject",
145+
}
146+
147+
server := testExchangeTokenServer{
148+
url: "/",
149+
authorization: "Basic cmJyZ25vZ25yaG9uZ28zYmk0Z2I5Z2hnOWc6bm90c29zZWNyZXQ=",
150+
contentType: "application/x-www-form-urlencoded",
151+
body: workforcePoolRequestBodyWithClientId,
152+
response: baseCredsResponseBody,
153+
}
154+
155+
tok, err := run(t, &config, &server)
156+
157+
if err != nil {
158+
t.Fatalf("Unexpected error: %e", err)
159+
}
160+
validateToken(t, tok)
161+
}
162+
163+
func TestWorkforcePoolTokenWithoutClientID(t *testing.T) {
164+
config := Config{
165+
Audience: "//iam.googleapis.com/locations/eu/workforcePools/pool-id/providers/provider-id",
166+
SubjectTokenType: "urn:ietf:params:oauth:token-type:id_token",
167+
ClientSecret: "notsosecret",
168+
CredentialSource: testBaseCredSource,
169+
Scopes: []string{"https://www.googleapis.com/auth/devstorage.full_control"},
170+
WorkforcePoolUserProject: "myProject",
171+
}
172+
173+
server := testExchangeTokenServer{
174+
url: "/",
175+
authorization: "",
176+
contentType: "application/x-www-form-urlencoded",
177+
body: workforcePoolRequestBodyWithoutClientId,
178+
response: baseCredsResponseBody,
179+
}
180+
181+
tok, err := run(t, &config, &server)
182+
183+
if err != nil {
184+
t.Fatalf("Unexpected error: %e", err)
185+
}
186+
validateToken(t, tok)
187+
}
188+
189+
func TestNonworkforceWithWorkforcePoolUserProject(t *testing.T) {
190+
config := Config{
191+
Audience: "32555940559.apps.googleusercontent.com",
192+
SubjectTokenType: "urn:ietf:params:oauth:token-type:id_token",
193+
TokenURL: "https://sts.googleapis.com",
194+
ClientSecret: "notsosecret",
195+
ClientID: "rbrgnognrhongo3bi4gb9ghg9g",
196+
CredentialSource: testBaseCredSource,
197+
Scopes: []string{"https://www.googleapis.com/auth/devstorage.full_control"},
198+
WorkforcePoolUserProject: "myProject",
199+
}
200+
201+
_, err := config.TokenSource(context.Background())
202+
203+
if err == nil {
204+
t.Fatalf("Expected error but found none")
205+
}
206+
if got, want := err.Error(), "oauth2/google: workforce_pool_user_project should not be set for non-workforce pool credentials"; got != want {
207+
t.Errorf("Incorrect error received.\nExpected: %s\nRecieved: %s", want, got)
208+
}
98209
}
99210

100211
func TestValidateURLTokenURL(t *testing.T) {
@@ -210,3 +321,41 @@ func TestValidateURLImpersonateURL(t *testing.T) {
210321
})
211322
}
212323
}
324+
325+
func TestWorkforcePoolCreation(t *testing.T) {
326+
var audienceValidatyTests = []struct {
327+
audience string
328+
expectSuccess bool
329+
}{
330+
{"//iam.googleapis.com/locations/global/workforcePools/pool-id/providers/provider-id", true},
331+
{"//iam.googleapis.com/locations/eu/workforcePools/pool-id/providers/provider-id", true},
332+
{"//iam.googleapis.com/locations/eu/workforcePools/workloadIdentityPools/providers/provider-id", true},
333+
{"identitynamespace:1f12345:my_provider", false},
334+
{"//iam.googleapis.com/projects/123456/locations/global/workloadIdentityPools/pool-id/providers/provider-id", false},
335+
{"//iam.googleapis.com/projects/123456/locations/eu/workloadIdentityPools/pool-id/providers/provider-id", false},
336+
{"//iam.googleapis.com/projects/123456/locations/global/workloadIdentityPools/workforcePools/providers/provider-id", false},
337+
{"//iamgoogleapis.com/locations/eu/workforcePools/pool-id/providers/provider-id", false},
338+
{"//iam.googleapiscom/locations/eu/workforcePools/pool-id/providers/provider-id", false},
339+
{"//iam.googleapis.com/locations/workforcePools/pool-id/providers/provider-id", false},
340+
{"//iam.googleapis.com/locations/eu/workforcePool/pool-id/providers/provider-id", false},
341+
{"//iam.googleapis.com/locations//workforcePool/pool-id/providers/provider-id", false},
342+
}
343+
344+
ctx := context.Background()
345+
for _, tt := range audienceValidatyTests {
346+
t.Run(" "+tt.audience, func(t *testing.T) { // We prepend a space ahead of the test input when outputting for sake of readability.
347+
config := testConfig
348+
config.TokenURL = "https://sts.googleapis.com" // Setting the most basic acceptable tokenURL
349+
config.ServiceAccountImpersonationURL = "https://iamcredentials.googleapis.com"
350+
config.Audience = tt.audience
351+
config.WorkforcePoolUserProject = "myProject"
352+
_, err := config.TokenSource(ctx)
353+
354+
if tt.expectSuccess && err != nil {
355+
t.Errorf("got %v but want nil", err)
356+
} else if !tt.expectSuccess && err == nil {
357+
t.Errorf("got nil but expected an error")
358+
}
359+
})
360+
}
361+
}

0 commit comments

Comments
 (0)