diff --git a/auth/credentials/doc.go b/auth/credentials/doc.go index f36bebc34498..1dbb2866b918 100644 --- a/auth/credentials/doc.go +++ b/auth/credentials/doc.go @@ -25,53 +25,6 @@ // For more information on using workload identity federation, refer to // https://cloud.google.com/iam/docs/how-to#using-workload-identity-federation. // -// # 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: -// -// - [Amazon Web Services (AWS)](https://cloud.google.com/iam/docs/workload-identity-federation-with-other-clouds#aws) -// - [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. -// // # Credentials // // The [cloud.google.com/go/auth.Credentials] type represents Google @@ -85,4 +38,8 @@ // OpenID Connect (OIDC). Workload identity federation is recommended for // non-Google Cloud environments as it avoids the need to download, manage, and // store service account private keys locally. +// +// # Workforce Identity Federation +// +// For more information on this feature see [cloud.google.com/go/auth/credentials/externalaccount]. package credentials diff --git a/auth/credentials/externalaccount/doc.go b/auth/credentials/externalaccount/doc.go new file mode 100644 index 000000000000..e494a6f03fd3 --- /dev/null +++ b/auth/credentials/externalaccount/doc.go @@ -0,0 +1,110 @@ +// Copyright 2024 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package externalaccount provides support for creating workload identity +// federation and workforce identity federation token providers 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 [SubjectTokenProvider] interface for OIDC/SAML providers, or one that +// implements [AwsSecurityCredentialsProvider] for AWS providers. This can then +// be used when building a [Options].The [cloud.google.com/go/auth.Credentials] +// created from the options using [NewCredentials] 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.WithTokenProvider(yourTokenProvider)) +// +// # 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: +// +// - [Amazon Web Services (AWS)](https://cloud.google.com/iam/docs/workload-identity-federation-with-other-clouds#aws) +// - [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. +package externalaccount diff --git a/auth/credentials/externalaccount/externalaccount.go b/auth/credentials/externalaccount/externalaccount.go new file mode 100644 index 000000000000..f350e1973803 --- /dev/null +++ b/auth/credentials/externalaccount/externalaccount.go @@ -0,0 +1,364 @@ +// Copyright 2024 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package externalaccount + +import ( + "context" + "fmt" + "net/http" + + "cloud.google.com/go/auth" + iexacc "cloud.google.com/go/auth/credentials/internal/externalaccount" + "cloud.google.com/go/auth/internal" + "cloud.google.com/go/auth/internal/credsfile" +) + +// Options for creating a [cloud.google.com/go/auth.Credentials]. +type Options 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.UNIVERSE_DOMAIN/v1/token, with UNIVERSE_DOMAIN set + // to the default service domain googleapis.com unless UniverseDomain is + // set. 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. + 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. 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. Optional. + 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. Optional. + QuotaProjectID string + // Scopes contains the desired scopes for the returned access token. + // Optional. + Scopes []string + // WorkforcePoolUserProject should be set when it is 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 + // UniverseDomain is the default service domain for a given Cloud universe. + // This value will be used in the default STS token URL. The default value + // is "googleapis.com". It will not be used if TokenURL is set. Optional. + UniverseDomain string + // SubjectTokenProvider is an optional token provider for OIDC/SAML + // credentials. One of SubjectTokenProvider, AWSSecurityCredentialProvider + // or CredentialSource must be provided. Optional. + SubjectTokenProvider SubjectTokenProvider + // AwsSecurityCredentialsProvider is an AWS Security Credential provider + // for AWS credentials. One of SubjectTokenProvider, + // AWSSecurityCredentialProvider or CredentialSource must be provided. Optional. + AwsSecurityCredentialsProvider AwsSecurityCredentialsProvider + + // Client configures the underlying client used to make network requests + // when fetching tokens. Optional. + Client *http.Client +} + +// 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 + // 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 + // 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 + // 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 + + // Headers are the headers to attach to the request for URL sourced + // credentials. + Headers map[string]string + // RegionURL is the metadata URL to retrieve the region from for EC2 AWS + // credentials. + RegionURL string + // 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 + // IMDSv2SessionTokenURL is the URL to retrieve the session token when using + // IMDSv2 in AWS. + IMDSv2SessionTokenURL string + // Format is the format type for the subject token. Used for File and URL + // sourced credentials. + Format *Format +} + +// Format contains information needed to retrieve 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 + // 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 +} + +// 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 + // TimeoutMillis is the timeout duration, in milliseconds. Defaults to 30000 milliseconds when not provided. Optional. + TimeoutMillis int + // 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 +} + +// SubjectTokenProvider can be used to supply a subject token to exchange for a +// GCP access token. +type SubjectTokenProvider interface { + // SubjectToken should return a valid subject token or an error. + // The external account token provider does not cache the returned subject + // token, so caching logic should be implemented in the provider to prevent + // multiple requests for the same subject token. + SubjectToken(ctx context.Context, opts *RequestOptions) (string, error) +} + +// RequestOptions contains information about the requested subject token or AWS +// security credentials from the Google external account credential. +type RequestOptions 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 +} + +// AwsSecurityCredentialsProvider can be used to supply AwsSecurityCredentials +// and an AWS Region to exchange for a GCP access token. +type AwsSecurityCredentialsProvider interface { + // AwsRegion should return the AWS region or an error. + AwsRegion(ctx context.Context, opts *RequestOptions) (string, error) + // GetAwsSecurityCredentials should return a valid set of + // AwsSecurityCredentials or an error. The external account token provider + // does not cache the returned security credentials, so caching logic should + // be implemented in the provider to prevent multiple requests for the + // same security credentials. + AwsSecurityCredentials(ctx context.Context, opts *RequestOptions) (*AwsSecurityCredentials, error) +} + +// 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"` + // SessionToken is the AWS Session token. This should be provided for + // temporary AWS security credentials - Optional. + SessionToken string `json:"Token"` +} + +func (o *Options) validate() error { + if o == nil { + return fmt.Errorf("externalaccount: options must be provided") + } + return nil +} + +func (o *Options) client() *http.Client { + if o.Client != nil { + return o.Client + } + return internal.CloneDefaultClient() +} + +func (o *Options) toInternalOpts() *iexacc.Options { + if o == nil { + return nil + } + iOpts := &iexacc.Options{ + Audience: o.Audience, + SubjectTokenType: o.SubjectTokenType, + TokenURL: o.TokenURL, + TokenInfoURL: o.TokenInfoURL, + ServiceAccountImpersonationURL: o.ServiceAccountImpersonationURL, + ServiceAccountImpersonationLifetimeSeconds: o.ServiceAccountImpersonationLifetimeSeconds, + ClientSecret: o.ClientSecret, + ClientID: o.ClientID, + QuotaProjectID: o.QuotaProjectID, + Scopes: o.Scopes, + WorkforcePoolUserProject: o.WorkforcePoolUserProject, + UniverseDomain: o.UniverseDomain, + SubjectTokenProvider: toInternalSubjectTokenProvider(o.SubjectTokenProvider), + AwsSecurityCredentialsProvider: toInternalAwsSecurityCredentialsProvider(o.AwsSecurityCredentialsProvider), + Client: o.client(), + } + if o.CredentialSource != nil { + cs := o.CredentialSource + iOpts.CredentialSource = &credsfile.CredentialSource{ + File: cs.File, + URL: cs.URL, + Headers: cs.Headers, + EnvironmentID: cs.EnvironmentID, + RegionURL: cs.RegionURL, + RegionalCredVerificationURL: cs.RegionalCredVerificationURL, + CredVerificationURL: cs.URL, + IMDSv2SessionTokenURL: cs.IMDSv2SessionTokenURL, + } + if cs.Executable != nil { + cse := cs.Executable + iOpts.CredentialSource.Executable = &credsfile.ExecutableConfig{ + Command: cse.Command, + TimeoutMillis: cse.TimeoutMillis, + OutputFile: cse.OutputFile, + } + } + if cs.Format != nil { + csf := cs.Format + iOpts.CredentialSource.Format = &credsfile.Format{ + Type: csf.Type, + SubjectTokenFieldName: csf.SubjectTokenFieldName, + } + } + } + return iOpts +} + +// NewCredentials returns a [cloud.google.com/go/auth.Credentials] configured +// with the provided options. +func NewCredentials(opts *Options) (*auth.Credentials, error) { + if err := opts.validate(); err != nil { + return nil, err + } + + tp, err := iexacc.NewTokenProvider(opts.toInternalOpts()) + if err != nil { + return nil, err + } + + var udp, qpp auth.CredentialsPropertyProvider + if opts.UniverseDomain != "" { + udp = internal.StaticCredentialsProperty(opts.UniverseDomain) + } + if opts.QuotaProjectID != "" { + qpp = internal.StaticCredentialsProperty(opts.QuotaProjectID) + } + return auth.NewCredentials(&auth.CredentialsOptions{ + TokenProvider: auth.NewCachedTokenProvider(tp, nil), + UniverseDomainProvider: udp, + QuotaProjectIDProvider: qpp, + }), nil +} + +func toInternalSubjectTokenProvider(stp SubjectTokenProvider) iexacc.SubjectTokenProvider { + if stp == nil { + return nil + } + return &subjectTokenProviderAdapter{stp: stp} +} + +func toInternalAwsSecurityCredentialsProvider(scp AwsSecurityCredentialsProvider) iexacc.AwsSecurityCredentialsProvider { + if scp == nil { + return nil + } + return &awsSecurityCredentialsAdapter{scp: scp} +} + +func toInternalAwsSecurityCredentials(sc *AwsSecurityCredentials) *iexacc.AwsSecurityCredentials { + if sc == nil { + return nil + } + return &iexacc.AwsSecurityCredentials{ + AccessKeyID: sc.AccessKeyID, + SecretAccessKey: sc.SecretAccessKey, + SessionToken: sc.SessionToken, + } +} + +func toRequestOptions(opts *iexacc.RequestOptions) *RequestOptions { + if opts == nil { + return nil + } + return &RequestOptions{ + Audience: opts.Audience, + SubjectTokenType: opts.SubjectTokenType, + } +} + +// subjectTokenProviderAdapter is an adapter to convert the user supplied +// interface to its internal counterpart. +type subjectTokenProviderAdapter struct { + stp SubjectTokenProvider +} + +func (tp *subjectTokenProviderAdapter) SubjectToken(ctx context.Context, opts *iexacc.RequestOptions) (string, error) { + return tp.stp.SubjectToken(ctx, toRequestOptions(opts)) +} + +// awsSecurityCredentialsAdapter is an adapter to convert the user supplied +// interface to its internal counterpart. +type awsSecurityCredentialsAdapter struct { + scp AwsSecurityCredentialsProvider +} + +func (sc *awsSecurityCredentialsAdapter) AwsRegion(ctx context.Context, opts *iexacc.RequestOptions) (string, error) { + return sc.scp.AwsRegion(ctx, toRequestOptions(opts)) +} + +func (sc *awsSecurityCredentialsAdapter) AwsSecurityCredentials(ctx context.Context, opts *iexacc.RequestOptions) (*iexacc.AwsSecurityCredentials, error) { + resp, err := sc.scp.AwsSecurityCredentials(ctx, toRequestOptions(opts)) + if err != nil { + return nil, err + } + return toInternalAwsSecurityCredentials(resp), nil +} diff --git a/auth/credentials/externalaccount/externalaccount_test.go b/auth/credentials/externalaccount/externalaccount_test.go new file mode 100644 index 000000000000..7f958a851387 --- /dev/null +++ b/auth/credentials/externalaccount/externalaccount_test.go @@ -0,0 +1,280 @@ +// Copyright 2024 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package externalaccount + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + iexacc "cloud.google.com/go/auth/credentials/internal/externalaccount" +) + +const ( + accessKeyID = "accessKeyID" + secretAccessKey = "secret" + sessionToken = "sessionTok" + subjectTok = `%7B%22url%22%3A%22https%3A%2F%2Fsts.us-east-2.amazonaws.com%3FAction%3DGetCallerIdentity%5Cu0026Version%3D2011-06-15%22%2C%22method%22%3A%22POST%22%2C%22headers%22%3A%5B%7B%22key%22%3A%22Authorization%22%2C%22value%22%3A%22AWS4-HMAC-SHA256+Credential%3DaccessKeyID%2F20110909%2Fus-east-2%2Fsts%2Faws4_request%2C+SignedHeaders%3Dhost%3Bx-amz-date%3Bx-amz-security-token%3Bx-goog-cloud-target-resource%2C+Signature%3D19e8a661c61d39d19a9c82e272deef7784908176b82b0eb42f328d2c640f369b%22%7D%2C%7B%22key%22%3A%22Host%22%2C%22value%22%3A%22sts.us-east-2.amazonaws.com%22%7D%2C%7B%22key%22%3A%22X-Amz-Date%22%2C%22value%22%3A%2220110909T233600Z%22%7D%2C%7B%22key%22%3A%22X-Amz-Security-Token%22%2C%22value%22%3A%22sessionTok%22%7D%2C%7B%22key%22%3A%22X-Goog-Cloud-Target-Resource%22%2C%22value%22%3A%2232555940559.apps.googleusercontent.com%22%7D%5D%7D` +) + +var ( + defaultTime = time.Date(2011, 9, 9, 23, 36, 0, 0, time.UTC) +) + +func TestNewCredentials_AwsSecurityCredentials(t *testing.T) { + opts := &Options{ + Audience: "32555940559.apps.googleusercontent.com", + SubjectTokenType: "urn:ietf:params:oauth:token-type:jwt", + ClientSecret: "notsosecret", + ClientID: "rbrgnognrhongo3bi4gb9ghg9g", + } + opts.AwsSecurityCredentialsProvider = &fakeAwsCredsProvider{ + awsRegion: "us-east-2", + creds: &AwsSecurityCredentials{ + AccessKeyID: accessKeyID, + SecretAccessKey: secretAccessKey, + SessionToken: sessionToken, + }, + } + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + defer r.Body.Close() + if r.URL.Path == "/sts" { + r.ParseForm() + if got, want := r.Form.Get("subject_token"), subjectTok; got != want { + t.Errorf("got %q, want %q", got, want) + } + + resp := &struct { + AccessToken string `json:"access_token"` + ExpiresIn int `json:"expires_in"` + }{ + AccessToken: "a_fake_token_sts", + ExpiresIn: 60, + } + if err := json.NewEncoder(w).Encode(&resp); err != nil { + t.Error(err) + } + } else if r.URL.Path == "/impersonate" { + if want := "a_fake_token_sts"; !strings.Contains(r.Header.Get("Authorization"), want) { + t.Errorf("missing sts token: got %q, want %q", r.Header.Get("Authorization"), want) + } + + resp := &struct { + AccessToken string `json:"accessToken"` + ExpireTime string `json:"expireTime"` + }{ + AccessToken: "a_fake_token", + ExpireTime: "2006-01-02T15:04:05Z", + } + if err := json.NewEncoder(w).Encode(&resp); err != nil { + t.Error(err) + } + } else { + t.Errorf("unexpected call to %q", r.URL.Path) + } + })) + opts.ServiceAccountImpersonationURL = ts.URL + "/impersonate" + opts.TokenURL = ts.URL + "/sts" + + oldNow := iexacc.Now + defer func() { + iexacc.Now = oldNow + }() + iexacc.Now = func() time.Time { + return defaultTime + } + + creds, err := NewCredentials(opts) + if err != nil { + t.Fatalf("NewCredentials() = %v", err) + } + if _, err := creds.Token(context.Background()); err != nil { + t.Fatalf("creds.Token() = %v", err) + } +} + +func TestNewCredentials_SubjectTokenProvider(t *testing.T) { + opts := &Options{ + Audience: "32555940559.apps.googleusercontent.com", + SubjectTokenType: "urn:ietf:params:oauth:token-type:jwt", + ClientSecret: "notsosecret", + ClientID: "rbrgnognrhongo3bi4gb9ghg9g", + } + opts.SubjectTokenProvider = &fakeSubjectTokenProvider{ + subjectToken: "fake_token", + } + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + defer r.Body.Close() + if r.URL.Path == "/sts" { + r.ParseForm() + if got, want := r.Form.Get("subject_token"), "fake_token"; got != want { + t.Errorf("got %q, want %q", got, want) + } + + resp := &struct { + AccessToken string `json:"access_token"` + ExpiresIn int `json:"expires_in"` + }{ + AccessToken: "a_fake_token_sts", + ExpiresIn: 60, + } + if err := json.NewEncoder(w).Encode(&resp); err != nil { + t.Error(err) + } + } else if r.URL.Path == "/impersonate" { + if want := "a_fake_token_sts"; !strings.Contains(r.Header.Get("Authorization"), want) { + t.Errorf("missing sts token: got %q, want %q", r.Header.Get("Authorization"), want) + } + + resp := &struct { + AccessToken string `json:"accessToken"` + ExpireTime string `json:"expireTime"` + }{ + AccessToken: "a_fake_token", + ExpireTime: "2006-01-02T15:04:05Z", + } + if err := json.NewEncoder(w).Encode(&resp); err != nil { + t.Error(err) + } + } else { + t.Errorf("unexpected call to %q", r.URL.Path) + } + })) + opts.ServiceAccountImpersonationURL = ts.URL + "/impersonate" + opts.TokenURL = ts.URL + "/sts" + + oldNow := iexacc.Now + defer func() { + iexacc.Now = oldNow + }() + iexacc.Now = func() time.Time { + return defaultTime + } + + creds, err := NewCredentials(opts) + if err != nil { + t.Fatalf("NewCredentials() = %v", err) + } + if _, err := creds.Token(context.Background()); err != nil { + t.Fatalf("creds.Token() = %v", err) + } +} + +func TestNewCredentials_CredentialSourceURL(t *testing.T) { + opts := &Options{ + Audience: "//iam.googleapis.com/projects/$PROJECT_NUMBER/locations/global/workloadIdentityPools/$POOL_ID/providers/$PROVIDER_ID", + SubjectTokenType: "urn:ietf:params:oauth:token-type:jwt", + CredentialSource: &CredentialSource{ + Format: &Format{ + Type: "json", + SubjectTokenFieldName: "id_token", + }, + }, + } + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + defer r.Body.Close() + if r.URL.Path == "/token" { + resp := &struct { + Token string `json:"id_token"` + }{ + Token: "a_fake_token_base", + } + if err := json.NewEncoder(w).Encode(&resp); err != nil { + t.Error(err) + } + } else if r.URL.Path == "/sts" { + r.ParseForm() + if got, want := r.Form.Get("subject_token"), "a_fake_token_base"; got != want { + t.Errorf("got %q, want %q", got, want) + } + + resp := &struct { + AccessToken string `json:"access_token"` + ExpiresIn int `json:"expires_in"` + }{ + AccessToken: "a_fake_token_sts", + ExpiresIn: 60, + } + if err := json.NewEncoder(w).Encode(&resp); err != nil { + t.Error(err) + } + } else if r.URL.Path == "/impersonate" { + if want := "a_fake_token_sts"; !strings.Contains(r.Header.Get("Authorization"), want) { + t.Errorf("missing sts token: got %q, want %q", r.Header.Get("Authorization"), want) + } + + resp := &struct { + AccessToken string `json:"accessToken"` + ExpireTime string `json:"expireTime"` + }{ + AccessToken: "a_fake_token", + ExpireTime: "2006-01-02T15:04:05Z", + } + if err := json.NewEncoder(w).Encode(&resp); err != nil { + t.Error(err) + } + } else { + t.Errorf("unexpected call to %q", r.URL.Path) + } + })) + opts.ServiceAccountImpersonationURL = ts.URL + "/impersonate" + opts.TokenURL = ts.URL + "/sts" + opts.CredentialSource.URL = ts.URL + "/token" + + creds, err := NewCredentials(opts) + if err != nil { + t.Fatalf("NewCredentials() = %v", err) + } + if _, err := creds.Token(context.Background()); err != nil { + t.Fatalf("creds.Token() = %v", err) + } +} + +type fakeAwsCredsProvider struct { + credsErr error + regionErr error + awsRegion string + creds *AwsSecurityCredentials +} + +func (acp fakeAwsCredsProvider) AwsRegion(ctx context.Context, opts *RequestOptions) (string, error) { + if acp.regionErr != nil { + return "", acp.regionErr + } + return acp.awsRegion, nil +} + +func (acp fakeAwsCredsProvider) AwsSecurityCredentials(ctx context.Context, opts *RequestOptions) (*AwsSecurityCredentials, error) { + if acp.credsErr != nil { + return nil, acp.credsErr + } + return acp.creds, nil +} + +type fakeSubjectTokenProvider struct { + err error + subjectToken string +} + +func (p fakeSubjectTokenProvider) SubjectToken(ctx context.Context, options *RequestOptions) (string, error) { + if p.err != nil { + return "", p.err + } + return p.subjectToken, nil +} diff --git a/auth/credentials/filetypes.go b/auth/credentials/filetypes.go index 8a0c49d91501..a66e56d70f82 100644 --- a/auth/credentials/filetypes.go +++ b/auth/credentials/filetypes.go @@ -165,14 +165,16 @@ func handleExternalAccount(f *credsfile.ExternalAccountFile, opts *DetectOptions TokenURL: f.TokenURL, TokenInfoURL: f.TokenInfoURL, ServiceAccountImpersonationURL: f.ServiceAccountImpersonationURL, - ServiceAccountImpersonationLifetimeSeconds: f.ServiceAccountImpersonation.TokenLifetimeSeconds, - ClientSecret: f.ClientSecret, - ClientID: f.ClientID, - CredentialSource: f.CredentialSource, - QuotaProjectID: f.QuotaProjectID, - Scopes: opts.scopes(), - WorkforcePoolUserProject: f.WorkforcePoolUserProject, - Client: opts.client(), + ClientSecret: f.ClientSecret, + ClientID: f.ClientID, + CredentialSource: f.CredentialSource, + QuotaProjectID: f.QuotaProjectID, + Scopes: opts.scopes(), + WorkforcePoolUserProject: f.WorkforcePoolUserProject, + Client: opts.client(), + } + if f.ServiceAccountImpersonation != nil { + externalOpts.ServiceAccountImpersonationLifetimeSeconds = f.ServiceAccountImpersonation.TokenLifetimeSeconds } return externalaccount.NewTokenProvider(externalOpts) } diff --git a/auth/credentials/internal/externalaccount/aws_provider.go b/auth/credentials/internal/externalaccount/aws_provider.go index f5447966c93b..d9e1dcddf64d 100644 --- a/auth/credentials/internal/externalaccount/aws_provider.go +++ b/auth/credentials/internal/externalaccount/aws_provider.go @@ -60,6 +60,8 @@ const ( // The AWS authorization header name for the auto-generated date. awsDateHeader = "x-amz-date" + defaultRegionalCredentialVerificationURL = "https://sts.{region}.amazonaws.com?Action=GetCallerIdentity&Version=2011-06-15" + // Supported AWS configuration environment variables. awsAccessKeyIDEnvVar = "AWS_ACCESS_KEY_ID" awsDefaultRegionEnvVar = "AWS_DEFAULT_REGION" @@ -81,14 +83,20 @@ type awsSubjectProvider struct { TargetResource string requestSigner *awsRequestSigner region string + securityCredentialsProvider AwsSecurityCredentialsProvider + reqOpts *RequestOptions Client *http.Client } func (sp *awsSubjectProvider) subjectToken(ctx context.Context) (string, error) { + // Set Defaults + if sp.RegionalCredVerificationURL == "" { + sp.RegionalCredVerificationURL = defaultRegionalCredentialVerificationURL + } if sp.requestSigner == nil { headers := make(map[string]string) - if shouldUseMetadataServer() { + if sp.shouldUseMetadataServer() { awsSessionToken, err := sp.getAWSSessionToken(ctx) if err != nil { return "", err @@ -170,6 +178,9 @@ func (sp *awsSubjectProvider) subjectToken(ctx context.Context) (string, error) } func (sp *awsSubjectProvider) providerType() string { + if sp.securityCredentialsProvider != nil { + return programmaticProviderType + } return awsProviderType } @@ -200,6 +211,9 @@ func (sp *awsSubjectProvider) getAWSSessionToken(ctx context.Context) (string, e } func (sp *awsSubjectProvider) getRegion(ctx context.Context, headers map[string]string) (string, error) { + if sp.securityCredentialsProvider != nil { + return sp.securityCredentialsProvider.AwsRegion(ctx, sp.reqOpts) + } if canRetrieveRegionFromEnvironment() { if envAwsRegion := getenv(awsRegionEnvVar); envAwsRegion != "" { return envAwsRegion, nil @@ -244,12 +258,15 @@ func (sp *awsSubjectProvider) getRegion(ctx context.Context, headers map[string] return string(respBody[:bodyLen-1]), nil } -func (sp *awsSubjectProvider) getSecurityCredentials(ctx context.Context, headers map[string]string) (result awsSecurityCredentials, err error) { +func (sp *awsSubjectProvider) getSecurityCredentials(ctx context.Context, headers map[string]string) (result *AwsSecurityCredentials, err error) { + if sp.securityCredentialsProvider != nil { + return sp.securityCredentialsProvider.AwsSecurityCredentials(ctx, sp.reqOpts) + } if canRetrieveSecurityCredentialFromEnvironment() { - return awsSecurityCredentials{ + return &AwsSecurityCredentials{ AccessKeyID: getenv(awsAccessKeyIDEnvVar), SecretAccessKey: getenv(awsSecretAccessKeyEnvVar), - SecurityToken: getenv(awsSessionTokenEnvVar), + SessionToken: getenv(awsSessionTokenEnvVar), }, nil } @@ -272,8 +289,8 @@ func (sp *awsSubjectProvider) getSecurityCredentials(ctx context.Context, header return credentials, nil } -func (sp *awsSubjectProvider) getMetadataSecurityCredentials(ctx context.Context, roleName string, headers map[string]string) (awsSecurityCredentials, error) { - var result awsSecurityCredentials +func (sp *awsSubjectProvider) getMetadataSecurityCredentials(ctx context.Context, roleName string, headers map[string]string) (*AwsSecurityCredentials, error) { + var result *AwsSecurityCredentials req, err := http.NewRequestWithContext(ctx, "GET", fmt.Sprintf("%s/%s", sp.CredVerificationURL, roleName), nil) if err != nil { @@ -328,16 +345,10 @@ func (sp *awsSubjectProvider) getMetadataRoleName(ctx context.Context, headers m return string(respBody), nil } -type awsSecurityCredentials struct { - AccessKeyID string `json:"AccessKeyID"` - SecretAccessKey string `json:"SecretAccessKey"` - SecurityToken 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 } // signRequest adds the appropriate headers to an http.Request @@ -345,10 +356,10 @@ type awsRequestSigner struct { func (rs *awsRequestSigner) signRequest(req *http.Request) error { // req is assumed non-nil signedRequest := cloneRequest(req) - timestamp := now() + timestamp := Now() signedRequest.Header.Set("host", requestHost(req)) - if rs.AwsSecurityCredentials.SecurityToken != "" { - signedRequest.Header.Set(awsSecurityTokenHeader, rs.AwsSecurityCredentials.SecurityToken) + if rs.AwsSecurityCredentials.SessionToken != "" { + signedRequest.Header.Set(awsSecurityTokenHeader, rs.AwsSecurityCredentials.SessionToken) } if signedRequest.Header.Get("date") == "" { signedRequest.Header.Set(awsDateHeader, timestamp.Format(awsTimeFormatLong)) @@ -531,6 +542,6 @@ func canRetrieveSecurityCredentialFromEnvironment() bool { return getenv(awsAccessKeyIDEnvVar) != "" && getenv(awsSecretAccessKeyEnvVar) != "" } -func shouldUseMetadataServer() bool { - return !canRetrieveRegionFromEnvironment() || !canRetrieveSecurityCredentialFromEnvironment() +func (sp *awsSubjectProvider) shouldUseMetadataServer() bool { + return sp.securityCredentialsProvider == nil && (!canRetrieveRegionFromEnvironment() || !canRetrieveSecurityCredentialFromEnvironment()) } diff --git a/auth/credentials/internal/externalaccount/aws_provider_test.go b/auth/credentials/internal/externalaccount/aws_provider_test.go index c6cea3553a99..5c572fdf8935 100644 --- a/auth/credentials/internal/externalaccount/aws_provider_test.go +++ b/auth/credentials/internal/externalaccount/aws_provider_test.go @@ -17,6 +17,7 @@ package externalaccount import ( "context" "encoding/json" + "errors" "fmt" "net/http" "net/http/httptest" @@ -34,7 +35,7 @@ type validateHeaders func(r *http.Request) const ( accessKeyID = "ASIARD4OQDT6A77FR3CL" secretAccessKey = "Y8AfSaucF37G4PpvfguKZ3/l7Id4uocLXxX0+VTx" - securityToken = "IQoJb3JpZ2luX2VjEIz//////////wEaCXVzLWVhc3QtMiJGMEQCIH7MHX/Oy/OB8OlLQa9GrqU1B914+iMikqWQW7vPCKlgAiA/Lsv8Jcafn14owfxXn95FURZNKaaphj0ykpmS+Ki+CSq0AwhlEAAaDDA3NzA3MTM5MTk5NiIMx9sAeP1ovlMTMKLjKpEDwuJQg41/QUKx0laTZYjPlQvjwSqS3OB9P1KAXPWSLkliVMMqaHqelvMF/WO/glv3KwuTfQsavRNs3v5pcSEm4SPO3l7mCs7KrQUHwGP0neZhIKxEXy+Ls//1C/Bqt53NL+LSbaGv6RPHaX82laz2qElphg95aVLdYgIFY6JWV5fzyjgnhz0DQmy62/Vi8pNcM2/VnxeCQ8CC8dRDSt52ry2v+nc77vstuI9xV5k8mPtnaPoJDRANh0bjwY5Sdwkbp+mGRUJBAQRlNgHUJusefXQgVKBCiyJY4w3Csd8Bgj9IyDV+Azuy1jQqfFZWgP68LSz5bURyIjlWDQunO82stZ0BgplKKAa/KJHBPCp8Qi6i99uy7qh76FQAqgVTsnDuU6fGpHDcsDSGoCls2HgZjZFPeOj8mmRhFk1Xqvkbjuz8V1cJk54d3gIJvQt8gD2D6yJQZecnuGWd5K2e2HohvCc8Fc9kBl1300nUJPV+k4tr/A5R/0QfEKOZL1/k5lf1g9CREnrM8LVkGxCgdYMxLQow1uTL+QU67AHRRSp5PhhGX4Rek+01vdYSnJCMaPhSEgcLqDlQkhk6MPsyT91QMXcWmyO+cAZwUPwnRamFepuP4K8k2KVXs/LIJHLELwAZ0ekyaS7CptgOqS7uaSTFG3U+vzFZLEnGvWQ7y9IPNQZ+Dffgh4p3vF4J68y9049sI6Sr5d5wbKkcbm8hdCDHZcv4lnqohquPirLiFQ3q7B17V9krMPu3mz1cg4Ekgcrn/E09NTsxAqD8NcZ7C7ECom9r+X3zkDOxaajW6hu3Az8hGlyylDaMiFfRbBJpTIlxp7jfa7CxikNgNtEKLH9iCzvuSg2vhA==" + sessionToken = "IQoJb3JpZ2luX2VjEIz//////////wEaCXVzLWVhc3QtMiJGMEQCIH7MHX/Oy/OB8OlLQa9GrqU1B914+iMikqWQW7vPCKlgAiA/Lsv8Jcafn14owfxXn95FURZNKaaphj0ykpmS+Ki+CSq0AwhlEAAaDDA3NzA3MTM5MTk5NiIMx9sAeP1ovlMTMKLjKpEDwuJQg41/QUKx0laTZYjPlQvjwSqS3OB9P1KAXPWSLkliVMMqaHqelvMF/WO/glv3KwuTfQsavRNs3v5pcSEm4SPO3l7mCs7KrQUHwGP0neZhIKxEXy+Ls//1C/Bqt53NL+LSbaGv6RPHaX82laz2qElphg95aVLdYgIFY6JWV5fzyjgnhz0DQmy62/Vi8pNcM2/VnxeCQ8CC8dRDSt52ry2v+nc77vstuI9xV5k8mPtnaPoJDRANh0bjwY5Sdwkbp+mGRUJBAQRlNgHUJusefXQgVKBCiyJY4w3Csd8Bgj9IyDV+Azuy1jQqfFZWgP68LSz5bURyIjlWDQunO82stZ0BgplKKAa/KJHBPCp8Qi6i99uy7qh76FQAqgVTsnDuU6fGpHDcsDSGoCls2HgZjZFPeOj8mmRhFk1Xqvkbjuz8V1cJk54d3gIJvQt8gD2D6yJQZecnuGWd5K2e2HohvCc8Fc9kBl1300nUJPV+k4tr/A5R/0QfEKOZL1/k5lf1g9CREnrM8LVkGxCgdYMxLQow1uTL+QU67AHRRSp5PhhGX4Rek+01vdYSnJCMaPhSEgcLqDlQkhk6MPsyT91QMXcWmyO+cAZwUPwnRamFepuP4K8k2KVXs/LIJHLELwAZ0ekyaS7CptgOqS7uaSTFG3U+vzFZLEnGvWQ7y9IPNQZ+Dffgh4p3vF4J68y9049sI6Sr5d5wbKkcbm8hdCDHZcv4lnqohquPirLiFQ3q7B17V9krMPu3mz1cg4Ekgcrn/E09NTsxAqD8NcZ7C7ECom9r+X3zkDOxaajW6hu3Az8hGlyylDaMiFfRbBJpTIlxp7jfa7CxikNgNtEKLH9iCzvuSg2vhA==" ) var ( @@ -42,10 +43,10 @@ var ( secondDefaultTime = time.Date(2020, 8, 11, 6, 55, 22, 0, time.UTC) requestSignerWithToken = &awsRequestSigner{ RegionName: "us-east-2", - AwsSecurityCredentials: awsSecurityCredentials{ + AwsSecurityCredentials: &AwsSecurityCredentials{ AccessKeyID: accessKeyID, SecretAccessKey: secretAccessKey, - SecurityToken: securityToken, + SessionToken: sessionToken, }, } ) @@ -61,9 +62,9 @@ func TestAWSv4Signature_GetRequest(t *testing.T) { "Authorization": []string{"AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/20110909/us-east-1/host/aws4_request, SignedHeaders=date;host, Signature=b27ccfbfa7df52a200ff74193ca6e32d4b48b8856fab7ebf1c595d0670a7e470"}, } - oldNow := now - defer func() { now = oldNow }() - now = setTime(defaultTime) + oldNow := Now + defer func() { Now = oldNow }() + Now = setTime(defaultTime) testRequestSigner(t, defaultRequestSigner, input, output) } @@ -79,9 +80,9 @@ func TestAWSv4Signature_GetRequestWithRelativePath(t *testing.T) { "Authorization": []string{"AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/20110909/us-east-1/host/aws4_request, SignedHeaders=date;host, Signature=b27ccfbfa7df52a200ff74193ca6e32d4b48b8856fab7ebf1c595d0670a7e470"}, } - oldNow := now - defer func() { now = oldNow }() - now = setTime(defaultTime) + oldNow := Now + defer func() { Now = oldNow }() + Now = setTime(defaultTime) testRequestSigner(t, defaultRequestSigner, input, output) } @@ -97,9 +98,9 @@ func TestAWSv4Signature_GetRequestWithDotPath(t *testing.T) { "Authorization": []string{"AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/20110909/us-east-1/host/aws4_request, SignedHeaders=date;host, Signature=b27ccfbfa7df52a200ff74193ca6e32d4b48b8856fab7ebf1c595d0670a7e470"}, } - oldNow := now - defer func() { now = oldNow }() - now = setTime(defaultTime) + oldNow := Now + defer func() { Now = oldNow }() + Now = setTime(defaultTime) testRequestSigner(t, defaultRequestSigner, input, output) } @@ -115,9 +116,9 @@ func TestAWSv4Signature_GetRequestWithPointlessDotPath(t *testing.T) { "Authorization": []string{"AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/20110909/us-east-1/host/aws4_request, SignedHeaders=date;host, Signature=910e4d6c9abafaf87898e1eb4c929135782ea25bb0279703146455745391e63a"}, } - oldNow := now - defer func() { now = oldNow }() - now = setTime(defaultTime) + oldNow := Now + defer func() { Now = oldNow }() + Now = setTime(defaultTime) testRequestSigner(t, defaultRequestSigner, input, output) } @@ -133,9 +134,9 @@ func TestAWSv4Signature_GetRequestWithUtf8Path(t *testing.T) { "Authorization": []string{"AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/20110909/us-east-1/host/aws4_request, SignedHeaders=date;host, Signature=8d6634c189aa8c75c2e51e106b6b5121bed103fdb351f7d7d4381c738823af74"}, } - oldNow := now - defer func() { now = oldNow }() - now = setTime(defaultTime) + oldNow := Now + defer func() { Now = oldNow }() + Now = setTime(defaultTime) testRequestSigner(t, defaultRequestSigner, input, output) } @@ -151,9 +152,9 @@ func TestAWSv4Signature_GetRequestWithDuplicateQuery(t *testing.T) { "Authorization": []string{"AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/20110909/us-east-1/host/aws4_request, SignedHeaders=date;host, Signature=be7148d34ebccdc6423b19085378aa0bee970bdc61d144bd1a8c48c33079ab09"}, } - oldNow := now - defer func() { now = oldNow }() - now = setTime(defaultTime) + oldNow := Now + defer func() { Now = oldNow }() + Now = setTime(defaultTime) testRequestSigner(t, defaultRequestSigner, input, output) } @@ -169,9 +170,9 @@ func TestAWSv4Signature_GetRequestWithMisorderedQuery(t *testing.T) { "Authorization": []string{"AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/20110909/us-east-1/host/aws4_request, SignedHeaders=date;host, Signature=feb926e49e382bec75c9d7dcb2a1b6dc8aa50ca43c25d2bc51143768c0875acc"}, } - oldNow := now - defer func() { now = oldNow }() - now = setTime(defaultTime) + oldNow := Now + defer func() { Now = oldNow }() + Now = setTime(defaultTime) testRequestSigner(t, defaultRequestSigner, input, output) } @@ -187,9 +188,9 @@ func TestAWSv4Signature_GetRequestWithUtf8Query(t *testing.T) { "Authorization": []string{"AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/20110909/us-east-1/host/aws4_request, SignedHeaders=date;host, Signature=6fb359e9a05394cc7074e0feb42573a2601abc0c869a953e8c5c12e4e01f1a8c"}, } - oldNow := now - defer func() { now = oldNow }() - now = setTime(defaultTime) + oldNow := Now + defer func() { Now = oldNow }() + Now = setTime(defaultTime) testRequestSigner(t, defaultRequestSigner, input, output) } @@ -207,9 +208,9 @@ func TestAWSv4Signature_PostRequest(t *testing.T) { "Zoo": []string{"zoobar"}, } - oldNow := now - defer func() { now = oldNow }() - now = setTime(defaultTime) + oldNow := Now + defer func() { Now = oldNow }() + Now = setTime(defaultTime) testRequestSigner(t, defaultRequestSigner, input, output) } @@ -227,9 +228,9 @@ func TestAWSv4Signature_PostRequestWithCapitalizedHeaderValue(t *testing.T) { "Zoo": []string{"ZOOBAR"}, } - oldNow := now - defer func() { now = oldNow }() - now = setTime(defaultTime) + oldNow := Now + defer func() { Now = oldNow }() + Now = setTime(defaultTime) testRequestSigner(t, defaultRequestSigner, input, output) } @@ -247,9 +248,9 @@ func TestAWSv4Signature_PostRequestPhfft(t *testing.T) { "P": []string{"phfft"}, } - oldNow := now - defer func() { now = oldNow }() - now = setTime(defaultTime) + oldNow := Now + defer func() { Now = oldNow }() + Now = setTime(defaultTime) testRequestSigner(t, defaultRequestSigner, input, output) } @@ -267,9 +268,9 @@ func TestAWSv4Signature_PostRequestWithBody(t *testing.T) { "Authorization": []string{"AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/20110909/us-east-1/host/aws4_request, SignedHeaders=content-type;date;host, Signature=5a15b22cf462f047318703b92e6f4f38884e4a7ab7b1d6426ca46a8bd1c26cbc"}, } - oldNow := now - defer func() { now = oldNow }() - now = setTime(defaultTime) + oldNow := Now + defer func() { Now = oldNow }() + Now = setTime(defaultTime) testRequestSigner(t, defaultRequestSigner, input, output) } @@ -285,9 +286,9 @@ func TestAWSv4Signature_PostRequestWithQueryString(t *testing.T) { "Authorization": []string{"AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/20110909/us-east-1/host/aws4_request, SignedHeaders=date;host, Signature=b6e3b79003ce0743a491606ba1035a804593b0efb1e20a11cba83f8c25a57a92"}, } - oldNow := now - defer func() { now = oldNow }() - now = setTime(defaultTime) + oldNow := Now + defer func() { Now = oldNow }() + Now = setTime(defaultTime) testRequestSigner(t, defaultRequestSigner, input, output) } @@ -300,12 +301,12 @@ func TestAWSv4Signature_GetRequestWithSecurityToken(t *testing.T) { "Host": []string{"ec2.us-east-2.amazonaws.com"}, "Authorization": []string{"AWS4-HMAC-SHA256 Credential=" + accessKeyID + "/20200811/us-east-2/ec2/aws4_request, SignedHeaders=host;x-amz-date;x-amz-security-token, Signature=631ea80cddfaa545fdadb120dc92c9f18166e38a5c47b50fab9fce476e022855"}, "X-Amz-Date": []string{"20200811T065522Z"}, - "X-Amz-Security-Token": []string{securityToken}, + "X-Amz-Security-Token": []string{sessionToken}, } - oldNow := now - defer func() { now = oldNow }() - now = setTime(secondDefaultTime) + oldNow := Now + defer func() { Now = oldNow }() + Now = setTime(secondDefaultTime) testRequestSigner(t, requestSignerWithToken, input, output) } @@ -318,12 +319,12 @@ func TestAWSv4Signature_PostRequestWithSecurityToken(t *testing.T) { "Authorization": []string{"AWS4-HMAC-SHA256 Credential=" + accessKeyID + "/20200811/us-east-2/sts/aws4_request, SignedHeaders=host;x-amz-date;x-amz-security-token, Signature=73452984e4a880ffdc5c392355733ec3f5ba310d5e0609a89244440cadfe7a7a"}, "Host": []string{"sts.us-east-2.amazonaws.com"}, "X-Amz-Date": []string{"20200811T065522Z"}, - "X-Amz-Security-Token": []string{securityToken}, + "X-Amz-Security-Token": []string{sessionToken}, } - oldNow := now - defer func() { now = oldNow }() - now = setTime(secondDefaultTime) + oldNow := Now + defer func() { Now = oldNow }() + Now = setTime(secondDefaultTime) testRequestSigner(t, requestSignerWithToken, input, output) } @@ -341,12 +342,12 @@ func TestAWSv4Signature_PostRequestWithSecurityTokenAndAdditionalHeaders(t *test "X-Amz-Date": []string{"20200811T065522Z"}, "Content-Type": []string{"application/x-amz-json-1.0"}, "X-Amz-Target": []string{"DynamoDB_20120810.CreateTable"}, - "X-Amz-Security-Token": []string{securityToken}, + "X-Amz-Security-Token": []string{sessionToken}, } - oldNow := now - defer func() { now = oldNow }() - now = setTime(secondDefaultTime) + oldNow := Now + defer func() { Now = oldNow }() + Now = setTime(secondDefaultTime) testRequestSigner(t, requestSignerWithToken, input, output) } @@ -354,7 +355,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, }, @@ -369,9 +370,9 @@ func TestAWSv4Signature_PostRequestWithAmzDateButNoSecurityToken(t *testing.T) { "X-Amz-Date": []string{"20200811T065522Z"}, } - oldNow := now - defer func() { now = oldNow }() - now = setTime(secondDefaultTime) + oldNow := Now + defer func() { Now = oldNow }() + Now = setTime(secondDefaultTime) testRequestSigner(t, requestSigner, input, output) } @@ -433,7 +434,7 @@ func createDefaultAwsTestServer() *testAwsServer { map[string]string{ "SecretAccessKey": secretAccessKey, "AccessKeyId": accessKeyID, - "Token": securityToken, + "Token": sessionToken, }, "", noHeaderValidation, @@ -465,7 +466,7 @@ func createDefaultAwsTestServerWithImdsv2(t *testing.T) *testAwsServer { map[string]string{ "SecretAccessKey": secretAccessKey, "AccessKeyId": accessKeyID, - "Token": securityToken, + "Token": sessionToken, }, "sessiontoken", validateSessionTokenHeaders, @@ -492,8 +493,8 @@ func notFound(w http.ResponseWriter, r *http.Request) { func noHeaderValidation(r *http.Request) {} -func (server *testAwsServer) getCredentialSource(url string) credsfile.CredentialSource { - return credsfile.CredentialSource{ +func (server *testAwsServer) getCredentialSource(url string) *credsfile.CredentialSource { + return &credsfile.CredentialSource{ EnvironmentID: "aws1", URL: url + server.url, RegionURL: url + server.regionURL, @@ -502,15 +503,15 @@ func (server *testAwsServer) getCredentialSource(url string) credsfile.Credentia } } -func getExpectedSubjectToken(url, region, accessKeyID, secretAccessKey, securityToken string) string { +func getExpectedSubjectToken(url, region, accessKeyID, secretAccessKey, sessionToken string) string { req, _ := http.NewRequest("POST", url, nil) req.Header.Set("x-goog-cloud-target-resource", cloneTestOpts().Audience) signer := &awsRequestSigner{ RegionName: region, - AwsSecurityCredentials: awsSecurityCredentials{ + AwsSecurityCredentials: &AwsSecurityCredentials{ AccessKeyID: accessKeyID, SecretAccessKey: secretAccessKey, - SecurityToken: securityToken, + SessionToken: sessionToken, }, } signer.signRequest(req) @@ -532,10 +533,10 @@ func getExpectedSubjectToken(url, region, accessKeyID, secretAccessKey, security }, } - if securityToken != "" { + if sessionToken != "" { result.Headers = append(result.Headers, awsRequestHeader{ Key: "X-Amz-Security-Token", - Value: securityToken, + Value: sessionToken, }) } @@ -556,13 +557,13 @@ func TestAWSCredential_BasicRequest(t *testing.T) { opts.CredentialSource = server.getCredentialSource(ts.URL) oldGetenv := getenv - oldNow := now + oldNow := Now defer func() { getenv = oldGetenv - now = oldNow + Now = oldNow }() getenv = setEnvironment(map[string]string{}) - now = setTime(defaultTime) + Now = setTime(defaultTime) base, err := newSubjectTokenProvider(opts) if err != nil { @@ -583,7 +584,7 @@ func TestAWSCredential_BasicRequest(t *testing.T) { "us-east-2", accessKeyID, secretAccessKey, - securityToken, + sessionToken, ) if got != want { @@ -599,13 +600,13 @@ func TestAWSCredential_IMDSv2(t *testing.T) { opts.CredentialSource = server.getCredentialSource(ts.URL) oldGetenv := getenv - oldNow := now + oldNow := Now defer func() { getenv = oldGetenv - now = oldNow + Now = oldNow }() getenv = setEnvironment(map[string]string{}) - now = setTime(defaultTime) + Now = setTime(defaultTime) base, err := newSubjectTokenProvider(opts) if err != nil { @@ -622,7 +623,7 @@ func TestAWSCredential_IMDSv2(t *testing.T) { "us-east-2", accessKeyID, secretAccessKey, - securityToken, + sessionToken, ) if got != want { @@ -639,13 +640,13 @@ func TestAWSCredential_BasicRequestWithoutSecurityToken(t *testing.T) { opts.CredentialSource = server.getCredentialSource(ts.URL) oldGetenv := getenv - oldNow := now + oldNow := Now defer func() { getenv = oldGetenv - now = oldNow + Now = oldNow }() getenv = setEnvironment(map[string]string{}) - now = setTime(defaultTime) + Now = setTime(defaultTime) base, err := newSubjectTokenProvider(opts) if err != nil { @@ -678,17 +679,17 @@ func TestAWSCredential_BasicRequestWithEnv(t *testing.T) { opts.CredentialSource = server.getCredentialSource(ts.URL) oldGetenv := getenv - oldNow := now + oldNow := Now defer func() { getenv = oldGetenv - now = oldNow + Now = oldNow }() getenv = setEnvironment(map[string]string{ "AWS_ACCESS_KEY_ID": "AKIDEXAMPLE", "AWS_SECRET_ACCESS_KEY": "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY", "AWS_REGION": "us-west-1", }) - now = setTime(defaultTime) + Now = setTime(defaultTime) base, err := newSubjectTokenProvider(opts) if err != nil { @@ -721,17 +722,17 @@ func TestAWSCredential_BasicRequestWithDefaultEnv(t *testing.T) { opts.CredentialSource = server.getCredentialSource(ts.URL) oldGetenv := getenv - oldNow := now + oldNow := Now defer func() { getenv = oldGetenv - now = oldNow + Now = oldNow }() getenv = setEnvironment(map[string]string{ "AWS_ACCESS_KEY_ID": "AKIDEXAMPLE", "AWS_SECRET_ACCESS_KEY": "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY", "AWS_REGION": "us-west-1", }) - now = setTime(defaultTime) + Now = setTime(defaultTime) base, err := newSubjectTokenProvider(opts) if err != nil { @@ -762,10 +763,10 @@ func TestAWSCredential_BasicRequestWithTwoRegions(t *testing.T) { opts.CredentialSource = server.getCredentialSource(ts.URL) oldGetenv := getenv - oldNow := now + oldNow := Now defer func() { getenv = oldGetenv - now = oldNow + Now = oldNow }() getenv = setEnvironment(map[string]string{ "AWS_ACCESS_KEY_ID": "AKIDEXAMPLE", @@ -773,7 +774,7 @@ func TestAWSCredential_BasicRequestWithTwoRegions(t *testing.T) { "AWS_REGION": "us-west-1", "AWS_DEFAULT_REGION": "us-east-1", }) - now = setTime(defaultTime) + Now = setTime(defaultTime) base, err := newSubjectTokenProvider(opts) if err != nil { @@ -1037,17 +1038,17 @@ func TestAWSCredential_ShouldNotCallMetadataEndpointWhenCredsAreInEnv(t *testing opts.CredentialSource.IMDSv2SessionTokenURL = metadataTs.URL oldGetenv := getenv - oldNow := now + oldNow := Now defer func() { getenv = oldGetenv - now = oldNow + Now = oldNow }() getenv = setEnvironment(map[string]string{ "AWS_ACCESS_KEY_ID": "AKIDEXAMPLE", "AWS_SECRET_ACCESS_KEY": "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY", "AWS_REGION": "us-west-1", }) - now = setTime(defaultTime) + Now = setTime(defaultTime) base, err := newSubjectTokenProvider(opts) if err != nil { @@ -1080,16 +1081,16 @@ func TestAWSCredential_ShouldCallMetadataEndpointWhenNoRegion(t *testing.T) { opts.CredentialSource = server.getCredentialSource(ts.URL) oldGetenv := getenv - oldNow := now + oldNow := Now defer func() { getenv = oldGetenv - now = oldNow + Now = oldNow }() getenv = setEnvironment(map[string]string{ "AWS_ACCESS_KEY_ID": accessKeyID, "AWS_SECRET_ACCESS_KEY": secretAccessKey, }) - now = setTime(defaultTime) + Now = setTime(defaultTime) base, err := newSubjectTokenProvider(opts) if err != nil { @@ -1122,16 +1123,16 @@ func TestAWSCredential_ShouldCallMetadataEndpointWhenNoAccessKey(t *testing.T) { opts.CredentialSource = server.getCredentialSource(ts.URL) oldGetenv := getenv - oldNow := now + oldNow := Now defer func() { getenv = oldGetenv - now = oldNow + Now = oldNow }() getenv = setEnvironment(map[string]string{ "AWS_SECRET_ACCESS_KEY": "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY", "AWS_REGION": "us-west-1", }) - now = setTime(defaultTime) + Now = setTime(defaultTime) base, err := newSubjectTokenProvider(opts) if err != nil { @@ -1148,7 +1149,7 @@ func TestAWSCredential_ShouldCallMetadataEndpointWhenNoAccessKey(t *testing.T) { "us-west-1", accessKeyID, secretAccessKey, - securityToken, + sessionToken, ) if got != want { @@ -1164,16 +1165,16 @@ func TestAWSCredential_ShouldCallMetadataEndpointWhenNoSecretAccessKey(t *testin opts.CredentialSource = server.getCredentialSource(ts.URL) oldGetenv := getenv - oldNow := now + oldNow := Now defer func() { getenv = oldGetenv - now = oldNow + Now = oldNow }() getenv = setEnvironment(map[string]string{ "AWS_ACCESS_KEY_ID": "AKIDEXAMPLE", "AWS_REGION": "us-west-1", }) - now = setTime(defaultTime) + Now = setTime(defaultTime) base, err := newSubjectTokenProvider(opts) if err != nil { @@ -1190,7 +1191,7 @@ func TestAWSCredential_ShouldCallMetadataEndpointWhenNoSecretAccessKey(t *testin "us-west-1", accessKeyID, secretAccessKey, - securityToken, + sessionToken, ) if got != want { @@ -1201,12 +1202,12 @@ func TestAWSCredential_ShouldCallMetadataEndpointWhenNoSecretAccessKey(t *testin func TestAWSCredential_Validations(t *testing.T) { var metadataServerValidityTests = []struct { name string - credSource credsfile.CredentialSource + credSource *credsfile.CredentialSource errText string }{ { name: "No Metadata Server URLs", - credSource: credsfile.CredentialSource{ + credSource: &credsfile.CredentialSource{ EnvironmentID: "aws1", RegionURL: "", URL: "", @@ -1214,7 +1215,7 @@ func TestAWSCredential_Validations(t *testing.T) { }, }, { name: "IPv4 Metadata Server URLs", - credSource: credsfile.CredentialSource{ + credSource: &credsfile.CredentialSource{ EnvironmentID: "aws1", RegionURL: "http://169.254.169.254/latest/meta-data/placement/availability-zone", URL: "http://169.254.169.254/latest/meta-data/iam/security-credentials", @@ -1222,7 +1223,7 @@ func TestAWSCredential_Validations(t *testing.T) { }, }, { name: "IPv6 Metadata Server URLs", - credSource: credsfile.CredentialSource{ + credSource: &credsfile.CredentialSource{ EnvironmentID: "aws1", RegionURL: "http://[fd00:ec2::254]/latest/meta-data/placement/availability-zone", URL: "http://[fd00:ec2::254]/latest/meta-data/iam/security-credentials", @@ -1256,6 +1257,156 @@ func TestAWSCredential_Validations(t *testing.T) { } } +func TestAWSCredential_ProgrammaticAuth(t *testing.T) { + opts := cloneTestOpts() + opts.AwsSecurityCredentialsProvider = &fakeAwsCredsProvider{ + awsRegion: "us-east-2", + creds: &AwsSecurityCredentials{ + AccessKeyID: accessKeyID, + SecretAccessKey: secretAccessKey, + SessionToken: sessionToken, + }, + } + + oldNow := Now + defer func() { + Now = oldNow + }() + Now = setTime(defaultTime) + + base, err := newSubjectTokenProvider(opts) + if err != nil { + t.Fatalf("newSubjectTokenProvider() = %v", err) + } + + got, err := base.subjectToken(context.Background()) + if err != nil { + t.Fatalf("subjectToken() = %v", err) + } + + want := getExpectedSubjectToken( + "https://sts.us-east-2.amazonaws.com?Action=GetCallerIdentity&Version=2011-06-15", + "us-east-2", + accessKeyID, + secretAccessKey, + sessionToken, + ) + + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestAWSCredential_ProgrammaticAuthNoSessionToken(t *testing.T) { + opts := cloneTestOpts() + opts.AwsSecurityCredentialsProvider = fakeAwsCredsProvider{ + awsRegion: "us-east-2", + creds: &AwsSecurityCredentials{ + AccessKeyID: accessKeyID, + SecretAccessKey: secretAccessKey, + }, + } + + oldNow := Now + defer func() { + Now = oldNow + }() + Now = setTime(defaultTime) + + base, err := newSubjectTokenProvider(opts) + if err != nil { + t.Fatalf("newSubjectTokenProvider() = %v", err) + } + + got, err := base.subjectToken(context.Background()) + if err != nil { + t.Fatalf("subjectToken() = %v", err) + } + + want := getExpectedSubjectToken( + "https://sts.us-east-2.amazonaws.com?Action=GetCallerIdentity&Version=2011-06-15", + "us-east-2", + accessKeyID, + secretAccessKey, + "", + ) + + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestAWSCredential_ProgrammaticAuthError(t *testing.T) { + opts := cloneTestOpts() + testErr := errors.New("test error") + opts.AwsSecurityCredentialsProvider = fakeAwsCredsProvider{ + awsRegion: "us-east-2", + credsErr: testErr, + } + + base, err := newSubjectTokenProvider(opts) + if err != nil { + t.Fatalf("newSubjectTokenProvider() = %v", err) + } + + _, gotErr := base.subjectToken(context.Background()) + if gotErr == nil { + t.Fatalf("subjectToken() = nil, want error") + } + if gotErr != testErr { + t.Errorf("got = %v, want %v", err, testErr) + } +} + +func TestAWSCredential_ProgrammaticAuthRegionError(t *testing.T) { + opts := cloneTestOpts() + testErr := errors.New("test error") + opts.AwsSecurityCredentialsProvider = fakeAwsCredsProvider{ + regionErr: testErr, + creds: &AwsSecurityCredentials{ + AccessKeyID: accessKeyID, + SecretAccessKey: secretAccessKey, + }, + } + + base, err := newSubjectTokenProvider(opts) + if err != nil { + t.Fatalf("newSubjectTokenProvider() = %v", err) + } + + _, gotErr := base.subjectToken(context.Background()) + if gotErr == nil { + t.Fatalf("subjectToken() = nil, want error") + } + if gotErr != testErr { + t.Errorf("got = %v, want %v", err, testErr) + } +} + +func TestAWSCredential_ProgrammaticAuthOptions(t *testing.T) { + opts := cloneTestOpts() + wantOpts := &RequestOptions{Audience: opts.Audience, SubjectTokenType: opts.SubjectTokenType} + + opts.AwsSecurityCredentialsProvider = fakeAwsCredsProvider{ + awsRegion: "us-east-2", + creds: &AwsSecurityCredentials{ + AccessKeyID: accessKeyID, + SecretAccessKey: secretAccessKey, + }, + reqOpts: wantOpts, + } + + base, err := newSubjectTokenProvider(opts) + if err != nil { + t.Fatalf("newSubjectTokenProvider() = %v", err) + } + + _, err = base.subjectToken(context.Background()) + if err != nil { + t.Fatalf("subjectToken() = %v", err) + } +} + func setTime(testTime time.Time) func() time.Time { return func() time.Time { return testTime @@ -1270,7 +1421,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", }, @@ -1302,3 +1453,41 @@ func testRequestSigner(t *testing.T, rs *awsRequestSigner, input, wantOutput *ht } } } + +type fakeAwsCredsProvider struct { + credsErr error + regionErr error + awsRegion string + creds *AwsSecurityCredentials + reqOpts *RequestOptions +} + +func (acp fakeAwsCredsProvider) AwsRegion(ctx context.Context, opts *RequestOptions) (string, error) { + if acp.regionErr != nil { + return "", acp.regionErr + } + if acp.reqOpts != nil { + if acp.reqOpts.Audience != opts.Audience { + return "", errors.New("audience does not match") + } + if acp.reqOpts.SubjectTokenType != opts.SubjectTokenType { + return "", errors.New("audience does not match") + } + } + return acp.awsRegion, nil +} + +func (acp fakeAwsCredsProvider) AwsSecurityCredentials(ctx context.Context, opts *RequestOptions) (*AwsSecurityCredentials, error) { + if acp.credsErr != nil { + return nil, acp.credsErr + } + if acp.reqOpts != nil { + if acp.reqOpts.Audience != opts.Audience { + return nil, errors.New("Audience does not match") + } + if acp.reqOpts.SubjectTokenType != opts.SubjectTokenType { + return nil, errors.New("Audience does not match") + } + } + return acp.creds, nil +} diff --git a/auth/credentials/internal/externalaccount/executable_provider_test.go b/auth/credentials/internal/externalaccount/executable_provider_test.go index b4c61b9477c9..59908d73f803 100644 --- a/auth/credentials/internal/externalaccount/executable_provider_test.go +++ b/auth/credentials/internal/externalaccount/executable_provider_test.go @@ -44,7 +44,7 @@ func TestCreateExecutableCredential(t *testing.T) { name: "Basic Creation", executableConfig: credsfile.ExecutableConfig{ Command: "blarg", - TimeoutMillis: Int(50000), + TimeoutMillis: 50000, }, wantTimeout: 50000 * time.Millisecond, }, @@ -64,7 +64,7 @@ func TestCreateExecutableCredential(t *testing.T) { name: "Timeout Too Low", executableConfig: credsfile.ExecutableConfig{ Command: "blarg", - TimeoutMillis: Int(4999), + TimeoutMillis: 4999, }, skipErrorEquals: true, }, @@ -72,7 +72,7 @@ func TestCreateExecutableCredential(t *testing.T) { name: "Timeout Lower Bound", executableConfig: credsfile.ExecutableConfig{ Command: "blarg", - TimeoutMillis: Int(5000), + TimeoutMillis: 5000, }, wantTimeout: 5000 * time.Millisecond, }, @@ -80,7 +80,7 @@ func TestCreateExecutableCredential(t *testing.T) { name: "Timeout Upper Bound", executableConfig: credsfile.ExecutableConfig{ Command: "blarg", - TimeoutMillis: Int(120000), + TimeoutMillis: 120000, }, wantTimeout: 120000 * time.Millisecond, }, @@ -88,7 +88,7 @@ func TestCreateExecutableCredential(t *testing.T) { name: "Timeout Too High", executableConfig: credsfile.ExecutableConfig{ Command: "blarg", - TimeoutMillis: Int(120001), + TimeoutMillis: 120001, }, skipErrorEquals: true, }, @@ -97,7 +97,7 @@ func TestCreateExecutableCredential(t *testing.T) { t.Run(tt.name, func(t *testing.T) { ecs, err := newSubjectTokenProvider(&Options{ Client: internal.CloneDefaultClient(), - CredentialSource: credsfile.CredentialSource{ + CredentialSource: &credsfile.CredentialSource{ Executable: &tt.executableConfig, }, }) @@ -143,7 +143,7 @@ func TestExecutableCredentialGetEnvironment(t *testing.T) { opts: &Options{ Audience: "//iam.googleapis.com/projects/123/locations/global/workloadIdentityPools/pool/providers/oidc", SubjectTokenType: jwtTokenType, - CredentialSource: credsfile.CredentialSource{ + CredentialSource: &credsfile.CredentialSource{ Executable: &credsfile.ExecutableConfig{ Command: "blarg", }, @@ -167,7 +167,7 @@ func TestExecutableCredentialGetEnvironment(t *testing.T) { 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: jwtTokenType, - CredentialSource: credsfile.CredentialSource{ + CredentialSource: &credsfile.CredentialSource{ Executable: &credsfile.ExecutableConfig{ Command: "blarg", OutputFile: "/path/to/generated/cached/credentials", @@ -194,7 +194,7 @@ func TestExecutableCredentialGetEnvironment(t *testing.T) { Audience: "//iam.googleapis.com/projects/123/locations/global/workloadIdentityPools/pool/providers/oidc", ServiceAccountImpersonationURL: "test@project.iam.gserviceaccount.com", SubjectTokenType: jwtTokenType, - CredentialSource: credsfile.CredentialSource{ + CredentialSource: &credsfile.CredentialSource{ Executable: &credsfile.ExecutableConfig{ Command: "blarg", OutputFile: "/path/to/generated/cached/credentials", @@ -241,10 +241,10 @@ func TestExecutableCredentialGetEnvironment(t *testing.T) { } func TestRetrieveExecutableSubjectTokenExecutableErrors(t *testing.T) { - cs := credsfile.CredentialSource{ + cs := &credsfile.CredentialSource{ Executable: &credsfile.ExecutableConfig{ Command: "blarg", - TimeoutMillis: Int(5000), + TimeoutMillis: 5000, }, } @@ -473,10 +473,10 @@ func TestRetrieveExecutableSubjectTokenExecutableErrors(t *testing.T) { } func TestRetrieveExecutableSubjectTokenSuccesses(t *testing.T) { - cs := credsfile.CredentialSource{ + cs := &credsfile.CredentialSource{ Executable: &credsfile.ExecutableConfig{ Command: "blarg", - TimeoutMillis: Int(5000), + TimeoutMillis: 5000, }, } @@ -582,10 +582,10 @@ func TestRetrieveOutputFileSubjectTokenNotJSON(t *testing.T) { } defer os.Remove(outputFile.Name()) - cs := credsfile.CredentialSource{ + cs := &credsfile.CredentialSource{ Executable: &credsfile.ExecutableConfig{ Command: "blarg", - TimeoutMillis: Int(5000), + TimeoutMillis: 5000, OutputFile: outputFile.Name(), }, } @@ -730,10 +730,10 @@ func TestRetrieveOutputFileSubjectTokenFailureTests(t *testing.T) { } defer os.Remove(outputFile.Name()) - cs := credsfile.CredentialSource{ + cs := &credsfile.CredentialSource{ Executable: &credsfile.ExecutableConfig{ Command: "blarg", - TimeoutMillis: Int(5000), + TimeoutMillis: 5000, OutputFile: outputFile.Name(), }, } @@ -832,10 +832,10 @@ func TestRetrieveOutputFileSubjectTokenInvalidCache(t *testing.T) { } defer os.Remove(outputFile.Name()) - cs := credsfile.CredentialSource{ + cs := &credsfile.CredentialSource{ Executable: &credsfile.ExecutableConfig{ Command: "blarg", - TimeoutMillis: Int(5000), + TimeoutMillis: 5000, OutputFile: outputFile.Name(), }, } @@ -937,10 +937,10 @@ func TestRetrieveOutputFileSubjectTokenJwt(t *testing.T) { } defer os.Remove(outputFile.Name()) - cs := credsfile.CredentialSource{ + cs := &credsfile.CredentialSource{ Executable: &credsfile.ExecutableConfig{ Command: "blarg", - TimeoutMillis: Int(5000), + TimeoutMillis: 5000, OutputFile: outputFile.Name(), }, } @@ -1022,10 +1022,6 @@ func Bool(b bool) *bool { return &b } -func Int(i int) *int { - return &i -} - func TestServiceAccountImpersonationRE(t *testing.T) { tests := []struct { name string diff --git a/auth/credentials/internal/externalaccount/externalaccount.go b/auth/credentials/internal/externalaccount/externalaccount.go index 0de5fd84cf7f..b33279387fe3 100644 --- a/auth/credentials/internal/externalaccount/externalaccount.go +++ b/auth/credentials/internal/externalaccount/externalaccount.go @@ -35,8 +35,8 @@ const ( ) var ( - // now aliases time.Now for testing - now = func() time.Time { + // Now aliases time.Now for testing + Now = func() time.Time { return time.Now().UTC() } validWorkforceAudiencePattern *regexp.Regexp = regexp.MustCompile(`//iam\.googleapis\.com/locations/[^/]+/workforcePools/`) @@ -70,7 +70,7 @@ type Options struct { ClientID string // CredentialSource contains the necessary information to retrieve the token itself, as well // as some environmental information. - CredentialSource credsfile.CredentialSource + CredentialSource *credsfile.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 @@ -81,17 +81,106 @@ type Options struct { // serviceusage.services.use IAM permission to use the project for // billing/quota. Optional. WorkforcePoolUserProject string + // UniverseDomain is the default service domain for a given Cloud universe. + // This value will be used in the default STS token URL. The default value + // is "googleapis.com". It will not be used if TokenURL is set. Optional. + UniverseDomain string + // SubjectTokenProvider is an optional token provider for OIDC/SAML + // credentials. One of SubjectTokenProvider, AWSSecurityCredentialProvider + // or CredentialSource must be provided. Optional. + SubjectTokenProvider SubjectTokenProvider + // AwsSecurityCredentialsProvider is an AWS Security Credential provider + // for AWS credentials. One of SubjectTokenProvider, + // AWSSecurityCredentialProvider or CredentialSource must be provided. Optional. + AwsSecurityCredentialsProvider AwsSecurityCredentialsProvider // Client for token request. Client *http.Client } +// SubjectTokenProvider can be used to supply a subject token to exchange for a +// GCP access token. +type SubjectTokenProvider interface { + // SubjectToken should return a valid subject token or an error. + // The external account token provider does not cache the returned subject + // token, so caching logic should be implemented in the provider to prevent + // multiple requests for the same subject token. + SubjectToken(ctx context.Context, opts *RequestOptions) (string, error) +} + +// RequestOptions contains information about the requested subject token or AWS +// security credentials from the Google external account credential. +type RequestOptions 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 +} + +// AwsSecurityCredentialsProvider can be used to supply AwsSecurityCredentials +// and an AWS Region to exchange for a GCP access token. +type AwsSecurityCredentialsProvider interface { + // AwsRegion should return the AWS region or an error. + AwsRegion(ctx context.Context, opts *RequestOptions) (string, error) + // GetAwsSecurityCredentials should return a valid set of + // AwsSecurityCredentials or an error. The external account token provider + // does not cache the returned security credentials, so caching logic should + // be implemented in the provider to prevent multiple requests for the + // same security credentials. + AwsSecurityCredentials(ctx context.Context, opts *RequestOptions) (*AwsSecurityCredentials, error) +} + +// 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"` + // SessionToken is the AWS Session token. This should be provided for + // temporary AWS security credentials - Optional. + SessionToken string `json:"Token"` +} + +func (o *Options) validate() error { + if o.Audience == "" { + return fmt.Errorf("externalaccount: Audience must be set") + } + if o.SubjectTokenType == "" { + return fmt.Errorf("externalaccount: Subject token type must be set") + } + if o.WorkforcePoolUserProject != "" { + if valid := validWorkforceAudiencePattern.MatchString(o.Audience); !valid { + return fmt.Errorf("externalaccount: workforce_pool_user_project should not be set for non-workforce pool credentials") + } + } + count := 0 + if o.CredentialSource != nil { + count++ + } + if o.SubjectTokenProvider != nil { + count++ + } + if o.AwsSecurityCredentialsProvider != nil { + count++ + } + if count == 0 { + return fmt.Errorf("externalaccount: one of CredentialSource, SubjectTokenProvider, or AwsSecurityCredentialsProvider must be set") + } + if count > 1 { + return fmt.Errorf("externalaccount: only one of CredentialSource, SubjectTokenProvider, or AwsSecurityCredentialsProvider must be set") + } + return nil +} + // NewTokenProvider returns a [cloud.google.com/go/auth.TokenProvider] // configured with the provided options. func NewTokenProvider(opts *Options) (auth.TokenProvider, error) { - if opts.WorkforcePoolUserProject != "" { - if valid := validWorkforceAudiencePattern.MatchString(opts.Audience); !valid { - return nil, fmt.Errorf("credentials: workforce_pool_user_project should not be set for non-workforce pool credentials") - } + if err := opts.validate(); err != nil { + return nil, err } stp, err := newSubjectTokenProvider(opts) if err != nil { @@ -185,14 +274,24 @@ func (tp *tokenProvider) Token(ctx context.Context) (*auth.Token, error) { if stsResp.ExpiresIn <= 0 { return nil, fmt.Errorf("credentials: got invalid expiry from security token service") } - tok.Expiry = now().Add(time.Duration(stsResp.ExpiresIn) * time.Second) + tok.Expiry = Now().Add(time.Duration(stsResp.ExpiresIn) * time.Second) return tok, nil } // newSubjectTokenProvider determines the type of credsfile.CredentialSource needed to create a // subjectTokenProvider func newSubjectTokenProvider(o *Options) (subjectTokenProvider, error) { - if len(o.CredentialSource.EnvironmentID) > 3 && o.CredentialSource.EnvironmentID[:3] == "aws" { + reqOpts := &RequestOptions{Audience: o.Audience, SubjectTokenType: o.SubjectTokenType} + + if o.AwsSecurityCredentialsProvider != nil { + return &awsSubjectProvider{ + securityCredentialsProvider: o.AwsSecurityCredentialsProvider, + TargetResource: o.Audience, + reqOpts: reqOpts, + }, nil + } else if o.SubjectTokenProvider != nil { + return &programmaticProvider{stp: o.SubjectTokenProvider, opts: reqOpts}, nil + } else if len(o.CredentialSource.EnvironmentID) > 3 && o.CredentialSource.EnvironmentID[:3] == "aws" { if awsVersion, err := strconv.Atoi(o.CredentialSource.EnvironmentID[3:]); err == nil { if awsVersion != 1 { return nil, fmt.Errorf("credentials: aws version '%d' is not supported in the current build", awsVersion) @@ -224,10 +323,10 @@ func newSubjectTokenProvider(o *Options) (subjectTokenProvider, error) { execProvider := &executableSubjectProvider{} execProvider.Command = ec.Command - if ec.TimeoutMillis == nil { + if ec.TimeoutMillis == 0 { execProvider.Timeout = executableDefaultTimeout } else { - execProvider.Timeout = time.Duration(*ec.TimeoutMillis) * time.Millisecond + execProvider.Timeout = time.Duration(ec.TimeoutMillis) * time.Millisecond if execProvider.Timeout < timeoutMinimum || execProvider.Timeout > timeoutMaximum { return nil, fmt.Errorf("credentials: invalid `timeout_millis` field — executable timeout must be between %v and %v seconds", timeoutMinimum.Seconds(), timeoutMaximum.Seconds()) } diff --git a/auth/credentials/internal/externalaccount/externalaccount_test.go b/auth/credentials/internal/externalaccount/externalaccount_test.go index 508733b2bbf0..50fa912be264 100644 --- a/auth/credentials/internal/externalaccount/externalaccount_test.go +++ b/auth/credentials/internal/externalaccount/externalaccount_test.go @@ -52,9 +52,9 @@ var ( Scopes: []string{"https://www.googleapis.com/auth/devstorage.full_control"}, Client: internal.CloneDefaultClient(), } - testBaseCredSource = credsfile.CredentialSource{ + testBaseCredSource = &credsfile.CredentialSource{ File: textBaseCredPath, - Format: credsfile.Format{Type: fileTypeText}, + Format: &credsfile.Format{Type: fileTypeText}, } testNow = func() time.Time { return time.Unix(expiry, 0) } ) @@ -228,7 +228,7 @@ func TestNonworkforceWithWorkforcePoolUserProject(t *testing.T) { if err == nil { t.Fatalf("got nil, want an error") } - if got, want := err.Error(), "credentials: workforce_pool_user_project should not be set for non-workforce pool credentials"; got != want { + if got, want := err.Error(), "externalaccount: workforce_pool_user_project should not be set for non-workforce pool credentials"; got != want { t.Errorf("got %v, want %v", got, want) } } @@ -308,9 +308,9 @@ func run(t *testing.T, opts *Options, tets *testExchangeTokenServer) (*auth.Toke defer server.Close() opts.TokenURL = server.URL - oldNow := now - defer func() { now = oldNow }() - now = testNow + oldNow := Now + defer func() { Now = oldNow }() + Now = testNow stp, err := newSubjectTokenProvider(opts) if err != nil { @@ -354,3 +354,111 @@ func cloneTestOpts() *Options { func expectedMetricsHeader(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) } + +func TestOptionsValidate(t *testing.T) { + tests := []struct { + name string + o *Options + wantErr bool + }{ + { + name: "works", + o: &Options{ + Audience: "32555940559.apps.googleusercontent.com", + SubjectTokenType: jwtTokenType, + TokenURL: "http://localhost:8080/v1/token", + TokenInfoURL: "http://localhost:8080/v1/tokeninfo", + ServiceAccountImpersonationURL: "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/service-gcs-admin@$PROJECT_ID.iam.gserviceaccount.com:generateAccessToken", + ClientSecret: "notsosecret", + ClientID: "rbrgnognrhongo3bi4gb9ghg9g", + Client: internal.CloneDefaultClient(), + CredentialSource: testBaseCredSource, + }, + }, + { + name: "missing aud", + o: &Options{ + SubjectTokenType: jwtTokenType, + TokenURL: "http://localhost:8080/v1/token", + TokenInfoURL: "http://localhost:8080/v1/tokeninfo", + ServiceAccountImpersonationURL: "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/service-gcs-admin@$PROJECT_ID.iam.gserviceaccount.com:generateAccessToken", + ClientSecret: "notsosecret", + ClientID: "rbrgnognrhongo3bi4gb9ghg9g", + Client: internal.CloneDefaultClient(), + CredentialSource: testBaseCredSource, + }, + wantErr: true, + }, + { + name: "missing subjectTokenType", + o: &Options{ + Audience: "32555940559.apps.googleusercontent.com", + TokenURL: "http://localhost:8080/v1/token", + TokenInfoURL: "http://localhost:8080/v1/tokeninfo", + ServiceAccountImpersonationURL: "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/service-gcs-admin@$PROJECT_ID.iam.gserviceaccount.com:generateAccessToken", + ClientSecret: "notsosecret", + ClientID: "rbrgnognrhongo3bi4gb9ghg9g", + Client: internal.CloneDefaultClient(), + CredentialSource: testBaseCredSource, + }, + wantErr: true, + }, + { + name: "invalid workforcepool", + o: &Options{ + WorkforcePoolUserProject: "blah", + Audience: "32555940559.apps.googleusercontent.com", + SubjectTokenType: jwtTokenType, + TokenURL: "http://localhost:8080/v1/token", + TokenInfoURL: "http://localhost:8080/v1/tokeninfo", + ServiceAccountImpersonationURL: "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/service-gcs-admin@$PROJECT_ID.iam.gserviceaccount.com:generateAccessToken", + ClientSecret: "notsosecret", + ClientID: "rbrgnognrhongo3bi4gb9ghg9g", + Client: internal.CloneDefaultClient(), + CredentialSource: testBaseCredSource, + }, + wantErr: true, + }, + { + name: "no creds", + o: &Options{ + Audience: "32555940559.apps.googleusercontent.com", + SubjectTokenType: jwtTokenType, + TokenURL: "http://localhost:8080/v1/token", + TokenInfoURL: "http://localhost:8080/v1/tokeninfo", + ServiceAccountImpersonationURL: "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/service-gcs-admin@$PROJECT_ID.iam.gserviceaccount.com:generateAccessToken", + ClientSecret: "notsosecret", + ClientID: "rbrgnognrhongo3bi4gb9ghg9g", + Client: internal.CloneDefaultClient(), + }, + wantErr: true, + }, + { + name: "too many creds", + o: &Options{ + Audience: "32555940559.apps.googleusercontent.com", + SubjectTokenType: jwtTokenType, + TokenURL: "http://localhost:8080/v1/token", + TokenInfoURL: "http://localhost:8080/v1/tokeninfo", + ServiceAccountImpersonationURL: "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/service-gcs-admin@$PROJECT_ID.iam.gserviceaccount.com:generateAccessToken", + ClientSecret: "notsosecret", + ClientID: "rbrgnognrhongo3bi4gb9ghg9g", + Client: internal.CloneDefaultClient(), + CredentialSource: testBaseCredSource, + SubjectTokenProvider: fakeSubjectTokenProvider{}, + }, + wantErr: true, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + err := tc.o.validate() + if err == nil && tc.wantErr { + t.Fatalf("o.validate() = nil, want error") + } + if err != nil && !tc.wantErr { + t.Fatalf("o.validate() = non-nil error, want error") + } + }) + } +} diff --git a/auth/credentials/internal/externalaccount/file_provider.go b/auth/credentials/internal/externalaccount/file_provider.go index 503b6402a708..8186939fe1de 100644 --- a/auth/credentials/internal/externalaccount/file_provider.go +++ b/auth/credentials/internal/externalaccount/file_provider.go @@ -32,7 +32,7 @@ const ( type fileSubjectProvider struct { File string - Format credsfile.Format + Format *credsfile.Format } func (sp *fileSubjectProvider) subjectToken(context.Context) (string, error) { @@ -46,6 +46,10 @@ func (sp *fileSubjectProvider) subjectToken(context.Context) (string, error) { return "", fmt.Errorf("credentials: failed to read credential file: %w", err) } tokenBytes = bytes.TrimSpace(tokenBytes) + + if sp.Format == nil { + return string(tokenBytes), nil + } switch sp.Format.Type { case fileTypeJSON: jsonData := make(map[string]interface{}) @@ -62,7 +66,7 @@ func (sp *fileSubjectProvider) subjectToken(context.Context) (string, error) { return "", errors.New("credentials: improperly formatted subject token") } return token, nil - case fileTypeText, "": + case fileTypeText: return string(tokenBytes), nil default: return "", errors.New("credentials: invalid credential_source file format type: " + sp.Format.Type) diff --git a/auth/credentials/internal/externalaccount/file_provider_test.go b/auth/credentials/internal/externalaccount/file_provider_test.go index 9f529b703418..f7bcb4619e0b 100644 --- a/auth/credentials/internal/externalaccount/file_provider_test.go +++ b/auth/credentials/internal/externalaccount/file_provider_test.go @@ -24,29 +24,29 @@ import ( func TestRetrieveFileSubjectToken(t *testing.T) { var tests = []struct { name string - cs credsfile.CredentialSource + cs *credsfile.CredentialSource want string }{ { name: "untyped file format", - cs: credsfile.CredentialSource{ + cs: &credsfile.CredentialSource{ File: textBaseCredPath, }, want: "street123", }, { name: "text file format", - cs: credsfile.CredentialSource{ + cs: &credsfile.CredentialSource{ File: textBaseCredPath, - Format: credsfile.Format{Type: fileTypeText}, + Format: &credsfile.Format{Type: fileTypeText}, }, want: "street123", }, { name: "JSON file format", - cs: credsfile.CredentialSource{ + cs: &credsfile.CredentialSource{ File: jsonBaseCredPath, - Format: credsfile.Format{Type: fileTypeJSON, SubjectTokenFieldName: "SubjToken"}, + Format: &credsfile.Format{Type: fileTypeJSON, SubjectTokenFieldName: "SubjToken"}, }, want: "321road", }, diff --git a/auth/credentials/internal/externalaccount/impersonate_test.go b/auth/credentials/internal/externalaccount/impersonate_test.go index 0df1908c55e1..4a791ca1ba3a 100644 --- a/auth/credentials/internal/externalaccount/impersonate_test.go +++ b/auth/credentials/internal/externalaccount/impersonate_test.go @@ -118,12 +118,12 @@ func TestImpersonation(t *testing.T) { tp, err := NewTokenProvider(testImpersonateOpts) if err != nil { - t.Fatalf("Failed to create TokenSource: %v", err) + t.Fatalf("Failed to create Provider: %v", err) } - oldNow := now - defer func() { now = oldNow }() - now = testNow + oldNow := Now + defer func() { Now = oldNow }() + Now = testNow tok, err := tp.Token(context.Background()) if err != nil { diff --git a/auth/credentials/internal/externalaccount/programmatic_provider.go b/auth/credentials/internal/externalaccount/programmatic_provider.go new file mode 100644 index 000000000000..be3c87351f77 --- /dev/null +++ b/auth/credentials/internal/externalaccount/programmatic_provider.go @@ -0,0 +1,30 @@ +// Copyright 2024 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package externalaccount + +import "context" + +type programmaticProvider struct { + opts *RequestOptions + stp SubjectTokenProvider +} + +func (pp *programmaticProvider) providerType() string { + return programmaticProviderType +} + +func (pp *programmaticProvider) subjectToken(ctx context.Context) (string, error) { + return pp.stp.SubjectToken(ctx, pp.opts) +} diff --git a/auth/credentials/internal/externalaccount/programmatic_provider_test.go b/auth/credentials/internal/externalaccount/programmatic_provider_test.go new file mode 100644 index 000000000000..2e202a00df5a --- /dev/null +++ b/auth/credentials/internal/externalaccount/programmatic_provider_test.go @@ -0,0 +1,107 @@ +// Copyright 2024 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package externalaccount + +import ( + "context" + "errors" + "testing" +) + +func TestRetrieveSubjectToken_ProgrammaticAuth(t *testing.T) { + want := "subjectToken" + opts := cloneTestOpts() + + opts.SubjectTokenProvider = fakeSubjectTokenProvider{ + subjectToken: want, + } + + base, err := newSubjectTokenProvider(opts) + if err != nil { + t.Fatalf("newSubjectTokenProvider(): %v", err) + } + + got, err := base.subjectToken(context.Background()) + if err != nil { + t.Fatalf("subjectToken(): %v", err) + } + + if got != want { + t.Fatalf("got %s, want %s", got, want) + } +} + +func TestRetrieveSubjectToken_ProgrammaticAuthFails(t *testing.T) { + want := errors.New("test error") + opts := cloneTestOpts() + + opts.SubjectTokenProvider = fakeSubjectTokenProvider{ + err: want, + } + + base, err := newSubjectTokenProvider(opts) + if err != nil { + t.Fatalf("newSubjectTokenProvider(): %v", err) + } + + _, got := base.subjectToken(context.Background()) + if got == nil { + t.Fatalf("subjectToken() = %v, want nil", got) + } + if got != want { + t.Errorf("got %v, want %v", got, want) + } +} + +func TestRetrieveSubjectToken_ProgrammaticAuthOptions(t *testing.T) { + opts := cloneTestOpts() + tokOpts := &RequestOptions{Audience: opts.Audience, SubjectTokenType: opts.SubjectTokenType} + + opts.SubjectTokenProvider = fakeSubjectTokenProvider{ + subjectToken: "subjectToken", + expectedOptions: tokOpts, + } + + base, err := newSubjectTokenProvider(opts) + if err != nil { + t.Fatalf("newSubjectTokenProvider(): %v", err) + } + + if _, err = base.subjectToken(context.Background()); err != nil { + t.Fatalf("subjectToken(): %v", err) + } +} + +type fakeSubjectTokenProvider struct { + err error + subjectToken string + expectedOptions *RequestOptions +} + +func (p fakeSubjectTokenProvider) SubjectToken(ctx context.Context, options *RequestOptions) (string, error) { + if p.err != nil { + return "", p.err + } + if p.expectedOptions != nil { + if p.expectedOptions.Audience != options.Audience { + return "", errors.New("audience does not match") + } + if p.expectedOptions.SubjectTokenType != options.SubjectTokenType { + return "", errors.New("audience does not match") + } + } + + return p.subjectToken, nil +} diff --git a/auth/credentials/internal/externalaccount/url_provider.go b/auth/credentials/internal/externalaccount/url_provider.go index 58b247250c38..22b8af1c11b8 100644 --- a/auth/credentials/internal/externalaccount/url_provider.go +++ b/auth/credentials/internal/externalaccount/url_provider.go @@ -26,15 +26,16 @@ import ( ) const ( - fileTypeText = "text" - fileTypeJSON = "json" - urlProviderType = "url" + fileTypeText = "text" + fileTypeJSON = "json" + urlProviderType = "url" + programmaticProviderType = "programmatic" ) type urlSubjectProvider struct { URL string Headers map[string]string - Format credsfile.Format + Format *credsfile.Format Client *http.Client } @@ -61,6 +62,9 @@ func (sp *urlSubjectProvider) subjectToken(ctx context.Context) (string, error) return "", fmt.Errorf("credentials: status code %d: %s", c, respBody) } + if sp.Format == nil { + return string(respBody), nil + } switch sp.Format.Type { case "json": jsonData := make(map[string]interface{}) @@ -77,7 +81,7 @@ func (sp *urlSubjectProvider) subjectToken(ctx context.Context) (string, error) return "", errors.New("credentials: improperly formatted subject token") } return token, nil - case fileTypeText, "": + case fileTypeText: return string(respBody), nil default: return "", errors.New("credentials: invalid credential_source file format type: " + sp.Format.Type) diff --git a/auth/credentials/internal/externalaccount/url_provider_test.go b/auth/credentials/internal/externalaccount/url_provider_test.go index 95d638fa77f1..0ac9ef9912a8 100644 --- a/auth/credentials/internal/externalaccount/url_provider_test.go +++ b/auth/credentials/internal/externalaccount/url_provider_test.go @@ -36,9 +36,9 @@ func TestRetrieveURLSubjectToken_Text(t *testing.T) { defer ts.Close() opts := cloneTestOpts() - opts.CredentialSource = credsfile.CredentialSource{ + opts.CredentialSource = &credsfile.CredentialSource{ URL: ts.URL, - Format: credsfile.Format{Type: fileTypeText}, + Format: &credsfile.Format{Type: fileTypeText}, Headers: map[string]string{ "Metadata": "True", }, @@ -71,7 +71,7 @@ func TestRetrieveURLSubjectToken_Untyped(t *testing.T) { defer ts.Close() opts := cloneTestOpts() - opts.CredentialSource = credsfile.CredentialSource{ + opts.CredentialSource = &credsfile.CredentialSource{ URL: ts.URL, } @@ -99,9 +99,9 @@ func TestRetrieveURLSubjectToken_JSON(t *testing.T) { defer ts.Close() opts := cloneTestOpts() - opts.CredentialSource = credsfile.CredentialSource{ + opts.CredentialSource = &credsfile.CredentialSource{ URL: ts.URL, - Format: credsfile.Format{Type: fileTypeJSON, SubjectTokenFieldName: "SubjToken"}, + Format: &credsfile.Format{Type: fileTypeJSON, SubjectTokenFieldName: "SubjToken"}, } base, err := newSubjectTokenProvider(opts) diff --git a/auth/internal/credsfile/filetype.go b/auth/internal/credsfile/filetype.go index 51824ebde83a..69e30779f987 100644 --- a/auth/internal/credsfile/filetype.go +++ b/auth/internal/credsfile/filetype.go @@ -14,7 +14,9 @@ package credsfile -import "encoding/json" +import ( + "encoding/json" +) // Config3LO is the internals of a client creds file. type Config3LO struct { @@ -57,19 +59,19 @@ type UserCredentialsFile struct { // ExternalAccountFile representation. type ExternalAccountFile struct { - Type string `json:"type"` - ClientID string `json:"client_id"` - ClientSecret string `json:"client_secret"` - Audience string `json:"audience"` - SubjectTokenType string `json:"subject_token_type"` - ServiceAccountImpersonationURL string `json:"service_account_impersonation_url"` - TokenURL string `json:"token_url"` - CredentialSource CredentialSource `json:"credential_source"` - TokenInfoURL string `json:"token_info_url"` - ServiceAccountImpersonation ServiceAccountImpersonationInfo `json:"service_account_impersonation"` - QuotaProjectID string `json:"quota_project_id"` - WorkforcePoolUserProject string `json:"workforce_pool_user_project"` - UniverseDomain string `json:"universe_domain"` + Type string `json:"type"` + ClientID string `json:"client_id"` + ClientSecret string `json:"client_secret"` + Audience string `json:"audience"` + SubjectTokenType string `json:"subject_token_type"` + ServiceAccountImpersonationURL string `json:"service_account_impersonation_url"` + TokenURL string `json:"token_url"` + CredentialSource *CredentialSource `json:"credential_source,omitempty"` + TokenInfoURL string `json:"token_info_url"` + ServiceAccountImpersonation *ServiceAccountImpersonationInfo `json:"service_account_impersonation,omitempty"` + QuotaProjectID string `json:"quota_project_id"` + WorkforcePoolUserProject string `json:"workforce_pool_user_project"` + UniverseDomain string `json:"universe_domain"` } // ExternalAccountAuthorizedUserFile representation. @@ -94,13 +96,13 @@ type CredentialSource struct { File string `json:"file"` URL string `json:"url"` Headers map[string]string `json:"headers"` - Executable *ExecutableConfig `json:"executable"` + Executable *ExecutableConfig `json:"executable,omitempty"` 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"` + Format *Format `json:"format,omitempty"` } // Format describes the format of a [CredentialSource]. @@ -115,7 +117,7 @@ type Format struct { // [CredentialSource]. type ExecutableConfig struct { Command string `json:"command"` - TimeoutMillis *int `json:"timeout_millis"` + TimeoutMillis int `json:"timeout_millis"` OutputFile string `json:"output_file"` } diff --git a/auth/internal/credsfile/parse_test.go b/auth/internal/credsfile/parse_test.go index 65ce46a8164c..2d1770f4192b 100644 --- a/auth/internal/credsfile/parse_test.go +++ b/auth/internal/credsfile/parse_test.go @@ -186,7 +186,7 @@ func TestParseExternalAccount_AWS(t *testing.T) { SubjectTokenType: "urn:ietf:params:aws:token-type:aws4_request", ServiceAccountImpersonationURL: "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/$EMAIL:generateAccessToken", TokenURL: "https://sts.googleapis.com/v1/token", - CredentialSource: CredentialSource{ + CredentialSource: &CredentialSource{ URL: "http://169.254.169.254/latest/meta-data/iam/security-credentials", EnvironmentID: "aws1", RegionURL: "http://169.254.169.254/latest/meta-data/placement/availability-zone", @@ -214,9 +214,9 @@ func TestParseExternalAccount_URL(t *testing.T) { SubjectTokenType: "urn:ietf:params:oauth:token-type:jwt", ServiceAccountImpersonationURL: "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/$EMAIL:generateAccessToken", TokenURL: "https://sts.googleapis.com/v1/token", - CredentialSource: CredentialSource{ + CredentialSource: &CredentialSource{ URL: "http://localhost:5000/token", - Format: Format{ + Format: &Format{ Type: "json", SubjectTokenFieldName: "id_token", }, @@ -242,7 +242,7 @@ func TestParseExternalAccount_File(t *testing.T) { SubjectTokenType: "urn:ietf:params:oauth:token-type:saml2", ServiceAccountImpersonationURL: "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/$EMAIL:generateAccessToken", TokenURL: "https://sts.googleapis.com/v1/token", - CredentialSource: CredentialSource{ + CredentialSource: &CredentialSource{ File: "/var/run/saml/assertion/token", }, } @@ -266,15 +266,14 @@ func TestParseExternalAccount_Cmd(t *testing.T) { SubjectTokenType: "urn:ietf:params:oauth:token-type:saml2", ServiceAccountImpersonationURL: "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/$EMAIL@project.iam.gserviceaccount.com:generateAccessToken", TokenURL: "https://sts.googleapis.com/v1/token", - CredentialSource: CredentialSource{ + CredentialSource: &CredentialSource{ Executable: &ExecutableConfig{ Command: "/path/to/executable --arg1=value1 --arg2=value2", OutputFile: "/path/to/cached/credentials", }, }, } - timeout := 5000 - want.CredentialSource.Executable.TimeoutMillis = &timeout + want.CredentialSource.Executable.TimeoutMillis = 5000 if diff := cmp.Diff(want, got); diff != "" { t.Errorf("(-want +got):\n%s", diff) }