From 95bec9538152e03de0cfbaf64cd3af163b8cef30 Mon Sep 17 00:00:00 2001 From: aeitzman Date: Mon, 26 Feb 2024 18:02:12 +0000 Subject: [PATCH] google/externalaccount: moves externalaccount package out of internal and exports it go/programmable-auth-design for context. Adds support for user defined supplier methods to return subject tokens and AWS security credentials. Change-Id: I7bc41f8c5202ae933fce516632f5049bbeb3d378 GitHub-Last-Rev: ac519b242f8315df572f1b205b0670f139bfc6c3 GitHub-Pull-Request: golang/oauth2#690 Reviewed-on: https://go-review.googlesource.com/c/oauth2/+/550835 LUCI-TryBot-Result: Go LUCI Reviewed-by: Leo Siracusa Reviewed-by: Chris Smith TryBot-Result: Gopher Robot Reviewed-by: Cody Oss --- google/doc.go | 86 +--- google/{internal => }/externalaccount/aws.go | 112 +++-- .../externalaccount/aws_test.go | 269 +++++++++- google/externalaccount/basecredentials.go | 463 ++++++++++++++++++ .../externalaccount/basecredentials_test.go | 219 ++++++++- .../externalaccount/executablecredsource.go | 26 +- .../executablecredsource_test.go | 22 +- .../externalaccount/filecredsource.go | 14 +- .../externalaccount/filecredsource_test.go | 6 +- .../{internal => }/externalaccount/header.go | 0 .../externalaccount/header_test.go | 0 .../programmaticrefreshcredsource.go | 21 + .../programmaticrefreshcredsource_test.go | 122 +++++ .../externalaccount/testdata/3pi_cred.json | 0 .../externalaccount/testdata/3pi_cred.txt | 0 .../externalaccount/urlcredsource.go | 18 +- .../externalaccount/urlcredsource_test.go | 14 +- google/google.go | 9 +- .../externalaccount/basecredentials.go | 254 ---------- google/internal/externalaccount/err.go | 18 - google/internal/externalaccount/err_test.go | 19 - .../externalaccount/impersonate_test.go | 144 ------ .../impersonate.go | 2 +- 23 files changed, 1191 insertions(+), 647 deletions(-) rename google/{internal => }/externalaccount/aws.go (77%) rename google/{internal => }/externalaccount/aws_test.go (84%) create mode 100644 google/externalaccount/basecredentials.go rename google/{internal => }/externalaccount/basecredentials_test.go (54%) rename google/{internal => }/externalaccount/executablecredsource.go (85%) rename google/{internal => }/externalaccount/executablecredsource_test.go (98%) rename google/{internal => }/externalaccount/filecredsource.go (62%) rename google/{internal => }/externalaccount/filecredsource_test.go (93%) rename google/{internal => }/externalaccount/header.go (100%) rename google/{internal => }/externalaccount/header_test.go (100%) create mode 100644 google/externalaccount/programmaticrefreshcredsource.go create mode 100644 google/externalaccount/programmaticrefreshcredsource_test.go rename google/{internal => }/externalaccount/testdata/3pi_cred.json (100%) rename google/{internal => }/externalaccount/testdata/3pi_cred.txt (100%) rename google/{internal => }/externalaccount/urlcredsource.go (61%) rename google/{internal => }/externalaccount/urlcredsource_test.go (92%) delete mode 100644 google/internal/externalaccount/basecredentials.go delete mode 100644 google/internal/externalaccount/err.go delete mode 100644 google/internal/externalaccount/err_test.go delete mode 100644 google/internal/externalaccount/impersonate_test.go rename google/internal/{externalaccount => impersonate}/impersonate.go (99%) diff --git a/google/doc.go b/google/doc.go index 03c42c6f8..830d268c1 100644 --- a/google/doc.go +++ b/google/doc.go @@ -22,91 +22,9 @@ // the other by JWTConfigFromJSON. The returned Config can be used to obtain a TokenSource or // create an http.Client. // -// # Workload Identity Federation +// # Workload and Workforce Identity Federation // -// Using workload identity federation, your application can access Google Cloud -// resources from Amazon Web Services (AWS), Microsoft Azure or any identity -// provider that supports OpenID Connect (OIDC) or SAML 2.0. -// Traditionally, applications running outside Google Cloud have used service -// account keys to access Google Cloud resources. Using identity federation, -// you can allow your workload to impersonate a service account. -// This lets you access Google Cloud resources directly, eliminating the -// maintenance and security burden associated with service account keys. -// -// Follow the detailed instructions on how to configure Workload Identity Federation -// in various platforms: -// -// Amazon Web Services (AWS): https://cloud.google.com/iam/docs/workload-identity-federation-with-other-clouds#aws -// Microsoft Azure: https://cloud.google.com/iam/docs/workload-identity-federation-with-other-clouds#azure -// OIDC identity provider: https://cloud.google.com/iam/docs/workload-identity-federation-with-other-providers#oidc -// SAML 2.0 identity provider: https://cloud.google.com/iam/docs/workload-identity-federation-with-other-providers#saml -// -// For OIDC and SAML providers, the library can retrieve tokens in three ways: -// from a local file location (file-sourced credentials), from a server -// (URL-sourced credentials), or from a local executable (executable-sourced -// credentials). -// For file-sourced credentials, a background process needs to be continuously -// refreshing the file location with a new OIDC/SAML token prior to expiration. -// For tokens with one hour lifetimes, the token needs to be updated in the file -// every hour. The token can be stored directly as plain text or in JSON format. -// For URL-sourced credentials, a local server needs to host a GET endpoint to -// return the OIDC/SAML token. The response can be in plain text or JSON. -// Additional required request headers can also be specified. -// For executable-sourced credentials, an application needs to be available to -// output the OIDC/SAML token and other information in a JSON format. -// For more information on how these work (and how to implement -// executable-sourced credentials), please check out: -// https://cloud.google.com/iam/docs/workload-identity-federation-with-other-providers#create_a_credential_configuration -// -// Note that this library does not perform any validation on the token_url, token_info_url, -// or service_account_impersonation_url fields of the credential configuration. -// It is not recommended to use a credential configuration that you did not generate with -// the gcloud CLI unless you verify that the URL fields point to a googleapis.com domain. -// -// # Workforce Identity Federation -// -// Workforce identity federation lets you use an external identity provider (IdP) to -// authenticate and authorize a workforce—a group of users, such as employees, partners, -// and contractors—using IAM, so that the users can access Google Cloud services. -// Workforce identity federation extends Google Cloud's identity capabilities to support -// syncless, attribute-based single sign on. -// -// With workforce identity federation, your workforce can access Google Cloud resources -// using an external identity provider (IdP) that supports OpenID Connect (OIDC) or -// SAML 2.0 such as Azure Active Directory (Azure AD), Active Directory Federation -// Services (AD FS), Okta, and others. -// -// Follow the detailed instructions on how to configure Workload Identity Federation -// in various platforms: -// -// Azure AD: https://cloud.google.com/iam/docs/workforce-sign-in-azure-ad -// Okta: https://cloud.google.com/iam/docs/workforce-sign-in-okta -// OIDC identity provider: https://cloud.google.com/iam/docs/configuring-workforce-identity-federation#oidc -// SAML 2.0 identity provider: https://cloud.google.com/iam/docs/configuring-workforce-identity-federation#saml -// -// For workforce identity federation, the library can retrieve tokens in three ways: -// from a local file location (file-sourced credentials), from a server -// (URL-sourced credentials), or from a local executable (executable-sourced -// credentials). -// For file-sourced credentials, a background process needs to be continuously -// refreshing the file location with a new OIDC/SAML token prior to expiration. -// For tokens with one hour lifetimes, the token needs to be updated in the file -// every hour. The token can be stored directly as plain text or in JSON format. -// For URL-sourced credentials, a local server needs to host a GET endpoint to -// return the OIDC/SAML token. The response can be in plain text or JSON. -// Additional required request headers can also be specified. -// For executable-sourced credentials, an application needs to be available to -// output the OIDC/SAML token and other information in a JSON format. -// For more information on how these work (and how to implement -// executable-sourced credentials), please check out: -// https://cloud.google.com/iam/docs/workforce-obtaining-short-lived-credentials#generate_a_configuration_file_for_non-interactive_sign-in -// -// # Security considerations -// -// Note that this library does not perform any validation on the token_url, token_info_url, -// or service_account_impersonation_url fields of the credential configuration. -// It is not recommended to use a credential configuration that you did not generate with -// the gcloud CLI unless you verify that the URL fields point to a googleapis.com domain. +// For information on how to use Workload and Workforce Identity Federation, see [golang.org/x/oauth2/google/externalaccount]. // // # Credentials // diff --git a/google/internal/externalaccount/aws.go b/google/externalaccount/aws.go similarity index 77% rename from google/internal/externalaccount/aws.go rename to google/externalaccount/aws.go index bd4efd19b..da61d0c0e 100644 --- a/google/internal/externalaccount/aws.go +++ b/google/externalaccount/aws.go @@ -26,22 +26,28 @@ import ( "golang.org/x/oauth2" ) -type awsSecurityCredentials struct { - AccessKeyID string `json:"AccessKeyID"` +// AwsSecurityCredentials models AWS security credentials. +type AwsSecurityCredentials struct { + // AccessKeyId is the AWS Access Key ID - Required. + AccessKeyID string `json:"AccessKeyID"` + // SecretAccessKey is the AWS Secret Access Key - Required. SecretAccessKey string `json:"SecretAccessKey"` - SecurityToken string `json:"Token"` + // SessionToken is the AWS Session token. This should be provided for temporary AWS security credentials - Optional. + SessionToken string `json:"Token"` } // awsRequestSigner is a utility class to sign http requests using a AWS V4 signature. type awsRequestSigner struct { RegionName string - AwsSecurityCredentials awsSecurityCredentials + AwsSecurityCredentials *AwsSecurityCredentials } // getenv aliases os.Getenv for testing var getenv = os.Getenv const ( + defaultRegionalCredentialVerificationUrl = "https://sts.{region}.amazonaws.com?Action=GetCallerIdentity&Version=2011-06-15" + // AWS Signature Version 4 signing algorithm identifier. awsAlgorithm = "AWS4-HMAC-SHA256" @@ -197,8 +203,8 @@ func (rs *awsRequestSigner) SignRequest(req *http.Request) error { signedRequest.Header.Add("host", requestHost(req)) - if rs.AwsSecurityCredentials.SecurityToken != "" { - signedRequest.Header.Add(awsSecurityTokenHeader, rs.AwsSecurityCredentials.SecurityToken) + if rs.AwsSecurityCredentials.SessionToken != "" { + signedRequest.Header.Add(awsSecurityTokenHeader, rs.AwsSecurityCredentials.SessionToken) } if signedRequest.Header.Get("date") == "" { @@ -251,16 +257,18 @@ func (rs *awsRequestSigner) generateAuthentication(req *http.Request, timestamp } type awsCredentialSource struct { - EnvironmentID string - RegionURL string - RegionalCredVerificationURL string - CredVerificationURL string - IMDSv2SessionTokenURL string - TargetResource string - requestSigner *awsRequestSigner - region string - ctx context.Context - client *http.Client + environmentID string + regionURL string + regionalCredVerificationURL string + credVerificationURL string + imdsv2SessionTokenURL string + targetResource string + requestSigner *awsRequestSigner + region string + ctx context.Context + client *http.Client + awsSecurityCredentialsSupplier AwsSecurityCredentialsSupplier + supplierOptions SupplierOptions } type awsRequestHeader struct { @@ -292,18 +300,25 @@ func canRetrieveSecurityCredentialFromEnvironment() bool { return getenv(awsAccessKeyId) != "" && getenv(awsSecretAccessKey) != "" } -func shouldUseMetadataServer() bool { - return !canRetrieveRegionFromEnvironment() || !canRetrieveSecurityCredentialFromEnvironment() +func (cs awsCredentialSource) shouldUseMetadataServer() bool { + return cs.awsSecurityCredentialsSupplier == nil && (!canRetrieveRegionFromEnvironment() || !canRetrieveSecurityCredentialFromEnvironment()) } func (cs awsCredentialSource) credentialSourceType() string { + if cs.awsSecurityCredentialsSupplier != nil { + return "programmatic" + } return "aws" } func (cs awsCredentialSource) subjectToken() (string, error) { + // Set Defaults + if cs.regionalCredVerificationURL == "" { + cs.regionalCredVerificationURL = defaultRegionalCredentialVerificationUrl + } if cs.requestSigner == nil { headers := make(map[string]string) - if shouldUseMetadataServer() { + if cs.shouldUseMetadataServer() { awsSessionToken, err := cs.getAWSSessionToken() if err != nil { return "", err @@ -318,8 +333,8 @@ func (cs awsCredentialSource) subjectToken() (string, error) { if err != nil { return "", err } - - if cs.region, err = cs.getRegion(headers); err != nil { + cs.region, err = cs.getRegion(headers) + if err != nil { return "", err } @@ -331,7 +346,7 @@ func (cs awsCredentialSource) subjectToken() (string, error) { // Generate the signed request to AWS STS GetCallerIdentity API. // Use the required regional endpoint. Otherwise, the request will fail. - req, err := http.NewRequest("POST", strings.Replace(cs.RegionalCredVerificationURL, "{region}", cs.region, 1), nil) + req, err := http.NewRequest("POST", strings.Replace(cs.regionalCredVerificationURL, "{region}", cs.region, 1), nil) if err != nil { return "", err } @@ -339,8 +354,8 @@ func (cs awsCredentialSource) subjectToken() (string, error) { // provider, with or without the HTTPS prefix. // Including this header as part of the signature is recommended to // ensure data integrity. - if cs.TargetResource != "" { - req.Header.Add("x-goog-cloud-target-resource", cs.TargetResource) + if cs.targetResource != "" { + req.Header.Add("x-goog-cloud-target-resource", cs.targetResource) } cs.requestSigner.SignRequest(req) @@ -387,11 +402,11 @@ func (cs awsCredentialSource) subjectToken() (string, error) { } func (cs *awsCredentialSource) getAWSSessionToken() (string, error) { - if cs.IMDSv2SessionTokenURL == "" { + if cs.imdsv2SessionTokenURL == "" { return "", nil } - req, err := http.NewRequest("PUT", cs.IMDSv2SessionTokenURL, nil) + req, err := http.NewRequest("PUT", cs.imdsv2SessionTokenURL, nil) if err != nil { return "", err } @@ -410,25 +425,29 @@ func (cs *awsCredentialSource) getAWSSessionToken() (string, error) { } if resp.StatusCode != 200 { - return "", fmt.Errorf("oauth2/google: unable to retrieve AWS session token - %s", string(respBody)) + return "", fmt.Errorf("oauth2/google/externalaccount: unable to retrieve AWS session token - %s", string(respBody)) } return string(respBody), nil } func (cs *awsCredentialSource) getRegion(headers map[string]string) (string, error) { + if cs.awsSecurityCredentialsSupplier != nil { + return cs.awsSecurityCredentialsSupplier.AwsRegion(cs.ctx, cs.supplierOptions) + } if canRetrieveRegionFromEnvironment() { if envAwsRegion := getenv(awsRegion); envAwsRegion != "" { + cs.region = envAwsRegion return envAwsRegion, nil } return getenv("AWS_DEFAULT_REGION"), nil } - if cs.RegionURL == "" { - return "", errors.New("oauth2/google: unable to determine AWS region") + if cs.regionURL == "" { + return "", errors.New("oauth2/google/externalaccount: unable to determine AWS region") } - req, err := http.NewRequest("GET", cs.RegionURL, nil) + req, err := http.NewRequest("GET", cs.regionURL, nil) if err != nil { return "", err } @@ -449,7 +468,7 @@ func (cs *awsCredentialSource) getRegion(headers map[string]string) (string, err } if resp.StatusCode != 200 { - return "", fmt.Errorf("oauth2/google: unable to retrieve AWS region - %s", string(respBody)) + return "", fmt.Errorf("oauth2/google/externalaccount: unable to retrieve AWS region - %s", string(respBody)) } // This endpoint will return the region in format: us-east-2b. @@ -461,12 +480,15 @@ func (cs *awsCredentialSource) getRegion(headers map[string]string) (string, err return string(respBody[:respBodyEnd]), nil } -func (cs *awsCredentialSource) getSecurityCredentials(headers map[string]string) (result awsSecurityCredentials, err error) { +func (cs *awsCredentialSource) getSecurityCredentials(headers map[string]string) (result *AwsSecurityCredentials, err error) { + if cs.awsSecurityCredentialsSupplier != nil { + return cs.awsSecurityCredentialsSupplier.AwsSecurityCredentials(cs.ctx, cs.supplierOptions) + } if canRetrieveSecurityCredentialFromEnvironment() { - return awsSecurityCredentials{ + return &AwsSecurityCredentials{ AccessKeyID: getenv(awsAccessKeyId), SecretAccessKey: getenv(awsSecretAccessKey), - SecurityToken: getenv(awsSessionToken), + SessionToken: getenv(awsSessionToken), }, nil } @@ -481,20 +503,20 @@ func (cs *awsCredentialSource) getSecurityCredentials(headers map[string]string) } if credentials.AccessKeyID == "" { - return result, errors.New("oauth2/google: missing AccessKeyId credential") + return result, errors.New("oauth2/google/externalaccount: missing AccessKeyId credential") } if credentials.SecretAccessKey == "" { - return result, errors.New("oauth2/google: missing SecretAccessKey credential") + return result, errors.New("oauth2/google/externalaccount: missing SecretAccessKey credential") } - return credentials, nil + return &credentials, nil } -func (cs *awsCredentialSource) getMetadataSecurityCredentials(roleName string, headers map[string]string) (awsSecurityCredentials, error) { - var result awsSecurityCredentials +func (cs *awsCredentialSource) getMetadataSecurityCredentials(roleName string, headers map[string]string) (AwsSecurityCredentials, error) { + var result AwsSecurityCredentials - req, err := http.NewRequest("GET", fmt.Sprintf("%s/%s", cs.CredVerificationURL, roleName), nil) + req, err := http.NewRequest("GET", fmt.Sprintf("%s/%s", cs.credVerificationURL, roleName), nil) if err != nil { return result, err } @@ -516,7 +538,7 @@ func (cs *awsCredentialSource) getMetadataSecurityCredentials(roleName string, h } if resp.StatusCode != 200 { - return result, fmt.Errorf("oauth2/google: unable to retrieve AWS security credentials - %s", string(respBody)) + return result, fmt.Errorf("oauth2/google/externalaccount: unable to retrieve AWS security credentials - %s", string(respBody)) } err = json.Unmarshal(respBody, &result) @@ -524,11 +546,11 @@ func (cs *awsCredentialSource) getMetadataSecurityCredentials(roleName string, h } func (cs *awsCredentialSource) getMetadataRoleName(headers map[string]string) (string, error) { - if cs.CredVerificationURL == "" { - return "", errors.New("oauth2/google: unable to determine the AWS metadata server security credentials endpoint") + if cs.credVerificationURL == "" { + return "", errors.New("oauth2/google/externalaccount: unable to determine the AWS metadata server security credentials endpoint") } - req, err := http.NewRequest("GET", cs.CredVerificationURL, nil) + req, err := http.NewRequest("GET", cs.credVerificationURL, nil) if err != nil { return "", err } @@ -549,7 +571,7 @@ func (cs *awsCredentialSource) getMetadataRoleName(headers map[string]string) (s } if resp.StatusCode != 200 { - return "", fmt.Errorf("oauth2/google: unable to retrieve AWS role name - %s", string(respBody)) + return "", fmt.Errorf("oauth2/google/externalaccount: unable to retrieve AWS role name - %s", string(respBody)) } return string(respBody), nil diff --git a/google/internal/externalaccount/aws_test.go b/google/externalaccount/aws_test.go similarity index 84% rename from google/internal/externalaccount/aws_test.go rename to google/externalaccount/aws_test.go index 28dc5284b..4a2261bd8 100644 --- a/google/internal/externalaccount/aws_test.go +++ b/google/externalaccount/aws_test.go @@ -7,6 +7,7 @@ package externalaccount import ( "context" "encoding/json" + "errors" "fmt" "net/http" "net/http/httptest" @@ -36,7 +37,7 @@ func setEnvironment(env map[string]string) func(string) string { var defaultRequestSigner = &awsRequestSigner{ RegionName: "us-east-1", - AwsSecurityCredentials: awsSecurityCredentials{ + AwsSecurityCredentials: &AwsSecurityCredentials{ AccessKeyID: "AKIDEXAMPLE", SecretAccessKey: "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY", }, @@ -50,10 +51,10 @@ const ( var requestSignerWithToken = &awsRequestSigner{ RegionName: "us-east-2", - AwsSecurityCredentials: awsSecurityCredentials{ + AwsSecurityCredentials: &AwsSecurityCredentials{ AccessKeyID: accessKeyID, SecretAccessKey: secretAccessKey, - SecurityToken: securityToken, + SessionToken: securityToken, }, } @@ -388,7 +389,7 @@ func TestAWSv4Signature_PostRequestWithSecurityTokenAndAdditionalHeaders(t *test func TestAWSv4Signature_PostRequestWithAmzDateButNoSecurityToken(t *testing.T) { var requestSigner = &awsRequestSigner{ RegionName: "us-east-2", - AwsSecurityCredentials: awsSecurityCredentials{ + AwsSecurityCredentials: &AwsSecurityCredentials{ AccessKeyID: accessKeyID, SecretAccessKey: secretAccessKey, }, @@ -526,8 +527,8 @@ func notFound(w http.ResponseWriter, r *http.Request) { func noHeaderValidation(r *http.Request) {} -func (server *testAwsServer) getCredentialSource(url string) CredentialSource { - return CredentialSource{ +func (server *testAwsServer) getCredentialSource(url string) *CredentialSource { + return &CredentialSource{ EnvironmentID: "aws1", URL: url + server.url, RegionURL: url + server.regionURL, @@ -541,10 +542,10 @@ func getExpectedSubjectToken(url, region, accessKeyID, secretAccessKey, security req.Header.Add("x-goog-cloud-target-resource", testFileConfig.Audience) signer := &awsRequestSigner{ RegionName: region, - AwsSecurityCredentials: awsSecurityCredentials{ + AwsSecurityCredentials: &AwsSecurityCredentials{ AccessKeyID: accessKeyID, SecretAccessKey: secretAccessKey, - SecurityToken: securityToken, + SessionToken: securityToken, }, } signer.SignRequest(req) @@ -588,7 +589,6 @@ func TestAWSCredential_BasicRequest(t *testing.T) { tfc := testFileConfig tfc.CredentialSource = server.getCredentialSource(ts.URL) - oldGetenv := getenv oldNow := now defer func() { @@ -846,7 +846,7 @@ func TestAWSCredential_RequestWithBadVersion(t *testing.T) { if err == nil { t.Fatalf("parse() should have failed") } - if got, want := err.Error(), "oauth2/google: aws version '3' is not supported in the current build"; !reflect.DeepEqual(got, want) { + if got, want := err.Error(), "oauth2/google/externalaccount: aws version '3' is not supported in the current build"; !reflect.DeepEqual(got, want) { t.Errorf("subjectToken = %q, want %q", got, want) } } @@ -875,7 +875,7 @@ func TestAWSCredential_RequestWithNoRegionURL(t *testing.T) { t.Fatalf("retrieveSubjectToken() should have failed") } - if got, want := err.Error(), "oauth2/google: unable to determine AWS region"; !reflect.DeepEqual(got, want) { + if got, want := err.Error(), "oauth2/google/externalaccount: unable to determine AWS region"; !reflect.DeepEqual(got, want) { t.Errorf("subjectToken = %q, want %q", got, want) } } @@ -905,7 +905,7 @@ func TestAWSCredential_RequestWithBadRegionURL(t *testing.T) { t.Fatalf("retrieveSubjectToken() should have failed") } - if got, want := err.Error(), "oauth2/google: unable to retrieve AWS region - Not Found"; !reflect.DeepEqual(got, want) { + if got, want := err.Error(), "oauth2/google/externalaccount: unable to retrieve AWS region - Not Found"; !reflect.DeepEqual(got, want) { t.Errorf("subjectToken = %q, want %q", got, want) } } @@ -937,7 +937,7 @@ func TestAWSCredential_RequestWithMissingCredential(t *testing.T) { t.Fatalf("retrieveSubjectToken() should have failed") } - if got, want := err.Error(), "oauth2/google: missing AccessKeyId credential"; !reflect.DeepEqual(got, want) { + if got, want := err.Error(), "oauth2/google/externalaccount: missing AccessKeyId credential"; !reflect.DeepEqual(got, want) { t.Errorf("subjectToken = %q, want %q", got, want) } } @@ -969,7 +969,7 @@ func TestAWSCredential_RequestWithIncompleteCredential(t *testing.T) { t.Fatalf("retrieveSubjectToken() should have failed") } - if got, want := err.Error(), "oauth2/google: missing SecretAccessKey credential"; !reflect.DeepEqual(got, want) { + if got, want := err.Error(), "oauth2/google/externalaccount: missing SecretAccessKey credential"; !reflect.DeepEqual(got, want) { t.Errorf("subjectToken = %q, want %q", got, want) } } @@ -998,7 +998,7 @@ func TestAWSCredential_RequestWithNoCredentialURL(t *testing.T) { t.Fatalf("retrieveSubjectToken() should have failed") } - if got, want := err.Error(), "oauth2/google: unable to determine the AWS metadata server security credentials endpoint"; !reflect.DeepEqual(got, want) { + if got, want := err.Error(), "oauth2/google/externalaccount: unable to determine the AWS metadata server security credentials endpoint"; !reflect.DeepEqual(got, want) { t.Errorf("subjectToken = %q, want %q", got, want) } } @@ -1027,7 +1027,7 @@ func TestAWSCredential_RequestWithBadCredentialURL(t *testing.T) { t.Fatalf("retrieveSubjectToken() should have failed") } - if got, want := err.Error(), "oauth2/google: unable to retrieve AWS role name - Not Found"; !reflect.DeepEqual(got, want) { + if got, want := err.Error(), "oauth2/google/externalaccount: unable to retrieve AWS role name - Not Found"; !reflect.DeepEqual(got, want) { t.Errorf("subjectToken = %q, want %q", got, want) } } @@ -1056,7 +1056,7 @@ func TestAWSCredential_RequestWithBadFinalCredentialURL(t *testing.T) { t.Fatalf("retrieveSubjectToken() should have failed") } - if got, want := err.Error(), "oauth2/google: unable to retrieve AWS security credentials - Not Found"; !reflect.DeepEqual(got, want) { + if got, want := err.Error(), "oauth2/google/externalaccount: unable to retrieve AWS security credentials - Not Found"; !reflect.DeepEqual(got, want) { t.Errorf("subjectToken = %q, want %q", got, want) } } @@ -1235,6 +1235,192 @@ func TestAWSCredential_ShouldCallMetadataEndpointWhenNoSecretAccessKey(t *testin } } +func TestAWSCredential_ProgrammaticAuth(t *testing.T) { + tfc := testFileConfig + securityCredentials := AwsSecurityCredentials{ + AccessKeyID: accessKeyID, + SecretAccessKey: secretAccessKey, + SessionToken: securityToken, + } + + tfc.AwsSecurityCredentialsSupplier = testAwsSupplier{ + awsRegion: "us-east-2", + err: nil, + credentials: &securityCredentials, + } + + oldNow := now + defer func() { + now = oldNow + }() + now = setTime(defaultTime) + + base, err := tfc.parse(context.Background()) + if err != nil { + t.Fatalf("parse() failed %v", err) + } + + out, err := base.subjectToken() + if err != nil { + t.Fatalf("retrieveSubjectToken() failed: %v", err) + } + + expected := getExpectedSubjectToken( + "https://sts.us-east-2.amazonaws.com?Action=GetCallerIdentity&Version=2011-06-15", + "us-east-2", + accessKeyID, + secretAccessKey, + securityToken, + ) + + if got, want := out, expected; !reflect.DeepEqual(got, want) { + t.Errorf("subjectToken = \n%q\n want \n%q", got, want) + } +} + +func TestAWSCredential_ProgrammaticAuthNoSessionToken(t *testing.T) { + tfc := testFileConfig + securityCredentials := AwsSecurityCredentials{ + AccessKeyID: accessKeyID, + SecretAccessKey: secretAccessKey, + } + + tfc.AwsSecurityCredentialsSupplier = testAwsSupplier{ + awsRegion: "us-east-2", + err: nil, + credentials: &securityCredentials, + } + + oldNow := now + defer func() { + now = oldNow + }() + now = setTime(defaultTime) + + base, err := tfc.parse(context.Background()) + if err != nil { + t.Fatalf("parse() failed %v", err) + } + + out, err := base.subjectToken() + if err != nil { + t.Fatalf("retrieveSubjectToken() failed: %v", err) + } + + expected := getExpectedSubjectToken( + "https://sts.us-east-2.amazonaws.com?Action=GetCallerIdentity&Version=2011-06-15", + "us-east-2", + accessKeyID, + secretAccessKey, + "", + ) + + if got, want := out, expected; !reflect.DeepEqual(got, want) { + t.Errorf("subjectToken = \n%q\n want \n%q", got, want) + } +} + +func TestAWSCredential_ProgrammaticAuthError(t *testing.T) { + tfc := testFileConfig + testErr := errors.New("test error") + tfc.AwsSecurityCredentialsSupplier = testAwsSupplier{ + awsRegion: "us-east-2", + err: testErr, + credentials: nil, + } + + base, err := tfc.parse(context.Background()) + if err != nil { + t.Fatalf("parse() failed %v", err) + } + + _, err = base.subjectToken() + if err == nil { + t.Fatalf("subjectToken() should have failed") + } + if err != testErr { + t.Errorf("error = %e, want %e", err, testErr) + } +} + +func TestAWSCredential_ProgrammaticAuthRegionError(t *testing.T) { + tfc := testFileConfig + securityCredentials := AwsSecurityCredentials{ + AccessKeyID: accessKeyID, + SecretAccessKey: secretAccessKey, + } + + testErr := errors.New("test") + tfc.AwsSecurityCredentialsSupplier = testAwsSupplier{ + awsRegion: "", + regionErr: testErr, + credentials: &securityCredentials, + } + + base, err := tfc.parse(context.Background()) + if err != nil { + t.Fatalf("parse() failed %v", err) + } + + _, err = base.subjectToken() + if err == nil { + t.Fatalf("subjectToken() should have failed") + } + if err != testErr { + t.Errorf("error = %e, want %e", err, testErr) + } +} + +func TestAWSCredential_ProgrammaticAuthOptions(t *testing.T) { + tfc := testFileConfig + securityCredentials := AwsSecurityCredentials{ + AccessKeyID: accessKeyID, + SecretAccessKey: secretAccessKey, + } + expectedOptions := SupplierOptions{Audience: tfc.Audience, SubjectTokenType: tfc.SubjectTokenType} + + tfc.AwsSecurityCredentialsSupplier = testAwsSupplier{ + awsRegion: "us-east-2", + credentials: &securityCredentials, + expectedOptions: &expectedOptions, + } + + base, err := tfc.parse(context.Background()) + if err != nil { + t.Fatalf("parse() failed %v", err) + } + + _, err = base.subjectToken() + if err != nil { + t.Fatalf("subjectToken() failed %v", err) + } +} + +func TestAWSCredential_ProgrammaticAuthContext(t *testing.T) { + tfc := testFileConfig + securityCredentials := AwsSecurityCredentials{ + AccessKeyID: accessKeyID, + SecretAccessKey: secretAccessKey, + } + ctx := context.Background() + + tfc.AwsSecurityCredentialsSupplier = testAwsSupplier{ + awsRegion: "us-east-2", + credentials: &securityCredentials, + expectedContext: ctx, + } + + base, err := tfc.parse(ctx) + if err != nil { + t.Fatalf("parse() failed %v", err) + } + + _, err = base.subjectToken() + if err != nil { + t.Fatalf("subjectToken() failed %v", err) + } +} + func TestAwsCredential_CredentialSourceType(t *testing.T) { server := createDefaultAwsTestServer() ts := httptest.NewServer(server) @@ -1251,3 +1437,52 @@ func TestAwsCredential_CredentialSourceType(t *testing.T) { t.Errorf("got %v but want %v", got, want) } } + +type testAwsSupplier struct { + err error + regionErr error + awsRegion string + credentials *AwsSecurityCredentials + expectedOptions *SupplierOptions + expectedContext context.Context +} + +func (supp testAwsSupplier) AwsRegion(ctx context.Context, options SupplierOptions) (string, error) { + if supp.regionErr != nil { + return "", supp.regionErr + } + if supp.expectedOptions != nil { + if supp.expectedOptions.Audience != options.Audience { + return "", errors.New("Audience does not match") + } + if supp.expectedOptions.SubjectTokenType != options.SubjectTokenType { + return "", errors.New("Audience does not match") + } + } + if supp.expectedContext != nil { + if supp.expectedContext != ctx { + return "", errors.New("Context does not match") + } + } + return supp.awsRegion, nil +} + +func (supp testAwsSupplier) AwsSecurityCredentials(ctx context.Context, options SupplierOptions) (*AwsSecurityCredentials, error) { + if supp.err != nil { + return nil, supp.err + } + if supp.expectedOptions != nil { + if supp.expectedOptions.Audience != options.Audience { + return nil, errors.New("Audience does not match") + } + if supp.expectedOptions.SubjectTokenType != options.SubjectTokenType { + return nil, errors.New("Audience does not match") + } + } + if supp.expectedContext != nil { + if supp.expectedContext != ctx { + return nil, errors.New("Context does not match") + } + } + return supp.credentials, nil +} diff --git a/google/externalaccount/basecredentials.go b/google/externalaccount/basecredentials.go new file mode 100644 index 000000000..71342e42b --- /dev/null +++ b/google/externalaccount/basecredentials.go @@ -0,0 +1,463 @@ +// Copyright 2020 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +/* +Package externalaccount provides support for creating workload identity +federation and workforce identity federation token sources that can be +used to access Google Cloud resources from external identity providers. + +# Workload Identity Federation + +Using workload identity federation, your application can access Google Cloud +resources from Amazon Web Services (AWS), Microsoft Azure or any identity +provider that supports OpenID Connect (OIDC) or SAML 2.0. +Traditionally, applications running outside Google Cloud have used service +account keys to access Google Cloud resources. Using identity federation, +you can allow your workload to impersonate a service account. +This lets you access Google Cloud resources directly, eliminating the +maintenance and security burden associated with service account keys. + +Follow the detailed instructions on how to configure Workload Identity Federation +in various platforms: + +Amazon Web Services (AWS): https://cloud.google.com/iam/docs/workload-identity-federation-with-other-clouds#aws +Microsoft Azure: https://cloud.google.com/iam/docs/workload-identity-federation-with-other-clouds#azure +OIDC identity provider: https://cloud.google.com/iam/docs/workload-identity-federation-with-other-providers#oidc +SAML 2.0 identity provider: https://cloud.google.com/iam/docs/workload-identity-federation-with-other-providers#saml + +For OIDC and SAML providers, the library can retrieve tokens in fours ways: +from a local file location (file-sourced credentials), from a server +(URL-sourced credentials), from a local executable (executable-sourced +credentials), or from a user defined function that returns an OIDC or SAML token. +For file-sourced credentials, a background process needs to be continuously +refreshing the file location with a new OIDC/SAML token prior to expiration. +For tokens with one hour lifetimes, the token needs to be updated in the file +every hour. The token can be stored directly as plain text or in JSON format. +For URL-sourced credentials, a local server needs to host a GET endpoint to +return the OIDC/SAML token. The response can be in plain text or JSON. +Additional required request headers can also be specified. +For executable-sourced credentials, an application needs to be available to +output the OIDC/SAML token and other information in a JSON format. +For more information on how these work (and how to implement +executable-sourced credentials), please check out: +https://cloud.google.com/iam/docs/workload-identity-federation-with-other-providers#create_a_credential_configuration + +To use a custom function to supply the token, define a struct that implements the [SubjectTokenSupplier] interface for OIDC/SAML providers, +or one that implements [AwsSecurityCredentialsSupplier] for AWS providers. This can then be used when building a [Config]. +The [golang.org/x/oauth2.TokenSource] created from the config using [NewTokenSource] can then be used to access Google +Cloud resources. For instance, you can create a new client from the +[cloud.google.com/go/storage] package and pass in option.WithTokenSource(yourTokenSource)) + +Note that this library does not perform any validation on the token_url, token_info_url, +or service_account_impersonation_url fields of the credential configuration. +It is not recommended to use a credential configuration that you did not generate with +the gcloud CLI unless you verify that the URL fields point to a googleapis.com domain. + +# Workforce Identity Federation + +Workforce identity federation lets you use an external identity provider (IdP) to +authenticate and authorize a workforce—a group of users, such as employees, partners, +and contractors—using IAM, so that the users can access Google Cloud services. +Workforce identity federation extends Google Cloud's identity capabilities to support +syncless, attribute-based single sign on. + +With workforce identity federation, your workforce can access Google Cloud resources +using an external identity provider (IdP) that supports OpenID Connect (OIDC) or +SAML 2.0 such as Azure Active Directory (Azure AD), Active Directory Federation +Services (AD FS), Okta, and others. + +Follow the detailed instructions on how to configure Workload Identity Federation +in various platforms: + +Azure AD: https://cloud.google.com/iam/docs/workforce-sign-in-azure-ad +Okta: https://cloud.google.com/iam/docs/workforce-sign-in-okta +OIDC identity provider: https://cloud.google.com/iam/docs/configuring-workforce-identity-federation#oidc +SAML 2.0 identity provider: https://cloud.google.com/iam/docs/configuring-workforce-identity-federation#saml + +For workforce identity federation, the library can retrieve tokens in four ways: +from a local file location (file-sourced credentials), from a server +(URL-sourced credentials), from a local executable (executable-sourced +credentials), or from a user supplied function that returns an OIDC or SAML token. +For file-sourced credentials, a background process needs to be continuously +refreshing the file location with a new OIDC/SAML token prior to expiration. +For tokens with one hour lifetimes, the token needs to be updated in the file +every hour. The token can be stored directly as plain text or in JSON format. +For URL-sourced credentials, a local server needs to host a GET endpoint to +return the OIDC/SAML token. The response can be in plain text or JSON. +Additional required request headers can also be specified. +For executable-sourced credentials, an application needs to be available to +output the OIDC/SAML token and other information in a JSON format. +For more information on how these work (and how to implement +executable-sourced credentials), please check out: +https://cloud.google.com/iam/docs/workforce-obtaining-short-lived-credentials#generate_a_configuration_file_for_non-interactive_sign-in + +To use a custom function to supply the token, define a struct that implements the [SubjectTokenSupplier] interface for OIDC/SAML providers. +This can then be used when building a [Config]. +The [golang.org/x/oauth2.TokenSource] created from the config using [NewTokenSource] can then be used access Google +Cloud resources. For instance, you can create a new client from the +[cloud.google.com/go/storage] package and pass in option.WithTokenSource(yourTokenSource)) + +# Security considerations + +Note that this library does not perform any validation on the token_url, token_info_url, +or service_account_impersonation_url fields of the credential configuration. +It is not recommended to use a credential configuration that you did not generate with +the gcloud CLI unless you verify that the URL fields point to a googleapis.com domain. +*/ +package externalaccount + +import ( + "context" + "fmt" + "net/http" + "regexp" + "strconv" + "time" + + "golang.org/x/oauth2" + "golang.org/x/oauth2/google/internal/impersonate" + "golang.org/x/oauth2/google/internal/stsexchange" +) + +// now aliases time.Now for testing +var now = func() time.Time { + return time.Now().UTC() +} + +// Config stores the configuration for fetching tokens with external credentials. +type Config struct { + // Audience is the Secure Token Service (STS) audience which contains the resource name for the workload + // identity pool or the workforce pool and the provider identifier in that pool. Required. + Audience string + // SubjectTokenType is the STS token type based on the Oauth2.0 token exchange spec. + // Expected values include: + // “urn:ietf:params:oauth:token-type:jwt” + // “urn:ietf:params:oauth:token-type:id-token” + // “urn:ietf:params:oauth:token-type:saml2” + // “urn:ietf:params:aws:token-type:aws4_request” + // Required. + SubjectTokenType string + // TokenURL is the STS token exchange endpoint. If not provided, will default to + // https://sts.googleapis.com/v1/token. Optional. + TokenURL string + // TokenInfoURL is the token_info endpoint used to retrieve the account related information ( + // user attributes like account identifier, eg. email, username, uid, etc). This is + // needed for gCloud session account identification. Optional. + TokenInfoURL string + // ServiceAccountImpersonationURL is the URL for the service account impersonation request. This is only + // required for workload identity pools when APIs to be accessed have not integrated with UberMint. Optional. + ServiceAccountImpersonationURL string + // ServiceAccountImpersonationLifetimeSeconds is the number of seconds the service account impersonation + // token will be valid for. If not provided, it will default to 3600. Optional. + ServiceAccountImpersonationLifetimeSeconds int + // ClientSecret is currently only required if token_info endpoint also + // needs to be called with the generated GCP access token. When provided, STS will be + // called with additional basic authentication using ClientId as username and ClientSecret as password. Optional. + ClientSecret string + // ClientID is only required in conjunction with ClientSecret, as described above. Optional. + ClientID string + // CredentialSource contains the necessary information to retrieve the token itself, as well + // as some environmental information. One of SubjectTokenSupplier, AWSSecurityCredentialSupplier or + // CredentialSource must be provided. Optional. + CredentialSource *CredentialSource + // QuotaProjectID is injected by gCloud. If the value is non-empty, the Auth libraries + // will set the x-goog-user-project header which overrides the project associated with the credentials. Optional. + QuotaProjectID string + // Scopes contains the desired scopes for the returned access token. Optional. + Scopes []string + // WorkforcePoolUserProject is the workforce pool user project number when the credential + // corresponds to a workforce pool and not a workload identity pool. + // The underlying principal must still have serviceusage.services.use IAM + // permission to use the project for billing/quota. Optional. + WorkforcePoolUserProject string + // SubjectTokenSupplier is an optional token supplier for OIDC/SAML credentials. + // One of SubjectTokenSupplier, AWSSecurityCredentialSupplier or CredentialSource must be provided. Optional. + SubjectTokenSupplier SubjectTokenSupplier + // AwsSecurityCredentialsSupplier is an AWS Security Credential supplier for AWS credentials. + // One of SubjectTokenSupplier, AWSSecurityCredentialSupplier or CredentialSource must be provided. Optional. + AwsSecurityCredentialsSupplier AwsSecurityCredentialsSupplier +} + +var ( + validWorkforceAudiencePattern *regexp.Regexp = regexp.MustCompile(`//iam\.googleapis\.com/locations/[^/]+/workforcePools/`) +) + +func validateWorkforceAudience(input string) bool { + return validWorkforceAudiencePattern.MatchString(input) +} + +// NewTokenSource Returns an external account TokenSource using the provided external account config. +func NewTokenSource(ctx context.Context, conf Config) (oauth2.TokenSource, error) { + if conf.Audience == "" { + return nil, fmt.Errorf("oauth2/google/externalaccount: Audience must be set") + } + if conf.SubjectTokenType == "" { + return nil, fmt.Errorf("oauth2/google/externalaccount: Subject token type must be set") + } + if conf.WorkforcePoolUserProject != "" { + valid := validateWorkforceAudience(conf.Audience) + if !valid { + return nil, fmt.Errorf("oauth2/google/externalaccount: Workforce pool user project should not be set for non-workforce pool credentials") + } + } + count := 0 + if conf.CredentialSource != nil { + count++ + } + if conf.SubjectTokenSupplier != nil { + count++ + } + if conf.AwsSecurityCredentialsSupplier != nil { + count++ + } + if count == 0 { + return nil, fmt.Errorf("oauth2/google/externalaccount: One of CredentialSource, SubjectTokenSupplier, or AwsSecurityCredentialsSupplier must be set") + } + if count > 1 { + return nil, fmt.Errorf("oauth2/google/externalaccount: Only one of CredentialSource, SubjectTokenSupplier, or AwsSecurityCredentialsSupplier must be set") + } + return conf.tokenSource(ctx, "https") +} + +// tokenSource is a private function that's directly called by some of the tests, +// because the unit test URLs are mocked, and would otherwise fail the +// validity check. +func (c *Config) tokenSource(ctx context.Context, scheme string) (oauth2.TokenSource, error) { + + ts := tokenSource{ + ctx: ctx, + conf: c, + } + if c.ServiceAccountImpersonationURL == "" { + return oauth2.ReuseTokenSource(nil, ts), nil + } + scopes := c.Scopes + ts.conf.Scopes = []string{"https://www.googleapis.com/auth/cloud-platform"} + imp := impersonate.ImpersonateTokenSource{ + Ctx: ctx, + URL: c.ServiceAccountImpersonationURL, + Scopes: scopes, + Ts: oauth2.ReuseTokenSource(nil, ts), + TokenLifetimeSeconds: c.ServiceAccountImpersonationLifetimeSeconds, + } + return oauth2.ReuseTokenSource(nil, imp), nil +} + +// Subject token file types. +const ( + fileTypeText = "text" + fileTypeJSON = "json" + defaultTokenUrl = "https://sts.googleapis.com/v1/token" +) + +// Format contains information needed to retireve a subject token for URL or File sourced credentials. +type Format struct { + // Type should be either "text" or "json". This determines whether the file or URL sourced credentials + // expect a simple text subject token or if the subject token will be contained in a JSON object. + // When not provided "text" type is assumed. + Type string `json:"type"` + // SubjectTokenFieldName is only required for JSON format. This is the field name that the credentials will check + // for the subject token in the file or URL response. This would be "access_token" for azure. + SubjectTokenFieldName string `json:"subject_token_field_name"` +} + +// CredentialSource stores the information necessary to retrieve the credentials for the STS exchange. +type CredentialSource struct { + // File is the location for file sourced credentials. + // One field amongst File, URL, Executable, or EnvironmentID should be provided, depending on the kind of credential in question. + File string `json:"file"` + + // Url is the URL to call for URL sourced credentials. + // One field amongst File, URL, Executable, or EnvironmentID should be provided, depending on the kind of credential in question. + URL string `json:"url"` + // Headers are the headers to attach to the request for URL sourced credentials. + Headers map[string]string `json:"headers"` + + // Executable is the configuration object for executable sourced credentials. + // One field amongst File, URL, Executable, or EnvironmentID should be provided, depending on the kind of credential in question. + Executable *ExecutableConfig `json:"executable"` + + // EnvironmentID is the EnvironmentID used for AWS sourced credentials. This should start with "AWS". + // One field amongst File, URL, Executable, or EnvironmentID should be provided, depending on the kind of credential in question. + EnvironmentID string `json:"environment_id"` + // RegionURL is the metadata URL to retrieve the region from for EC2 AWS credentials. + RegionURL string `json:"region_url"` + // RegionalCredVerificationURL is the AWS regional credential verification URL, will default to + // "https://sts.{region}.amazonaws.com?Action=GetCallerIdentity&Version=2011-06-15" if not provided." + RegionalCredVerificationURL string `json:"regional_cred_verification_url"` + // IMDSv2SessionTokenURL is the URL to retrieve the session token when using IMDSv2 in AWS. + IMDSv2SessionTokenURL string `json:"imdsv2_session_token_url"` + // Format is the format type for the subject token. Used for File and URL sourced credentials. Expected values are "text" or "json". + Format Format `json:"format"` +} + +// ExecutableConfig contains information needed for executable sourced credentials. +type ExecutableConfig struct { + // Command is the the full command to run to retrieve the subject token. + // This can include arguments. Must be an absolute path for the program. Required. + Command string `json:"command"` + // TimeoutMillis is the timeout duration, in milliseconds. Defaults to 30000 milliseconds when not provided. Optional. + TimeoutMillis *int `json:"timeout_millis"` + // OutputFile is the absolute path to the output file where the executable will cache the response. + // If specified the auth libraries will first check this location before running the executable. Optional. + OutputFile string `json:"output_file"` +} + +// SubjectTokenSupplier can be used to supply a subject token to exchange for a GCP access token. +type SubjectTokenSupplier interface { + // SubjectToken should return a valid subject token or an error. + // The external account token source does not cache the returned subject token, so caching + // logic should be implemented in the supplier to prevent multiple requests for the same subject token. + SubjectToken(ctx context.Context, options SupplierOptions) (string, error) +} + +// AWSSecurityCredentialsSupplier can be used to supply AwsSecurityCredentials and an AWS Region to +// exchange for a GCP access token. +type AwsSecurityCredentialsSupplier interface { + // AwsRegion should return the AWS region or an error. + AwsRegion(ctx context.Context, options SupplierOptions) (string, error) + // GetAwsSecurityCredentials should return a valid set of AwsSecurityCredentials or an error. + // The external account token source does not cache the returned security credentials, so caching + // logic should be implemented in the supplier to prevent multiple requests for the same security credentials. + AwsSecurityCredentials(ctx context.Context, options SupplierOptions) (*AwsSecurityCredentials, error) +} + +// SupplierOptions contains information about the requested subject token or AWS security credentials from the +// Google external account credential. +type SupplierOptions struct { + // Audience is the requested audience for the external account credential. + Audience string + // Subject token type is the requested subject token type for the external account credential. Expected values include: + // “urn:ietf:params:oauth:token-type:jwt” + // “urn:ietf:params:oauth:token-type:id-token” + // “urn:ietf:params:oauth:token-type:saml2” + // “urn:ietf:params:aws:token-type:aws4_request” + SubjectTokenType string +} + +// parse determines the type of CredentialSource needed. +func (c *Config) parse(ctx context.Context) (baseCredentialSource, error) { + //set Defaults + if c.TokenURL == "" { + c.TokenURL = defaultTokenUrl + } + supplierOptions := SupplierOptions{Audience: c.Audience, SubjectTokenType: c.SubjectTokenType} + + if c.AwsSecurityCredentialsSupplier != nil { + awsCredSource := awsCredentialSource{ + awsSecurityCredentialsSupplier: c.AwsSecurityCredentialsSupplier, + targetResource: c.Audience, + supplierOptions: supplierOptions, + ctx: ctx, + } + return awsCredSource, nil + } else if c.SubjectTokenSupplier != nil { + return programmaticRefreshCredentialSource{subjectTokenSupplier: c.SubjectTokenSupplier, supplierOptions: supplierOptions, ctx: ctx}, nil + } else if len(c.CredentialSource.EnvironmentID) > 3 && c.CredentialSource.EnvironmentID[:3] == "aws" { + if awsVersion, err := strconv.Atoi(c.CredentialSource.EnvironmentID[3:]); err == nil { + if awsVersion != 1 { + return nil, fmt.Errorf("oauth2/google/externalaccount: aws version '%d' is not supported in the current build", awsVersion) + } + + awsCredSource := awsCredentialSource{ + environmentID: c.CredentialSource.EnvironmentID, + regionURL: c.CredentialSource.RegionURL, + regionalCredVerificationURL: c.CredentialSource.RegionalCredVerificationURL, + credVerificationURL: c.CredentialSource.URL, + targetResource: c.Audience, + ctx: ctx, + } + if c.CredentialSource.IMDSv2SessionTokenURL != "" { + awsCredSource.imdsv2SessionTokenURL = c.CredentialSource.IMDSv2SessionTokenURL + } + + return awsCredSource, nil + } + } else if c.CredentialSource.File != "" { + return fileCredentialSource{File: c.CredentialSource.File, Format: c.CredentialSource.Format}, nil + } else if c.CredentialSource.URL != "" { + return urlCredentialSource{URL: c.CredentialSource.URL, Headers: c.CredentialSource.Headers, Format: c.CredentialSource.Format, ctx: ctx}, nil + } else if c.CredentialSource.Executable != nil { + return createExecutableCredential(ctx, c.CredentialSource.Executable, c) + } + return nil, fmt.Errorf("oauth2/google/externalaccount: unable to parse credential source") +} + +type baseCredentialSource interface { + credentialSourceType() string + subjectToken() (string, error) +} + +// tokenSource is the source that handles external credentials. It is used to retrieve Tokens. +type tokenSource struct { + ctx context.Context + conf *Config +} + +func getMetricsHeaderValue(conf *Config, credSource baseCredentialSource) string { + return fmt.Sprintf("gl-go/%s auth/%s google-byoid-sdk source/%s sa-impersonation/%t config-lifetime/%t", + goVersion(), + "unknown", + credSource.credentialSourceType(), + conf.ServiceAccountImpersonationURL != "", + conf.ServiceAccountImpersonationLifetimeSeconds != 0) +} + +// Token allows tokenSource to conform to the oauth2.TokenSource interface. +func (ts tokenSource) Token() (*oauth2.Token, error) { + conf := ts.conf + + credSource, err := conf.parse(ts.ctx) + if err != nil { + return nil, err + } + subjectToken, err := credSource.subjectToken() + + if err != nil { + return nil, err + } + stsRequest := stsexchange.TokenExchangeRequest{ + GrantType: "urn:ietf:params:oauth:grant-type:token-exchange", + Audience: conf.Audience, + Scope: conf.Scopes, + RequestedTokenType: "urn:ietf:params:oauth:token-type:access_token", + SubjectToken: subjectToken, + SubjectTokenType: conf.SubjectTokenType, + } + header := make(http.Header) + header.Add("Content-Type", "application/x-www-form-urlencoded") + header.Add("x-goog-api-client", getMetricsHeaderValue(conf, credSource)) + clientAuth := stsexchange.ClientAuthentication{ + AuthStyle: oauth2.AuthStyleInHeader, + ClientID: conf.ClientID, + ClientSecret: conf.ClientSecret, + } + var options map[string]interface{} + // Do not pass workforce_pool_user_project when client authentication is used. + // The client ID is sufficient for determining the user project. + if conf.WorkforcePoolUserProject != "" && conf.ClientID == "" { + options = map[string]interface{}{ + "userProject": conf.WorkforcePoolUserProject, + } + } + stsResp, err := stsexchange.ExchangeToken(ts.ctx, conf.TokenURL, &stsRequest, clientAuth, header, options) + if err != nil { + return nil, err + } + + accessToken := &oauth2.Token{ + AccessToken: stsResp.AccessToken, + TokenType: stsResp.TokenType, + } + if stsResp.ExpiresIn < 0 { + return nil, fmt.Errorf("oauth2/google/externalaccount: got invalid expiry from security token service") + } else if stsResp.ExpiresIn >= 0 { + accessToken.Expiry = now().Add(time.Duration(stsResp.ExpiresIn) * time.Second) + } + + if stsResp.RefreshToken != "" { + accessToken.RefreshToken = stsResp.RefreshToken + } + return accessToken, nil +} diff --git a/google/internal/externalaccount/basecredentials_test.go b/google/externalaccount/basecredentials_test.go similarity index 54% rename from google/internal/externalaccount/basecredentials_test.go rename to google/externalaccount/basecredentials_test.go index 9bdf8e01d..5e896eed0 100644 --- a/google/internal/externalaccount/basecredentials_test.go +++ b/google/externalaccount/basecredentials_test.go @@ -17,13 +17,15 @@ import ( ) const ( - textBaseCredPath = "testdata/3pi_cred.txt" - jsonBaseCredPath = "testdata/3pi_cred.json" + textBaseCredPath = "testdata/3pi_cred.txt" + jsonBaseCredPath = "testdata/3pi_cred.json" + baseImpersonateCredsReqBody = "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%2Fcloud-platform&subject_token=street123&subject_token_type=urn%3Aietf%3Aparams%3Aoauth%3Atoken-type%3Ajwt" + baseImpersonateCredsRespBody = `{"accessToken":"Second.Access.Token","expireTime":"2020-12-28T15:01:23Z"}` ) var testBaseCredSource = CredentialSource{ File: textBaseCredPath, - Format: format{Type: fileTypeText}, + Format: Format{Type: fileTypeText}, } var testConfig = Config{ @@ -32,7 +34,7 @@ var testConfig = Config{ TokenInfoURL: "http://localhost:8080/v1/tokeninfo", ClientSecret: "notsosecret", ClientID: "rbrgnognrhongo3bi4gb9ghg9g", - CredentialSource: testBaseCredSource, + CredentialSource: &testBaseCredSource, Scopes: []string{"https://www.googleapis.com/auth/devstorage.full_control"}, } @@ -112,6 +114,60 @@ func validateToken(t *testing.T, tok *oauth2.Token) { } } +func createImpersonationServer(urlWanted, authWanted, bodyWanted, response string, t *testing.T) *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got, want := r.URL.String(), urlWanted; got != want { + t.Errorf("URL.String(): got %v but want %v", got, want) + } + headerAuth := r.Header.Get("Authorization") + if got, want := headerAuth, authWanted; got != want { + t.Errorf("got %v but want %v", got, want) + } + headerContentType := r.Header.Get("Content-Type") + if got, want := headerContentType, "application/json"; got != want { + t.Errorf("got %v but want %v", got, want) + } + body, err := ioutil.ReadAll(r.Body) + if err != nil { + t.Fatalf("Failed reading request body: %v.", err) + } + if got, want := string(body), bodyWanted; got != want { + t.Errorf("Unexpected impersonation payload: got %v but want %v", got, want) + } + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(response)) + })) +} + +func createTargetServer(metricsHeaderWanted string, t *testing.T) *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got, want := r.URL.String(), "/"; got != want { + t.Errorf("URL.String(): got %v but want %v", got, want) + } + headerAuth := r.Header.Get("Authorization") + if got, want := headerAuth, "Basic cmJyZ25vZ25yaG9uZ28zYmk0Z2I5Z2hnOWc6bm90c29zZWNyZXQ="; got != want { + t.Errorf("got %v but want %v", got, want) + } + headerContentType := r.Header.Get("Content-Type") + if got, want := headerContentType, "application/x-www-form-urlencoded"; got != want { + t.Errorf("got %v but want %v", got, want) + } + headerMetrics := r.Header.Get("x-goog-api-client") + if got, want := headerMetrics, metricsHeaderWanted; got != want { + t.Errorf("got %v but want %v", got, want) + } + body, err := ioutil.ReadAll(r.Body) + if err != nil { + t.Fatalf("Failed reading request body: %v.", err) + } + if got, want := string(body), baseImpersonateCredsReqBody; got != want { + t.Errorf("Unexpected exchange payload: got %v but want %v", got, want) + } + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(baseCredsResponseBody)) + })) +} + func getExpectedMetricsHeader(source string, saImpersonation bool, configLifetime bool) string { return fmt.Sprintf("gl-go/%s auth/unknown google-byoid-sdk source/%s sa-impersonation/%t config-lifetime/%t", goVersion(), source, saImpersonation, configLifetime) } @@ -122,7 +178,7 @@ func TestToken(t *testing.T) { SubjectTokenType: "urn:ietf:params:oauth:token-type:id_token", ClientSecret: "notsosecret", ClientID: "rbrgnognrhongo3bi4gb9ghg9g", - CredentialSource: testBaseCredSource, + CredentialSource: &testBaseCredSource, Scopes: []string{"https://www.googleapis.com/auth/devstorage.full_control"}, } @@ -149,7 +205,7 @@ func TestWorkforcePoolTokenWithClientID(t *testing.T) { SubjectTokenType: "urn:ietf:params:oauth:token-type:id_token", ClientSecret: "notsosecret", ClientID: "rbrgnognrhongo3bi4gb9ghg9g", - CredentialSource: testBaseCredSource, + CredentialSource: &testBaseCredSource, Scopes: []string{"https://www.googleapis.com/auth/devstorage.full_control"}, WorkforcePoolUserProject: "myProject", } @@ -176,7 +232,7 @@ func TestWorkforcePoolTokenWithoutClientID(t *testing.T) { Audience: "//iam.googleapis.com/locations/eu/workforcePools/pool-id/providers/provider-id", SubjectTokenType: "urn:ietf:params:oauth:token-type:id_token", ClientSecret: "notsosecret", - CredentialSource: testBaseCredSource, + CredentialSource: &testBaseCredSource, Scopes: []string{"https://www.googleapis.com/auth/devstorage.full_control"}, WorkforcePoolUserProject: "myProject", } @@ -205,17 +261,17 @@ func TestNonworkforceWithWorkforcePoolUserProject(t *testing.T) { TokenURL: "https://sts.googleapis.com", ClientSecret: "notsosecret", ClientID: "rbrgnognrhongo3bi4gb9ghg9g", - CredentialSource: testBaseCredSource, + CredentialSource: &testBaseCredSource, Scopes: []string{"https://www.googleapis.com/auth/devstorage.full_control"}, WorkforcePoolUserProject: "myProject", } - _, err := config.TokenSource(context.Background()) + _, err := NewTokenSource(context.Background(), config) if err == nil { t.Fatalf("Expected error but found none") } - if got, want := err.Error(), "oauth2/google: workforce_pool_user_project should not be set for non-workforce pool credentials"; got != want { + if got, want := err.Error(), "oauth2/google/externalaccount: Workforce pool user project should not be set for non-workforce pool credentials"; got != want { t.Errorf("Incorrect error received.\nExpected: %s\nRecieved: %s", want, got) } } @@ -247,7 +303,7 @@ func TestWorkforcePoolCreation(t *testing.T) { config.ServiceAccountImpersonationURL = "https://iamcredentials.googleapis.com" config.Audience = tt.audience config.WorkforcePoolUserProject = "myProject" - _, err := config.TokenSource(ctx) + _, err := NewTokenSource(ctx, config) if tt.expectSuccess && err != nil { t.Errorf("got %v but want nil", err) @@ -257,3 +313,144 @@ func TestWorkforcePoolCreation(t *testing.T) { }) } } + +var impersonationTests = []struct { + name string + config Config + expectedImpersonationBody string + expectedMetricsHeader string +}{ + { + name: "Base Impersonation", + config: Config{ + Audience: "32555940559.apps.googleusercontent.com", + SubjectTokenType: "urn:ietf:params:oauth:token-type:jwt", + TokenInfoURL: "http://localhost:8080/v1/tokeninfo", + ClientSecret: "notsosecret", + ClientID: "rbrgnognrhongo3bi4gb9ghg9g", + CredentialSource: &testBaseCredSource, + Scopes: []string{"https://www.googleapis.com/auth/devstorage.full_control"}, + }, + expectedImpersonationBody: "{\"lifetime\":\"3600s\",\"scope\":[\"https://www.googleapis.com/auth/devstorage.full_control\"]}", + expectedMetricsHeader: getExpectedMetricsHeader("file", true, false), + }, + { + name: "With TokenLifetime Set", + config: Config{ + Audience: "32555940559.apps.googleusercontent.com", + SubjectTokenType: "urn:ietf:params:oauth:token-type:jwt", + TokenInfoURL: "http://localhost:8080/v1/tokeninfo", + ClientSecret: "notsosecret", + ClientID: "rbrgnognrhongo3bi4gb9ghg9g", + CredentialSource: &testBaseCredSource, + Scopes: []string{"https://www.googleapis.com/auth/devstorage.full_control"}, + ServiceAccountImpersonationLifetimeSeconds: 10000, + }, + expectedImpersonationBody: "{\"lifetime\":\"10000s\",\"scope\":[\"https://www.googleapis.com/auth/devstorage.full_control\"]}", + expectedMetricsHeader: getExpectedMetricsHeader("file", true, true), + }, +} + +func TestImpersonation(t *testing.T) { + for _, tt := range impersonationTests { + t.Run(tt.name, func(t *testing.T) { + testImpersonateConfig := tt.config + impersonateServer := createImpersonationServer("/", "Bearer Sample.Access.Token", tt.expectedImpersonationBody, baseImpersonateCredsRespBody, t) + defer impersonateServer.Close() + testImpersonateConfig.ServiceAccountImpersonationURL = impersonateServer.URL + + targetServer := createTargetServer(tt.expectedMetricsHeader, t) + defer targetServer.Close() + testImpersonateConfig.TokenURL = targetServer.URL + + ourTS, err := testImpersonateConfig.tokenSource(context.Background(), "http") + if err != nil { + t.Fatalf("Failed to create TokenSource: %v", err) + } + + oldNow := now + defer func() { now = oldNow }() + now = testNow + + tok, err := ourTS.Token() + if err != nil { + t.Fatalf("Unexpected error: %e", err) + } + if got, want := tok.AccessToken, "Second.Access.Token"; got != want { + t.Errorf("Unexpected access token: got %v, but wanted %v", got, want) + } + if got, want := tok.TokenType, "Bearer"; got != want { + t.Errorf("Unexpected TokenType: got %v, but wanted %v", got, want) + } + }) + } +} + +var newTokenTests = []struct { + name string + config Config +}{ + { + name: "Missing Audience", + config: Config{ + SubjectTokenType: "urn:ietf:params:oauth:token-type:jwt", + TokenInfoURL: "http://localhost:8080/v1/tokeninfo", + ClientSecret: "notsosecret", + ClientID: "rbrgnognrhongo3bi4gb9ghg9g", + CredentialSource: &testBaseCredSource, + Scopes: []string{"https://www.googleapis.com/auth/devstorage.full_control"}, + ServiceAccountImpersonationLifetimeSeconds: 10000, + }, + }, + { + name: "Missing Subject Token Type", + config: Config{ + Audience: "32555940559.apps.googleusercontent.com", + TokenInfoURL: "http://localhost:8080/v1/tokeninfo", + ClientSecret: "notsosecret", + ClientID: "rbrgnognrhongo3bi4gb9ghg9g", + CredentialSource: &testBaseCredSource, + Scopes: []string{"https://www.googleapis.com/auth/devstorage.full_control"}, + ServiceAccountImpersonationLifetimeSeconds: 10000, + }, + }, + { + name: "No Cred Source", + config: Config{ + Audience: "32555940559.apps.googleusercontent.com", + SubjectTokenType: "urn:ietf:params:oauth:token-type:jwt", + TokenInfoURL: "http://localhost:8080/v1/tokeninfo", + ClientSecret: "notsosecret", + ClientID: "rbrgnognrhongo3bi4gb9ghg9g", + Scopes: []string{"https://www.googleapis.com/auth/devstorage.full_control"}, + ServiceAccountImpersonationLifetimeSeconds: 10000, + }, + }, + { + name: "Cred Source and Supplier", + config: Config{ + Audience: "32555940559.apps.googleusercontent.com", + SubjectTokenType: "urn:ietf:params:oauth:token-type:jwt", + TokenInfoURL: "http://localhost:8080/v1/tokeninfo", + CredentialSource: &testBaseCredSource, + AwsSecurityCredentialsSupplier: testAwsSupplier{}, + ClientSecret: "notsosecret", + ClientID: "rbrgnognrhongo3bi4gb9ghg9g", + Scopes: []string{"https://www.googleapis.com/auth/devstorage.full_control"}, + ServiceAccountImpersonationLifetimeSeconds: 10000, + }, + }, +} + +func TestNewToken(t *testing.T) { + for _, tt := range newTokenTests { + t.Run(tt.name, func(t *testing.T) { + testConfig := tt.config + + _, err := NewTokenSource(context.Background(), testConfig) + if err == nil { + t.Fatalf("expected error when calling NewToken()") + } + }) + } +} diff --git a/google/internal/externalaccount/executablecredsource.go b/google/externalaccount/executablecredsource.go similarity index 85% rename from google/internal/externalaccount/executablecredsource.go rename to google/externalaccount/executablecredsource.go index 843d1c330..dca5681a4 100644 --- a/google/internal/externalaccount/executablecredsource.go +++ b/google/externalaccount/executablecredsource.go @@ -39,51 +39,51 @@ func (nce nonCacheableError) Error() string { } func missingFieldError(source, field string) error { - return fmt.Errorf("oauth2/google: %v missing `%q` field", source, field) + return fmt.Errorf("oauth2/google/externalaccount: %v missing `%q` field", source, field) } func jsonParsingError(source, data string) error { - return fmt.Errorf("oauth2/google: unable to parse %v\nResponse: %v", source, data) + return fmt.Errorf("oauth2/google/externalaccount: unable to parse %v\nResponse: %v", source, data) } func malformedFailureError() error { - return nonCacheableError{"oauth2/google: response must include `error` and `message` fields when unsuccessful"} + return nonCacheableError{"oauth2/google/externalaccount: response must include `error` and `message` fields when unsuccessful"} } func userDefinedError(code, message string) error { - return nonCacheableError{fmt.Sprintf("oauth2/google: response contains unsuccessful response: (%v) %v", code, message)} + return nonCacheableError{fmt.Sprintf("oauth2/google/externalaccount: response contains unsuccessful response: (%v) %v", code, message)} } func unsupportedVersionError(source string, version int) error { - return fmt.Errorf("oauth2/google: %v contains unsupported version: %v", source, version) + return fmt.Errorf("oauth2/google/externalaccount: %v contains unsupported version: %v", source, version) } func tokenExpiredError() error { - return nonCacheableError{"oauth2/google: the token returned by the executable is expired"} + return nonCacheableError{"oauth2/google/externalaccount: the token returned by the executable is expired"} } func tokenTypeError(source string) error { - return fmt.Errorf("oauth2/google: %v contains unsupported token type", source) + return fmt.Errorf("oauth2/google/externalaccount: %v contains unsupported token type", source) } func exitCodeError(exitCode int) error { - return fmt.Errorf("oauth2/google: executable command failed with exit code %v", exitCode) + return fmt.Errorf("oauth2/google/externalaccount: executable command failed with exit code %v", exitCode) } func executableError(err error) error { - return fmt.Errorf("oauth2/google: executable command failed: %v", err) + return fmt.Errorf("oauth2/google/externalaccount: executable command failed: %v", err) } func executablesDisallowedError() error { - return errors.New("oauth2/google: executables need to be explicitly allowed (set GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES to '1') to run") + return errors.New("oauth2/google/externalaccount: executables need to be explicitly allowed (set GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES to '1') to run") } func timeoutRangeError() error { - return errors.New("oauth2/google: invalid `timeout_millis` field — executable timeout must be between 5 and 120 seconds") + return errors.New("oauth2/google/externalaccount: invalid `timeout_millis` field — executable timeout must be between 5 and 120 seconds") } func commandMissingError() error { - return errors.New("oauth2/google: missing `command` field — executable command must be provided") + return errors.New("oauth2/google/externalaccount: missing `command` field — executable command must be provided") } type environment interface { @@ -146,7 +146,7 @@ type executableCredentialSource struct { // CreateExecutableCredential creates an executableCredentialSource given an ExecutableConfig. // It also performs defaulting and type conversions. -func CreateExecutableCredential(ctx context.Context, ec *ExecutableConfig, config *Config) (executableCredentialSource, error) { +func createExecutableCredential(ctx context.Context, ec *ExecutableConfig, config *Config) (executableCredentialSource, error) { if ec.Command == "" { return executableCredentialSource{}, commandMissingError() } diff --git a/google/internal/externalaccount/executablecredsource_test.go b/google/externalaccount/executablecredsource_test.go similarity index 98% rename from google/internal/externalaccount/executablecredsource_test.go rename to google/externalaccount/executablecredsource_test.go index 18ee049ff..69ec21ae1 100644 --- a/google/internal/externalaccount/executablecredsource_test.go +++ b/google/externalaccount/executablecredsource_test.go @@ -128,7 +128,7 @@ var creationTests = []struct { func TestCreateExecutableCredential(t *testing.T) { for _, tt := range creationTests { t.Run(tt.name, func(t *testing.T) { - ecs, err := CreateExecutableCredential(context.Background(), &tt.executableConfig, nil) + ecs, err := createExecutableCredential(context.Background(), &tt.executableConfig, nil) if tt.expectedErr != nil { if err == nil { t.Fatalf("Expected error but found none") @@ -169,7 +169,7 @@ var getEnvironmentTests = []struct { config: Config{ Audience: "//iam.googleapis.com/projects/123/locations/global/workloadIdentityPools/pool/providers/oidc", SubjectTokenType: "urn:ietf:params:oauth:token-type:jwt", - CredentialSource: CredentialSource{ + CredentialSource: &CredentialSource{ Executable: &ExecutableConfig{ Command: "blarg", }, @@ -193,7 +193,7 @@ var getEnvironmentTests = []struct { Audience: "//iam.googleapis.com/projects/123/locations/global/workloadIdentityPools/pool/providers/oidc", ServiceAccountImpersonationURL: "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/test@project.iam.gserviceaccount.com:generateAccessToken", SubjectTokenType: "urn:ietf:params:oauth:token-type:jwt", - CredentialSource: CredentialSource{ + CredentialSource: &CredentialSource{ Executable: &ExecutableConfig{ Command: "blarg", OutputFile: "/path/to/generated/cached/credentials", @@ -220,7 +220,7 @@ var getEnvironmentTests = []struct { Audience: "//iam.googleapis.com/projects/123/locations/global/workloadIdentityPools/pool/providers/oidc", ServiceAccountImpersonationURL: "test@project.iam.gserviceaccount.com", SubjectTokenType: "urn:ietf:params:oauth:token-type:jwt", - CredentialSource: CredentialSource{ + CredentialSource: &CredentialSource{ Executable: &ExecutableConfig{ Command: "blarg", OutputFile: "/path/to/generated/cached/credentials", @@ -247,7 +247,7 @@ func TestExecutableCredentialGetEnvironment(t *testing.T) { t.Run(tt.name, func(t *testing.T) { config := tt.config - ecs, err := CreateExecutableCredential(context.Background(), config.CredentialSource.Executable, &config) + ecs, err := createExecutableCredential(context.Background(), config.CredentialSource.Executable, &config) if err != nil { t.Fatalf("creation failed %v", err) } @@ -471,7 +471,7 @@ func TestRetrieveExecutableSubjectTokenExecutableErrors(t *testing.T) { } tfc := testFileConfig - tfc.CredentialSource = cs + tfc.CredentialSource = &cs base, err := tfc.parse(context.Background()) if err != nil { @@ -578,7 +578,7 @@ func TestRetrieveExecutableSubjectTokenSuccesses(t *testing.T) { } tfc := testFileConfig - tfc.CredentialSource = cs + tfc.CredentialSource = &cs base, err := tfc.parse(context.Background()) if err != nil { @@ -629,7 +629,7 @@ func TestRetrieveOutputFileSubjectTokenNotJSON(t *testing.T) { } tfc := testFileConfig - tfc.CredentialSource = cs + tfc.CredentialSource = &cs base, err := tfc.parse(context.Background()) if err != nil { @@ -778,7 +778,7 @@ func TestRetrieveOutputFileSubjectTokenFailureTests(t *testing.T) { } tfc := testFileConfig - tfc.CredentialSource = cs + tfc.CredentialSource = &cs base, err := tfc.parse(context.Background()) if err != nil { @@ -881,7 +881,7 @@ func TestRetrieveOutputFileSubjectTokenInvalidCache(t *testing.T) { } tfc := testFileConfig - tfc.CredentialSource = cs + tfc.CredentialSource = &cs base, err := tfc.parse(context.Background()) if err != nil { @@ -986,7 +986,7 @@ func TestRetrieveOutputFileSubjectTokenJwt(t *testing.T) { } tfc := testFileConfig - tfc.CredentialSource = cs + tfc.CredentialSource = &cs base, err := tfc.parse(context.Background()) if err != nil { diff --git a/google/internal/externalaccount/filecredsource.go b/google/externalaccount/filecredsource.go similarity index 62% rename from google/internal/externalaccount/filecredsource.go rename to google/externalaccount/filecredsource.go index f35f73c5c..33766b972 100644 --- a/google/internal/externalaccount/filecredsource.go +++ b/google/externalaccount/filecredsource.go @@ -16,7 +16,7 @@ import ( type fileCredentialSource struct { File string - Format format + Format Format } func (cs fileCredentialSource) credentialSourceType() string { @@ -26,12 +26,12 @@ func (cs fileCredentialSource) credentialSourceType() string { func (cs fileCredentialSource) subjectToken() (string, error) { tokenFile, err := os.Open(cs.File) if err != nil { - return "", fmt.Errorf("oauth2/google: failed to open credential file %q", cs.File) + return "", fmt.Errorf("oauth2/google/externalaccount: failed to open credential file %q", cs.File) } defer tokenFile.Close() tokenBytes, err := ioutil.ReadAll(io.LimitReader(tokenFile, 1<<20)) if err != nil { - return "", fmt.Errorf("oauth2/google: failed to read credential file: %v", err) + return "", fmt.Errorf("oauth2/google/externalaccount: failed to read credential file: %v", err) } tokenBytes = bytes.TrimSpace(tokenBytes) switch cs.Format.Type { @@ -39,15 +39,15 @@ func (cs fileCredentialSource) subjectToken() (string, error) { jsonData := make(map[string]interface{}) err = json.Unmarshal(tokenBytes, &jsonData) if err != nil { - return "", fmt.Errorf("oauth2/google: failed to unmarshal subject token file: %v", err) + return "", fmt.Errorf("oauth2/google/externalaccount: failed to unmarshal subject token file: %v", err) } val, ok := jsonData[cs.Format.SubjectTokenFieldName] if !ok { - return "", errors.New("oauth2/google: provided subject_token_field_name not found in credentials") + return "", errors.New("oauth2/google/externalaccount: provided subject_token_field_name not found in credentials") } token, ok := val.(string) if !ok { - return "", errors.New("oauth2/google: improperly formatted subject token") + return "", errors.New("oauth2/google/externalaccount: improperly formatted subject token") } return token, nil case "text": @@ -55,7 +55,7 @@ func (cs fileCredentialSource) subjectToken() (string, error) { case "": return string(tokenBytes), nil default: - return "", errors.New("oauth2/google: invalid credential_source file format type") + return "", errors.New("oauth2/google/externalaccount: invalid credential_source file format type") } } diff --git a/google/internal/externalaccount/filecredsource_test.go b/google/externalaccount/filecredsource_test.go similarity index 93% rename from google/internal/externalaccount/filecredsource_test.go rename to google/externalaccount/filecredsource_test.go index c20700f1d..dc561bd90 100644 --- a/google/internal/externalaccount/filecredsource_test.go +++ b/google/externalaccount/filecredsource_test.go @@ -36,7 +36,7 @@ func TestRetrieveFileSubjectToken(t *testing.T) { name: "TextFileSource", cs: CredentialSource{ File: textBaseCredPath, - Format: format{Type: fileTypeText}, + Format: Format{Type: fileTypeText}, }, want: "street123", }, @@ -44,7 +44,7 @@ func TestRetrieveFileSubjectToken(t *testing.T) { name: "JSONFileSource", cs: CredentialSource{ File: jsonBaseCredPath, - Format: format{Type: fileTypeJSON, SubjectTokenFieldName: "SubjToken"}, + Format: Format{Type: fileTypeJSON, SubjectTokenFieldName: "SubjToken"}, }, want: "321road", }, @@ -53,7 +53,7 @@ func TestRetrieveFileSubjectToken(t *testing.T) { for _, test := range fileSourceTests { test := test tfc := testFileConfig - tfc.CredentialSource = test.cs + tfc.CredentialSource = &test.cs t.Run(test.name, func(t *testing.T) { base, err := tfc.parse(context.Background()) diff --git a/google/internal/externalaccount/header.go b/google/externalaccount/header.go similarity index 100% rename from google/internal/externalaccount/header.go rename to google/externalaccount/header.go diff --git a/google/internal/externalaccount/header_test.go b/google/externalaccount/header_test.go similarity index 100% rename from google/internal/externalaccount/header_test.go rename to google/externalaccount/header_test.go diff --git a/google/externalaccount/programmaticrefreshcredsource.go b/google/externalaccount/programmaticrefreshcredsource.go new file mode 100644 index 000000000..6c1abdf2d --- /dev/null +++ b/google/externalaccount/programmaticrefreshcredsource.go @@ -0,0 +1,21 @@ +// Copyright 2024 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package externalaccount + +import "context" + +type programmaticRefreshCredentialSource struct { + supplierOptions SupplierOptions + subjectTokenSupplier SubjectTokenSupplier + ctx context.Context +} + +func (cs programmaticRefreshCredentialSource) credentialSourceType() string { + return "programmatic" +} + +func (cs programmaticRefreshCredentialSource) subjectToken() (string, error) { + return cs.subjectTokenSupplier.SubjectToken(cs.ctx, cs.supplierOptions) +} diff --git a/google/externalaccount/programmaticrefreshcredsource_test.go b/google/externalaccount/programmaticrefreshcredsource_test.go new file mode 100644 index 000000000..7ec16c730 --- /dev/null +++ b/google/externalaccount/programmaticrefreshcredsource_test.go @@ -0,0 +1,122 @@ +// Copyright 2024 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package externalaccount + +import ( + "context" + "errors" + "testing" +) + +func TestRetrieveSubjectToken_ProgrammaticAuth(t *testing.T) { + tfc := testConfig + + tfc.SubjectTokenSupplier = testSubjectTokenSupplier{ + subjectToken: "subjectToken", + } + + base, err := tfc.parse(context.Background()) + if err != nil { + t.Fatalf("parse() failed %v", err) + } + + out, err := base.subjectToken() + if err != nil { + t.Fatalf("retrieveSubjectToken() failed: %v", err) + } + + if out != "subjectToken" { + t.Errorf("subjectToken = \n%q\n want \nSubjectToken", out) + } +} + +func TestRetrieveSubjectToken_ProgrammaticAuthFails(t *testing.T) { + tfc := testConfig + testError := errors.New("test error") + + tfc.SubjectTokenSupplier = testSubjectTokenSupplier{ + err: testError, + } + + base, err := tfc.parse(context.Background()) + if err != nil { + t.Fatalf("parse() failed %v", err) + } + + _, err = base.subjectToken() + if err == nil { + t.Fatalf("subjectToken() should have failed") + } + if testError != err { + t.Errorf("subjectToken = %e, want %e", err, testError) + } +} + +func TestRetrieveSubjectToken_ProgrammaticAuthOptions(t *testing.T) { + tfc := testConfig + expectedOptions := SupplierOptions{Audience: tfc.Audience, SubjectTokenType: tfc.SubjectTokenType} + + tfc.SubjectTokenSupplier = testSubjectTokenSupplier{ + subjectToken: "subjectToken", + expectedOptions: &expectedOptions, + } + + base, err := tfc.parse(context.Background()) + if err != nil { + t.Fatalf("parse() failed %v", err) + } + + _, err = base.subjectToken() + if err != nil { + t.Fatalf("retrieveSubjectToken() failed: %v", err) + } +} + +func TestRetrieveSubjectToken_ProgrammaticAuthContext(t *testing.T) { + tfc := testConfig + ctx := context.Background() + + tfc.SubjectTokenSupplier = testSubjectTokenSupplier{ + subjectToken: "subjectToken", + expectedContext: ctx, + } + + base, err := tfc.parse(ctx) + if err != nil { + t.Fatalf("parse() failed %v", err) + } + + _, err = base.subjectToken() + if err != nil { + t.Fatalf("retrieveSubjectToken() failed: %v", err) + } +} + +type testSubjectTokenSupplier struct { + err error + subjectToken string + expectedOptions *SupplierOptions + expectedContext context.Context +} + +func (supp testSubjectTokenSupplier) SubjectToken(ctx context.Context, options SupplierOptions) (string, error) { + if supp.err != nil { + return "", supp.err + } + if supp.expectedOptions != nil { + if supp.expectedOptions.Audience != options.Audience { + return "", errors.New("Audience does not match") + } + if supp.expectedOptions.SubjectTokenType != options.SubjectTokenType { + return "", errors.New("Audience does not match") + } + } + if supp.expectedContext != nil { + if supp.expectedContext != ctx { + return "", errors.New("Context does not match") + } + } + return supp.subjectToken, nil +} diff --git a/google/internal/externalaccount/testdata/3pi_cred.json b/google/externalaccount/testdata/3pi_cred.json similarity index 100% rename from google/internal/externalaccount/testdata/3pi_cred.json rename to google/externalaccount/testdata/3pi_cred.json diff --git a/google/internal/externalaccount/testdata/3pi_cred.txt b/google/externalaccount/testdata/3pi_cred.txt similarity index 100% rename from google/internal/externalaccount/testdata/3pi_cred.txt rename to google/externalaccount/testdata/3pi_cred.txt diff --git a/google/internal/externalaccount/urlcredsource.go b/google/externalaccount/urlcredsource.go similarity index 61% rename from google/internal/externalaccount/urlcredsource.go rename to google/externalaccount/urlcredsource.go index 606bb4e80..71a7184e0 100644 --- a/google/internal/externalaccount/urlcredsource.go +++ b/google/externalaccount/urlcredsource.go @@ -19,7 +19,7 @@ import ( type urlCredentialSource struct { URL string Headers map[string]string - Format format + Format Format ctx context.Context } @@ -31,7 +31,7 @@ func (cs urlCredentialSource) subjectToken() (string, error) { client := oauth2.NewClient(cs.ctx, nil) req, err := http.NewRequest("GET", cs.URL, nil) if err != nil { - return "", fmt.Errorf("oauth2/google: HTTP request for URL-sourced credential failed: %v", err) + return "", fmt.Errorf("oauth2/google/externalaccount: HTTP request for URL-sourced credential failed: %v", err) } req = req.WithContext(cs.ctx) @@ -40,16 +40,16 @@ func (cs urlCredentialSource) subjectToken() (string, error) { } resp, err := client.Do(req) if err != nil { - return "", fmt.Errorf("oauth2/google: invalid response when retrieving subject token: %v", err) + return "", fmt.Errorf("oauth2/google/externalaccount: invalid response when retrieving subject token: %v", err) } defer resp.Body.Close() respBody, err := ioutil.ReadAll(io.LimitReader(resp.Body, 1<<20)) if err != nil { - return "", fmt.Errorf("oauth2/google: invalid body in subject token URL query: %v", err) + return "", fmt.Errorf("oauth2/google/externalaccount: invalid body in subject token URL query: %v", err) } if c := resp.StatusCode; c < 200 || c > 299 { - return "", fmt.Errorf("oauth2/google: status code %d: %s", c, respBody) + return "", fmt.Errorf("oauth2/google/externalaccount: status code %d: %s", c, respBody) } switch cs.Format.Type { @@ -57,15 +57,15 @@ func (cs urlCredentialSource) subjectToken() (string, error) { jsonData := make(map[string]interface{}) err = json.Unmarshal(respBody, &jsonData) if err != nil { - return "", fmt.Errorf("oauth2/google: failed to unmarshal subject token file: %v", err) + return "", fmt.Errorf("oauth2/google/externalaccount: failed to unmarshal subject token file: %v", err) } val, ok := jsonData[cs.Format.SubjectTokenFieldName] if !ok { - return "", errors.New("oauth2/google: provided subject_token_field_name not found in credentials") + return "", errors.New("oauth2/google/externalaccount: provided subject_token_field_name not found in credentials") } token, ok := val.(string) if !ok { - return "", errors.New("oauth2/google: improperly formatted subject token") + return "", errors.New("oauth2/google/externalaccount: improperly formatted subject token") } return token, nil case "text": @@ -73,7 +73,7 @@ func (cs urlCredentialSource) subjectToken() (string, error) { case "": return string(respBody), nil default: - return "", errors.New("oauth2/google: invalid credential_source file format type") + return "", errors.New("oauth2/google/externalaccount: invalid credential_source file format type") } } diff --git a/google/internal/externalaccount/urlcredsource_test.go b/google/externalaccount/urlcredsource_test.go similarity index 92% rename from google/internal/externalaccount/urlcredsource_test.go rename to google/externalaccount/urlcredsource_test.go index 699f7729e..4968db4dd 100644 --- a/google/internal/externalaccount/urlcredsource_test.go +++ b/google/externalaccount/urlcredsource_test.go @@ -28,11 +28,11 @@ func TestRetrieveURLSubjectToken_Text(t *testing.T) { heads["Metadata"] = "True" cs := CredentialSource{ URL: ts.URL, - Format: format{Type: fileTypeText}, + Format: Format{Type: fileTypeText}, Headers: heads, } tfc := testFileConfig - tfc.CredentialSource = cs + tfc.CredentialSource = &cs base, err := tfc.parse(context.Background()) if err != nil { @@ -60,7 +60,7 @@ func TestRetrieveURLSubjectToken_Untyped(t *testing.T) { URL: ts.URL, } tfc := testFileConfig - tfc.CredentialSource = cs + tfc.CredentialSource = &cs base, err := tfc.parse(context.Background()) if err != nil { @@ -93,10 +93,10 @@ func TestRetrieveURLSubjectToken_JSON(t *testing.T) { })) cs := CredentialSource{ URL: ts.URL, - Format: format{Type: fileTypeJSON, SubjectTokenFieldName: "SubjToken"}, + Format: Format{Type: fileTypeJSON, SubjectTokenFieldName: "SubjToken"}, } tfc := testFileConfig - tfc.CredentialSource = cs + tfc.CredentialSource = &cs base, err := tfc.parse(context.Background()) if err != nil { @@ -115,10 +115,10 @@ func TestRetrieveURLSubjectToken_JSON(t *testing.T) { func TestURLCredential_CredentialSourceType(t *testing.T) { cs := CredentialSource{ URL: "http://example.com", - Format: format{Type: fileTypeText}, + Format: Format{Type: fileTypeText}, } tfc := testFileConfig - tfc.CredentialSource = cs + tfc.CredentialSource = &cs base, err := tfc.parse(context.Background()) if err != nil { diff --git a/google/google.go b/google/google.go index c66c53527..ba931c2c3 100644 --- a/google/google.go +++ b/google/google.go @@ -15,8 +15,9 @@ import ( "cloud.google.com/go/compute/metadata" "golang.org/x/oauth2" - "golang.org/x/oauth2/google/internal/externalaccount" + "golang.org/x/oauth2/google/externalaccount" "golang.org/x/oauth2/google/internal/externalaccountauthorizeduser" + "golang.org/x/oauth2/google/internal/impersonate" "golang.org/x/oauth2/jwt" ) @@ -200,12 +201,12 @@ func (f *credentialsFile) tokenSource(ctx context.Context, params CredentialsPar ServiceAccountImpersonationLifetimeSeconds: f.ServiceAccountImpersonation.TokenLifetimeSeconds, ClientSecret: f.ClientSecret, ClientID: f.ClientID, - CredentialSource: f.CredentialSource, + CredentialSource: &f.CredentialSource, QuotaProjectID: f.QuotaProjectID, Scopes: params.Scopes, WorkforcePoolUserProject: f.WorkforcePoolUserProject, } - return cfg.TokenSource(ctx) + return externalaccount.NewTokenSource(ctx, *cfg) case externalAccountAuthorizedUserKey: cfg := &externalaccountauthorizeduser.Config{ Audience: f.Audience, @@ -228,7 +229,7 @@ func (f *credentialsFile) tokenSource(ctx context.Context, params CredentialsPar if err != nil { return nil, err } - imp := externalaccount.ImpersonateTokenSource{ + imp := impersonate.ImpersonateTokenSource{ Ctx: ctx, URL: f.ServiceAccountImpersonationURL, Scopes: params.Scopes, diff --git a/google/internal/externalaccount/basecredentials.go b/google/internal/externalaccount/basecredentials.go deleted file mode 100644 index 33288d367..000000000 --- a/google/internal/externalaccount/basecredentials.go +++ /dev/null @@ -1,254 +0,0 @@ -// Copyright 2020 The Go Authors. All rights reserved. -// Use of this source code is governed by a BSD-style -// license that can be found in the LICENSE file. - -package externalaccount - -import ( - "context" - "fmt" - "net/http" - "regexp" - "strconv" - "time" - - "golang.org/x/oauth2" - "golang.org/x/oauth2/google/internal/stsexchange" -) - -// now aliases time.Now for testing -var now = func() time.Time { - return time.Now().UTC() -} - -// Config stores the configuration for fetching tokens with external credentials. -type Config struct { - // Audience is the Secure Token Service (STS) audience which contains the resource name for the workload - // identity pool or the workforce pool and the provider identifier in that pool. - Audience string - // SubjectTokenType is the STS token type based on the Oauth2.0 token exchange spec - // e.g. `urn:ietf:params:oauth:token-type:jwt`. - SubjectTokenType string - // TokenURL is the STS token exchange endpoint. - TokenURL string - // TokenInfoURL is the token_info endpoint used to retrieve the account related information ( - // user attributes like account identifier, eg. email, username, uid, etc). This is - // needed for gCloud session account identification. - TokenInfoURL string - // ServiceAccountImpersonationURL is the URL for the service account impersonation request. This is only - // required for workload identity pools when APIs to be accessed have not integrated with UberMint. - ServiceAccountImpersonationURL string - // ServiceAccountImpersonationLifetimeSeconds is the number of seconds the service account impersonation - // token will be valid for. - ServiceAccountImpersonationLifetimeSeconds int - // ClientSecret is currently only required if token_info endpoint also - // needs to be called with the generated GCP access token. When provided, STS will be - // called with additional basic authentication using client_id as username and client_secret as password. - ClientSecret string - // ClientID is only required in conjunction with ClientSecret, as described above. - ClientID string - // CredentialSource contains the necessary information to retrieve the token itself, as well - // as some environmental information. - CredentialSource CredentialSource - // QuotaProjectID is injected by gCloud. If the value is non-empty, the Auth libraries - // will set the x-goog-user-project which overrides the project associated with the credentials. - QuotaProjectID string - // Scopes contains the desired scopes for the returned access token. - Scopes []string - // The optional workforce pool user project number when the credential - // corresponds to a workforce pool and not a workload identity pool. - // The underlying principal must still have serviceusage.services.use IAM - // permission to use the project for billing/quota. - WorkforcePoolUserProject string -} - -var ( - validWorkforceAudiencePattern *regexp.Regexp = regexp.MustCompile(`//iam\.googleapis\.com/locations/[^/]+/workforcePools/`) -) - -func validateWorkforceAudience(input string) bool { - return validWorkforceAudiencePattern.MatchString(input) -} - -// TokenSource Returns an external account TokenSource struct. This is to be called by package google to construct a google.Credentials. -func (c *Config) TokenSource(ctx context.Context) (oauth2.TokenSource, error) { - return c.tokenSource(ctx, "https") -} - -// tokenSource is a private function that's directly called by some of the tests, -// because the unit test URLs are mocked, and would otherwise fail the -// validity check. -func (c *Config) tokenSource(ctx context.Context, scheme string) (oauth2.TokenSource, error) { - if c.WorkforcePoolUserProject != "" { - valid := validateWorkforceAudience(c.Audience) - if !valid { - return nil, fmt.Errorf("oauth2/google: workforce_pool_user_project should not be set for non-workforce pool credentials") - } - } - - ts := tokenSource{ - ctx: ctx, - conf: c, - } - if c.ServiceAccountImpersonationURL == "" { - return oauth2.ReuseTokenSource(nil, ts), nil - } - scopes := c.Scopes - ts.conf.Scopes = []string{"https://www.googleapis.com/auth/cloud-platform"} - imp := ImpersonateTokenSource{ - Ctx: ctx, - URL: c.ServiceAccountImpersonationURL, - Scopes: scopes, - Ts: oauth2.ReuseTokenSource(nil, ts), - TokenLifetimeSeconds: c.ServiceAccountImpersonationLifetimeSeconds, - } - return oauth2.ReuseTokenSource(nil, imp), nil -} - -// Subject token file types. -const ( - fileTypeText = "text" - fileTypeJSON = "json" -) - -type format struct { - // Type is either "text" or "json". When not provided "text" type is assumed. - Type string `json:"type"` - // SubjectTokenFieldName is only required for JSON format. This would be "access_token" for azure. - SubjectTokenFieldName string `json:"subject_token_field_name"` -} - -// CredentialSource stores the information necessary to retrieve the credentials for the STS exchange. -// One field amongst File, URL, and Executable should be filled, depending on the kind of credential in question. -// The EnvironmentID should start with AWS if being used for an AWS credential. -type CredentialSource struct { - File string `json:"file"` - - URL string `json:"url"` - Headers map[string]string `json:"headers"` - - Executable *ExecutableConfig `json:"executable"` - - EnvironmentID string `json:"environment_id"` - RegionURL string `json:"region_url"` - RegionalCredVerificationURL string `json:"regional_cred_verification_url"` - CredVerificationURL string `json:"cred_verification_url"` - IMDSv2SessionTokenURL string `json:"imdsv2_session_token_url"` - Format format `json:"format"` -} - -type ExecutableConfig struct { - Command string `json:"command"` - TimeoutMillis *int `json:"timeout_millis"` - OutputFile string `json:"output_file"` -} - -// parse determines the type of CredentialSource needed. -func (c *Config) parse(ctx context.Context) (baseCredentialSource, error) { - if len(c.CredentialSource.EnvironmentID) > 3 && c.CredentialSource.EnvironmentID[:3] == "aws" { - if awsVersion, err := strconv.Atoi(c.CredentialSource.EnvironmentID[3:]); err == nil { - if awsVersion != 1 { - return nil, fmt.Errorf("oauth2/google: aws version '%d' is not supported in the current build", awsVersion) - } - - awsCredSource := awsCredentialSource{ - EnvironmentID: c.CredentialSource.EnvironmentID, - RegionURL: c.CredentialSource.RegionURL, - RegionalCredVerificationURL: c.CredentialSource.RegionalCredVerificationURL, - CredVerificationURL: c.CredentialSource.URL, - TargetResource: c.Audience, - ctx: ctx, - } - if c.CredentialSource.IMDSv2SessionTokenURL != "" { - awsCredSource.IMDSv2SessionTokenURL = c.CredentialSource.IMDSv2SessionTokenURL - } - - return awsCredSource, nil - } - } else if c.CredentialSource.File != "" { - return fileCredentialSource{File: c.CredentialSource.File, Format: c.CredentialSource.Format}, nil - } else if c.CredentialSource.URL != "" { - return urlCredentialSource{URL: c.CredentialSource.URL, Headers: c.CredentialSource.Headers, Format: c.CredentialSource.Format, ctx: ctx}, nil - } else if c.CredentialSource.Executable != nil { - return CreateExecutableCredential(ctx, c.CredentialSource.Executable, c) - } - return nil, fmt.Errorf("oauth2/google: unable to parse credential source") -} - -type baseCredentialSource interface { - credentialSourceType() string - subjectToken() (string, error) -} - -// tokenSource is the source that handles external credentials. It is used to retrieve Tokens. -type tokenSource struct { - ctx context.Context - conf *Config -} - -func getMetricsHeaderValue(conf *Config, credSource baseCredentialSource) string { - return fmt.Sprintf("gl-go/%s auth/%s google-byoid-sdk source/%s sa-impersonation/%t config-lifetime/%t", - goVersion(), - "unknown", - credSource.credentialSourceType(), - conf.ServiceAccountImpersonationURL != "", - conf.ServiceAccountImpersonationLifetimeSeconds != 0) -} - -// Token allows tokenSource to conform to the oauth2.TokenSource interface. -func (ts tokenSource) Token() (*oauth2.Token, error) { - conf := ts.conf - - credSource, err := conf.parse(ts.ctx) - if err != nil { - return nil, err - } - subjectToken, err := credSource.subjectToken() - - if err != nil { - return nil, err - } - stsRequest := stsexchange.TokenExchangeRequest{ - GrantType: "urn:ietf:params:oauth:grant-type:token-exchange", - Audience: conf.Audience, - Scope: conf.Scopes, - RequestedTokenType: "urn:ietf:params:oauth:token-type:access_token", - SubjectToken: subjectToken, - SubjectTokenType: conf.SubjectTokenType, - } - header := make(http.Header) - header.Add("Content-Type", "application/x-www-form-urlencoded") - header.Add("x-goog-api-client", getMetricsHeaderValue(conf, credSource)) - clientAuth := stsexchange.ClientAuthentication{ - AuthStyle: oauth2.AuthStyleInHeader, - ClientID: conf.ClientID, - ClientSecret: conf.ClientSecret, - } - var options map[string]interface{} - // Do not pass workforce_pool_user_project when client authentication is used. - // The client ID is sufficient for determining the user project. - if conf.WorkforcePoolUserProject != "" && conf.ClientID == "" { - options = map[string]interface{}{ - "userProject": conf.WorkforcePoolUserProject, - } - } - stsResp, err := stsexchange.ExchangeToken(ts.ctx, conf.TokenURL, &stsRequest, clientAuth, header, options) - if err != nil { - return nil, err - } - - accessToken := &oauth2.Token{ - AccessToken: stsResp.AccessToken, - TokenType: stsResp.TokenType, - } - if stsResp.ExpiresIn < 0 { - return nil, fmt.Errorf("oauth2/google: got invalid expiry from security token service") - } else if stsResp.ExpiresIn >= 0 { - accessToken.Expiry = now().Add(time.Duration(stsResp.ExpiresIn) * time.Second) - } - - if stsResp.RefreshToken != "" { - accessToken.RefreshToken = stsResp.RefreshToken - } - return accessToken, nil -} diff --git a/google/internal/externalaccount/err.go b/google/internal/externalaccount/err.go deleted file mode 100644 index 233a78cef..000000000 --- a/google/internal/externalaccount/err.go +++ /dev/null @@ -1,18 +0,0 @@ -// Copyright 2020 The Go Authors. All rights reserved. -// Use of this source code is governed by a BSD-style -// license that can be found in the LICENSE file. - -package externalaccount - -import "fmt" - -// Error for handling OAuth related error responses as stated in rfc6749#5.2. -type Error struct { - Code string - URI string - Description string -} - -func (err *Error) Error() string { - return fmt.Sprintf("got error code %s from %s: %s", err.Code, err.URI, err.Description) -} diff --git a/google/internal/externalaccount/err_test.go b/google/internal/externalaccount/err_test.go deleted file mode 100644 index 687380d71..000000000 --- a/google/internal/externalaccount/err_test.go +++ /dev/null @@ -1,19 +0,0 @@ -// Copyright 2020 The Go Authors. All rights reserved. -// Use of this source code is governed by a BSD-style -// license that can be found in the LICENSE file. - -package externalaccount - -import "testing" - -func TestError(t *testing.T) { - e := Error{ - "42", - "http:thisIsAPlaceholder", - "The Answer!", - } - want := "got error code 42 from http:thisIsAPlaceholder: The Answer!" - if got := e.Error(); got != want { - t.Errorf("Got error message %q; want %q", got, want) - } -} diff --git a/google/internal/externalaccount/impersonate_test.go b/google/internal/externalaccount/impersonate_test.go deleted file mode 100644 index 0ab6d6190..000000000 --- a/google/internal/externalaccount/impersonate_test.go +++ /dev/null @@ -1,144 +0,0 @@ -// Copyright 2021 The Go Authors. All rights reserved. -// Use of this source code is governed by a BSD-style -// license that can be found in the LICENSE file. - -package externalaccount - -import ( - "context" - "io/ioutil" - "net/http" - "net/http/httptest" - "testing" -) - -var ( - baseImpersonateCredsReqBody = "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%2Fcloud-platform&subject_token=street123&subject_token_type=urn%3Aietf%3Aparams%3Aoauth%3Atoken-type%3Ajwt" - baseImpersonateCredsRespBody = `{"accessToken":"Second.Access.Token","expireTime":"2020-12-28T15:01:23Z"}` -) - -func createImpersonationServer(urlWanted, authWanted, bodyWanted, response string, t *testing.T) *httptest.Server { - return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if got, want := r.URL.String(), urlWanted; got != want { - t.Errorf("URL.String(): got %v but want %v", got, want) - } - headerAuth := r.Header.Get("Authorization") - if got, want := headerAuth, authWanted; got != want { - t.Errorf("got %v but want %v", got, want) - } - headerContentType := r.Header.Get("Content-Type") - if got, want := headerContentType, "application/json"; got != want { - t.Errorf("got %v but want %v", got, want) - } - body, err := ioutil.ReadAll(r.Body) - if err != nil { - t.Fatalf("Failed reading request body: %v.", err) - } - if got, want := string(body), bodyWanted; got != want { - t.Errorf("Unexpected impersonation payload: got %v but want %v", got, want) - } - w.Header().Set("Content-Type", "application/json") - w.Write([]byte(response)) - })) -} - -func createTargetServer(metricsHeaderWanted string, t *testing.T) *httptest.Server { - return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if got, want := r.URL.String(), "/"; got != want { - t.Errorf("URL.String(): got %v but want %v", got, want) - } - headerAuth := r.Header.Get("Authorization") - if got, want := headerAuth, "Basic cmJyZ25vZ25yaG9uZ28zYmk0Z2I5Z2hnOWc6bm90c29zZWNyZXQ="; got != want { - t.Errorf("got %v but want %v", got, want) - } - headerContentType := r.Header.Get("Content-Type") - if got, want := headerContentType, "application/x-www-form-urlencoded"; got != want { - t.Errorf("got %v but want %v", got, want) - } - headerMetrics := r.Header.Get("x-goog-api-client") - if got, want := headerMetrics, metricsHeaderWanted; got != want { - t.Errorf("got %v but want %v", got, want) - } - body, err := ioutil.ReadAll(r.Body) - if err != nil { - t.Fatalf("Failed reading request body: %v.", err) - } - if got, want := string(body), baseImpersonateCredsReqBody; got != want { - t.Errorf("Unexpected exchange payload: got %v but want %v", got, want) - } - w.Header().Set("Content-Type", "application/json") - w.Write([]byte(baseCredsResponseBody)) - })) -} - -var impersonationTests = []struct { - name string - config Config - expectedImpersonationBody string - expectedMetricsHeader string -}{ - { - name: "Base Impersonation", - config: Config{ - Audience: "32555940559.apps.googleusercontent.com", - SubjectTokenType: "urn:ietf:params:oauth:token-type:jwt", - TokenInfoURL: "http://localhost:8080/v1/tokeninfo", - ClientSecret: "notsosecret", - ClientID: "rbrgnognrhongo3bi4gb9ghg9g", - CredentialSource: testBaseCredSource, - Scopes: []string{"https://www.googleapis.com/auth/devstorage.full_control"}, - }, - expectedImpersonationBody: "{\"lifetime\":\"3600s\",\"scope\":[\"https://www.googleapis.com/auth/devstorage.full_control\"]}", - expectedMetricsHeader: getExpectedMetricsHeader("file", true, false), - }, - { - name: "With TokenLifetime Set", - config: Config{ - Audience: "32555940559.apps.googleusercontent.com", - SubjectTokenType: "urn:ietf:params:oauth:token-type:jwt", - TokenInfoURL: "http://localhost:8080/v1/tokeninfo", - ClientSecret: "notsosecret", - ClientID: "rbrgnognrhongo3bi4gb9ghg9g", - CredentialSource: testBaseCredSource, - Scopes: []string{"https://www.googleapis.com/auth/devstorage.full_control"}, - ServiceAccountImpersonationLifetimeSeconds: 10000, - }, - expectedImpersonationBody: "{\"lifetime\":\"10000s\",\"scope\":[\"https://www.googleapis.com/auth/devstorage.full_control\"]}", - expectedMetricsHeader: getExpectedMetricsHeader("file", true, true), - }, -} - -func TestImpersonation(t *testing.T) { - for _, tt := range impersonationTests { - t.Run(tt.name, func(t *testing.T) { - testImpersonateConfig := tt.config - impersonateServer := createImpersonationServer("/", "Bearer Sample.Access.Token", tt.expectedImpersonationBody, baseImpersonateCredsRespBody, t) - defer impersonateServer.Close() - testImpersonateConfig.ServiceAccountImpersonationURL = impersonateServer.URL - - targetServer := createTargetServer(tt.expectedMetricsHeader, t) - defer targetServer.Close() - testImpersonateConfig.TokenURL = targetServer.URL - - ourTS, err := testImpersonateConfig.tokenSource(context.Background(), "http") - if err != nil { - t.Fatalf("Failed to create TokenSource: %v", err) - } - - oldNow := now - defer func() { now = oldNow }() - now = testNow - - tok, err := ourTS.Token() - if err != nil { - t.Fatalf("Unexpected error: %e", err) - } - if got, want := tok.AccessToken, "Second.Access.Token"; got != want { - t.Errorf("Unexpected access token: got %v, but wanted %v", got, want) - } - if got, want := tok.TokenType, "Bearer"; got != want { - t.Errorf("Unexpected TokenType: got %v, but wanted %v", got, want) - } - }) - } -} diff --git a/google/internal/externalaccount/impersonate.go b/google/internal/impersonate/impersonate.go similarity index 99% rename from google/internal/externalaccount/impersonate.go rename to google/internal/impersonate/impersonate.go index 54c8f209f..6bc3af110 100644 --- a/google/internal/externalaccount/impersonate.go +++ b/google/internal/impersonate/impersonate.go @@ -2,7 +2,7 @@ // Use of this source code is governed by a BSD-style // license that can be found in the LICENSE file. -package externalaccount +package impersonate import ( "bytes"