1
2
3
4
5
6
7
8
9
10
11
12
13
14
15 package externalaccount
16
17 import (
18 "context"
19 "encoding/json"
20 "fmt"
21 "io"
22 "net/http"
23 "net/http/httptest"
24 "testing"
25 "time"
26
27 "cloud.google.com/go/auth"
28 "cloud.google.com/go/auth/credentials/internal/stsexchange"
29 "cloud.google.com/go/auth/internal"
30 "cloud.google.com/go/auth/internal/credsfile"
31 )
32
33 const (
34 textBaseCredPath = "testdata/3pi_cred.txt"
35 jsonBaseCredPath = "testdata/3pi_cred.json"
36 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"
37 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"}`
38 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"
39 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"
40 correctAT = "Sample.Access.Token"
41 expiry int64 = 234852
42 )
43
44 var (
45 testOpts = &Options{
46 Audience: "32555940559.apps.googleusercontent.com",
47 SubjectTokenType: jwtTokenType,
48 TokenInfoURL: "http://localhost:8080/v1/tokeninfo",
49 ClientSecret: "notsosecret",
50 ClientID: "rbrgnognrhongo3bi4gb9ghg9g",
51 CredentialSource: testBaseCredSource,
52 Scopes: []string{"https://www.googleapis.com/auth/devstorage.full_control"},
53 Client: internal.CloneDefaultClient(),
54 }
55 testBaseCredSource = &credsfile.CredentialSource{
56 File: textBaseCredPath,
57 Format: &credsfile.Format{Type: fileTypeText},
58 }
59 testNow = func() time.Time { return time.Unix(expiry, 0) }
60 )
61
62 func TestToken(t *testing.T) {
63 tests := []struct {
64 name string
65 respBody *stsexchange.TokenResponse
66 wantError bool
67 }{
68 {
69 name: "works",
70 respBody: &stsexchange.TokenResponse{
71 AccessToken: correctAT,
72 IssuedTokenType: "urn:ietf:params:oauth:token-type:access_token",
73 TokenType: "Bearer",
74 ExpiresIn: 3600,
75 Scope: "https://www.googleapis.com/auth/cloud-platform",
76 },
77 },
78 {
79 name: "no exp time on tok",
80 respBody: &stsexchange.TokenResponse{
81 AccessToken: correctAT,
82 IssuedTokenType: "urn:ietf:params:oauth:token-type:access_token",
83 TokenType: "Bearer",
84 Scope: "https://www.googleapis.com/auth/cloud-platform",
85 },
86 wantError: true,
87 },
88 {
89 name: "negative exp time",
90 respBody: &stsexchange.TokenResponse{
91 AccessToken: correctAT,
92 IssuedTokenType: "urn:ietf:params:oauth:token-type:access_token",
93 TokenType: "Bearer",
94 ExpiresIn: -1,
95 Scope: "https://www.googleapis.com/auth/cloud-platform",
96 },
97 wantError: true,
98 },
99 }
100 for _, tt := range tests {
101 opts := &Options{
102 Audience: "32555940559.apps.googleusercontent.com",
103 SubjectTokenType: idTokenType,
104 ClientSecret: "notsosecret",
105 ClientID: "rbrgnognrhongo3bi4gb9ghg9g",
106 CredentialSource: testBaseCredSource,
107 Scopes: []string{"https://www.googleapis.com/auth/devstorage.full_control"},
108 }
109
110 respBody, err := json.Marshal(tt.respBody)
111 if err != nil {
112 t.Fatal(err)
113 }
114
115 server := &testExchangeTokenServer{
116 url: "/",
117 authorization: "Basic cmJyZ25vZ25yaG9uZ28zYmk0Z2I5Z2hnOWc6bm90c29zZWNyZXQ=",
118 contentType: "application/x-www-form-urlencoded",
119 body: baseCredsRequestBody,
120 response: string(respBody),
121 metricsHeader: expectedMetricsHeader("file", false, false),
122 }
123
124 tok, err := run(t, opts, server)
125 if err != nil && !tt.wantError {
126 t.Fatal(err)
127 }
128 if tt.wantError {
129 if err == nil {
130 t.Fatal("want err, got nil")
131 }
132 continue
133 }
134 validateToken(t, tok)
135 }
136 opts := &Options{
137 Audience: "32555940559.apps.googleusercontent.com",
138 SubjectTokenType: idTokenType,
139 ClientSecret: "notsosecret",
140 ClientID: "rbrgnognrhongo3bi4gb9ghg9g",
141 CredentialSource: testBaseCredSource,
142 Scopes: []string{"https://www.googleapis.com/auth/devstorage.full_control"},
143 }
144
145 server := &testExchangeTokenServer{
146 url: "/",
147 authorization: "Basic cmJyZ25vZ25yaG9uZ28zYmk0Z2I5Z2hnOWc6bm90c29zZWNyZXQ=",
148 contentType: "application/x-www-form-urlencoded",
149 body: baseCredsRequestBody,
150 response: baseCredsResponseBody,
151 metricsHeader: expectedMetricsHeader("file", false, false),
152 }
153
154 tok, err := run(t, opts, server)
155 if err != nil {
156 t.Fatal(err)
157 }
158 validateToken(t, tok)
159 }
160
161 func TestWorkforcePoolTokenWithClientID(t *testing.T) {
162 opts := Options{
163 Audience: "//iam.googleapis.com/locations/eu/workforcePools/pool-id/providers/provider-id",
164 SubjectTokenType: idTokenType,
165 ClientSecret: "notsosecret",
166 ClientID: "rbrgnognrhongo3bi4gb9ghg9g",
167 CredentialSource: testBaseCredSource,
168 Scopes: []string{"https://www.googleapis.com/auth/devstorage.full_control"},
169 WorkforcePoolUserProject: "myProject",
170 }
171
172 server := testExchangeTokenServer{
173 url: "/",
174 authorization: "Basic cmJyZ25vZ25yaG9uZ28zYmk0Z2I5Z2hnOWc6bm90c29zZWNyZXQ=",
175 contentType: "application/x-www-form-urlencoded",
176 body: workforcePoolRequestBodyWithClientID,
177 response: baseCredsResponseBody,
178 metricsHeader: expectedMetricsHeader("file", false, false),
179 }
180
181 tok, err := run(t, &opts, &server)
182 if err != nil {
183 t.Fatal(err)
184 }
185 validateToken(t, tok)
186 }
187
188 func TestWorkforcePoolTokenWithoutClientID(t *testing.T) {
189 opts := Options{
190 Audience: "//iam.googleapis.com/locations/eu/workforcePools/pool-id/providers/provider-id",
191 SubjectTokenType: idTokenType,
192 ClientSecret: "notsosecret",
193 CredentialSource: testBaseCredSource,
194 Scopes: []string{"https://www.googleapis.com/auth/devstorage.full_control"},
195 WorkforcePoolUserProject: "myProject",
196 }
197
198 server := testExchangeTokenServer{
199 url: "/",
200 authorization: "",
201 contentType: "application/x-www-form-urlencoded",
202 body: workforcePoolRequestBodyWithoutClientID,
203 response: baseCredsResponseBody,
204 metricsHeader: expectedMetricsHeader("file", false, false),
205 }
206
207 tok, err := run(t, &opts, &server)
208 if err != nil {
209 t.Fatal(err)
210 }
211 validateToken(t, tok)
212 }
213
214 func TestNonworkforceWithWorkforcePoolUserProject(t *testing.T) {
215 opts := &Options{
216 Audience: "32555940559.apps.googleusercontent.com",
217 SubjectTokenType: idTokenType,
218 TokenURL: "https://sts.googleapis.com",
219 ClientSecret: "notsosecret",
220 ClientID: "rbrgnognrhongo3bi4gb9ghg9g",
221 CredentialSource: testBaseCredSource,
222 Scopes: []string{"https://www.googleapis.com/auth/devstorage.full_control"},
223 WorkforcePoolUserProject: "myProject",
224 Client: internal.CloneDefaultClient(),
225 }
226
227 _, err := NewTokenProvider(opts)
228 if err == nil {
229 t.Fatalf("got nil, want an error")
230 }
231 if got, want := err.Error(), "externalaccount: workforce_pool_user_project should not be set for non-workforce pool credentials"; got != want {
232 t.Errorf("got %v, want %v", got, want)
233 }
234 }
235
236 func TestWorkforcePoolCreation(t *testing.T) {
237 var audienceValidatyTests = []struct {
238 audience string
239 expectSuccess bool
240 }{
241 {"//iam.googleapis.com/locations/global/workforcePools/pool-id/providers/provider-id", true},
242 {"//iam.googleapis.com/locations/eu/workforcePools/pool-id/providers/provider-id", true},
243 {"//iam.googleapis.com/locations/eu/workforcePools/workloadIdentityPools/providers/provider-id", true},
244 {"identitynamespace:1f12345:my_provider", false},
245 {"//iam.googleapis.com/projects/123456/locations/global/workloadIdentityPools/pool-id/providers/provider-id", false},
246 {"//iam.googleapis.com/projects/123456/locations/eu/workloadIdentityPools/pool-id/providers/provider-id", false},
247 {"//iam.googleapis.com/projects/123456/locations/global/workloadIdentityPools/workforcePools/providers/provider-id", false},
248 {"//iamgoogleapis.com/locations/eu/workforcePools/pool-id/providers/provider-id", false},
249 {"//iam.googleapiscom/locations/eu/workforcePools/pool-id/providers/provider-id", false},
250 {"//iam.googleapis.com/locations/workforcePools/pool-id/providers/provider-id", false},
251 {"//iam.googleapis.com/locations/eu/workforcePool/pool-id/providers/provider-id", false},
252 {"//iam.googleapis.com/locations//workforcePool/pool-id/providers/provider-id", false},
253 }
254 for _, tt := range audienceValidatyTests {
255 t.Run(" "+tt.audience, func(t *testing.T) {
256 opts := testOpts
257 opts.TokenURL = "https://sts.googleapis.com"
258 opts.ServiceAccountImpersonationURL = "https://iamcredentials.googleapis.com"
259 opts.Audience = tt.audience
260 opts.WorkforcePoolUserProject = "myProject"
261 _, err := NewTokenProvider(opts)
262
263 if tt.expectSuccess && err != nil {
264 t.Errorf("got %v, want nil", err)
265 } else if !tt.expectSuccess && err == nil {
266 t.Errorf("got nil, want an error")
267 }
268 })
269 }
270 }
271
272 type testExchangeTokenServer struct {
273 url string
274 authorization string
275 contentType string
276 body string
277 response string
278 metricsHeader string
279 }
280
281 func run(t *testing.T, opts *Options, tets *testExchangeTokenServer) (*auth.Token, error) {
282 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
283 if got, want := r.URL.String(), tets.url; got != want {
284 t.Errorf("got %v, want %v", got, want)
285 }
286 headerAuth := r.Header.Get("Authorization")
287 if got, want := headerAuth, tets.authorization; got != want {
288 t.Errorf("got %v, want %v", got, want)
289 }
290 headerContentType := r.Header.Get("Content-Type")
291 if got, want := headerContentType, tets.contentType; got != want {
292 t.Errorf("got %v, want %v", got, want)
293 }
294 headerMetrics := r.Header.Get("x-goog-api-client")
295 if got, want := headerMetrics, tets.metricsHeader; got != want {
296 t.Errorf("got %v but want %v", got, want)
297 }
298 body, err := io.ReadAll(r.Body)
299 if err != nil {
300 t.Fatalf("Failed reading request body: %s.", err)
301 }
302 if got, want := string(body), tets.body; got != want {
303 t.Errorf("got %v, want %v", got, want)
304 }
305 w.Header().Set("Content-Type", "application/json")
306 w.Write([]byte(tets.response))
307 }))
308 defer server.Close()
309 opts.TokenURL = server.URL
310
311 oldNow := Now
312 defer func() { Now = oldNow }()
313 Now = testNow
314
315 stp, err := newSubjectTokenProvider(opts)
316 if err != nil {
317 t.Fatal(err)
318 }
319 tp := &tokenProvider{
320 opts: opts,
321 client: internal.CloneDefaultClient(),
322 stp: stp,
323 }
324
325 return tp.Token(context.Background())
326 }
327
328 func validateToken(t *testing.T, tok *auth.Token) {
329 if got, want := tok.Value, correctAT; got != want {
330 t.Errorf("got %v, want %v", got, want)
331 }
332 if got, want := tok.Type, internal.TokenTypeBearer; got != want {
333 t.Errorf("got %v, want %v", got, want)
334 }
335
336 if got, want := tok.Expiry, testNow().Add(time.Duration(3600)*time.Second); got != want {
337 t.Errorf("got %v, want %v", got, want)
338 }
339 }
340
341 func cloneTestOpts() *Options {
342 return &Options{
343 Audience: "32555940559.apps.googleusercontent.com",
344 SubjectTokenType: jwtTokenType,
345 TokenURL: "http://localhost:8080/v1/token",
346 TokenInfoURL: "http://localhost:8080/v1/tokeninfo",
347 ServiceAccountImpersonationURL: "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/service-gcs-admin@$PROJECT_ID.iam.gserviceaccount.com:generateAccessToken",
348 ClientSecret: "notsosecret",
349 ClientID: "rbrgnognrhongo3bi4gb9ghg9g",
350 Client: internal.CloneDefaultClient(),
351 }
352 }
353
354 func expectedMetricsHeader(source string, saImpersonation bool, configLifetime bool) string {
355 return fmt.Sprintf("gl-go/%s auth/unknown google-byoid-sdk source/%s sa-impersonation/%t config-lifetime/%t", goVersion(), source, saImpersonation, configLifetime)
356 }
357
358 func TestOptionsValidate(t *testing.T) {
359 tests := []struct {
360 name string
361 o *Options
362 wantErr bool
363 }{
364 {
365 name: "works",
366 o: &Options{
367 Audience: "32555940559.apps.googleusercontent.com",
368 SubjectTokenType: jwtTokenType,
369 TokenURL: "http://localhost:8080/v1/token",
370 TokenInfoURL: "http://localhost:8080/v1/tokeninfo",
371 ServiceAccountImpersonationURL: "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/service-gcs-admin@$PROJECT_ID.iam.gserviceaccount.com:generateAccessToken",
372 ClientSecret: "notsosecret",
373 ClientID: "rbrgnognrhongo3bi4gb9ghg9g",
374 Client: internal.CloneDefaultClient(),
375 CredentialSource: testBaseCredSource,
376 },
377 },
378 {
379 name: "missing aud",
380 o: &Options{
381 SubjectTokenType: jwtTokenType,
382 TokenURL: "http://localhost:8080/v1/token",
383 TokenInfoURL: "http://localhost:8080/v1/tokeninfo",
384 ServiceAccountImpersonationURL: "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/service-gcs-admin@$PROJECT_ID.iam.gserviceaccount.com:generateAccessToken",
385 ClientSecret: "notsosecret",
386 ClientID: "rbrgnognrhongo3bi4gb9ghg9g",
387 Client: internal.CloneDefaultClient(),
388 CredentialSource: testBaseCredSource,
389 },
390 wantErr: true,
391 },
392 {
393 name: "missing subjectTokenType",
394 o: &Options{
395 Audience: "32555940559.apps.googleusercontent.com",
396 TokenURL: "http://localhost:8080/v1/token",
397 TokenInfoURL: "http://localhost:8080/v1/tokeninfo",
398 ServiceAccountImpersonationURL: "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/service-gcs-admin@$PROJECT_ID.iam.gserviceaccount.com:generateAccessToken",
399 ClientSecret: "notsosecret",
400 ClientID: "rbrgnognrhongo3bi4gb9ghg9g",
401 Client: internal.CloneDefaultClient(),
402 CredentialSource: testBaseCredSource,
403 },
404 wantErr: true,
405 },
406 {
407 name: "invalid workforcepool",
408 o: &Options{
409 WorkforcePoolUserProject: "blah",
410 Audience: "32555940559.apps.googleusercontent.com",
411 SubjectTokenType: jwtTokenType,
412 TokenURL: "http://localhost:8080/v1/token",
413 TokenInfoURL: "http://localhost:8080/v1/tokeninfo",
414 ServiceAccountImpersonationURL: "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/service-gcs-admin@$PROJECT_ID.iam.gserviceaccount.com:generateAccessToken",
415 ClientSecret: "notsosecret",
416 ClientID: "rbrgnognrhongo3bi4gb9ghg9g",
417 Client: internal.CloneDefaultClient(),
418 CredentialSource: testBaseCredSource,
419 },
420 wantErr: true,
421 },
422 {
423 name: "no creds",
424 o: &Options{
425 Audience: "32555940559.apps.googleusercontent.com",
426 SubjectTokenType: jwtTokenType,
427 TokenURL: "http://localhost:8080/v1/token",
428 TokenInfoURL: "http://localhost:8080/v1/tokeninfo",
429 ServiceAccountImpersonationURL: "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/service-gcs-admin@$PROJECT_ID.iam.gserviceaccount.com:generateAccessToken",
430 ClientSecret: "notsosecret",
431 ClientID: "rbrgnognrhongo3bi4gb9ghg9g",
432 Client: internal.CloneDefaultClient(),
433 },
434 wantErr: true,
435 },
436 {
437 name: "too many creds",
438 o: &Options{
439 Audience: "32555940559.apps.googleusercontent.com",
440 SubjectTokenType: jwtTokenType,
441 TokenURL: "http://localhost:8080/v1/token",
442 TokenInfoURL: "http://localhost:8080/v1/tokeninfo",
443 ServiceAccountImpersonationURL: "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/service-gcs-admin@$PROJECT_ID.iam.gserviceaccount.com:generateAccessToken",
444 ClientSecret: "notsosecret",
445 ClientID: "rbrgnognrhongo3bi4gb9ghg9g",
446 Client: internal.CloneDefaultClient(),
447 CredentialSource: testBaseCredSource,
448 SubjectTokenProvider: fakeSubjectTokenProvider{},
449 },
450 wantErr: true,
451 },
452 }
453 for _, tc := range tests {
454 t.Run(tc.name, func(t *testing.T) {
455 err := tc.o.validate()
456 if err == nil && tc.wantErr {
457 t.Fatalf("o.validate() = nil, want error")
458 }
459 if err != nil && !tc.wantErr {
460 t.Fatalf("o.validate() = non-nil error, want error")
461 }
462 })
463 }
464 }
465
466 func TestOptionsResolveTokenURL(t *testing.T) {
467 tests := []struct {
468 name string
469 o *Options
470 want string
471 }{
472 {
473 name: "default",
474 o: &Options{},
475 want: "https://sts.googleapis.com/v1/token",
476 },
477 {
478 name: "Options TokenURL",
479 o: &Options{
480 TokenURL: "http://localhost:8080/v1/token",
481 },
482 want: "http://localhost:8080/v1/token",
483 },
484 {
485 name: "Options UniverseDomain",
486 o: &Options{
487 UniverseDomain: "example.com",
488 },
489 want: "https://sts.example.com/v1/token",
490 },
491 {
492 name: "Options TokenURL overrides UniverseDomain",
493 o: &Options{
494 TokenURL: "http://localhost:8080/v1/token",
495 UniverseDomain: "example.com",
496 },
497 want: "http://localhost:8080/v1/token",
498 },
499 }
500 for _, tc := range tests {
501 t.Run(tc.name, func(t *testing.T) {
502 tc.o.resolveTokenURL()
503 if tc.o.TokenURL != tc.want {
504 t.Errorf("got %s, want %s", tc.o.TokenURL, tc.want)
505 }
506 })
507 }
508 }
509
View as plain text