| // Copyright 2021 Google LLC. |
| // Use of this source code is governed by a BSD-style |
| // license that can be found in the LICENSE file. |
| |
| // To run this test locally, you will need to do the following: |
| // • Navigate to your Google Cloud Project |
| // • Get a copy of a Service Account Key File for testing (should be in .json format) |
| // • If you are unable to obtain an existing key file, create one: |
| // • > IAM and Admin > Service Accounts |
| // • Under the needed Service Account > Actions > Manage Keys |
| // • Add Key > Create New Key |
| // • Select JSON, and the click Create |
| // • Look for an available VM Instance, or create one- > Compute > Compute Engine > VM Instances |
| // • On the VM Instance, click the SSH Button. Then upload: |
| // • Your Service Account Key File |
| // • This script, along with setup.sh |
| // • A copy of env.conf, containing the required environment variables (see existing skeleton)/ |
| // • Set your environment variables (Usually this will be `source env.conf`) |
| // • Ensure that your VM is properly set up to run the integration test e.g. |
| // • wget -c https://golang.org/dl/go1.15.2.linux-amd64.tar.gz |
| // • Check https://golang.org/dl/for the latest version of Go |
| // • sudo tar -C /usr/local -xvzf go1.15.2.linux-amd64.tar.gz |
| // • go mod init google.golang.org/api/google-api-go-client |
| // • go mod tidy |
| // • Run setup.sh |
| // • go test -tags integration` |
| |
| package byoid |
| |
| import ( |
| "context" |
| "encoding/json" |
| "encoding/xml" |
| "flag" |
| "fmt" |
| "io" |
| "log" |
| "net/http" |
| "net/http/httptest" |
| "net/url" |
| "os" |
| "testing" |
| "time" |
| |
| "golang.org/x/oauth2/google" |
| "google.golang.org/api/dns/v1" |
| "google.golang.org/api/idtoken" |
| "google.golang.org/api/option" |
| ) |
| |
| const ( |
| envCredentials = "GOOGLE_APPLICATION_CREDENTIALS" |
| envAudienceOIDC = "GCLOUD_TESTS_GOLANG_AUDIENCE_OIDC" |
| envAudienceAWS = "GCLOUD_TESTS_GOLANG_AUDIENCE_AWS" |
| envProject = "GOOGLE_CLOUD_PROJECT" |
| ) |
| |
| var ( |
| oidcAudience string |
| awsAudience string |
| oidcToken string |
| clientID string |
| projectID string |
| ) |
| |
| // TestMain contains all of the setup code that needs to be run once before any of the tests are run |
| func TestMain(m *testing.M) { |
| flag.Parse() |
| if testing.Short() { |
| // This line runs all of our individual tests |
| os.Exit(m.Run()) |
| } |
| keyFileName := os.Getenv(envCredentials) |
| if keyFileName == "" { |
| log.Fatalf("Please set %s to your keyfile", envCredentials) |
| } |
| |
| projectID = os.Getenv(envProject) |
| if projectID == "" { |
| log.Fatalf("Please set %s to the ID of the project", envProject) |
| } |
| |
| oidcAudience = os.Getenv(envAudienceOIDC) |
| if oidcAudience == "" { |
| log.Fatalf("Please set %s to the OIDC Audience", envAudienceOIDC) |
| } |
| |
| awsAudience = os.Getenv(envAudienceAWS) |
| if awsAudience == "" { |
| log.Fatalf("Please set %s to the AWS Audience", envAudienceAWS) |
| } |
| |
| var err error |
| |
| clientID, err = getClientID(keyFileName) |
| if err != nil { |
| log.Fatalf("Error getting Client ID: %v", err) |
| } |
| |
| oidcToken, err = generateGoogleToken(keyFileName) |
| if err != nil { |
| log.Fatalf("Error generating Google token: %v", err) |
| } |
| |
| // This line runs all of our individual tests |
| os.Exit(m.Run()) |
| } |
| |
| // keyFile is a struct to extract the relevant json fields for our ServiceAccount KeyFile |
| type keyFile struct { |
| ClientEmail string `json:"client_email"` |
| ClientID string `json:"client_id"` |
| } |
| |
| func getClientID(keyFileName string) (string, error) { |
| kf, err := os.Open(keyFileName) |
| if err != nil { |
| return "", err |
| } |
| defer kf.Close() |
| |
| decoder := json.NewDecoder(kf) |
| var keyFileSettings keyFile |
| if err = decoder.Decode(&keyFileSettings); err != nil { |
| return "", err |
| } |
| |
| return fmt.Sprintf("projects/-/serviceAccounts/%s", keyFileSettings.ClientEmail), nil |
| } |
| |
| func generateGoogleToken(keyFileName string) (string, error) { |
| ts, err := idtoken.NewTokenSource(context.Background(), oidcAudience, option.WithAuthCredentialsFile(option.ServiceAccount, keyFileName)) |
| if err != nil { |
| return "", nil |
| } |
| |
| token, err := ts.Token() |
| if err != nil { |
| return "", nil |
| } |
| |
| return token.AccessToken, nil |
| } |
| |
| // writeConfig writes a temporary config file to memory, and cleans it up after |
| // testing code is run. |
| func writeConfig(t *testing.T, c config, f func(name string)) { |
| t.Helper() |
| |
| // Set up config file. |
| configFile, err := os.CreateTemp("", "config.json") |
| if err != nil { |
| t.Fatalf("Error creating config file: %v", err) |
| } |
| defer os.Remove(configFile.Name()) |
| |
| err = json.NewEncoder(configFile).Encode(c) |
| if err != nil { |
| t.Errorf("Error writing to config file: %v", err) |
| } |
| configFile.Close() |
| |
| f(configFile.Name()) |
| } |
| |
| // testBYOID makes sure that the default credentials works for |
| // whatever preconditions have been set beforehand |
| // by using those credentials to run our client libraries. |
| // |
| // In each test we will set up whatever preconditions we need, |
| // and then use this function. |
| func testBYOID(t *testing.T, c config) { |
| t.Helper() |
| |
| writeConfig(t, c, func(name string) { |
| // Once the default credentials are obtained, |
| // we should be able to access Google Cloud resources. |
| dnsService, err := dns.NewService(context.Background(), option.WithAuthCredentialsFile(option.ExternalAccount, name)) |
| if err != nil { |
| t.Fatalf("Could not establish DNS Service: %v", err) |
| } |
| |
| _, err = dnsService.Projects.Get(projectID).Do() |
| if err != nil { |
| t.Fatalf("DNS Service failed: %v", err) |
| } |
| }) |
| } |
| |
| // These structs makes writing our config as json to a file much easier. |
| type config struct { |
| Type string `json:"type"` |
| Audience string `json:"audience"` |
| SubjectTokenType string `json:"subject_token_type"` |
| TokenURL string `json:"token_url"` |
| ServiceAccountImpersonationURL string `json:"service_account_impersonation_url"` |
| ServiceAccountImpersonation serviceAccountImpersonationInfo `json:"service_account_impersonation,omitempty"` |
| CredentialSource credentialSource `json:"credential_source"` |
| } |
| |
| type serviceAccountImpersonationInfo struct { |
| TokenLifetimeSeconds int `json:"token_lifetime_seconds,omitempty"` |
| } |
| |
| type credentialSource struct { |
| File string `json:"file,omitempty"` |
| URL string `json:"url,omitempty"` |
| Executable executableConfig `json:"executable,omitempty"` |
| EnvironmentID string `json:"environment_id,omitempty"` |
| RegionURL string `json:"region_url,omitempty"` |
| RegionalCredVerificationURL string `json:"regional_cred_verification_url,omitempty"` |
| } |
| |
| type executableConfig struct { |
| Command string `json:"command,omitempty"` |
| TimeoutMillis int `json:"timeout_millis,omitempty"` |
| OutputFile string `json:"output_file,omitempty"` |
| } |
| |
| // Tests to make sure File based external credentials continues to work. |
| func TestFileBasedCredentials(t *testing.T) { |
| if testing.Short() { |
| t.Skip("skipping integration test") |
| } |
| // Set up Token as a file |
| tokenFile, err := os.CreateTemp("", "token.txt") |
| if err != nil { |
| t.Fatalf("Error creating token file:") |
| } |
| defer os.Remove(tokenFile.Name()) |
| |
| tokenFile.WriteString(oidcToken) |
| tokenFile.Close() |
| |
| // Run our test! |
| testBYOID(t, config{ |
| Type: "external_account", |
| Audience: oidcAudience, |
| SubjectTokenType: "urn:ietf:params:oauth:token-type:jwt", |
| TokenURL: "https://sts.googleapis.com/v1beta/token", |
| ServiceAccountImpersonationURL: fmt.Sprintf("https://iamcredentials.googleapis.com/v1/%s:generateAccessToken", clientID), |
| CredentialSource: credentialSource{ |
| File: tokenFile.Name(), |
| }, |
| }) |
| } |
| |
| // Tests to make sure URL based external credentials work properly. |
| func TestURLBasedCredentials(t *testing.T) { |
| if testing.Short() { |
| t.Skip("skipping integration test") |
| } |
| //Set up a server to return a token |
| ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| if r.Method != "GET" { |
| t.Errorf("Unexpected request method, %v is found", r.Method) |
| } |
| w.Write([]byte(oidcToken)) |
| })) |
| |
| testBYOID(t, config{ |
| Type: "external_account", |
| Audience: oidcAudience, |
| SubjectTokenType: "urn:ietf:params:oauth:token-type:jwt", |
| TokenURL: "https://sts.googleapis.com/v1/token", |
| ServiceAccountImpersonationURL: fmt.Sprintf("https://iamcredentials.googleapis.com/v1/%s:generateAccessToken", clientID), |
| CredentialSource: credentialSource{ |
| URL: ts.URL, |
| }, |
| }) |
| } |
| |
| // Tests to make sure AWS based external credentials work properly. |
| func TestAWSBasedCredentials(t *testing.T) { |
| if testing.Short() { |
| t.Skip("skipping integration test") |
| } |
| data := url.Values{} |
| data.Set("audience", clientID) |
| data.Set("includeEmail", "true") |
| |
| client, err := google.DefaultClient(context.Background(), "https://www.googleapis.com/auth/cloud-platform") |
| if err != nil { |
| t.Fatalf("Failed to create default client: %v", err) |
| } |
| resp, err := client.PostForm(fmt.Sprintf("https://iamcredentials.googleapis.com/v1/%s:generateIdToken", clientID), data) |
| if err != nil { |
| t.Fatalf("Failed to generate an ID token: %v", err) |
| } |
| if resp.StatusCode != 200 { |
| t.Fatalf("Failed to get Google ID token for AWS test: %v", err) |
| } |
| |
| var res map[string]interface{} |
| |
| if err = json.NewDecoder(resp.Body).Decode(&res); err != nil { |
| t.Fatalf("Could not successfully parse response from generateIDToken: %v", err) |
| } |
| token, ok := res["token"] |
| if !ok { |
| t.Fatalf("Didn't receieve an ID token back from generateIDToken") |
| } |
| |
| data = url.Values{} |
| data.Set("Action", "AssumeRoleWithWebIdentity") |
| data.Set("Version", "2011-06-15") |
| data.Set("DurationSeconds", "3600") |
| data.Set("RoleSessionName", os.Getenv("GCLOUD_TESTS_GOLANG_AWS_ROLE_NAME")) |
| data.Set("RoleArn", os.Getenv("GCLOUD_TESTS_GOLANG_AWS_ROLE_ID")) |
| data.Set("WebIdentityToken", token.(string)) |
| |
| resp, err = http.PostForm("https://sts.amazonaws.com/", data) |
| if err != nil { |
| t.Fatalf("Failed to post data to AWS: %v", err) |
| } |
| bodyBytes, err := io.ReadAll(resp.Body) |
| if err != nil { |
| t.Fatalf("Failed to parse response body from AWS: %v", err) |
| } |
| |
| var respVars struct { |
| SessionToken string `xml:"AssumeRoleWithWebIdentityResult>Credentials>SessionToken"` |
| SecretAccessKey string `xml:"AssumeRoleWithWebIdentityResult>Credentials>SecretAccessKey"` |
| AccessKeyID string `xml:"AssumeRoleWithWebIdentityResult>Credentials>AccessKeyId"` |
| } |
| |
| if err = xml.Unmarshal(bodyBytes, &respVars); err != nil { |
| t.Fatalf("Failed to unmarshal XML response from AWS.") |
| } |
| |
| if respVars.SessionToken == "" || respVars.SecretAccessKey == "" || respVars.AccessKeyID == "" { |
| t.Fatalf("Couldn't find the required variables in the response from the AWS server.") |
| } |
| |
| currSessTokEnv := os.Getenv("AWS_SESSION_TOKEN") |
| defer os.Setenv("AWS_SESSION_TOKEN", currSessTokEnv) |
| os.Setenv("AWS_SESSION_TOKEN", respVars.SessionToken) |
| |
| currSecAccKey := os.Getenv("AWS_SECRET_ACCESS_KEY") |
| defer os.Setenv("AWS_SECRET_ACCESS_KEY", currSecAccKey) |
| os.Setenv("AWS_SECRET_ACCESS_KEY", respVars.SecretAccessKey) |
| |
| currAccKeyID := os.Getenv("AWS_ACCESS_KEY_ID") |
| defer os.Setenv("AWS_ACCESS_KEY_ID", currAccKeyID) |
| os.Setenv("AWS_ACCESS_KEY_ID", respVars.AccessKeyID) |
| |
| currRegion := os.Getenv("AWS_REGION") |
| defer os.Setenv("AWS_REGION", currRegion) |
| os.Setenv("AWS_REGION", "us-east-1") |
| |
| testBYOID(t, config{ |
| Type: "external_account", |
| Audience: awsAudience, |
| SubjectTokenType: "urn:ietf:params:aws:token-type:aws4_request", |
| TokenURL: "https://sts.googleapis.com/v1/token", |
| ServiceAccountImpersonationURL: fmt.Sprintf("https://iamcredentials.googleapis.com/v1/%s:generateAccessToken", clientID), |
| CredentialSource: credentialSource{ |
| EnvironmentID: "aws1", |
| RegionalCredVerificationURL: "https://sts.us-east-1.amazonaws.com?Action=GetCallerIdentity&Version=2011-06-15", |
| }, |
| }) |
| } |
| |
| // Tests to make sure executable based external credentials continues to work. |
| // We're using the same setup as file based external account credentials, and using `cat` as the command |
| func TestExecutableBasedCredentials(t *testing.T) { |
| if testing.Short() { |
| t.Skip("skipping integration test") |
| } |
| |
| // Set up Script as a executable file |
| scriptFile, err := os.CreateTemp("", "script.sh") |
| if err != nil { |
| t.Fatalf("Error creating token file:") |
| } |
| defer os.Remove(scriptFile.Name()) |
| |
| fmt.Fprintf(scriptFile, `#!/bin/bash |
| echo "{\"success\":true,\"version\":1,\"expiration_time\":%v,\"token_type\":\"urn:ietf:params:oauth:token-type:jwt\",\"id_token\":\"%v\"}"`, |
| time.Now().Add(time.Hour).Unix(), oidcToken) |
| scriptFile.Close() |
| os.Chmod(scriptFile.Name(), 0700) |
| |
| // Run our test! |
| testBYOID(t, config{ |
| Type: "external_account", |
| Audience: oidcAudience, |
| SubjectTokenType: "urn:ietf:params:oauth:token-type:jwt", |
| TokenURL: "https://sts.googleapis.com/v1/token", |
| ServiceAccountImpersonationURL: fmt.Sprintf("https://iamcredentials.googleapis.com/v1/%s:generateAccessToken", clientID), |
| CredentialSource: credentialSource{ |
| Executable: executableConfig{ |
| Command: scriptFile.Name(), |
| }, |
| }, |
| }) |
| } |
| |
| func TestConfigurableTokenLifetime(t *testing.T) { |
| if testing.Short() { |
| t.Skip("skipping integration test") |
| } |
| |
| // Set up Token as a file |
| tokenFile, err := os.CreateTemp("", "token.txt") |
| if err != nil { |
| t.Fatalf("Error creating token file:") |
| } |
| defer os.Remove(tokenFile.Name()) |
| |
| tokenFile.WriteString(oidcToken) |
| tokenFile.Close() |
| |
| const tokenLifetimeSeconds = 2800 |
| const safetyBuffer = 5 |
| |
| writeConfig(t, config{ |
| Type: "external_account", |
| Audience: oidcAudience, |
| SubjectTokenType: "urn:ietf:params:oauth:token-type:jwt", |
| TokenURL: "https://sts.googleapis.com/v1/token", |
| ServiceAccountImpersonationURL: fmt.Sprintf("https://iamcredentials.googleapis.com/v1/%s:generateAccessToken", clientID), |
| ServiceAccountImpersonation: serviceAccountImpersonationInfo{ |
| TokenLifetimeSeconds: tokenLifetimeSeconds, |
| }, |
| CredentialSource: credentialSource{ |
| File: tokenFile.Name(), |
| }, |
| }, func(filename string) { |
| b, err := os.ReadFile(filename) |
| if err != nil { |
| t.Fatalf("Coudn't read temp config file") |
| } |
| |
| creds, err := google.CredentialsFromJSON(context.Background(), b, "https://www.googleapis.com/auth/cloud-platform") |
| if err != nil { |
| t.Fatalf("Error retrieving credentials") |
| } |
| |
| token, err := creds.TokenSource.Token() |
| if err != nil { |
| t.Fatalf("Error getting token") |
| } |
| |
| now := time.Now() |
| expiryMax := now.Add((safetyBuffer + tokenLifetimeSeconds) * time.Second) |
| expiryMin := now.Add((tokenLifetimeSeconds - safetyBuffer) * time.Second) |
| if token.Expiry.Before(expiryMin) || token.Expiry.After(expiryMax) { |
| t.Fatalf("Expiry time not set correctly. Got %v, want %v", token.Expiry, expiryMax) |
| } |
| }) |
| } |