1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28 package byoid
29
30 import (
31 "context"
32 "encoding/json"
33 "encoding/xml"
34 "flag"
35 "fmt"
36 "io"
37 "log"
38 "net/http"
39 "net/http/httptest"
40 "net/url"
41 "os"
42 "testing"
43 "time"
44
45 "golang.org/x/oauth2/google"
46 "google.golang.org/api/dns/v1"
47 "google.golang.org/api/idtoken"
48 "google.golang.org/api/option"
49 )
50
51 const (
52 envCredentials = "GOOGLE_APPLICATION_CREDENTIALS"
53 envAudienceOIDC = "GCLOUD_TESTS_GOLANG_AUDIENCE_OIDC"
54 envAudienceAWS = "GCLOUD_TESTS_GOLANG_AUDIENCE_AWS"
55 envProject = "GOOGLE_CLOUD_PROJECT"
56 )
57
58 var (
59 oidcAudience string
60 awsAudience string
61 oidcToken string
62 clientID string
63 projectID string
64 )
65
66
67 func TestMain(m *testing.M) {
68 flag.Parse()
69 if testing.Short() {
70
71 os.Exit(m.Run())
72 }
73 keyFileName := os.Getenv(envCredentials)
74 if keyFileName == "" {
75 log.Fatalf("Please set %s to your keyfile", envCredentials)
76 }
77
78 projectID = os.Getenv(envProject)
79 if projectID == "" {
80 log.Fatalf("Please set %s to the ID of the project", envProject)
81 }
82
83 oidcAudience = os.Getenv(envAudienceOIDC)
84 if oidcAudience == "" {
85 log.Fatalf("Please set %s to the OIDC Audience", envAudienceOIDC)
86 }
87
88 awsAudience = os.Getenv(envAudienceAWS)
89 if awsAudience == "" {
90 log.Fatalf("Please set %s to the AWS Audience", envAudienceAWS)
91 }
92
93 var err error
94
95 clientID, err = getClientID(keyFileName)
96 if err != nil {
97 log.Fatalf("Error getting Client ID: %v", err)
98 }
99
100 oidcToken, err = generateGoogleToken(keyFileName)
101 if err != nil {
102 log.Fatalf("Error generating Google token: %v", err)
103 }
104
105
106 os.Exit(m.Run())
107 }
108
109
110 type keyFile struct {
111 ClientEmail string `json:"client_email"`
112 ClientID string `json:"client_id"`
113 }
114
115 func getClientID(keyFileName string) (string, error) {
116 kf, err := os.Open(keyFileName)
117 if err != nil {
118 return "", err
119 }
120 defer kf.Close()
121
122 decoder := json.NewDecoder(kf)
123 var keyFileSettings keyFile
124 if err = decoder.Decode(&keyFileSettings); err != nil {
125 return "", err
126 }
127
128 return fmt.Sprintf("projects/-/serviceAccounts/%s", keyFileSettings.ClientEmail), nil
129 }
130
131 func generateGoogleToken(keyFileName string) (string, error) {
132 ts, err := idtoken.NewTokenSource(context.Background(), oidcAudience, option.WithCredentialsFile(keyFileName))
133 if err != nil {
134 return "", nil
135 }
136
137 token, err := ts.Token()
138 if err != nil {
139 return "", nil
140 }
141
142 return token.AccessToken, nil
143 }
144
145
146
147 func writeConfig(t *testing.T, c config, f func(name string)) {
148 t.Helper()
149
150
151 configFile, err := os.CreateTemp("", "config.json")
152 if err != nil {
153 t.Fatalf("Error creating config file: %v", err)
154 }
155 defer os.Remove(configFile.Name())
156
157 err = json.NewEncoder(configFile).Encode(c)
158 if err != nil {
159 t.Errorf("Error writing to config file: %v", err)
160 }
161 configFile.Close()
162
163 f(configFile.Name())
164 }
165
166
167
168
169
170
171
172 func testBYOID(t *testing.T, c config) {
173 t.Helper()
174
175 writeConfig(t, c, func(name string) {
176
177
178 dnsService, err := dns.NewService(context.Background(), option.WithCredentialsFile(name))
179 if err != nil {
180 t.Fatalf("Could not establish DNS Service: %v", err)
181 }
182
183 _, err = dnsService.Projects.Get(projectID).Do()
184 if err != nil {
185 t.Fatalf("DNS Service failed: %v", err)
186 }
187 })
188 }
189
190
191 type config struct {
192 Type string `json:"type"`
193 Audience string `json:"audience"`
194 SubjectTokenType string `json:"subject_token_type"`
195 TokenURL string `json:"token_url"`
196 ServiceAccountImpersonationURL string `json:"service_account_impersonation_url"`
197 ServiceAccountImpersonation serviceAccountImpersonationInfo `json:"service_account_impersonation,omitempty"`
198 CredentialSource credentialSource `json:"credential_source"`
199 }
200
201 type serviceAccountImpersonationInfo struct {
202 TokenLifetimeSeconds int `json:"token_lifetime_seconds,omitempty"`
203 }
204
205 type credentialSource struct {
206 File string `json:"file,omitempty"`
207 URL string `json:"url,omitempty"`
208 Executable executableConfig `json:"executable,omitempty"`
209 EnvironmentID string `json:"environment_id,omitempty"`
210 RegionURL string `json:"region_url,omitempty"`
211 RegionalCredVerificationURL string `json:"regional_cred_verification_url,omitempty"`
212 }
213
214 type executableConfig struct {
215 Command string `json:"command,omitempty"`
216 TimeoutMillis int `json:"timeout_millis,omitempty"`
217 OutputFile string `json:"output_file,omitempty"`
218 }
219
220
221 func TestFileBasedCredentials(t *testing.T) {
222 if testing.Short() {
223 t.Skip("skipping integration test")
224 }
225
226 tokenFile, err := os.CreateTemp("", "token.txt")
227 if err != nil {
228 t.Fatalf("Error creating token file:")
229 }
230 defer os.Remove(tokenFile.Name())
231
232 tokenFile.WriteString(oidcToken)
233 tokenFile.Close()
234
235
236 testBYOID(t, config{
237 Type: "external_account",
238 Audience: oidcAudience,
239 SubjectTokenType: "urn:ietf:params:oauth:token-type:jwt",
240 TokenURL: "https://sts.googleapis.com/v1beta/token",
241 ServiceAccountImpersonationURL: fmt.Sprintf("https://iamcredentials.googleapis.com/v1/%s:generateAccessToken", clientID),
242 CredentialSource: credentialSource{
243 File: tokenFile.Name(),
244 },
245 })
246 }
247
248
249 func TestURLBasedCredentials(t *testing.T) {
250 if testing.Short() {
251 t.Skip("skipping integration test")
252 }
253
254 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
255 if r.Method != "GET" {
256 t.Errorf("Unexpected request method, %v is found", r.Method)
257 }
258 w.Write([]byte(oidcToken))
259 }))
260
261 testBYOID(t, config{
262 Type: "external_account",
263 Audience: oidcAudience,
264 SubjectTokenType: "urn:ietf:params:oauth:token-type:jwt",
265 TokenURL: "https://sts.googleapis.com/v1/token",
266 ServiceAccountImpersonationURL: fmt.Sprintf("https://iamcredentials.googleapis.com/v1/%s:generateAccessToken", clientID),
267 CredentialSource: credentialSource{
268 URL: ts.URL,
269 },
270 })
271 }
272
273
274 func TestAWSBasedCredentials(t *testing.T) {
275 if testing.Short() {
276 t.Skip("skipping integration test")
277 }
278 data := url.Values{}
279 data.Set("audience", clientID)
280 data.Set("includeEmail", "true")
281
282 client, err := google.DefaultClient(context.Background(), "https://www.googleapis.com/auth/cloud-platform")
283 if err != nil {
284 t.Fatalf("Failed to create default client: %v", err)
285 }
286 resp, err := client.PostForm(fmt.Sprintf("https://iamcredentials.googleapis.com/v1/%s:generateIdToken", clientID), data)
287 if err != nil {
288 t.Fatalf("Failed to generate an ID token: %v", err)
289 }
290 if resp.StatusCode != 200 {
291 t.Fatalf("Failed to get Google ID token for AWS test: %v", err)
292 }
293
294 var res map[string]interface{}
295
296 if err = json.NewDecoder(resp.Body).Decode(&res); err != nil {
297 t.Fatalf("Could not successfully parse response from generateIDToken: %v", err)
298 }
299 token, ok := res["token"]
300 if !ok {
301 t.Fatalf("Didn't receieve an ID token back from generateIDToken")
302 }
303
304 data = url.Values{}
305 data.Set("Action", "AssumeRoleWithWebIdentity")
306 data.Set("Version", "2011-06-15")
307 data.Set("DurationSeconds", "3600")
308 data.Set("RoleSessionName", os.Getenv("GCLOUD_TESTS_GOLANG_AWS_ROLE_NAME"))
309 data.Set("RoleArn", os.Getenv("GCLOUD_TESTS_GOLANG_AWS_ROLE_ID"))
310 data.Set("WebIdentityToken", token.(string))
311
312 resp, err = http.PostForm("https://sts.amazonaws.com/", data)
313 if err != nil {
314 t.Fatalf("Failed to post data to AWS: %v", err)
315 }
316 bodyBytes, err := io.ReadAll(resp.Body)
317 if err != nil {
318 t.Fatalf("Failed to parse response body from AWS: %v", err)
319 }
320
321 var respVars struct {
322 SessionToken string `xml:"AssumeRoleWithWebIdentityResult>Credentials>SessionToken"`
323 SecretAccessKey string `xml:"AssumeRoleWithWebIdentityResult>Credentials>SecretAccessKey"`
324 AccessKeyID string `xml:"AssumeRoleWithWebIdentityResult>Credentials>AccessKeyId"`
325 }
326
327 if err = xml.Unmarshal(bodyBytes, &respVars); err != nil {
328 t.Fatalf("Failed to unmarshal XML response from AWS.")
329 }
330
331 if respVars.SessionToken == "" || respVars.SecretAccessKey == "" || respVars.AccessKeyID == "" {
332 t.Fatalf("Couldn't find the required variables in the response from the AWS server.")
333 }
334
335 currSessTokEnv := os.Getenv("AWS_SESSION_TOKEN")
336 defer os.Setenv("AWS_SESSION_TOKEN", currSessTokEnv)
337 os.Setenv("AWS_SESSION_TOKEN", respVars.SessionToken)
338
339 currSecAccKey := os.Getenv("AWS_SECRET_ACCESS_KEY")
340 defer os.Setenv("AWS_SECRET_ACCESS_KEY", currSecAccKey)
341 os.Setenv("AWS_SECRET_ACCESS_KEY", respVars.SecretAccessKey)
342
343 currAccKeyID := os.Getenv("AWS_ACCESS_KEY_ID")
344 defer os.Setenv("AWS_ACCESS_KEY_ID", currAccKeyID)
345 os.Setenv("AWS_ACCESS_KEY_ID", respVars.AccessKeyID)
346
347 currRegion := os.Getenv("AWS_REGION")
348 defer os.Setenv("AWS_REGION", currRegion)
349 os.Setenv("AWS_REGION", "us-east-1")
350
351 testBYOID(t, config{
352 Type: "external_account",
353 Audience: awsAudience,
354 SubjectTokenType: "urn:ietf:params:aws:token-type:aws4_request",
355 TokenURL: "https://sts.googleapis.com/v1/token",
356 ServiceAccountImpersonationURL: fmt.Sprintf("https://iamcredentials.googleapis.com/v1/%s:generateAccessToken", clientID),
357 CredentialSource: credentialSource{
358 EnvironmentID: "aws1",
359 RegionalCredVerificationURL: "https://sts.us-east-1.amazonaws.com?Action=GetCallerIdentity&Version=2011-06-15",
360 },
361 })
362 }
363
364
365
366 func TestExecutableBasedCredentials(t *testing.T) {
367 if testing.Short() {
368 t.Skip("skipping integration test")
369 }
370
371
372 scriptFile, err := os.CreateTemp("", "script.sh")
373 if err != nil {
374 t.Fatalf("Error creating token file:")
375 }
376 defer os.Remove(scriptFile.Name())
377
378 fmt.Fprintf(scriptFile, `#!/bin/bash
379 echo "{\"success\":true,\"version\":1,\"expiration_time\":%v,\"token_type\":\"urn:ietf:params:oauth:token-type:jwt\",\"id_token\":\"%v\"}"`,
380 time.Now().Add(time.Hour).Unix(), oidcToken)
381 scriptFile.Close()
382 os.Chmod(scriptFile.Name(), 0700)
383
384
385 testBYOID(t, config{
386 Type: "external_account",
387 Audience: oidcAudience,
388 SubjectTokenType: "urn:ietf:params:oauth:token-type:jwt",
389 TokenURL: "https://sts.googleapis.com/v1/token",
390 ServiceAccountImpersonationURL: fmt.Sprintf("https://iamcredentials.googleapis.com/v1/%s:generateAccessToken", clientID),
391 CredentialSource: credentialSource{
392 Executable: executableConfig{
393 Command: scriptFile.Name(),
394 },
395 },
396 })
397 }
398
399 func TestConfigurableTokenLifetime(t *testing.T) {
400 if testing.Short() {
401 t.Skip("skipping integration test")
402 }
403
404
405 tokenFile, err := os.CreateTemp("", "token.txt")
406 if err != nil {
407 t.Fatalf("Error creating token file:")
408 }
409 defer os.Remove(tokenFile.Name())
410
411 tokenFile.WriteString(oidcToken)
412 tokenFile.Close()
413
414 const tokenLifetimeSeconds = 2800
415 const safetyBuffer = 5
416
417 writeConfig(t, config{
418 Type: "external_account",
419 Audience: oidcAudience,
420 SubjectTokenType: "urn:ietf:params:oauth:token-type:jwt",
421 TokenURL: "https://sts.googleapis.com/v1/token",
422 ServiceAccountImpersonationURL: fmt.Sprintf("https://iamcredentials.googleapis.com/v1/%s:generateAccessToken", clientID),
423 ServiceAccountImpersonation: serviceAccountImpersonationInfo{
424 TokenLifetimeSeconds: tokenLifetimeSeconds,
425 },
426 CredentialSource: credentialSource{
427 File: tokenFile.Name(),
428 },
429 }, func(filename string) {
430 b, err := os.ReadFile(filename)
431 if err != nil {
432 t.Fatalf("Coudn't read temp config file")
433 }
434
435 creds, err := google.CredentialsFromJSON(context.Background(), b, "https://www.googleapis.com/auth/cloud-platform")
436 if err != nil {
437 t.Fatalf("Error retrieving credentials")
438 }
439
440 token, err := creds.TokenSource.Token()
441 if err != nil {
442 t.Fatalf("Error getting token")
443 }
444
445 now := time.Now()
446 expiryMax := now.Add((safetyBuffer + tokenLifetimeSeconds) * time.Second)
447 expiryMin := now.Add((tokenLifetimeSeconds - safetyBuffer) * time.Second)
448 if token.Expiry.Before(expiryMin) || token.Expiry.After(expiryMax) {
449 t.Fatalf("Expiry time not set correctly. Got %v, want %v", token.Expiry, expiryMax)
450 }
451 })
452 }
453
View as plain text