8 Commits

Author SHA1 Message Date
4ad99b3ed5 remove usage of appengine to get rid of unsafe imports 2024-04-23 18:30:17 +02:00
Chris Smith
d0e617c58c google: add Credentials.UniverseDomainProvider
* move MDS universe retrieval within Compute credentials

Change-Id: I847d2075ca11bde998a06220307626e902230c23
Reviewed-on: https://go-review.googlesource.com/c/oauth2/+/575936
Reviewed-by: Cody Oss <codyoss@google.com>
Auto-Submit: Cody Oss <codyoss@google.com>
Run-TryBot: Cody Oss <codyoss@google.com>
TryBot-Result: Gopher Robot <gobot@golang.org>
LUCI-TryBot-Result: Go LUCI <golang-scoped@luci-project-accounts.iam.gserviceaccount.com>
2024-04-03 20:36:14 +00:00
Jin Qin
3c9c1f6d00 oauth2/google: fix the logic of sts 0 value of expires_in
The sts response contains an optional field of `expires_in` and the value can be any integer.

https://github.com/golang/oauth2/blob/master/google/internal/externalaccount/basecredentials.go#L246-L248

In the case of less than `0`, we are going to throw an error. But in the case of equals to `0` practically it means "never expire" instead of "instantly expire" which doesn't make sense.

So we need to not set the expiration value for Token object. The current else if greater or equal is wrong.

It's never triggered only because we are sending positive `3600` in sts response.

Change-Id: Id227ca71130855235572b65ab178681e80d0da3a
GitHub-Last-Rev: a95c923d6a5d256fa92629a1fcb908495d7b1338
GitHub-Pull-Request: golang/oauth2#687
Reviewed-on: https://go-review.googlesource.com/c/oauth2/+/545895
Reviewed-by: Shin Fan <shinfan@google.com>
LUCI-TryBot-Result: Go LUCI <golang-scoped@luci-project-accounts.iam.gserviceaccount.com>
Auto-Submit: Cody Oss <codyoss@google.com>
Reviewed-by: Cody Oss <codyoss@google.com>
2024-03-12 20:05:50 +00:00
Jin Qin
5a05c654f9 oauth2/google: fix remove content-type header from idms get requests
This is a fix on the https://github.com/googleapis/google-cloud-go/pull/9508.
The aws provider in that library is a ported dependency from here.

Change-Id: I28e1efa4fdb8292210b695a164a55060c83dae88
GitHub-Last-Rev: c425f2d3b12082bdd477100648a9e46cab026da0
GitHub-Pull-Request: golang/oauth2#711
Reviewed-on: https://go-review.googlesource.com/c/oauth2/+/570875
Reviewed-by: Cody Oss <codyoss@google.com>
Reviewed-by: Chris Smith <chrisdsmith@google.com>
LUCI-TryBot-Result: Go LUCI <golang-scoped@luci-project-accounts.iam.gserviceaccount.com>
2024-03-12 14:54:40 +00:00
Jordan Liggitt
3a6776ada7 appengine: drop obsolete code for AppEngine envs <=Go 1.11
This library no longer builds on Go versions prior to Go 1.17,
so no longer needs to support compilation specific to AppEngine
environments on Go versions prior to Go 1.11

Related to #615

Change-Id: Ia9579ea2091cb86ee96065affb920370c4ba33ea
Reviewed-on: https://go-review.googlesource.com/c/oauth2/+/570595
Reviewed-by: Matt Hickford <matt.hickford@gmail.com>
LUCI-TryBot-Result: Go LUCI <golang-scoped@luci-project-accounts.iam.gserviceaccount.com>
Reviewed-by: Cody Oss <codyoss@google.com>
Run-TryBot: Matt Hickford <matt.hickford@gmail.com>
TryBot-Result: Gopher Robot <gobot@golang.org>
2024-03-11 19:47:38 +00:00
Gopher Robot
85231f99d6 go.mod: update golang.org/x dependencies
Update golang.org/x dependencies to their latest tagged versions.

Change-Id: I993c77edbea8426f558ab84c4ba769e0bdf6406d
Reviewed-on: https://go-review.googlesource.com/c/oauth2/+/568935
Reviewed-by: Than McIntosh <thanm@google.com>
LUCI-TryBot-Result: Go LUCI <golang-scoped@luci-project-accounts.iam.gserviceaccount.com>
Auto-Submit: Gopher Robot <gobot@golang.org>
Reviewed-by: Michael Knyszek <mknyszek@google.com>
2024-03-04 22:41:57 +00:00
Chris Smith
34a7afaa85 google/externalaccount: add Config.UniverseDomain
Change-Id: Ia1caee246da68c01addd06e1367ed1e43645826b
Reviewed-on: https://go-review.googlesource.com/c/oauth2/+/568216
Reviewed-by: Alex Eitzman <eitzman@google.com>
Reviewed-by: Cody Oss <codyoss@google.com>
LUCI-TryBot-Result: Go LUCI <golang-scoped@luci-project-accounts.iam.gserviceaccount.com>
2024-03-04 19:42:12 +00:00
aeitzman
95bec95381 google/externalaccount: moves externalaccount package out of internal and exports it
go/programmable-auth-design for context. Adds support for user defined
 supplier methods to return subject tokens and AWS security credentials.

Change-Id: I7bc41f8c5202ae933fce516632f5049bbeb3d378
GitHub-Last-Rev: ac519b242f8315df572f1b205b0670f139bfc6c3
GitHub-Pull-Request: golang/oauth2#690
Reviewed-on: https://go-review.googlesource.com/c/oauth2/+/550835
LUCI-TryBot-Result: Go LUCI <golang-scoped@luci-project-accounts.iam.gserviceaccount.com>
Reviewed-by: Leo Siracusa <leosiracusa@google.com>
Reviewed-by: Chris Smith <chrisdsmith@google.com>
TryBot-Result: Gopher Robot <gobot@golang.org>
Reviewed-by: Cody Oss <codyoss@google.com>
2024-02-27 21:55:11 +00:00
27 changed files with 1645 additions and 947 deletions

View File

@@ -22,7 +22,7 @@ import (
const ( const (
adcSetupURL = "https://cloud.google.com/docs/authentication/external/set-up-adc" adcSetupURL = "https://cloud.google.com/docs/authentication/external/set-up-adc"
universeDomainDefault = "googleapis.com" defaultUniverseDomain = "googleapis.com"
) )
// Credentials holds Google credentials, including "Application Default Credentials". // Credentials holds Google credentials, including "Application Default Credentials".
@@ -42,6 +42,17 @@ type Credentials struct {
// running on Google Cloud Platform. // running on Google Cloud Platform.
JSON []byte JSON []byte
// UniverseDomainProvider returns the default service domain for a given
// Cloud universe. Optional.
//
// On GCE, UniverseDomainProvider should return the universe domain value
// from Google Compute Engine (GCE)'s metadata server. See also [The attached service
// account](https://cloud.google.com/docs/authentication/application-default-credentials#attached-sa).
// If the GCE metadata server returns a 404 error, the default universe
// domain value should be returned. If the GCE metadata server returns an
// error other than 404, the error should be returned.
UniverseDomainProvider func() (string, error)
udMu sync.Mutex // guards universeDomain udMu sync.Mutex // guards universeDomain
// universeDomain is the default service domain for a given Cloud universe. // universeDomain is the default service domain for a given Cloud universe.
universeDomain string universeDomain string
@@ -58,60 +69,38 @@ type Credentials struct {
// See also [The attached service account](https://cloud.google.com/docs/authentication/application-default-credentials#attached-sa). // See also [The attached service account](https://cloud.google.com/docs/authentication/application-default-credentials#attached-sa).
func (c *Credentials) UniverseDomain() string { func (c *Credentials) UniverseDomain() string {
if c.universeDomain == "" { if c.universeDomain == "" {
return universeDomainDefault return defaultUniverseDomain
} }
return c.universeDomain return c.universeDomain
} }
// GetUniverseDomain returns the default service domain for a given Cloud // GetUniverseDomain returns the default service domain for a given Cloud
// universe. // universe. If present, UniverseDomainProvider will be invoked and its return
// value will be cached.
// //
// The default value is "googleapis.com". // The default value is "googleapis.com".
//
// It obtains the universe domain from the attached service account on GCE when
// authenticating via the GCE metadata server. See also [The attached service
// account](https://cloud.google.com/docs/authentication/application-default-credentials#attached-sa).
// If the GCE metadata server returns a 404 error, the default value is
// returned. If the GCE metadata server returns an error other than 404, the
// error is returned.
func (c *Credentials) GetUniverseDomain() (string, error) { func (c *Credentials) GetUniverseDomain() (string, error) {
c.udMu.Lock() c.udMu.Lock()
defer c.udMu.Unlock() defer c.udMu.Unlock()
if c.universeDomain == "" && metadata.OnGCE() { if c.universeDomain == "" && c.UniverseDomainProvider != nil {
// If we're on Google Compute Engine, an App Engine standard second // On Google Compute Engine, an App Engine standard second generation
// generation runtime, or App Engine flexible, use the metadata server. // runtime, or App Engine flexible, use an externally provided function
err := c.computeUniverseDomain() // to request the universe domain from the metadata server.
ud, err := c.UniverseDomainProvider()
if err != nil { if err != nil {
return "", err return "", err
} }
c.universeDomain = ud
} }
// If not on Google Compute Engine, or in case of any non-error path in // If no UniverseDomainProvider (meaning not on Google Compute Engine), or
// computeUniverseDomain that did not set universeDomain, set the default // in case of any (non-error) empty return value from
// universe domain. // UniverseDomainProvider, set the default universe domain.
if c.universeDomain == "" { if c.universeDomain == "" {
c.universeDomain = universeDomainDefault c.universeDomain = defaultUniverseDomain
} }
return c.universeDomain, nil return c.universeDomain, nil
} }
// computeUniverseDomain fetches the default service domain for a given Cloud
// universe from Google Compute Engine (GCE)'s metadata server. It's only valid
// to use this method if your program is running on a GCE instance.
func (c *Credentials) computeUniverseDomain() error {
var err error
c.universeDomain, err = metadata.Get("universe/universe_domain")
if err != nil {
if _, ok := err.(metadata.NotDefinedError); ok {
// http.StatusNotFound (404)
c.universeDomain = universeDomainDefault
return nil
} else {
return err
}
}
return nil
}
// DefaultCredentials is the old name of Credentials. // DefaultCredentials is the old name of Credentials.
// //
// Deprecated: use Credentials instead. // Deprecated: use Credentials instead.
@@ -199,9 +188,7 @@ func DefaultTokenSource(ctx context.Context, scope ...string) (oauth2.TokenSourc
// 2. A JSON file in a location known to the gcloud command-line tool. // 2. A JSON file in a location known to the gcloud command-line tool.
// On Windows, this is %APPDATA%/gcloud/application_default_credentials.json. // On Windows, this is %APPDATA%/gcloud/application_default_credentials.json.
// On other systems, $HOME/.config/gcloud/application_default_credentials.json. // On other systems, $HOME/.config/gcloud/application_default_credentials.json.
// 3. On Google App Engine standard first generation runtimes (<= Go 1.9) it uses // 3. On Google Compute Engine, Google App Engine standard second generation runtimes
// the appengine.AccessToken function.
// 4. On Google Compute Engine, Google App Engine standard second generation runtimes
// (>= Go 1.11), and Google App Engine flexible environment, it fetches // (>= Go 1.11), and Google App Engine flexible environment, it fetches
// credentials from the metadata server. // credentials from the metadata server.
func FindDefaultCredentialsWithParams(ctx context.Context, params CredentialsParams) (*Credentials, error) { func FindDefaultCredentialsWithParams(ctx context.Context, params CredentialsParams) (*Credentials, error) {
@@ -224,13 +211,26 @@ func FindDefaultCredentialsWithParams(ctx context.Context, params CredentialsPar
return CredentialsFromJSONWithParams(ctx, b, params) return CredentialsFromJSONWithParams(ctx, b, params)
} }
// Fourth, if we're on Google Compute Engine, an App Engine standard second generation runtime, // Third, if we're on Google Compute Engine, an App Engine standard second generation runtime,
// or App Engine flexible, use the metadata server. // or App Engine flexible, use the metadata server.
if metadata.OnGCE() { if metadata.OnGCE() {
id, _ := metadata.ProjectID() id, _ := metadata.ProjectID()
universeDomainProvider := func() (string, error) {
universeDomain, err := metadata.Get("universe/universe_domain")
if err != nil {
if _, ok := err.(metadata.NotDefinedError); ok {
// http.StatusNotFound (404)
return defaultUniverseDomain, nil
} else {
return "", err
}
}
return universeDomain, nil
}
return &Credentials{ return &Credentials{
ProjectID: id, ProjectID: id,
TokenSource: computeTokenSource("", params.EarlyTokenRefresh, params.Scopes...), TokenSource: computeTokenSource("", params.EarlyTokenRefresh, params.Scopes...),
UniverseDomainProvider: universeDomainProvider,
universeDomain: params.UniverseDomain, universeDomain: params.UniverseDomain,
}, nil }, nil
} }
@@ -277,7 +277,7 @@ func CredentialsFromJSONWithParams(ctx context.Context, jsonData []byte, params
} }
// Authorized user credentials are only supported in the googleapis.com universe. // Authorized user credentials are only supported in the googleapis.com universe.
if f.Type == userCredentialsKey { if f.Type == userCredentialsKey {
universeDomain = universeDomainDefault universeDomain = defaultUniverseDomain
} }
ts, err := f.tokenSource(ctx, params) ts, err := f.tokenSource(ctx, params)

View File

@@ -10,6 +10,8 @@ import (
"net/http/httptest" "net/http/httptest"
"strings" "strings"
"testing" "testing"
"cloud.google.com/go/compute/metadata"
) )
var saJSONJWT = []byte(`{ var saJSONJWT = []byte(`{
@@ -255,9 +257,14 @@ func TestCredentialsFromJSONWithParams_User_UniverseDomain_Params_UniverseDomain
func TestComputeUniverseDomain(t *testing.T) { func TestComputeUniverseDomain(t *testing.T) {
universeDomainPath := "/computeMetadata/v1/universe/universe_domain" universeDomainPath := "/computeMetadata/v1/universe/universe_domain"
universeDomainResponseBody := "example.com" universeDomainResponseBody := "example.com"
var requests int
s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requests++
if r.URL.Path != universeDomainPath { if r.URL.Path != universeDomainPath {
t.Errorf("got %s, want %s", r.URL.Path, universeDomainPath) t.Errorf("bad path, got %s, want %s", r.URL.Path, universeDomainPath)
}
if requests > 1 {
t.Errorf("too many requests, got %d, want 1", requests)
} }
w.Write([]byte(universeDomainResponseBody)) w.Write([]byte(universeDomainResponseBody))
})) }))
@@ -268,10 +275,18 @@ func TestComputeUniverseDomain(t *testing.T) {
params := CredentialsParams{ params := CredentialsParams{
Scopes: []string{scope}, Scopes: []string{scope},
} }
universeDomainProvider := func() (string, error) {
universeDomain, err := metadata.Get("universe/universe_domain")
if err != nil {
return "", err
}
return universeDomain, nil
}
// Copied from FindDefaultCredentialsWithParams, metadata.OnGCE() = true block // Copied from FindDefaultCredentialsWithParams, metadata.OnGCE() = true block
creds := &Credentials{ creds := &Credentials{
ProjectID: "fake_project", ProjectID: "fake_project",
TokenSource: computeTokenSource("", params.EarlyTokenRefresh, params.Scopes...), TokenSource: computeTokenSource("", params.EarlyTokenRefresh, params.Scopes...),
UniverseDomainProvider: universeDomainProvider,
universeDomain: params.UniverseDomain, // empty universeDomain: params.UniverseDomain, // empty
} }
c := make(chan bool) c := make(chan bool)
@@ -285,7 +300,7 @@ func TestComputeUniverseDomain(t *testing.T) {
} }
c <- true c <- true
}() }()
got, err := creds.GetUniverseDomain() // Second conflicting access. got, err := creds.GetUniverseDomain() // Second conflicting (and potentially uncached) access.
<-c <-c
if err != nil { if err != nil {
t.Error(err) t.Error(err)

View File

@@ -22,91 +22,9 @@
// the other by JWTConfigFromJSON. The returned Config can be used to obtain a TokenSource or // the other by JWTConfigFromJSON. The returned Config can be used to obtain a TokenSource or
// create an http.Client. // create an http.Client.
// //
// # Workload Identity Federation // # Workload and Workforce Identity Federation
// //
// Using workload identity federation, your application can access Google Cloud // For information on how to use Workload and Workforce Identity Federation, see [golang.org/x/oauth2/google/externalaccount].
// resources from Amazon Web Services (AWS), Microsoft Azure or any identity
// provider that supports OpenID Connect (OIDC) or SAML 2.0.
// Traditionally, applications running outside Google Cloud have used service
// account keys to access Google Cloud resources. Using identity federation,
// you can allow your workload to impersonate a service account.
// This lets you access Google Cloud resources directly, eliminating the
// maintenance and security burden associated with service account keys.
//
// Follow the detailed instructions on how to configure Workload Identity Federation
// in various platforms:
//
// Amazon Web Services (AWS): https://cloud.google.com/iam/docs/workload-identity-federation-with-other-clouds#aws
// Microsoft Azure: https://cloud.google.com/iam/docs/workload-identity-federation-with-other-clouds#azure
// OIDC identity provider: https://cloud.google.com/iam/docs/workload-identity-federation-with-other-providers#oidc
// SAML 2.0 identity provider: https://cloud.google.com/iam/docs/workload-identity-federation-with-other-providers#saml
//
// For OIDC and SAML providers, the library can retrieve tokens in three ways:
// from a local file location (file-sourced credentials), from a server
// (URL-sourced credentials), or from a local executable (executable-sourced
// credentials).
// For file-sourced credentials, a background process needs to be continuously
// refreshing the file location with a new OIDC/SAML token prior to expiration.
// For tokens with one hour lifetimes, the token needs to be updated in the file
// every hour. The token can be stored directly as plain text or in JSON format.
// For URL-sourced credentials, a local server needs to host a GET endpoint to
// return the OIDC/SAML token. The response can be in plain text or JSON.
// Additional required request headers can also be specified.
// For executable-sourced credentials, an application needs to be available to
// output the OIDC/SAML token and other information in a JSON format.
// For more information on how these work (and how to implement
// executable-sourced credentials), please check out:
// https://cloud.google.com/iam/docs/workload-identity-federation-with-other-providers#create_a_credential_configuration
//
// Note that this library does not perform any validation on the token_url, token_info_url,
// or service_account_impersonation_url fields of the credential configuration.
// It is not recommended to use a credential configuration that you did not generate with
// the gcloud CLI unless you verify that the URL fields point to a googleapis.com domain.
//
// # Workforce Identity Federation
//
// Workforce identity federation lets you use an external identity provider (IdP) to
// authenticate and authorize a workforce—a group of users, such as employees, partners,
// and contractors—using IAM, so that the users can access Google Cloud services.
// Workforce identity federation extends Google Cloud's identity capabilities to support
// syncless, attribute-based single sign on.
//
// With workforce identity federation, your workforce can access Google Cloud resources
// using an external identity provider (IdP) that supports OpenID Connect (OIDC) or
// SAML 2.0 such as Azure Active Directory (Azure AD), Active Directory Federation
// Services (AD FS), Okta, and others.
//
// Follow the detailed instructions on how to configure Workload Identity Federation
// in various platforms:
//
// Azure AD: https://cloud.google.com/iam/docs/workforce-sign-in-azure-ad
// Okta: https://cloud.google.com/iam/docs/workforce-sign-in-okta
// OIDC identity provider: https://cloud.google.com/iam/docs/configuring-workforce-identity-federation#oidc
// SAML 2.0 identity provider: https://cloud.google.com/iam/docs/configuring-workforce-identity-federation#saml
//
// For workforce identity federation, the library can retrieve tokens in three ways:
// from a local file location (file-sourced credentials), from a server
// (URL-sourced credentials), or from a local executable (executable-sourced
// credentials).
// For file-sourced credentials, a background process needs to be continuously
// refreshing the file location with a new OIDC/SAML token prior to expiration.
// For tokens with one hour lifetimes, the token needs to be updated in the file
// every hour. The token can be stored directly as plain text or in JSON format.
// For URL-sourced credentials, a local server needs to host a GET endpoint to
// return the OIDC/SAML token. The response can be in plain text or JSON.
// Additional required request headers can also be specified.
// For executable-sourced credentials, an application needs to be available to
// output the OIDC/SAML token and other information in a JSON format.
// For more information on how these work (and how to implement
// executable-sourced credentials), please check out:
// https://cloud.google.com/iam/docs/workforce-obtaining-short-lived-credentials#generate_a_configuration_file_for_non-interactive_sign-in
//
// # Security considerations
//
// Note that this library does not perform any validation on the token_url, token_info_url,
// or service_account_impersonation_url fields of the credential configuration.
// It is not recommended to use a credential configuration that you did not generate with
// the gcloud CLI unless you verify that the URL fields point to a googleapis.com domain.
// //
// # Credentials // # Credentials
// //

View File

@@ -51,7 +51,7 @@ import (
const ( const (
universeDomainPlaceholder = "UNIVERSE_DOMAIN" universeDomainPlaceholder = "UNIVERSE_DOMAIN"
identityBindingEndpointTemplate = "https://sts.UNIVERSE_DOMAIN/v1/token" identityBindingEndpointTemplate = "https://sts.UNIVERSE_DOMAIN/v1/token"
universeDomainDefault = "googleapis.com" defaultUniverseDomain = "googleapis.com"
) )
type accessBoundary struct { type accessBoundary struct {
@@ -117,7 +117,7 @@ type DownscopingConfig struct {
// configured universe domain. // configured universe domain.
func (dc *DownscopingConfig) identityBindingEndpoint() string { func (dc *DownscopingConfig) identityBindingEndpoint() string {
if dc.UniverseDomain == "" { if dc.UniverseDomain == "" {
return strings.Replace(identityBindingEndpointTemplate, universeDomainPlaceholder, universeDomainDefault, 1) return strings.Replace(identityBindingEndpointTemplate, universeDomainPlaceholder, defaultUniverseDomain, 1)
} }
return strings.Replace(identityBindingEndpointTemplate, universeDomainPlaceholder, dc.UniverseDomain, 1) return strings.Replace(identityBindingEndpointTemplate, universeDomainPlaceholder, dc.UniverseDomain, 1)
} }

View File

@@ -26,22 +26,28 @@ import (
"golang.org/x/oauth2" "golang.org/x/oauth2"
) )
type awsSecurityCredentials struct { // AwsSecurityCredentials models AWS security credentials.
type AwsSecurityCredentials struct {
// AccessKeyId is the AWS Access Key ID - Required.
AccessKeyID string `json:"AccessKeyID"` AccessKeyID string `json:"AccessKeyID"`
// SecretAccessKey is the AWS Secret Access Key - Required.
SecretAccessKey string `json:"SecretAccessKey"` SecretAccessKey string `json:"SecretAccessKey"`
SecurityToken string `json:"Token"` // SessionToken is the AWS Session token. This should be provided for temporary AWS security credentials - Optional.
SessionToken string `json:"Token"`
} }
// awsRequestSigner is a utility class to sign http requests using a AWS V4 signature. // awsRequestSigner is a utility class to sign http requests using a AWS V4 signature.
type awsRequestSigner struct { type awsRequestSigner struct {
RegionName string RegionName string
AwsSecurityCredentials awsSecurityCredentials AwsSecurityCredentials *AwsSecurityCredentials
} }
// getenv aliases os.Getenv for testing // getenv aliases os.Getenv for testing
var getenv = os.Getenv var getenv = os.Getenv
const ( const (
defaultRegionalCredentialVerificationUrl = "https://sts.{region}.amazonaws.com?Action=GetCallerIdentity&Version=2011-06-15"
// AWS Signature Version 4 signing algorithm identifier. // AWS Signature Version 4 signing algorithm identifier.
awsAlgorithm = "AWS4-HMAC-SHA256" awsAlgorithm = "AWS4-HMAC-SHA256"
@@ -197,8 +203,8 @@ func (rs *awsRequestSigner) SignRequest(req *http.Request) error {
signedRequest.Header.Add("host", requestHost(req)) signedRequest.Header.Add("host", requestHost(req))
if rs.AwsSecurityCredentials.SecurityToken != "" { if rs.AwsSecurityCredentials.SessionToken != "" {
signedRequest.Header.Add(awsSecurityTokenHeader, rs.AwsSecurityCredentials.SecurityToken) signedRequest.Header.Add(awsSecurityTokenHeader, rs.AwsSecurityCredentials.SessionToken)
} }
if signedRequest.Header.Get("date") == "" { if signedRequest.Header.Get("date") == "" {
@@ -251,16 +257,18 @@ func (rs *awsRequestSigner) generateAuthentication(req *http.Request, timestamp
} }
type awsCredentialSource struct { type awsCredentialSource struct {
EnvironmentID string environmentID string
RegionURL string regionURL string
RegionalCredVerificationURL string regionalCredVerificationURL string
CredVerificationURL string credVerificationURL string
IMDSv2SessionTokenURL string imdsv2SessionTokenURL string
TargetResource string targetResource string
requestSigner *awsRequestSigner requestSigner *awsRequestSigner
region string region string
ctx context.Context ctx context.Context
client *http.Client client *http.Client
awsSecurityCredentialsSupplier AwsSecurityCredentialsSupplier
supplierOptions SupplierOptions
} }
type awsRequestHeader struct { type awsRequestHeader struct {
@@ -292,18 +300,25 @@ func canRetrieveSecurityCredentialFromEnvironment() bool {
return getenv(awsAccessKeyId) != "" && getenv(awsSecretAccessKey) != "" return getenv(awsAccessKeyId) != "" && getenv(awsSecretAccessKey) != ""
} }
func shouldUseMetadataServer() bool { func (cs awsCredentialSource) shouldUseMetadataServer() bool {
return !canRetrieveRegionFromEnvironment() || !canRetrieveSecurityCredentialFromEnvironment() return cs.awsSecurityCredentialsSupplier == nil && (!canRetrieveRegionFromEnvironment() || !canRetrieveSecurityCredentialFromEnvironment())
} }
func (cs awsCredentialSource) credentialSourceType() string { func (cs awsCredentialSource) credentialSourceType() string {
if cs.awsSecurityCredentialsSupplier != nil {
return "programmatic"
}
return "aws" return "aws"
} }
func (cs awsCredentialSource) subjectToken() (string, error) { func (cs awsCredentialSource) subjectToken() (string, error) {
// Set Defaults
if cs.regionalCredVerificationURL == "" {
cs.regionalCredVerificationURL = defaultRegionalCredentialVerificationUrl
}
if cs.requestSigner == nil { if cs.requestSigner == nil {
headers := make(map[string]string) headers := make(map[string]string)
if shouldUseMetadataServer() { if cs.shouldUseMetadataServer() {
awsSessionToken, err := cs.getAWSSessionToken() awsSessionToken, err := cs.getAWSSessionToken()
if err != nil { if err != nil {
return "", err return "", err
@@ -318,8 +333,8 @@ func (cs awsCredentialSource) subjectToken() (string, error) {
if err != nil { if err != nil {
return "", err return "", err
} }
cs.region, err = cs.getRegion(headers)
if cs.region, err = cs.getRegion(headers); err != nil { if err != nil {
return "", err return "", err
} }
@@ -331,7 +346,7 @@ func (cs awsCredentialSource) subjectToken() (string, error) {
// Generate the signed request to AWS STS GetCallerIdentity API. // Generate the signed request to AWS STS GetCallerIdentity API.
// Use the required regional endpoint. Otherwise, the request will fail. // Use the required regional endpoint. Otherwise, the request will fail.
req, err := http.NewRequest("POST", strings.Replace(cs.RegionalCredVerificationURL, "{region}", cs.region, 1), nil) req, err := http.NewRequest("POST", strings.Replace(cs.regionalCredVerificationURL, "{region}", cs.region, 1), nil)
if err != nil { if err != nil {
return "", err return "", err
} }
@@ -339,8 +354,8 @@ func (cs awsCredentialSource) subjectToken() (string, error) {
// provider, with or without the HTTPS prefix. // provider, with or without the HTTPS prefix.
// Including this header as part of the signature is recommended to // Including this header as part of the signature is recommended to
// ensure data integrity. // ensure data integrity.
if cs.TargetResource != "" { if cs.targetResource != "" {
req.Header.Add("x-goog-cloud-target-resource", cs.TargetResource) req.Header.Add("x-goog-cloud-target-resource", cs.targetResource)
} }
cs.requestSigner.SignRequest(req) cs.requestSigner.SignRequest(req)
@@ -387,11 +402,11 @@ func (cs awsCredentialSource) subjectToken() (string, error) {
} }
func (cs *awsCredentialSource) getAWSSessionToken() (string, error) { func (cs *awsCredentialSource) getAWSSessionToken() (string, error) {
if cs.IMDSv2SessionTokenURL == "" { if cs.imdsv2SessionTokenURL == "" {
return "", nil return "", nil
} }
req, err := http.NewRequest("PUT", cs.IMDSv2SessionTokenURL, nil) req, err := http.NewRequest("PUT", cs.imdsv2SessionTokenURL, nil)
if err != nil { if err != nil {
return "", err return "", err
} }
@@ -410,25 +425,29 @@ func (cs *awsCredentialSource) getAWSSessionToken() (string, error) {
} }
if resp.StatusCode != 200 { if resp.StatusCode != 200 {
return "", fmt.Errorf("oauth2/google: unable to retrieve AWS session token - %s", string(respBody)) return "", fmt.Errorf("oauth2/google/externalaccount: unable to retrieve AWS session token - %s", string(respBody))
} }
return string(respBody), nil return string(respBody), nil
} }
func (cs *awsCredentialSource) getRegion(headers map[string]string) (string, error) { func (cs *awsCredentialSource) getRegion(headers map[string]string) (string, error) {
if cs.awsSecurityCredentialsSupplier != nil {
return cs.awsSecurityCredentialsSupplier.AwsRegion(cs.ctx, cs.supplierOptions)
}
if canRetrieveRegionFromEnvironment() { if canRetrieveRegionFromEnvironment() {
if envAwsRegion := getenv(awsRegion); envAwsRegion != "" { if envAwsRegion := getenv(awsRegion); envAwsRegion != "" {
cs.region = envAwsRegion
return envAwsRegion, nil return envAwsRegion, nil
} }
return getenv("AWS_DEFAULT_REGION"), nil return getenv("AWS_DEFAULT_REGION"), nil
} }
if cs.RegionURL == "" { if cs.regionURL == "" {
return "", errors.New("oauth2/google: unable to determine AWS region") return "", errors.New("oauth2/google/externalaccount: unable to determine AWS region")
} }
req, err := http.NewRequest("GET", cs.RegionURL, nil) req, err := http.NewRequest("GET", cs.regionURL, nil)
if err != nil { if err != nil {
return "", err return "", err
} }
@@ -449,7 +468,7 @@ func (cs *awsCredentialSource) getRegion(headers map[string]string) (string, err
} }
if resp.StatusCode != 200 { if resp.StatusCode != 200 {
return "", fmt.Errorf("oauth2/google: unable to retrieve AWS region - %s", string(respBody)) return "", fmt.Errorf("oauth2/google/externalaccount: unable to retrieve AWS region - %s", string(respBody))
} }
// This endpoint will return the region in format: us-east-2b. // This endpoint will return the region in format: us-east-2b.
@@ -461,12 +480,15 @@ func (cs *awsCredentialSource) getRegion(headers map[string]string) (string, err
return string(respBody[:respBodyEnd]), nil return string(respBody[:respBodyEnd]), nil
} }
func (cs *awsCredentialSource) getSecurityCredentials(headers map[string]string) (result awsSecurityCredentials, err error) { func (cs *awsCredentialSource) getSecurityCredentials(headers map[string]string) (result *AwsSecurityCredentials, err error) {
if cs.awsSecurityCredentialsSupplier != nil {
return cs.awsSecurityCredentialsSupplier.AwsSecurityCredentials(cs.ctx, cs.supplierOptions)
}
if canRetrieveSecurityCredentialFromEnvironment() { if canRetrieveSecurityCredentialFromEnvironment() {
return awsSecurityCredentials{ return &AwsSecurityCredentials{
AccessKeyID: getenv(awsAccessKeyId), AccessKeyID: getenv(awsAccessKeyId),
SecretAccessKey: getenv(awsSecretAccessKey), SecretAccessKey: getenv(awsSecretAccessKey),
SecurityToken: getenv(awsSessionToken), SessionToken: getenv(awsSessionToken),
}, nil }, nil
} }
@@ -481,24 +503,23 @@ func (cs *awsCredentialSource) getSecurityCredentials(headers map[string]string)
} }
if credentials.AccessKeyID == "" { if credentials.AccessKeyID == "" {
return result, errors.New("oauth2/google: missing AccessKeyId credential") return result, errors.New("oauth2/google/externalaccount: missing AccessKeyId credential")
} }
if credentials.SecretAccessKey == "" { if credentials.SecretAccessKey == "" {
return result, errors.New("oauth2/google: missing SecretAccessKey credential") return result, errors.New("oauth2/google/externalaccount: missing SecretAccessKey credential")
} }
return credentials, nil return &credentials, nil
} }
func (cs *awsCredentialSource) getMetadataSecurityCredentials(roleName string, headers map[string]string) (awsSecurityCredentials, error) { func (cs *awsCredentialSource) getMetadataSecurityCredentials(roleName string, headers map[string]string) (AwsSecurityCredentials, error) {
var result awsSecurityCredentials var result AwsSecurityCredentials
req, err := http.NewRequest("GET", fmt.Sprintf("%s/%s", cs.CredVerificationURL, roleName), nil) req, err := http.NewRequest("GET", fmt.Sprintf("%s/%s", cs.credVerificationURL, roleName), nil)
if err != nil { if err != nil {
return result, err return result, err
} }
req.Header.Add("Content-Type", "application/json")
for name, value := range headers { for name, value := range headers {
req.Header.Add(name, value) req.Header.Add(name, value)
@@ -516,7 +537,7 @@ func (cs *awsCredentialSource) getMetadataSecurityCredentials(roleName string, h
} }
if resp.StatusCode != 200 { if resp.StatusCode != 200 {
return result, fmt.Errorf("oauth2/google: unable to retrieve AWS security credentials - %s", string(respBody)) return result, fmt.Errorf("oauth2/google/externalaccount: unable to retrieve AWS security credentials - %s", string(respBody))
} }
err = json.Unmarshal(respBody, &result) err = json.Unmarshal(respBody, &result)
@@ -524,11 +545,11 @@ func (cs *awsCredentialSource) getMetadataSecurityCredentials(roleName string, h
} }
func (cs *awsCredentialSource) getMetadataRoleName(headers map[string]string) (string, error) { func (cs *awsCredentialSource) getMetadataRoleName(headers map[string]string) (string, error) {
if cs.CredVerificationURL == "" { if cs.credVerificationURL == "" {
return "", errors.New("oauth2/google: unable to determine the AWS metadata server security credentials endpoint") return "", errors.New("oauth2/google/externalaccount: unable to determine the AWS metadata server security credentials endpoint")
} }
req, err := http.NewRequest("GET", cs.CredVerificationURL, nil) req, err := http.NewRequest("GET", cs.credVerificationURL, nil)
if err != nil { if err != nil {
return "", err return "", err
} }
@@ -549,7 +570,7 @@ func (cs *awsCredentialSource) getMetadataRoleName(headers map[string]string) (s
} }
if resp.StatusCode != 200 { if resp.StatusCode != 200 {
return "", fmt.Errorf("oauth2/google: unable to retrieve AWS role name - %s", string(respBody)) return "", fmt.Errorf("oauth2/google/externalaccount: unable to retrieve AWS role name - %s", string(respBody))
} }
return string(respBody), nil return string(respBody), nil

View File

@@ -7,6 +7,7 @@ package externalaccount
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
@@ -36,7 +37,7 @@ func setEnvironment(env map[string]string) func(string) string {
var defaultRequestSigner = &awsRequestSigner{ var defaultRequestSigner = &awsRequestSigner{
RegionName: "us-east-1", RegionName: "us-east-1",
AwsSecurityCredentials: awsSecurityCredentials{ AwsSecurityCredentials: &AwsSecurityCredentials{
AccessKeyID: "AKIDEXAMPLE", AccessKeyID: "AKIDEXAMPLE",
SecretAccessKey: "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY", SecretAccessKey: "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY",
}, },
@@ -50,10 +51,10 @@ const (
var requestSignerWithToken = &awsRequestSigner{ var requestSignerWithToken = &awsRequestSigner{
RegionName: "us-east-2", RegionName: "us-east-2",
AwsSecurityCredentials: awsSecurityCredentials{ AwsSecurityCredentials: &AwsSecurityCredentials{
AccessKeyID: accessKeyID, AccessKeyID: accessKeyID,
SecretAccessKey: secretAccessKey, SecretAccessKey: secretAccessKey,
SecurityToken: securityToken, SessionToken: securityToken,
}, },
} }
@@ -388,7 +389,7 @@ func TestAWSv4Signature_PostRequestWithSecurityTokenAndAdditionalHeaders(t *test
func TestAWSv4Signature_PostRequestWithAmzDateButNoSecurityToken(t *testing.T) { func TestAWSv4Signature_PostRequestWithAmzDateButNoSecurityToken(t *testing.T) {
var requestSigner = &awsRequestSigner{ var requestSigner = &awsRequestSigner{
RegionName: "us-east-2", RegionName: "us-east-2",
AwsSecurityCredentials: awsSecurityCredentials{ AwsSecurityCredentials: &AwsSecurityCredentials{
AccessKeyID: accessKeyID, AccessKeyID: accessKeyID,
SecretAccessKey: secretAccessKey, SecretAccessKey: secretAccessKey,
}, },
@@ -526,8 +527,8 @@ func notFound(w http.ResponseWriter, r *http.Request) {
func noHeaderValidation(r *http.Request) {} func noHeaderValidation(r *http.Request) {}
func (server *testAwsServer) getCredentialSource(url string) CredentialSource { func (server *testAwsServer) getCredentialSource(url string) *CredentialSource {
return CredentialSource{ return &CredentialSource{
EnvironmentID: "aws1", EnvironmentID: "aws1",
URL: url + server.url, URL: url + server.url,
RegionURL: url + server.regionURL, RegionURL: url + server.regionURL,
@@ -541,10 +542,10 @@ func getExpectedSubjectToken(url, region, accessKeyID, secretAccessKey, security
req.Header.Add("x-goog-cloud-target-resource", testFileConfig.Audience) req.Header.Add("x-goog-cloud-target-resource", testFileConfig.Audience)
signer := &awsRequestSigner{ signer := &awsRequestSigner{
RegionName: region, RegionName: region,
AwsSecurityCredentials: awsSecurityCredentials{ AwsSecurityCredentials: &AwsSecurityCredentials{
AccessKeyID: accessKeyID, AccessKeyID: accessKeyID,
SecretAccessKey: secretAccessKey, SecretAccessKey: secretAccessKey,
SecurityToken: securityToken, SessionToken: securityToken,
}, },
} }
signer.SignRequest(req) signer.SignRequest(req)
@@ -588,7 +589,6 @@ func TestAWSCredential_BasicRequest(t *testing.T) {
tfc := testFileConfig tfc := testFileConfig
tfc.CredentialSource = server.getCredentialSource(ts.URL) tfc.CredentialSource = server.getCredentialSource(ts.URL)
oldGetenv := getenv oldGetenv := getenv
oldNow := now oldNow := now
defer func() { defer func() {
@@ -846,7 +846,7 @@ func TestAWSCredential_RequestWithBadVersion(t *testing.T) {
if err == nil { if err == nil {
t.Fatalf("parse() should have failed") t.Fatalf("parse() should have failed")
} }
if got, want := err.Error(), "oauth2/google: aws version '3' is not supported in the current build"; !reflect.DeepEqual(got, want) { if got, want := err.Error(), "oauth2/google/externalaccount: aws version '3' is not supported in the current build"; !reflect.DeepEqual(got, want) {
t.Errorf("subjectToken = %q, want %q", got, want) t.Errorf("subjectToken = %q, want %q", got, want)
} }
} }
@@ -875,7 +875,7 @@ func TestAWSCredential_RequestWithNoRegionURL(t *testing.T) {
t.Fatalf("retrieveSubjectToken() should have failed") t.Fatalf("retrieveSubjectToken() should have failed")
} }
if got, want := err.Error(), "oauth2/google: unable to determine AWS region"; !reflect.DeepEqual(got, want) { if got, want := err.Error(), "oauth2/google/externalaccount: unable to determine AWS region"; !reflect.DeepEqual(got, want) {
t.Errorf("subjectToken = %q, want %q", got, want) t.Errorf("subjectToken = %q, want %q", got, want)
} }
} }
@@ -905,7 +905,7 @@ func TestAWSCredential_RequestWithBadRegionURL(t *testing.T) {
t.Fatalf("retrieveSubjectToken() should have failed") t.Fatalf("retrieveSubjectToken() should have failed")
} }
if got, want := err.Error(), "oauth2/google: unable to retrieve AWS region - Not Found"; !reflect.DeepEqual(got, want) { if got, want := err.Error(), "oauth2/google/externalaccount: unable to retrieve AWS region - Not Found"; !reflect.DeepEqual(got, want) {
t.Errorf("subjectToken = %q, want %q", got, want) t.Errorf("subjectToken = %q, want %q", got, want)
} }
} }
@@ -937,7 +937,7 @@ func TestAWSCredential_RequestWithMissingCredential(t *testing.T) {
t.Fatalf("retrieveSubjectToken() should have failed") t.Fatalf("retrieveSubjectToken() should have failed")
} }
if got, want := err.Error(), "oauth2/google: missing AccessKeyId credential"; !reflect.DeepEqual(got, want) { if got, want := err.Error(), "oauth2/google/externalaccount: missing AccessKeyId credential"; !reflect.DeepEqual(got, want) {
t.Errorf("subjectToken = %q, want %q", got, want) t.Errorf("subjectToken = %q, want %q", got, want)
} }
} }
@@ -969,7 +969,7 @@ func TestAWSCredential_RequestWithIncompleteCredential(t *testing.T) {
t.Fatalf("retrieveSubjectToken() should have failed") t.Fatalf("retrieveSubjectToken() should have failed")
} }
if got, want := err.Error(), "oauth2/google: missing SecretAccessKey credential"; !reflect.DeepEqual(got, want) { if got, want := err.Error(), "oauth2/google/externalaccount: missing SecretAccessKey credential"; !reflect.DeepEqual(got, want) {
t.Errorf("subjectToken = %q, want %q", got, want) t.Errorf("subjectToken = %q, want %q", got, want)
} }
} }
@@ -998,7 +998,7 @@ func TestAWSCredential_RequestWithNoCredentialURL(t *testing.T) {
t.Fatalf("retrieveSubjectToken() should have failed") t.Fatalf("retrieveSubjectToken() should have failed")
} }
if got, want := err.Error(), "oauth2/google: unable to determine the AWS metadata server security credentials endpoint"; !reflect.DeepEqual(got, want) { if got, want := err.Error(), "oauth2/google/externalaccount: unable to determine the AWS metadata server security credentials endpoint"; !reflect.DeepEqual(got, want) {
t.Errorf("subjectToken = %q, want %q", got, want) t.Errorf("subjectToken = %q, want %q", got, want)
} }
} }
@@ -1027,7 +1027,7 @@ func TestAWSCredential_RequestWithBadCredentialURL(t *testing.T) {
t.Fatalf("retrieveSubjectToken() should have failed") t.Fatalf("retrieveSubjectToken() should have failed")
} }
if got, want := err.Error(), "oauth2/google: unable to retrieve AWS role name - Not Found"; !reflect.DeepEqual(got, want) { if got, want := err.Error(), "oauth2/google/externalaccount: unable to retrieve AWS role name - Not Found"; !reflect.DeepEqual(got, want) {
t.Errorf("subjectToken = %q, want %q", got, want) t.Errorf("subjectToken = %q, want %q", got, want)
} }
} }
@@ -1056,7 +1056,7 @@ func TestAWSCredential_RequestWithBadFinalCredentialURL(t *testing.T) {
t.Fatalf("retrieveSubjectToken() should have failed") t.Fatalf("retrieveSubjectToken() should have failed")
} }
if got, want := err.Error(), "oauth2/google: unable to retrieve AWS security credentials - Not Found"; !reflect.DeepEqual(got, want) { if got, want := err.Error(), "oauth2/google/externalaccount: unable to retrieve AWS security credentials - Not Found"; !reflect.DeepEqual(got, want) {
t.Errorf("subjectToken = %q, want %q", got, want) t.Errorf("subjectToken = %q, want %q", got, want)
} }
} }
@@ -1235,6 +1235,192 @@ func TestAWSCredential_ShouldCallMetadataEndpointWhenNoSecretAccessKey(t *testin
} }
} }
func TestAWSCredential_ProgrammaticAuth(t *testing.T) {
tfc := testFileConfig
securityCredentials := AwsSecurityCredentials{
AccessKeyID: accessKeyID,
SecretAccessKey: secretAccessKey,
SessionToken: securityToken,
}
tfc.AwsSecurityCredentialsSupplier = testAwsSupplier{
awsRegion: "us-east-2",
err: nil,
credentials: &securityCredentials,
}
oldNow := now
defer func() {
now = oldNow
}()
now = setTime(defaultTime)
base, err := tfc.parse(context.Background())
if err != nil {
t.Fatalf("parse() failed %v", err)
}
out, err := base.subjectToken()
if err != nil {
t.Fatalf("retrieveSubjectToken() failed: %v", err)
}
expected := getExpectedSubjectToken(
"https://sts.us-east-2.amazonaws.com?Action=GetCallerIdentity&Version=2011-06-15",
"us-east-2",
accessKeyID,
secretAccessKey,
securityToken,
)
if got, want := out, expected; !reflect.DeepEqual(got, want) {
t.Errorf("subjectToken = \n%q\n want \n%q", got, want)
}
}
func TestAWSCredential_ProgrammaticAuthNoSessionToken(t *testing.T) {
tfc := testFileConfig
securityCredentials := AwsSecurityCredentials{
AccessKeyID: accessKeyID,
SecretAccessKey: secretAccessKey,
}
tfc.AwsSecurityCredentialsSupplier = testAwsSupplier{
awsRegion: "us-east-2",
err: nil,
credentials: &securityCredentials,
}
oldNow := now
defer func() {
now = oldNow
}()
now = setTime(defaultTime)
base, err := tfc.parse(context.Background())
if err != nil {
t.Fatalf("parse() failed %v", err)
}
out, err := base.subjectToken()
if err != nil {
t.Fatalf("retrieveSubjectToken() failed: %v", err)
}
expected := getExpectedSubjectToken(
"https://sts.us-east-2.amazonaws.com?Action=GetCallerIdentity&Version=2011-06-15",
"us-east-2",
accessKeyID,
secretAccessKey,
"",
)
if got, want := out, expected; !reflect.DeepEqual(got, want) {
t.Errorf("subjectToken = \n%q\n want \n%q", got, want)
}
}
func TestAWSCredential_ProgrammaticAuthError(t *testing.T) {
tfc := testFileConfig
testErr := errors.New("test error")
tfc.AwsSecurityCredentialsSupplier = testAwsSupplier{
awsRegion: "us-east-2",
err: testErr,
credentials: nil,
}
base, err := tfc.parse(context.Background())
if err != nil {
t.Fatalf("parse() failed %v", err)
}
_, err = base.subjectToken()
if err == nil {
t.Fatalf("subjectToken() should have failed")
}
if err != testErr {
t.Errorf("error = %e, want %e", err, testErr)
}
}
func TestAWSCredential_ProgrammaticAuthRegionError(t *testing.T) {
tfc := testFileConfig
securityCredentials := AwsSecurityCredentials{
AccessKeyID: accessKeyID,
SecretAccessKey: secretAccessKey,
}
testErr := errors.New("test")
tfc.AwsSecurityCredentialsSupplier = testAwsSupplier{
awsRegion: "",
regionErr: testErr,
credentials: &securityCredentials,
}
base, err := tfc.parse(context.Background())
if err != nil {
t.Fatalf("parse() failed %v", err)
}
_, err = base.subjectToken()
if err == nil {
t.Fatalf("subjectToken() should have failed")
}
if err != testErr {
t.Errorf("error = %e, want %e", err, testErr)
}
}
func TestAWSCredential_ProgrammaticAuthOptions(t *testing.T) {
tfc := testFileConfig
securityCredentials := AwsSecurityCredentials{
AccessKeyID: accessKeyID,
SecretAccessKey: secretAccessKey,
}
expectedOptions := SupplierOptions{Audience: tfc.Audience, SubjectTokenType: tfc.SubjectTokenType}
tfc.AwsSecurityCredentialsSupplier = testAwsSupplier{
awsRegion: "us-east-2",
credentials: &securityCredentials,
expectedOptions: &expectedOptions,
}
base, err := tfc.parse(context.Background())
if err != nil {
t.Fatalf("parse() failed %v", err)
}
_, err = base.subjectToken()
if err != nil {
t.Fatalf("subjectToken() failed %v", err)
}
}
func TestAWSCredential_ProgrammaticAuthContext(t *testing.T) {
tfc := testFileConfig
securityCredentials := AwsSecurityCredentials{
AccessKeyID: accessKeyID,
SecretAccessKey: secretAccessKey,
}
ctx := context.Background()
tfc.AwsSecurityCredentialsSupplier = testAwsSupplier{
awsRegion: "us-east-2",
credentials: &securityCredentials,
expectedContext: ctx,
}
base, err := tfc.parse(ctx)
if err != nil {
t.Fatalf("parse() failed %v", err)
}
_, err = base.subjectToken()
if err != nil {
t.Fatalf("subjectToken() failed %v", err)
}
}
func TestAwsCredential_CredentialSourceType(t *testing.T) { func TestAwsCredential_CredentialSourceType(t *testing.T) {
server := createDefaultAwsTestServer() server := createDefaultAwsTestServer()
ts := httptest.NewServer(server) ts := httptest.NewServer(server)
@@ -1251,3 +1437,52 @@ func TestAwsCredential_CredentialSourceType(t *testing.T) {
t.Errorf("got %v but want %v", got, want) t.Errorf("got %v but want %v", got, want)
} }
} }
type testAwsSupplier struct {
err error
regionErr error
awsRegion string
credentials *AwsSecurityCredentials
expectedOptions *SupplierOptions
expectedContext context.Context
}
func (supp testAwsSupplier) AwsRegion(ctx context.Context, options SupplierOptions) (string, error) {
if supp.regionErr != nil {
return "", supp.regionErr
}
if supp.expectedOptions != nil {
if supp.expectedOptions.Audience != options.Audience {
return "", errors.New("Audience does not match")
}
if supp.expectedOptions.SubjectTokenType != options.SubjectTokenType {
return "", errors.New("Audience does not match")
}
}
if supp.expectedContext != nil {
if supp.expectedContext != ctx {
return "", errors.New("Context does not match")
}
}
return supp.awsRegion, nil
}
func (supp testAwsSupplier) AwsSecurityCredentials(ctx context.Context, options SupplierOptions) (*AwsSecurityCredentials, error) {
if supp.err != nil {
return nil, supp.err
}
if supp.expectedOptions != nil {
if supp.expectedOptions.Audience != options.Audience {
return nil, errors.New("Audience does not match")
}
if supp.expectedOptions.SubjectTokenType != options.SubjectTokenType {
return nil, errors.New("Audience does not match")
}
}
if supp.expectedContext != nil {
if supp.expectedContext != ctx {
return nil, errors.New("Context does not match")
}
}
return supp.credentials, nil
}

View File

@@ -0,0 +1,485 @@
// Copyright 2020 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
/*
Package externalaccount provides support for creating workload identity
federation and workforce identity federation token sources that can be
used to access Google Cloud resources from external identity providers.
# Workload Identity Federation
Using workload identity federation, your application can access Google Cloud
resources from Amazon Web Services (AWS), Microsoft Azure or any identity
provider that supports OpenID Connect (OIDC) or SAML 2.0.
Traditionally, applications running outside Google Cloud have used service
account keys to access Google Cloud resources. Using identity federation,
you can allow your workload to impersonate a service account.
This lets you access Google Cloud resources directly, eliminating the
maintenance and security burden associated with service account keys.
Follow the detailed instructions on how to configure Workload Identity Federation
in various platforms:
Amazon Web Services (AWS): https://cloud.google.com/iam/docs/workload-identity-federation-with-other-clouds#aws
Microsoft Azure: https://cloud.google.com/iam/docs/workload-identity-federation-with-other-clouds#azure
OIDC identity provider: https://cloud.google.com/iam/docs/workload-identity-federation-with-other-providers#oidc
SAML 2.0 identity provider: https://cloud.google.com/iam/docs/workload-identity-federation-with-other-providers#saml
For OIDC and SAML providers, the library can retrieve tokens in fours ways:
from a local file location (file-sourced credentials), from a server
(URL-sourced credentials), from a local executable (executable-sourced
credentials), or from a user defined function that returns an OIDC or SAML token.
For file-sourced credentials, a background process needs to be continuously
refreshing the file location with a new OIDC/SAML token prior to expiration.
For tokens with one hour lifetimes, the token needs to be updated in the file
every hour. The token can be stored directly as plain text or in JSON format.
For URL-sourced credentials, a local server needs to host a GET endpoint to
return the OIDC/SAML token. The response can be in plain text or JSON.
Additional required request headers can also be specified.
For executable-sourced credentials, an application needs to be available to
output the OIDC/SAML token and other information in a JSON format.
For more information on how these work (and how to implement
executable-sourced credentials), please check out:
https://cloud.google.com/iam/docs/workload-identity-federation-with-other-providers#create_a_credential_configuration
To use a custom function to supply the token, define a struct that implements the [SubjectTokenSupplier] interface for OIDC/SAML providers,
or one that implements [AwsSecurityCredentialsSupplier] for AWS providers. This can then be used when building a [Config].
The [golang.org/x/oauth2.TokenSource] created from the config using [NewTokenSource] can then be used to access Google
Cloud resources. For instance, you can create a new client from the
[cloud.google.com/go/storage] package and pass in option.WithTokenSource(yourTokenSource))
Note that this library does not perform any validation on the token_url, token_info_url,
or service_account_impersonation_url fields of the credential configuration.
It is not recommended to use a credential configuration that you did not generate with
the gcloud CLI unless you verify that the URL fields point to a googleapis.com domain.
# Workforce Identity Federation
Workforce identity federation lets you use an external identity provider (IdP) to
authenticate and authorize a workforce—a group of users, such as employees, partners,
and contractors—using IAM, so that the users can access Google Cloud services.
Workforce identity federation extends Google Cloud's identity capabilities to support
syncless, attribute-based single sign on.
With workforce identity federation, your workforce can access Google Cloud resources
using an external identity provider (IdP) that supports OpenID Connect (OIDC) or
SAML 2.0 such as Azure Active Directory (Azure AD), Active Directory Federation
Services (AD FS), Okta, and others.
Follow the detailed instructions on how to configure Workload Identity Federation
in various platforms:
Azure AD: https://cloud.google.com/iam/docs/workforce-sign-in-azure-ad
Okta: https://cloud.google.com/iam/docs/workforce-sign-in-okta
OIDC identity provider: https://cloud.google.com/iam/docs/configuring-workforce-identity-federation#oidc
SAML 2.0 identity provider: https://cloud.google.com/iam/docs/configuring-workforce-identity-federation#saml
For workforce identity federation, the library can retrieve tokens in four ways:
from a local file location (file-sourced credentials), from a server
(URL-sourced credentials), from a local executable (executable-sourced
credentials), or from a user supplied function that returns an OIDC or SAML token.
For file-sourced credentials, a background process needs to be continuously
refreshing the file location with a new OIDC/SAML token prior to expiration.
For tokens with one hour lifetimes, the token needs to be updated in the file
every hour. The token can be stored directly as plain text or in JSON format.
For URL-sourced credentials, a local server needs to host a GET endpoint to
return the OIDC/SAML token. The response can be in plain text or JSON.
Additional required request headers can also be specified.
For executable-sourced credentials, an application needs to be available to
output the OIDC/SAML token and other information in a JSON format.
For more information on how these work (and how to implement
executable-sourced credentials), please check out:
https://cloud.google.com/iam/docs/workforce-obtaining-short-lived-credentials#generate_a_configuration_file_for_non-interactive_sign-in
To use a custom function to supply the token, define a struct that implements the [SubjectTokenSupplier] interface for OIDC/SAML providers.
This can then be used when building a [Config].
The [golang.org/x/oauth2.TokenSource] created from the config using [NewTokenSource] can then be used access Google
Cloud resources. For instance, you can create a new client from the
[cloud.google.com/go/storage] package and pass in option.WithTokenSource(yourTokenSource))
# Security considerations
Note that this library does not perform any validation on the token_url, token_info_url,
or service_account_impersonation_url fields of the credential configuration.
It is not recommended to use a credential configuration that you did not generate with
the gcloud CLI unless you verify that the URL fields point to a googleapis.com domain.
*/
package externalaccount
import (
"context"
"fmt"
"net/http"
"regexp"
"strconv"
"strings"
"time"
"golang.org/x/oauth2"
"golang.org/x/oauth2/google/internal/impersonate"
"golang.org/x/oauth2/google/internal/stsexchange"
)
const (
universeDomainPlaceholder = "UNIVERSE_DOMAIN"
defaultTokenURL = "https://sts.UNIVERSE_DOMAIN/v1/token"
defaultUniverseDomain = "googleapis.com"
)
// now aliases time.Now for testing
var now = func() time.Time {
return time.Now().UTC()
}
// Config stores the configuration for fetching tokens with external credentials.
type Config struct {
// Audience is the Secure Token Service (STS) audience which contains the resource name for the workload
// identity pool or the workforce pool and the provider identifier in that pool. Required.
Audience string
// SubjectTokenType is the STS token type based on the Oauth2.0 token exchange spec.
// Expected values include:
// “urn:ietf:params:oauth:token-type:jwt”
// “urn:ietf:params:oauth:token-type:id-token”
// “urn:ietf:params:oauth:token-type:saml2”
// “urn:ietf:params:aws:token-type:aws4_request”
// Required.
SubjectTokenType string
// TokenURL is the STS token exchange endpoint. If not provided, will default to
// https://sts.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. Optional.
ServiceAccountImpersonationURL string
// ServiceAccountImpersonationLifetimeSeconds is the number of seconds the service account impersonation
// token will be valid for. If not provided, it will default to 3600. Optional.
ServiceAccountImpersonationLifetimeSeconds int
// ClientSecret is currently only required if token_info endpoint also
// needs to be called with the generated GCP access token. When provided, STS will be
// called with additional basic authentication using ClientId as username and ClientSecret as password. Optional.
ClientSecret string
// ClientID is only required in conjunction with ClientSecret, as described above. Optional.
ClientID string
// CredentialSource contains the necessary information to retrieve the token itself, as well
// as some environmental information. One of SubjectTokenSupplier, AWSSecurityCredentialSupplier or
// CredentialSource must be provided. Optional.
CredentialSource *CredentialSource
// QuotaProjectID is injected by gCloud. If the value is non-empty, the Auth libraries
// will set the x-goog-user-project header which overrides the project associated with the credentials. Optional.
QuotaProjectID string
// Scopes contains the desired scopes for the returned access token. Optional.
Scopes []string
// WorkforcePoolUserProject is the workforce pool user project number when the credential
// corresponds to a workforce pool and not a workload identity pool.
// The underlying principal must still have serviceusage.services.use IAM
// permission to use the project for billing/quota. Optional.
WorkforcePoolUserProject string
// SubjectTokenSupplier is an optional token supplier for OIDC/SAML credentials.
// One of SubjectTokenSupplier, AWSSecurityCredentialSupplier or CredentialSource must be provided. Optional.
SubjectTokenSupplier SubjectTokenSupplier
// AwsSecurityCredentialsSupplier is an AWS Security Credential supplier for AWS credentials.
// One of SubjectTokenSupplier, AWSSecurityCredentialSupplier or CredentialSource must be provided. Optional.
AwsSecurityCredentialsSupplier AwsSecurityCredentialsSupplier
// 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
}
var (
validWorkforceAudiencePattern *regexp.Regexp = regexp.MustCompile(`//iam\.googleapis\.com/locations/[^/]+/workforcePools/`)
)
func validateWorkforceAudience(input string) bool {
return validWorkforceAudiencePattern.MatchString(input)
}
// NewTokenSource Returns an external account TokenSource using the provided external account config.
func NewTokenSource(ctx context.Context, conf Config) (oauth2.TokenSource, error) {
if conf.Audience == "" {
return nil, fmt.Errorf("oauth2/google/externalaccount: Audience must be set")
}
if conf.SubjectTokenType == "" {
return nil, fmt.Errorf("oauth2/google/externalaccount: Subject token type must be set")
}
if conf.WorkforcePoolUserProject != "" {
valid := validateWorkforceAudience(conf.Audience)
if !valid {
return nil, fmt.Errorf("oauth2/google/externalaccount: Workforce pool user project should not be set for non-workforce pool credentials")
}
}
count := 0
if conf.CredentialSource != nil {
count++
}
if conf.SubjectTokenSupplier != nil {
count++
}
if conf.AwsSecurityCredentialsSupplier != nil {
count++
}
if count == 0 {
return nil, fmt.Errorf("oauth2/google/externalaccount: One of CredentialSource, SubjectTokenSupplier, or AwsSecurityCredentialsSupplier must be set")
}
if count > 1 {
return nil, fmt.Errorf("oauth2/google/externalaccount: Only one of CredentialSource, SubjectTokenSupplier, or AwsSecurityCredentialsSupplier must be set")
}
return conf.tokenSource(ctx, "https")
}
// tokenSource is a private function that's directly called by some of the tests,
// because the unit test URLs are mocked, and would otherwise fail the
// validity check.
func (c *Config) tokenSource(ctx context.Context, scheme string) (oauth2.TokenSource, error) {
ts := tokenSource{
ctx: ctx,
conf: c,
}
if c.ServiceAccountImpersonationURL == "" {
return oauth2.ReuseTokenSource(nil, ts), nil
}
scopes := c.Scopes
ts.conf.Scopes = []string{"https://www.googleapis.com/auth/cloud-platform"}
imp := impersonate.ImpersonateTokenSource{
Ctx: ctx,
URL: c.ServiceAccountImpersonationURL,
Scopes: scopes,
Ts: oauth2.ReuseTokenSource(nil, ts),
TokenLifetimeSeconds: c.ServiceAccountImpersonationLifetimeSeconds,
}
return oauth2.ReuseTokenSource(nil, imp), nil
}
// Subject token file types.
const (
fileTypeText = "text"
fileTypeJSON = "json"
)
// Format contains information needed to retireve a subject token for URL or File sourced credentials.
type Format struct {
// Type should be either "text" or "json". This determines whether the file or URL sourced credentials
// expect a simple text subject token or if the subject token will be contained in a JSON object.
// When not provided "text" type is assumed.
Type string `json:"type"`
// SubjectTokenFieldName is only required for JSON format. This is the field name that the credentials will check
// for the subject token in the file or URL response. This would be "access_token" for azure.
SubjectTokenFieldName string `json:"subject_token_field_name"`
}
// CredentialSource stores the information necessary to retrieve the credentials for the STS exchange.
type CredentialSource struct {
// File is the location for file sourced credentials.
// One field amongst File, URL, Executable, or EnvironmentID should be provided, depending on the kind of credential in question.
File string `json:"file"`
// Url is the URL to call for URL sourced credentials.
// One field amongst File, URL, Executable, or EnvironmentID should be provided, depending on the kind of credential in question.
URL string `json:"url"`
// Headers are the headers to attach to the request for URL sourced credentials.
Headers map[string]string `json:"headers"`
// Executable is the configuration object for executable sourced credentials.
// One field amongst File, URL, Executable, or EnvironmentID should be provided, depending on the kind of credential in question.
Executable *ExecutableConfig `json:"executable"`
// EnvironmentID is the EnvironmentID used for AWS sourced credentials. This should start with "AWS".
// One field amongst File, URL, Executable, or EnvironmentID should be provided, depending on the kind of credential in question.
EnvironmentID string `json:"environment_id"`
// RegionURL is the metadata URL to retrieve the region from for EC2 AWS credentials.
RegionURL string `json:"region_url"`
// RegionalCredVerificationURL is the AWS regional credential verification URL, will default to
// "https://sts.{region}.amazonaws.com?Action=GetCallerIdentity&Version=2011-06-15" if not provided."
RegionalCredVerificationURL string `json:"regional_cred_verification_url"`
// IMDSv2SessionTokenURL is the URL to retrieve the session token when using IMDSv2 in AWS.
IMDSv2SessionTokenURL string `json:"imdsv2_session_token_url"`
// Format is the format type for the subject token. Used for File and URL sourced credentials. Expected values are "text" or "json".
Format Format `json:"format"`
}
// ExecutableConfig contains information needed for executable sourced credentials.
type ExecutableConfig struct {
// Command is the the full command to run to retrieve the subject token.
// This can include arguments. Must be an absolute path for the program. Required.
Command string `json:"command"`
// TimeoutMillis is the timeout duration, in milliseconds. Defaults to 30000 milliseconds when not provided. Optional.
TimeoutMillis *int `json:"timeout_millis"`
// OutputFile is the absolute path to the output file where the executable will cache the response.
// If specified the auth libraries will first check this location before running the executable. Optional.
OutputFile string `json:"output_file"`
}
// SubjectTokenSupplier can be used to supply a subject token to exchange for a GCP access token.
type SubjectTokenSupplier interface {
// SubjectToken should return a valid subject token or an error.
// The external account token source does not cache the returned subject token, so caching
// logic should be implemented in the supplier to prevent multiple requests for the same subject token.
SubjectToken(ctx context.Context, options SupplierOptions) (string, error)
}
// AWSSecurityCredentialsSupplier can be used to supply AwsSecurityCredentials and an AWS Region to
// exchange for a GCP access token.
type AwsSecurityCredentialsSupplier interface {
// AwsRegion should return the AWS region or an error.
AwsRegion(ctx context.Context, options SupplierOptions) (string, error)
// GetAwsSecurityCredentials should return a valid set of AwsSecurityCredentials or an error.
// The external account token source does not cache the returned security credentials, so caching
// logic should be implemented in the supplier to prevent multiple requests for the same security credentials.
AwsSecurityCredentials(ctx context.Context, options SupplierOptions) (*AwsSecurityCredentials, error)
}
// SupplierOptions contains information about the requested subject token or AWS security credentials from the
// Google external account credential.
type SupplierOptions struct {
// Audience is the requested audience for the external account credential.
Audience string
// Subject token type is the requested subject token type for the external account credential. Expected values include:
// “urn:ietf:params:oauth:token-type:jwt”
// “urn:ietf:params:oauth:token-type:id-token”
// “urn:ietf:params:oauth:token-type:saml2”
// “urn:ietf:params:aws:token-type:aws4_request”
SubjectTokenType string
}
// tokenURL returns the default STS token endpoint with the configured universe
// domain.
func (c *Config) tokenURL() string {
if c.UniverseDomain == "" {
return strings.Replace(defaultTokenURL, universeDomainPlaceholder, defaultUniverseDomain, 1)
}
return strings.Replace(defaultTokenURL, universeDomainPlaceholder, c.UniverseDomain, 1)
}
// parse determines the type of CredentialSource needed.
func (c *Config) parse(ctx context.Context) (baseCredentialSource, error) {
//set Defaults
if c.TokenURL == "" {
c.TokenURL = c.tokenURL()
}
supplierOptions := SupplierOptions{Audience: c.Audience, SubjectTokenType: c.SubjectTokenType}
if c.AwsSecurityCredentialsSupplier != nil {
awsCredSource := awsCredentialSource{
awsSecurityCredentialsSupplier: c.AwsSecurityCredentialsSupplier,
targetResource: c.Audience,
supplierOptions: supplierOptions,
ctx: ctx,
}
return awsCredSource, nil
} else if c.SubjectTokenSupplier != nil {
return programmaticRefreshCredentialSource{subjectTokenSupplier: c.SubjectTokenSupplier, supplierOptions: supplierOptions, ctx: ctx}, nil
} else if len(c.CredentialSource.EnvironmentID) > 3 && c.CredentialSource.EnvironmentID[:3] == "aws" {
if awsVersion, err := strconv.Atoi(c.CredentialSource.EnvironmentID[3:]); err == nil {
if awsVersion != 1 {
return nil, fmt.Errorf("oauth2/google/externalaccount: aws version '%d' is not supported in the current build", awsVersion)
}
awsCredSource := awsCredentialSource{
environmentID: c.CredentialSource.EnvironmentID,
regionURL: c.CredentialSource.RegionURL,
regionalCredVerificationURL: c.CredentialSource.RegionalCredVerificationURL,
credVerificationURL: c.CredentialSource.URL,
targetResource: c.Audience,
ctx: ctx,
}
if c.CredentialSource.IMDSv2SessionTokenURL != "" {
awsCredSource.imdsv2SessionTokenURL = c.CredentialSource.IMDSv2SessionTokenURL
}
return awsCredSource, nil
}
} else if c.CredentialSource.File != "" {
return fileCredentialSource{File: c.CredentialSource.File, Format: c.CredentialSource.Format}, nil
} else if c.CredentialSource.URL != "" {
return urlCredentialSource{URL: c.CredentialSource.URL, Headers: c.CredentialSource.Headers, Format: c.CredentialSource.Format, ctx: ctx}, nil
} else if c.CredentialSource.Executable != nil {
return createExecutableCredential(ctx, c.CredentialSource.Executable, c)
}
return nil, fmt.Errorf("oauth2/google/externalaccount: unable to parse credential source")
}
type baseCredentialSource interface {
credentialSourceType() string
subjectToken() (string, error)
}
// tokenSource is the source that handles external credentials. It is used to retrieve Tokens.
type tokenSource struct {
ctx context.Context
conf *Config
}
func getMetricsHeaderValue(conf *Config, credSource baseCredentialSource) string {
return fmt.Sprintf("gl-go/%s auth/%s google-byoid-sdk source/%s sa-impersonation/%t config-lifetime/%t",
goVersion(),
"unknown",
credSource.credentialSourceType(),
conf.ServiceAccountImpersonationURL != "",
conf.ServiceAccountImpersonationLifetimeSeconds != 0)
}
// Token allows tokenSource to conform to the oauth2.TokenSource interface.
func (ts tokenSource) Token() (*oauth2.Token, error) {
conf := ts.conf
credSource, err := conf.parse(ts.ctx)
if err != nil {
return nil, err
}
subjectToken, err := credSource.subjectToken()
if err != nil {
return nil, err
}
stsRequest := stsexchange.TokenExchangeRequest{
GrantType: "urn:ietf:params:oauth:grant-type:token-exchange",
Audience: conf.Audience,
Scope: conf.Scopes,
RequestedTokenType: "urn:ietf:params:oauth:token-type:access_token",
SubjectToken: subjectToken,
SubjectTokenType: conf.SubjectTokenType,
}
header := make(http.Header)
header.Add("Content-Type", "application/x-www-form-urlencoded")
header.Add("x-goog-api-client", getMetricsHeaderValue(conf, credSource))
clientAuth := stsexchange.ClientAuthentication{
AuthStyle: oauth2.AuthStyleInHeader,
ClientID: conf.ClientID,
ClientSecret: conf.ClientSecret,
}
var options map[string]interface{}
// Do not pass workforce_pool_user_project when client authentication is used.
// The client ID is sufficient for determining the user project.
if conf.WorkforcePoolUserProject != "" && conf.ClientID == "" {
options = map[string]interface{}{
"userProject": conf.WorkforcePoolUserProject,
}
}
stsResp, err := stsexchange.ExchangeToken(ts.ctx, conf.TokenURL, &stsRequest, clientAuth, header, options)
if err != nil {
return nil, err
}
accessToken := &oauth2.Token{
AccessToken: stsResp.AccessToken,
TokenType: stsResp.TokenType,
}
// The RFC8693 doesn't define the explicit 0 of "expires_in" field behavior.
if stsResp.ExpiresIn <= 0 {
return nil, fmt.Errorf("oauth2/google/externalaccount: got invalid expiry from security token service")
}
accessToken.Expiry = now().Add(time.Duration(stsResp.ExpiresIn) * time.Second)
if stsResp.RefreshToken != "" {
accessToken.RefreshToken = stsResp.RefreshToken
}
return accessToken, nil
}

View File

@@ -0,0 +1,574 @@
// Copyright 2020 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package externalaccount
import (
"context"
"encoding/json"
"fmt"
"io/ioutil"
"net/http"
"net/http/httptest"
"testing"
"time"
"golang.org/x/oauth2"
)
const (
textBaseCredPath = "testdata/3pi_cred.txt"
jsonBaseCredPath = "testdata/3pi_cred.json"
baseImpersonateCredsReqBody = "audience=32555940559.apps.googleusercontent.com&grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Atoken-exchange&requested_token_type=urn%3Aietf%3Aparams%3Aoauth%3Atoken-type%3Aaccess_token&scope=https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fcloud-platform&subject_token=street123&subject_token_type=urn%3Aietf%3Aparams%3Aoauth%3Atoken-type%3Ajwt"
baseImpersonateCredsRespBody = `{"accessToken":"Second.Access.Token","expireTime":"2020-12-28T15:01:23Z"}`
)
var testBaseCredSource = CredentialSource{
File: textBaseCredPath,
Format: Format{Type: fileTypeText},
}
var testConfig = Config{
Audience: "32555940559.apps.googleusercontent.com",
SubjectTokenType: "urn:ietf:params:oauth:token-type:jwt",
TokenInfoURL: "http://localhost:8080/v1/tokeninfo",
ClientSecret: "notsosecret",
ClientID: "rbrgnognrhongo3bi4gb9ghg9g",
CredentialSource: &testBaseCredSource,
Scopes: []string{"https://www.googleapis.com/auth/devstorage.full_control"},
}
var (
baseCredsRequestBody = "audience=32555940559.apps.googleusercontent.com&grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Atoken-exchange&requested_token_type=urn%3Aietf%3Aparams%3Aoauth%3Atoken-type%3Aaccess_token&scope=https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fdevstorage.full_control&subject_token=street123&subject_token_type=urn%3Aietf%3Aparams%3Aoauth%3Atoken-type%3Aid_token"
baseCredsResponseBody = `{"access_token":"Sample.Access.Token","issued_token_type":"urn:ietf:params:oauth:token-type:access_token","token_type":"Bearer","expires_in":3600,"scope":"https://www.googleapis.com/auth/cloud-platform"}`
workforcePoolRequestBodyWithClientId = "audience=%2F%2Fiam.googleapis.com%2Flocations%2Feu%2FworkforcePools%2Fpool-id%2Fproviders%2Fprovider-id&grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Atoken-exchange&requested_token_type=urn%3Aietf%3Aparams%3Aoauth%3Atoken-type%3Aaccess_token&scope=https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fdevstorage.full_control&subject_token=street123&subject_token_type=urn%3Aietf%3Aparams%3Aoauth%3Atoken-type%3Aid_token"
workforcePoolRequestBodyWithoutClientId = "audience=%2F%2Fiam.googleapis.com%2Flocations%2Feu%2FworkforcePools%2Fpool-id%2Fproviders%2Fprovider-id&grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Atoken-exchange&options=%7B%22userProject%22%3A%22myProject%22%7D&requested_token_type=urn%3Aietf%3Aparams%3Aoauth%3Atoken-type%3Aaccess_token&scope=https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fdevstorage.full_control&subject_token=street123&subject_token_type=urn%3Aietf%3Aparams%3Aoauth%3Atoken-type%3Aid_token"
correctAT = "Sample.Access.Token"
expiry int64 = 234852
)
var (
testNow = func() time.Time { return time.Unix(expiry, 0) }
)
type testExchangeTokenServer struct {
url string
authorization string
contentType string
metricsHeader string
body string
response string
}
func run(t *testing.T, config *Config, tets *testExchangeTokenServer) (*oauth2.Token, error) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got, want := r.URL.String(), tets.url; got != want {
t.Errorf("URL.String(): got %v but want %v", got, want)
}
headerAuth := r.Header.Get("Authorization")
if got, want := headerAuth, tets.authorization; got != want {
t.Errorf("got %v but want %v", got, want)
}
headerContentType := r.Header.Get("Content-Type")
if got, want := headerContentType, tets.contentType; got != want {
t.Errorf("got %v but want %v", got, want)
}
headerMetrics := r.Header.Get("x-goog-api-client")
if got, want := headerMetrics, tets.metricsHeader; got != want {
t.Errorf("got %v but want %v", got, want)
}
body, err := ioutil.ReadAll(r.Body)
if err != nil {
t.Fatalf("Failed reading request body: %s.", err)
}
if got, want := string(body), tets.body; got != want {
t.Errorf("Unexpected exchange payload: got %v but want %v", got, want)
}
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(tets.response))
}))
defer server.Close()
config.TokenURL = server.URL
oldNow := now
defer func() { now = oldNow }()
now = testNow
ts := tokenSource{
ctx: context.Background(),
conf: config,
}
return ts.Token()
}
func validateToken(t *testing.T, tok *oauth2.Token, expectToken *oauth2.Token) {
if expectToken == nil {
return
}
if got, want := tok.AccessToken, expectToken.AccessToken; got != want {
t.Errorf("Unexpected access token: got %v, but wanted %v", got, want)
}
if got, want := tok.TokenType, expectToken.TokenType; got != want {
t.Errorf("Unexpected TokenType: got %v, but wanted %v", got, want)
}
if got, want := tok.Expiry, expectToken.Expiry; got != want {
t.Errorf("Unexpected Expiry: got %v, but wanted %v", got, want)
}
}
func createImpersonationServer(urlWanted, authWanted, bodyWanted, response string, t *testing.T) *httptest.Server {
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got, want := r.URL.String(), urlWanted; got != want {
t.Errorf("URL.String(): got %v but want %v", got, want)
}
headerAuth := r.Header.Get("Authorization")
if got, want := headerAuth, authWanted; got != want {
t.Errorf("got %v but want %v", got, want)
}
headerContentType := r.Header.Get("Content-Type")
if got, want := headerContentType, "application/json"; got != want {
t.Errorf("got %v but want %v", got, want)
}
body, err := ioutil.ReadAll(r.Body)
if err != nil {
t.Fatalf("Failed reading request body: %v.", err)
}
if got, want := string(body), bodyWanted; got != want {
t.Errorf("Unexpected impersonation payload: got %v but want %v", got, want)
}
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(response))
}))
}
func createTargetServer(metricsHeaderWanted string, t *testing.T) *httptest.Server {
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got, want := r.URL.String(), "/"; got != want {
t.Errorf("URL.String(): got %v but want %v", got, want)
}
headerAuth := r.Header.Get("Authorization")
if got, want := headerAuth, "Basic cmJyZ25vZ25yaG9uZ28zYmk0Z2I5Z2hnOWc6bm90c29zZWNyZXQ="; got != want {
t.Errorf("got %v but want %v", got, want)
}
headerContentType := r.Header.Get("Content-Type")
if got, want := headerContentType, "application/x-www-form-urlencoded"; got != want {
t.Errorf("got %v but want %v", got, want)
}
headerMetrics := r.Header.Get("x-goog-api-client")
if got, want := headerMetrics, metricsHeaderWanted; got != want {
t.Errorf("got %v but want %v", got, want)
}
body, err := ioutil.ReadAll(r.Body)
if err != nil {
t.Fatalf("Failed reading request body: %v.", err)
}
if got, want := string(body), baseImpersonateCredsReqBody; got != want {
t.Errorf("Unexpected exchange payload: got %v but want %v", got, want)
}
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(baseCredsResponseBody))
}))
}
func getExpectedMetricsHeader(source string, saImpersonation bool, configLifetime bool) string {
return fmt.Sprintf("gl-go/%s auth/unknown google-byoid-sdk source/%s sa-impersonation/%t config-lifetime/%t", goVersion(), source, saImpersonation, configLifetime)
}
func TestToken(t *testing.T) {
type MockSTSResponse struct {
AccessToken string `json:"access_token"`
IssuedTokenType string `json:"issued_token_type"`
TokenType string `json:"token_type"`
ExpiresIn int32 `json:"expires_in,omitempty"`
Scope string `json:"scopre,omitenpty"`
}
testCases := []struct {
name string
responseBody MockSTSResponse
expectToken *oauth2.Token
expectErrorMsg string
}{
{
name: "happy case",
responseBody: MockSTSResponse{
AccessToken: correctAT,
IssuedTokenType: "urn:ietf:params:oauth:token-type:access_token",
TokenType: "Bearer",
ExpiresIn: 3600,
Scope: "https://www.googleapis.com/auth/cloud-platform",
},
expectToken: &oauth2.Token{
AccessToken: correctAT,
TokenType: "Bearer",
Expiry: testNow().Add(time.Duration(3600) * time.Second),
},
},
{
name: "no expiry time on token",
responseBody: MockSTSResponse{
AccessToken: correctAT,
IssuedTokenType: "urn:ietf:params:oauth:token-type:access_token",
TokenType: "Bearer",
Scope: "https://www.googleapis.com/auth/cloud-platform",
},
expectToken: nil,
expectErrorMsg: "oauth2/google/externalaccount: got invalid expiry from security token service",
},
{
name: "negative expiry time",
responseBody: MockSTSResponse{
AccessToken: correctAT,
IssuedTokenType: "urn:ietf:params:oauth:token-type:access_token",
TokenType: "Bearer",
ExpiresIn: -1,
Scope: "https://www.googleapis.com/auth/cloud-platform",
},
expectToken: nil,
expectErrorMsg: "oauth2/google/externalaccount: got invalid expiry from security token service",
},
}
for _, testCase := range testCases {
config := Config{
Audience: "32555940559.apps.googleusercontent.com",
SubjectTokenType: "urn:ietf:params:oauth:token-type:id_token",
ClientSecret: "notsosecret",
ClientID: "rbrgnognrhongo3bi4gb9ghg9g",
CredentialSource: &testBaseCredSource,
Scopes: []string{"https://www.googleapis.com/auth/devstorage.full_control"},
}
responseBody, err := json.Marshal(testCase.responseBody)
if err != nil {
t.Errorf("Invalid response received.")
}
server := testExchangeTokenServer{
url: "/",
authorization: "Basic cmJyZ25vZ25yaG9uZ28zYmk0Z2I5Z2hnOWc6bm90c29zZWNyZXQ=",
contentType: "application/x-www-form-urlencoded",
metricsHeader: getExpectedMetricsHeader("file", false, false),
body: baseCredsRequestBody,
response: string(responseBody),
}
tok, err := run(t, &config, &server)
if err != nil && err.Error() != testCase.expectErrorMsg {
t.Errorf("Error not as expected: got = %v, and want = %v", err, testCase.expectErrorMsg)
}
validateToken(t, tok, testCase.expectToken)
}
}
func TestWorkforcePoolTokenWithClientID(t *testing.T) {
config := Config{
Audience: "//iam.googleapis.com/locations/eu/workforcePools/pool-id/providers/provider-id",
SubjectTokenType: "urn:ietf:params:oauth:token-type:id_token",
ClientSecret: "notsosecret",
ClientID: "rbrgnognrhongo3bi4gb9ghg9g",
CredentialSource: &testBaseCredSource,
Scopes: []string{"https://www.googleapis.com/auth/devstorage.full_control"},
WorkforcePoolUserProject: "myProject",
}
server := testExchangeTokenServer{
url: "/",
authorization: "Basic cmJyZ25vZ25yaG9uZ28zYmk0Z2I5Z2hnOWc6bm90c29zZWNyZXQ=",
contentType: "application/x-www-form-urlencoded",
metricsHeader: getExpectedMetricsHeader("file", false, false),
body: workforcePoolRequestBodyWithClientId,
response: baseCredsResponseBody,
}
tok, err := run(t, &config, &server)
if err != nil {
t.Fatalf("Unexpected error: %e", err)
}
expectToken := oauth2.Token{
AccessToken: correctAT,
TokenType: "Bearer",
Expiry: testNow().Add(time.Duration(3600) * time.Second),
}
validateToken(t, tok, &expectToken)
}
func TestWorkforcePoolTokenWithoutClientID(t *testing.T) {
config := Config{
Audience: "//iam.googleapis.com/locations/eu/workforcePools/pool-id/providers/provider-id",
SubjectTokenType: "urn:ietf:params:oauth:token-type:id_token",
ClientSecret: "notsosecret",
CredentialSource: &testBaseCredSource,
Scopes: []string{"https://www.googleapis.com/auth/devstorage.full_control"},
WorkforcePoolUserProject: "myProject",
}
server := testExchangeTokenServer{
url: "/",
authorization: "",
contentType: "application/x-www-form-urlencoded",
metricsHeader: getExpectedMetricsHeader("file", false, false),
body: workforcePoolRequestBodyWithoutClientId,
response: baseCredsResponseBody,
}
tok, err := run(t, &config, &server)
if err != nil {
t.Fatalf("Unexpected error: %e", err)
}
expectToken := oauth2.Token{
AccessToken: correctAT,
TokenType: "Bearer",
Expiry: testNow().Add(time.Duration(3600) * time.Second),
}
validateToken(t, tok, &expectToken)
}
func TestNonworkforceWithWorkforcePoolUserProject(t *testing.T) {
config := Config{
Audience: "32555940559.apps.googleusercontent.com",
SubjectTokenType: "urn:ietf:params:oauth:token-type:id_token",
TokenURL: "https://sts.googleapis.com",
ClientSecret: "notsosecret",
ClientID: "rbrgnognrhongo3bi4gb9ghg9g",
CredentialSource: &testBaseCredSource,
Scopes: []string{"https://www.googleapis.com/auth/devstorage.full_control"},
WorkforcePoolUserProject: "myProject",
}
_, err := NewTokenSource(context.Background(), config)
if err == nil {
t.Fatalf("Expected error but found none")
}
if got, want := err.Error(), "oauth2/google/externalaccount: Workforce pool user project should not be set for non-workforce pool credentials"; got != want {
t.Errorf("Incorrect error received.\nExpected: %s\nRecieved: %s", want, got)
}
}
func TestWorkforcePoolCreation(t *testing.T) {
var audienceValidatyTests = []struct {
audience string
expectSuccess bool
}{
{"//iam.googleapis.com/locations/global/workforcePools/pool-id/providers/provider-id", true},
{"//iam.googleapis.com/locations/eu/workforcePools/pool-id/providers/provider-id", true},
{"//iam.googleapis.com/locations/eu/workforcePools/workloadIdentityPools/providers/provider-id", true},
{"identitynamespace:1f12345:my_provider", false},
{"//iam.googleapis.com/projects/123456/locations/global/workloadIdentityPools/pool-id/providers/provider-id", false},
{"//iam.googleapis.com/projects/123456/locations/eu/workloadIdentityPools/pool-id/providers/provider-id", false},
{"//iam.googleapis.com/projects/123456/locations/global/workloadIdentityPools/workforcePools/providers/provider-id", false},
{"//iamgoogleapis.com/locations/eu/workforcePools/pool-id/providers/provider-id", false},
{"//iam.googleapiscom/locations/eu/workforcePools/pool-id/providers/provider-id", false},
{"//iam.googleapis.com/locations/workforcePools/pool-id/providers/provider-id", false},
{"//iam.googleapis.com/locations/eu/workforcePool/pool-id/providers/provider-id", false},
{"//iam.googleapis.com/locations//workforcePool/pool-id/providers/provider-id", false},
}
ctx := context.Background()
for _, tt := range audienceValidatyTests {
t.Run(" "+tt.audience, func(t *testing.T) { // We prepend a space ahead of the test input when outputting for sake of readability.
config := testConfig
config.TokenURL = "https://sts.googleapis.com" // Setting the most basic acceptable tokenURL
config.ServiceAccountImpersonationURL = "https://iamcredentials.googleapis.com"
config.Audience = tt.audience
config.WorkforcePoolUserProject = "myProject"
_, err := NewTokenSource(ctx, config)
if tt.expectSuccess && err != nil {
t.Errorf("got %v but want nil", err)
} else if !tt.expectSuccess && err == nil {
t.Errorf("got nil but expected an error")
}
})
}
}
var impersonationTests = []struct {
name string
config Config
expectedImpersonationBody string
expectedMetricsHeader string
}{
{
name: "Base Impersonation",
config: Config{
Audience: "32555940559.apps.googleusercontent.com",
SubjectTokenType: "urn:ietf:params:oauth:token-type:jwt",
TokenInfoURL: "http://localhost:8080/v1/tokeninfo",
ClientSecret: "notsosecret",
ClientID: "rbrgnognrhongo3bi4gb9ghg9g",
CredentialSource: &testBaseCredSource,
Scopes: []string{"https://www.googleapis.com/auth/devstorage.full_control"},
},
expectedImpersonationBody: "{\"lifetime\":\"3600s\",\"scope\":[\"https://www.googleapis.com/auth/devstorage.full_control\"]}",
expectedMetricsHeader: getExpectedMetricsHeader("file", true, false),
},
{
name: "With TokenLifetime Set",
config: Config{
Audience: "32555940559.apps.googleusercontent.com",
SubjectTokenType: "urn:ietf:params:oauth:token-type:jwt",
TokenInfoURL: "http://localhost:8080/v1/tokeninfo",
ClientSecret: "notsosecret",
ClientID: "rbrgnognrhongo3bi4gb9ghg9g",
CredentialSource: &testBaseCredSource,
Scopes: []string{"https://www.googleapis.com/auth/devstorage.full_control"},
ServiceAccountImpersonationLifetimeSeconds: 10000,
},
expectedImpersonationBody: "{\"lifetime\":\"10000s\",\"scope\":[\"https://www.googleapis.com/auth/devstorage.full_control\"]}",
expectedMetricsHeader: getExpectedMetricsHeader("file", true, true),
},
}
func TestImpersonation(t *testing.T) {
for _, tt := range impersonationTests {
t.Run(tt.name, func(t *testing.T) {
testImpersonateConfig := tt.config
impersonateServer := createImpersonationServer("/", "Bearer Sample.Access.Token", tt.expectedImpersonationBody, baseImpersonateCredsRespBody, t)
defer impersonateServer.Close()
testImpersonateConfig.ServiceAccountImpersonationURL = impersonateServer.URL
targetServer := createTargetServer(tt.expectedMetricsHeader, t)
defer targetServer.Close()
testImpersonateConfig.TokenURL = targetServer.URL
ourTS, err := testImpersonateConfig.tokenSource(context.Background(), "http")
if err != nil {
t.Fatalf("Failed to create TokenSource: %v", err)
}
oldNow := now
defer func() { now = oldNow }()
now = testNow
tok, err := ourTS.Token()
if err != nil {
t.Fatalf("Unexpected error: %e", err)
}
if got, want := tok.AccessToken, "Second.Access.Token"; got != want {
t.Errorf("Unexpected access token: got %v, but wanted %v", got, want)
}
if got, want := tok.TokenType, "Bearer"; got != want {
t.Errorf("Unexpected TokenType: got %v, but wanted %v", got, want)
}
})
}
}
var newTokenTests = []struct {
name string
config Config
}{
{
name: "Missing Audience",
config: Config{
SubjectTokenType: "urn:ietf:params:oauth:token-type:jwt",
TokenInfoURL: "http://localhost:8080/v1/tokeninfo",
ClientSecret: "notsosecret",
ClientID: "rbrgnognrhongo3bi4gb9ghg9g",
CredentialSource: &testBaseCredSource,
Scopes: []string{"https://www.googleapis.com/auth/devstorage.full_control"},
ServiceAccountImpersonationLifetimeSeconds: 10000,
},
},
{
name: "Missing Subject Token Type",
config: Config{
Audience: "32555940559.apps.googleusercontent.com",
TokenInfoURL: "http://localhost:8080/v1/tokeninfo",
ClientSecret: "notsosecret",
ClientID: "rbrgnognrhongo3bi4gb9ghg9g",
CredentialSource: &testBaseCredSource,
Scopes: []string{"https://www.googleapis.com/auth/devstorage.full_control"},
ServiceAccountImpersonationLifetimeSeconds: 10000,
},
},
{
name: "No Cred Source",
config: Config{
Audience: "32555940559.apps.googleusercontent.com",
SubjectTokenType: "urn:ietf:params:oauth:token-type:jwt",
TokenInfoURL: "http://localhost:8080/v1/tokeninfo",
ClientSecret: "notsosecret",
ClientID: "rbrgnognrhongo3bi4gb9ghg9g",
Scopes: []string{"https://www.googleapis.com/auth/devstorage.full_control"},
ServiceAccountImpersonationLifetimeSeconds: 10000,
},
},
{
name: "Cred Source and Supplier",
config: Config{
Audience: "32555940559.apps.googleusercontent.com",
SubjectTokenType: "urn:ietf:params:oauth:token-type:jwt",
TokenInfoURL: "http://localhost:8080/v1/tokeninfo",
CredentialSource: &testBaseCredSource,
AwsSecurityCredentialsSupplier: testAwsSupplier{},
ClientSecret: "notsosecret",
ClientID: "rbrgnognrhongo3bi4gb9ghg9g",
Scopes: []string{"https://www.googleapis.com/auth/devstorage.full_control"},
ServiceAccountImpersonationLifetimeSeconds: 10000,
},
},
}
func TestNewToken(t *testing.T) {
for _, tt := range newTokenTests {
t.Run(tt.name, func(t *testing.T) {
testConfig := tt.config
_, err := NewTokenSource(context.Background(), testConfig)
if err == nil {
t.Fatalf("expected error when calling NewToken()")
}
})
}
}
func TestConfig_TokenURL(t *testing.T) {
tests := []struct {
tokenURL string
universeDomain string
want string
}{
{
tokenURL: "https://sts.googleapis.com/v1/token",
universeDomain: "",
want: "https://sts.googleapis.com/v1/token",
},
{
tokenURL: "",
universeDomain: "",
want: "https://sts.googleapis.com/v1/token",
},
{
tokenURL: "",
universeDomain: "googleapis.com",
want: "https://sts.googleapis.com/v1/token",
},
{
tokenURL: "",
universeDomain: "example.com",
want: "https://sts.example.com/v1/token",
},
}
for _, tt := range tests {
config := &Config{
Audience: "//iam.googleapis.com/locations/eu/workforcePools/pool-id/providers/provider-id",
SubjectTokenType: "urn:ietf:params:oauth:token-type:id_token",
CredentialSource: &testBaseCredSource,
Scopes: []string{"https://www.googleapis.com/auth/devstorage.full_control"},
}
config.TokenURL = tt.tokenURL
config.UniverseDomain = tt.universeDomain
config.parse(context.Background())
if got := config.TokenURL; got != tt.want {
t.Errorf("got %q, want %q", got, tt.want)
}
}
}

View File

@@ -39,51 +39,51 @@ func (nce nonCacheableError) Error() string {
} }
func missingFieldError(source, field string) error { func missingFieldError(source, field string) error {
return fmt.Errorf("oauth2/google: %v missing `%q` field", source, field) return fmt.Errorf("oauth2/google/externalaccount: %v missing `%q` field", source, field)
} }
func jsonParsingError(source, data string) error { func jsonParsingError(source, data string) error {
return fmt.Errorf("oauth2/google: unable to parse %v\nResponse: %v", source, data) return fmt.Errorf("oauth2/google/externalaccount: unable to parse %v\nResponse: %v", source, data)
} }
func malformedFailureError() error { func malformedFailureError() error {
return nonCacheableError{"oauth2/google: response must include `error` and `message` fields when unsuccessful"} return nonCacheableError{"oauth2/google/externalaccount: response must include `error` and `message` fields when unsuccessful"}
} }
func userDefinedError(code, message string) error { func userDefinedError(code, message string) error {
return nonCacheableError{fmt.Sprintf("oauth2/google: response contains unsuccessful response: (%v) %v", code, message)} return nonCacheableError{fmt.Sprintf("oauth2/google/externalaccount: response contains unsuccessful response: (%v) %v", code, message)}
} }
func unsupportedVersionError(source string, version int) error { func unsupportedVersionError(source string, version int) error {
return fmt.Errorf("oauth2/google: %v contains unsupported version: %v", source, version) return fmt.Errorf("oauth2/google/externalaccount: %v contains unsupported version: %v", source, version)
} }
func tokenExpiredError() error { func tokenExpiredError() error {
return nonCacheableError{"oauth2/google: the token returned by the executable is expired"} return nonCacheableError{"oauth2/google/externalaccount: the token returned by the executable is expired"}
} }
func tokenTypeError(source string) error { func tokenTypeError(source string) error {
return fmt.Errorf("oauth2/google: %v contains unsupported token type", source) return fmt.Errorf("oauth2/google/externalaccount: %v contains unsupported token type", source)
} }
func exitCodeError(exitCode int) error { func exitCodeError(exitCode int) error {
return fmt.Errorf("oauth2/google: executable command failed with exit code %v", exitCode) return fmt.Errorf("oauth2/google/externalaccount: executable command failed with exit code %v", exitCode)
} }
func executableError(err error) error { func executableError(err error) error {
return fmt.Errorf("oauth2/google: executable command failed: %v", err) return fmt.Errorf("oauth2/google/externalaccount: executable command failed: %v", err)
} }
func executablesDisallowedError() error { func executablesDisallowedError() error {
return errors.New("oauth2/google: executables need to be explicitly allowed (set GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES to '1') to run") return errors.New("oauth2/google/externalaccount: executables need to be explicitly allowed (set GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES to '1') to run")
} }
func timeoutRangeError() error { func timeoutRangeError() error {
return errors.New("oauth2/google: invalid `timeout_millis` field — executable timeout must be between 5 and 120 seconds") return errors.New("oauth2/google/externalaccount: invalid `timeout_millis` field — executable timeout must be between 5 and 120 seconds")
} }
func commandMissingError() error { func commandMissingError() error {
return errors.New("oauth2/google: missing `command` field — executable command must be provided") return errors.New("oauth2/google/externalaccount: missing `command` field — executable command must be provided")
} }
type environment interface { type environment interface {
@@ -146,7 +146,7 @@ type executableCredentialSource struct {
// CreateExecutableCredential creates an executableCredentialSource given an ExecutableConfig. // CreateExecutableCredential creates an executableCredentialSource given an ExecutableConfig.
// It also performs defaulting and type conversions. // It also performs defaulting and type conversions.
func CreateExecutableCredential(ctx context.Context, ec *ExecutableConfig, config *Config) (executableCredentialSource, error) { func createExecutableCredential(ctx context.Context, ec *ExecutableConfig, config *Config) (executableCredentialSource, error) {
if ec.Command == "" { if ec.Command == "" {
return executableCredentialSource{}, commandMissingError() return executableCredentialSource{}, commandMissingError()
} }

View File

@@ -128,7 +128,7 @@ var creationTests = []struct {
func TestCreateExecutableCredential(t *testing.T) { func TestCreateExecutableCredential(t *testing.T) {
for _, tt := range creationTests { for _, tt := range creationTests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
ecs, err := CreateExecutableCredential(context.Background(), &tt.executableConfig, nil) ecs, err := createExecutableCredential(context.Background(), &tt.executableConfig, nil)
if tt.expectedErr != nil { if tt.expectedErr != nil {
if err == nil { if err == nil {
t.Fatalf("Expected error but found none") t.Fatalf("Expected error but found none")
@@ -169,7 +169,7 @@ var getEnvironmentTests = []struct {
config: Config{ config: Config{
Audience: "//iam.googleapis.com/projects/123/locations/global/workloadIdentityPools/pool/providers/oidc", Audience: "//iam.googleapis.com/projects/123/locations/global/workloadIdentityPools/pool/providers/oidc",
SubjectTokenType: "urn:ietf:params:oauth:token-type:jwt", SubjectTokenType: "urn:ietf:params:oauth:token-type:jwt",
CredentialSource: CredentialSource{ CredentialSource: &CredentialSource{
Executable: &ExecutableConfig{ Executable: &ExecutableConfig{
Command: "blarg", Command: "blarg",
}, },
@@ -193,7 +193,7 @@ var getEnvironmentTests = []struct {
Audience: "//iam.googleapis.com/projects/123/locations/global/workloadIdentityPools/pool/providers/oidc", 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", ServiceAccountImpersonationURL: "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/test@project.iam.gserviceaccount.com:generateAccessToken",
SubjectTokenType: "urn:ietf:params:oauth:token-type:jwt", SubjectTokenType: "urn:ietf:params:oauth:token-type:jwt",
CredentialSource: CredentialSource{ CredentialSource: &CredentialSource{
Executable: &ExecutableConfig{ Executable: &ExecutableConfig{
Command: "blarg", Command: "blarg",
OutputFile: "/path/to/generated/cached/credentials", OutputFile: "/path/to/generated/cached/credentials",
@@ -220,7 +220,7 @@ var getEnvironmentTests = []struct {
Audience: "//iam.googleapis.com/projects/123/locations/global/workloadIdentityPools/pool/providers/oidc", Audience: "//iam.googleapis.com/projects/123/locations/global/workloadIdentityPools/pool/providers/oidc",
ServiceAccountImpersonationURL: "test@project.iam.gserviceaccount.com", ServiceAccountImpersonationURL: "test@project.iam.gserviceaccount.com",
SubjectTokenType: "urn:ietf:params:oauth:token-type:jwt", SubjectTokenType: "urn:ietf:params:oauth:token-type:jwt",
CredentialSource: CredentialSource{ CredentialSource: &CredentialSource{
Executable: &ExecutableConfig{ Executable: &ExecutableConfig{
Command: "blarg", Command: "blarg",
OutputFile: "/path/to/generated/cached/credentials", OutputFile: "/path/to/generated/cached/credentials",
@@ -247,7 +247,7 @@ func TestExecutableCredentialGetEnvironment(t *testing.T) {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
config := tt.config config := tt.config
ecs, err := CreateExecutableCredential(context.Background(), config.CredentialSource.Executable, &config) ecs, err := createExecutableCredential(context.Background(), config.CredentialSource.Executable, &config)
if err != nil { if err != nil {
t.Fatalf("creation failed %v", err) t.Fatalf("creation failed %v", err)
} }
@@ -471,7 +471,7 @@ func TestRetrieveExecutableSubjectTokenExecutableErrors(t *testing.T) {
} }
tfc := testFileConfig tfc := testFileConfig
tfc.CredentialSource = cs tfc.CredentialSource = &cs
base, err := tfc.parse(context.Background()) base, err := tfc.parse(context.Background())
if err != nil { if err != nil {
@@ -578,7 +578,7 @@ func TestRetrieveExecutableSubjectTokenSuccesses(t *testing.T) {
} }
tfc := testFileConfig tfc := testFileConfig
tfc.CredentialSource = cs tfc.CredentialSource = &cs
base, err := tfc.parse(context.Background()) base, err := tfc.parse(context.Background())
if err != nil { if err != nil {
@@ -629,7 +629,7 @@ func TestRetrieveOutputFileSubjectTokenNotJSON(t *testing.T) {
} }
tfc := testFileConfig tfc := testFileConfig
tfc.CredentialSource = cs tfc.CredentialSource = &cs
base, err := tfc.parse(context.Background()) base, err := tfc.parse(context.Background())
if err != nil { if err != nil {
@@ -778,7 +778,7 @@ func TestRetrieveOutputFileSubjectTokenFailureTests(t *testing.T) {
} }
tfc := testFileConfig tfc := testFileConfig
tfc.CredentialSource = cs tfc.CredentialSource = &cs
base, err := tfc.parse(context.Background()) base, err := tfc.parse(context.Background())
if err != nil { if err != nil {
@@ -881,7 +881,7 @@ func TestRetrieveOutputFileSubjectTokenInvalidCache(t *testing.T) {
} }
tfc := testFileConfig tfc := testFileConfig
tfc.CredentialSource = cs tfc.CredentialSource = &cs
base, err := tfc.parse(context.Background()) base, err := tfc.parse(context.Background())
if err != nil { if err != nil {
@@ -986,7 +986,7 @@ func TestRetrieveOutputFileSubjectTokenJwt(t *testing.T) {
} }
tfc := testFileConfig tfc := testFileConfig
tfc.CredentialSource = cs tfc.CredentialSource = &cs
base, err := tfc.parse(context.Background()) base, err := tfc.parse(context.Background())
if err != nil { if err != nil {

View File

@@ -16,7 +16,7 @@ import (
type fileCredentialSource struct { type fileCredentialSource struct {
File string File string
Format format Format Format
} }
func (cs fileCredentialSource) credentialSourceType() string { func (cs fileCredentialSource) credentialSourceType() string {
@@ -26,12 +26,12 @@ func (cs fileCredentialSource) credentialSourceType() string {
func (cs fileCredentialSource) subjectToken() (string, error) { func (cs fileCredentialSource) subjectToken() (string, error) {
tokenFile, err := os.Open(cs.File) tokenFile, err := os.Open(cs.File)
if err != nil { if err != nil {
return "", fmt.Errorf("oauth2/google: failed to open credential file %q", cs.File) return "", fmt.Errorf("oauth2/google/externalaccount: failed to open credential file %q", cs.File)
} }
defer tokenFile.Close() defer tokenFile.Close()
tokenBytes, err := ioutil.ReadAll(io.LimitReader(tokenFile, 1<<20)) tokenBytes, err := ioutil.ReadAll(io.LimitReader(tokenFile, 1<<20))
if err != nil { if err != nil {
return "", fmt.Errorf("oauth2/google: failed to read credential file: %v", err) return "", fmt.Errorf("oauth2/google/externalaccount: failed to read credential file: %v", err)
} }
tokenBytes = bytes.TrimSpace(tokenBytes) tokenBytes = bytes.TrimSpace(tokenBytes)
switch cs.Format.Type { switch cs.Format.Type {
@@ -39,15 +39,15 @@ func (cs fileCredentialSource) subjectToken() (string, error) {
jsonData := make(map[string]interface{}) jsonData := make(map[string]interface{})
err = json.Unmarshal(tokenBytes, &jsonData) err = json.Unmarshal(tokenBytes, &jsonData)
if err != nil { if err != nil {
return "", fmt.Errorf("oauth2/google: failed to unmarshal subject token file: %v", err) return "", fmt.Errorf("oauth2/google/externalaccount: failed to unmarshal subject token file: %v", err)
} }
val, ok := jsonData[cs.Format.SubjectTokenFieldName] val, ok := jsonData[cs.Format.SubjectTokenFieldName]
if !ok { if !ok {
return "", errors.New("oauth2/google: provided subject_token_field_name not found in credentials") return "", errors.New("oauth2/google/externalaccount: provided subject_token_field_name not found in credentials")
} }
token, ok := val.(string) token, ok := val.(string)
if !ok { if !ok {
return "", errors.New("oauth2/google: improperly formatted subject token") return "", errors.New("oauth2/google/externalaccount: improperly formatted subject token")
} }
return token, nil return token, nil
case "text": case "text":
@@ -55,7 +55,7 @@ func (cs fileCredentialSource) subjectToken() (string, error) {
case "": case "":
return string(tokenBytes), nil return string(tokenBytes), nil
default: default:
return "", errors.New("oauth2/google: invalid credential_source file format type") return "", errors.New("oauth2/google/externalaccount: invalid credential_source file format type")
} }
} }

View File

@@ -36,7 +36,7 @@ func TestRetrieveFileSubjectToken(t *testing.T) {
name: "TextFileSource", name: "TextFileSource",
cs: CredentialSource{ cs: CredentialSource{
File: textBaseCredPath, File: textBaseCredPath,
Format: format{Type: fileTypeText}, Format: Format{Type: fileTypeText},
}, },
want: "street123", want: "street123",
}, },
@@ -44,7 +44,7 @@ func TestRetrieveFileSubjectToken(t *testing.T) {
name: "JSONFileSource", name: "JSONFileSource",
cs: CredentialSource{ cs: CredentialSource{
File: jsonBaseCredPath, File: jsonBaseCredPath,
Format: format{Type: fileTypeJSON, SubjectTokenFieldName: "SubjToken"}, Format: Format{Type: fileTypeJSON, SubjectTokenFieldName: "SubjToken"},
}, },
want: "321road", want: "321road",
}, },
@@ -53,7 +53,7 @@ func TestRetrieveFileSubjectToken(t *testing.T) {
for _, test := range fileSourceTests { for _, test := range fileSourceTests {
test := test test := test
tfc := testFileConfig tfc := testFileConfig
tfc.CredentialSource = test.cs tfc.CredentialSource = &test.cs
t.Run(test.name, func(t *testing.T) { t.Run(test.name, func(t *testing.T) {
base, err := tfc.parse(context.Background()) base, err := tfc.parse(context.Background())

View File

@@ -0,0 +1,21 @@
// Copyright 2024 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package externalaccount
import "context"
type programmaticRefreshCredentialSource struct {
supplierOptions SupplierOptions
subjectTokenSupplier SubjectTokenSupplier
ctx context.Context
}
func (cs programmaticRefreshCredentialSource) credentialSourceType() string {
return "programmatic"
}
func (cs programmaticRefreshCredentialSource) subjectToken() (string, error) {
return cs.subjectTokenSupplier.SubjectToken(cs.ctx, cs.supplierOptions)
}

View File

@@ -0,0 +1,122 @@
// Copyright 2024 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package externalaccount
import (
"context"
"errors"
"testing"
)
func TestRetrieveSubjectToken_ProgrammaticAuth(t *testing.T) {
tfc := testConfig
tfc.SubjectTokenSupplier = testSubjectTokenSupplier{
subjectToken: "subjectToken",
}
base, err := tfc.parse(context.Background())
if err != nil {
t.Fatalf("parse() failed %v", err)
}
out, err := base.subjectToken()
if err != nil {
t.Fatalf("retrieveSubjectToken() failed: %v", err)
}
if out != "subjectToken" {
t.Errorf("subjectToken = \n%q\n want \nSubjectToken", out)
}
}
func TestRetrieveSubjectToken_ProgrammaticAuthFails(t *testing.T) {
tfc := testConfig
testError := errors.New("test error")
tfc.SubjectTokenSupplier = testSubjectTokenSupplier{
err: testError,
}
base, err := tfc.parse(context.Background())
if err != nil {
t.Fatalf("parse() failed %v", err)
}
_, err = base.subjectToken()
if err == nil {
t.Fatalf("subjectToken() should have failed")
}
if testError != err {
t.Errorf("subjectToken = %e, want %e", err, testError)
}
}
func TestRetrieveSubjectToken_ProgrammaticAuthOptions(t *testing.T) {
tfc := testConfig
expectedOptions := SupplierOptions{Audience: tfc.Audience, SubjectTokenType: tfc.SubjectTokenType}
tfc.SubjectTokenSupplier = testSubjectTokenSupplier{
subjectToken: "subjectToken",
expectedOptions: &expectedOptions,
}
base, err := tfc.parse(context.Background())
if err != nil {
t.Fatalf("parse() failed %v", err)
}
_, err = base.subjectToken()
if err != nil {
t.Fatalf("retrieveSubjectToken() failed: %v", err)
}
}
func TestRetrieveSubjectToken_ProgrammaticAuthContext(t *testing.T) {
tfc := testConfig
ctx := context.Background()
tfc.SubjectTokenSupplier = testSubjectTokenSupplier{
subjectToken: "subjectToken",
expectedContext: ctx,
}
base, err := tfc.parse(ctx)
if err != nil {
t.Fatalf("parse() failed %v", err)
}
_, err = base.subjectToken()
if err != nil {
t.Fatalf("retrieveSubjectToken() failed: %v", err)
}
}
type testSubjectTokenSupplier struct {
err error
subjectToken string
expectedOptions *SupplierOptions
expectedContext context.Context
}
func (supp testSubjectTokenSupplier) SubjectToken(ctx context.Context, options SupplierOptions) (string, error) {
if supp.err != nil {
return "", supp.err
}
if supp.expectedOptions != nil {
if supp.expectedOptions.Audience != options.Audience {
return "", errors.New("Audience does not match")
}
if supp.expectedOptions.SubjectTokenType != options.SubjectTokenType {
return "", errors.New("Audience does not match")
}
}
if supp.expectedContext != nil {
if supp.expectedContext != ctx {
return "", errors.New("Context does not match")
}
}
return supp.subjectToken, nil
}

View File

@@ -19,7 +19,7 @@ import (
type urlCredentialSource struct { type urlCredentialSource struct {
URL string URL string
Headers map[string]string Headers map[string]string
Format format Format Format
ctx context.Context ctx context.Context
} }
@@ -31,7 +31,7 @@ func (cs urlCredentialSource) subjectToken() (string, error) {
client := oauth2.NewClient(cs.ctx, nil) client := oauth2.NewClient(cs.ctx, nil)
req, err := http.NewRequest("GET", cs.URL, nil) req, err := http.NewRequest("GET", cs.URL, nil)
if err != nil { if err != nil {
return "", fmt.Errorf("oauth2/google: HTTP request for URL-sourced credential failed: %v", err) return "", fmt.Errorf("oauth2/google/externalaccount: HTTP request for URL-sourced credential failed: %v", err)
} }
req = req.WithContext(cs.ctx) req = req.WithContext(cs.ctx)
@@ -40,16 +40,16 @@ func (cs urlCredentialSource) subjectToken() (string, error) {
} }
resp, err := client.Do(req) resp, err := client.Do(req)
if err != nil { if err != nil {
return "", fmt.Errorf("oauth2/google: invalid response when retrieving subject token: %v", err) return "", fmt.Errorf("oauth2/google/externalaccount: invalid response when retrieving subject token: %v", err)
} }
defer resp.Body.Close() defer resp.Body.Close()
respBody, err := ioutil.ReadAll(io.LimitReader(resp.Body, 1<<20)) respBody, err := ioutil.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil { if err != nil {
return "", fmt.Errorf("oauth2/google: invalid body in subject token URL query: %v", err) return "", fmt.Errorf("oauth2/google/externalaccount: invalid body in subject token URL query: %v", err)
} }
if c := resp.StatusCode; c < 200 || c > 299 { if c := resp.StatusCode; c < 200 || c > 299 {
return "", fmt.Errorf("oauth2/google: status code %d: %s", c, respBody) return "", fmt.Errorf("oauth2/google/externalaccount: status code %d: %s", c, respBody)
} }
switch cs.Format.Type { switch cs.Format.Type {
@@ -57,15 +57,15 @@ func (cs urlCredentialSource) subjectToken() (string, error) {
jsonData := make(map[string]interface{}) jsonData := make(map[string]interface{})
err = json.Unmarshal(respBody, &jsonData) err = json.Unmarshal(respBody, &jsonData)
if err != nil { if err != nil {
return "", fmt.Errorf("oauth2/google: failed to unmarshal subject token file: %v", err) return "", fmt.Errorf("oauth2/google/externalaccount: failed to unmarshal subject token file: %v", err)
} }
val, ok := jsonData[cs.Format.SubjectTokenFieldName] val, ok := jsonData[cs.Format.SubjectTokenFieldName]
if !ok { if !ok {
return "", errors.New("oauth2/google: provided subject_token_field_name not found in credentials") return "", errors.New("oauth2/google/externalaccount: provided subject_token_field_name not found in credentials")
} }
token, ok := val.(string) token, ok := val.(string)
if !ok { if !ok {
return "", errors.New("oauth2/google: improperly formatted subject token") return "", errors.New("oauth2/google/externalaccount: improperly formatted subject token")
} }
return token, nil return token, nil
case "text": case "text":
@@ -73,7 +73,7 @@ func (cs urlCredentialSource) subjectToken() (string, error) {
case "": case "":
return string(respBody), nil return string(respBody), nil
default: default:
return "", errors.New("oauth2/google: invalid credential_source file format type") return "", errors.New("oauth2/google/externalaccount: invalid credential_source file format type")
} }
} }

View File

@@ -28,11 +28,11 @@ func TestRetrieveURLSubjectToken_Text(t *testing.T) {
heads["Metadata"] = "True" heads["Metadata"] = "True"
cs := CredentialSource{ cs := CredentialSource{
URL: ts.URL, URL: ts.URL,
Format: format{Type: fileTypeText}, Format: Format{Type: fileTypeText},
Headers: heads, Headers: heads,
} }
tfc := testFileConfig tfc := testFileConfig
tfc.CredentialSource = cs tfc.CredentialSource = &cs
base, err := tfc.parse(context.Background()) base, err := tfc.parse(context.Background())
if err != nil { if err != nil {
@@ -60,7 +60,7 @@ func TestRetrieveURLSubjectToken_Untyped(t *testing.T) {
URL: ts.URL, URL: ts.URL,
} }
tfc := testFileConfig tfc := testFileConfig
tfc.CredentialSource = cs tfc.CredentialSource = &cs
base, err := tfc.parse(context.Background()) base, err := tfc.parse(context.Background())
if err != nil { if err != nil {
@@ -93,10 +93,10 @@ func TestRetrieveURLSubjectToken_JSON(t *testing.T) {
})) }))
cs := CredentialSource{ cs := CredentialSource{
URL: ts.URL, URL: ts.URL,
Format: format{Type: fileTypeJSON, SubjectTokenFieldName: "SubjToken"}, Format: Format{Type: fileTypeJSON, SubjectTokenFieldName: "SubjToken"},
} }
tfc := testFileConfig tfc := testFileConfig
tfc.CredentialSource = cs tfc.CredentialSource = &cs
base, err := tfc.parse(context.Background()) base, err := tfc.parse(context.Background())
if err != nil { if err != nil {
@@ -115,10 +115,10 @@ func TestRetrieveURLSubjectToken_JSON(t *testing.T) {
func TestURLCredential_CredentialSourceType(t *testing.T) { func TestURLCredential_CredentialSourceType(t *testing.T) {
cs := CredentialSource{ cs := CredentialSource{
URL: "http://example.com", URL: "http://example.com",
Format: format{Type: fileTypeText}, Format: Format{Type: fileTypeText},
} }
tfc := testFileConfig tfc := testFileConfig
tfc.CredentialSource = cs tfc.CredentialSource = &cs
base, err := tfc.parse(context.Background()) base, err := tfc.parse(context.Background())
if err != nil { if err != nil {

View File

@@ -15,8 +15,9 @@ import (
"cloud.google.com/go/compute/metadata" "cloud.google.com/go/compute/metadata"
"golang.org/x/oauth2" "golang.org/x/oauth2"
"golang.org/x/oauth2/google/internal/externalaccount" "golang.org/x/oauth2/google/externalaccount"
"golang.org/x/oauth2/google/internal/externalaccountauthorizeduser" "golang.org/x/oauth2/google/internal/externalaccountauthorizeduser"
"golang.org/x/oauth2/google/internal/impersonate"
"golang.org/x/oauth2/jwt" "golang.org/x/oauth2/jwt"
) )
@@ -200,12 +201,12 @@ func (f *credentialsFile) tokenSource(ctx context.Context, params CredentialsPar
ServiceAccountImpersonationLifetimeSeconds: f.ServiceAccountImpersonation.TokenLifetimeSeconds, ServiceAccountImpersonationLifetimeSeconds: f.ServiceAccountImpersonation.TokenLifetimeSeconds,
ClientSecret: f.ClientSecret, ClientSecret: f.ClientSecret,
ClientID: f.ClientID, ClientID: f.ClientID,
CredentialSource: f.CredentialSource, CredentialSource: &f.CredentialSource,
QuotaProjectID: f.QuotaProjectID, QuotaProjectID: f.QuotaProjectID,
Scopes: params.Scopes, Scopes: params.Scopes,
WorkforcePoolUserProject: f.WorkforcePoolUserProject, WorkforcePoolUserProject: f.WorkforcePoolUserProject,
} }
return cfg.TokenSource(ctx) return externalaccount.NewTokenSource(ctx, *cfg)
case externalAccountAuthorizedUserKey: case externalAccountAuthorizedUserKey:
cfg := &externalaccountauthorizeduser.Config{ cfg := &externalaccountauthorizeduser.Config{
Audience: f.Audience, Audience: f.Audience,
@@ -228,7 +229,7 @@ func (f *credentialsFile) tokenSource(ctx context.Context, params CredentialsPar
if err != nil { if err != nil {
return nil, err return nil, err
} }
imp := externalaccount.ImpersonateTokenSource{ imp := impersonate.ImpersonateTokenSource{
Ctx: ctx, Ctx: ctx,
URL: f.ServiceAccountImpersonationURL, URL: f.ServiceAccountImpersonationURL,
Scopes: params.Scopes, Scopes: params.Scopes,

View File

@@ -1,254 +0,0 @@
// Copyright 2020 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package externalaccount
import (
"context"
"fmt"
"net/http"
"regexp"
"strconv"
"time"
"golang.org/x/oauth2"
"golang.org/x/oauth2/google/internal/stsexchange"
)
// now aliases time.Now for testing
var now = func() time.Time {
return time.Now().UTC()
}
// Config stores the configuration for fetching tokens with external credentials.
type Config struct {
// Audience is the Secure Token Service (STS) audience which contains the resource name for the workload
// identity pool or the workforce pool and the provider identifier in that pool.
Audience string
// SubjectTokenType is the STS token type based on the Oauth2.0 token exchange spec
// e.g. `urn:ietf:params:oauth:token-type:jwt`.
SubjectTokenType string
// TokenURL is the STS token exchange endpoint.
TokenURL string
// TokenInfoURL is the token_info endpoint used to retrieve the account related information (
// user attributes like account identifier, eg. email, username, uid, etc). This is
// needed for gCloud session account identification.
TokenInfoURL string
// ServiceAccountImpersonationURL is the URL for the service account impersonation request. This is only
// required for workload identity pools when APIs to be accessed have not integrated with UberMint.
ServiceAccountImpersonationURL string
// ServiceAccountImpersonationLifetimeSeconds is the number of seconds the service account impersonation
// token will be valid for.
ServiceAccountImpersonationLifetimeSeconds int
// ClientSecret is currently only required if token_info endpoint also
// needs to be called with the generated GCP access token. When provided, STS will be
// called with additional basic authentication using client_id as username and client_secret as password.
ClientSecret string
// ClientID is only required in conjunction with ClientSecret, as described above.
ClientID string
// CredentialSource contains the necessary information to retrieve the token itself, as well
// as some environmental information.
CredentialSource CredentialSource
// QuotaProjectID is injected by gCloud. If the value is non-empty, the Auth libraries
// will set the x-goog-user-project which overrides the project associated with the credentials.
QuotaProjectID string
// Scopes contains the desired scopes for the returned access token.
Scopes []string
// The optional workforce pool user project number when the credential
// corresponds to a workforce pool and not a workload identity pool.
// The underlying principal must still have serviceusage.services.use IAM
// permission to use the project for billing/quota.
WorkforcePoolUserProject string
}
var (
validWorkforceAudiencePattern *regexp.Regexp = regexp.MustCompile(`//iam\.googleapis\.com/locations/[^/]+/workforcePools/`)
)
func validateWorkforceAudience(input string) bool {
return validWorkforceAudiencePattern.MatchString(input)
}
// TokenSource Returns an external account TokenSource struct. This is to be called by package google to construct a google.Credentials.
func (c *Config) TokenSource(ctx context.Context) (oauth2.TokenSource, error) {
return c.tokenSource(ctx, "https")
}
// tokenSource is a private function that's directly called by some of the tests,
// because the unit test URLs are mocked, and would otherwise fail the
// validity check.
func (c *Config) tokenSource(ctx context.Context, scheme string) (oauth2.TokenSource, error) {
if c.WorkforcePoolUserProject != "" {
valid := validateWorkforceAudience(c.Audience)
if !valid {
return nil, fmt.Errorf("oauth2/google: workforce_pool_user_project should not be set for non-workforce pool credentials")
}
}
ts := tokenSource{
ctx: ctx,
conf: c,
}
if c.ServiceAccountImpersonationURL == "" {
return oauth2.ReuseTokenSource(nil, ts), nil
}
scopes := c.Scopes
ts.conf.Scopes = []string{"https://www.googleapis.com/auth/cloud-platform"}
imp := ImpersonateTokenSource{
Ctx: ctx,
URL: c.ServiceAccountImpersonationURL,
Scopes: scopes,
Ts: oauth2.ReuseTokenSource(nil, ts),
TokenLifetimeSeconds: c.ServiceAccountImpersonationLifetimeSeconds,
}
return oauth2.ReuseTokenSource(nil, imp), nil
}
// Subject token file types.
const (
fileTypeText = "text"
fileTypeJSON = "json"
)
type format struct {
// Type is either "text" or "json". When not provided "text" type is assumed.
Type string `json:"type"`
// SubjectTokenFieldName is only required for JSON format. This would be "access_token" for azure.
SubjectTokenFieldName string `json:"subject_token_field_name"`
}
// CredentialSource stores the information necessary to retrieve the credentials for the STS exchange.
// One field amongst File, URL, and Executable should be filled, depending on the kind of credential in question.
// The EnvironmentID should start with AWS if being used for an AWS credential.
type CredentialSource struct {
File string `json:"file"`
URL string `json:"url"`
Headers map[string]string `json:"headers"`
Executable *ExecutableConfig `json:"executable"`
EnvironmentID string `json:"environment_id"`
RegionURL string `json:"region_url"`
RegionalCredVerificationURL string `json:"regional_cred_verification_url"`
CredVerificationURL string `json:"cred_verification_url"`
IMDSv2SessionTokenURL string `json:"imdsv2_session_token_url"`
Format format `json:"format"`
}
type ExecutableConfig struct {
Command string `json:"command"`
TimeoutMillis *int `json:"timeout_millis"`
OutputFile string `json:"output_file"`
}
// parse determines the type of CredentialSource needed.
func (c *Config) parse(ctx context.Context) (baseCredentialSource, error) {
if len(c.CredentialSource.EnvironmentID) > 3 && c.CredentialSource.EnvironmentID[:3] == "aws" {
if awsVersion, err := strconv.Atoi(c.CredentialSource.EnvironmentID[3:]); err == nil {
if awsVersion != 1 {
return nil, fmt.Errorf("oauth2/google: aws version '%d' is not supported in the current build", awsVersion)
}
awsCredSource := awsCredentialSource{
EnvironmentID: c.CredentialSource.EnvironmentID,
RegionURL: c.CredentialSource.RegionURL,
RegionalCredVerificationURL: c.CredentialSource.RegionalCredVerificationURL,
CredVerificationURL: c.CredentialSource.URL,
TargetResource: c.Audience,
ctx: ctx,
}
if c.CredentialSource.IMDSv2SessionTokenURL != "" {
awsCredSource.IMDSv2SessionTokenURL = c.CredentialSource.IMDSv2SessionTokenURL
}
return awsCredSource, nil
}
} else if c.CredentialSource.File != "" {
return fileCredentialSource{File: c.CredentialSource.File, Format: c.CredentialSource.Format}, nil
} else if c.CredentialSource.URL != "" {
return urlCredentialSource{URL: c.CredentialSource.URL, Headers: c.CredentialSource.Headers, Format: c.CredentialSource.Format, ctx: ctx}, nil
} else if c.CredentialSource.Executable != nil {
return CreateExecutableCredential(ctx, c.CredentialSource.Executable, c)
}
return nil, fmt.Errorf("oauth2/google: unable to parse credential source")
}
type baseCredentialSource interface {
credentialSourceType() string
subjectToken() (string, error)
}
// tokenSource is the source that handles external credentials. It is used to retrieve Tokens.
type tokenSource struct {
ctx context.Context
conf *Config
}
func getMetricsHeaderValue(conf *Config, credSource baseCredentialSource) string {
return fmt.Sprintf("gl-go/%s auth/%s google-byoid-sdk source/%s sa-impersonation/%t config-lifetime/%t",
goVersion(),
"unknown",
credSource.credentialSourceType(),
conf.ServiceAccountImpersonationURL != "",
conf.ServiceAccountImpersonationLifetimeSeconds != 0)
}
// Token allows tokenSource to conform to the oauth2.TokenSource interface.
func (ts tokenSource) Token() (*oauth2.Token, error) {
conf := ts.conf
credSource, err := conf.parse(ts.ctx)
if err != nil {
return nil, err
}
subjectToken, err := credSource.subjectToken()
if err != nil {
return nil, err
}
stsRequest := stsexchange.TokenExchangeRequest{
GrantType: "urn:ietf:params:oauth:grant-type:token-exchange",
Audience: conf.Audience,
Scope: conf.Scopes,
RequestedTokenType: "urn:ietf:params:oauth:token-type:access_token",
SubjectToken: subjectToken,
SubjectTokenType: conf.SubjectTokenType,
}
header := make(http.Header)
header.Add("Content-Type", "application/x-www-form-urlencoded")
header.Add("x-goog-api-client", getMetricsHeaderValue(conf, credSource))
clientAuth := stsexchange.ClientAuthentication{
AuthStyle: oauth2.AuthStyleInHeader,
ClientID: conf.ClientID,
ClientSecret: conf.ClientSecret,
}
var options map[string]interface{}
// Do not pass workforce_pool_user_project when client authentication is used.
// The client ID is sufficient for determining the user project.
if conf.WorkforcePoolUserProject != "" && conf.ClientID == "" {
options = map[string]interface{}{
"userProject": conf.WorkforcePoolUserProject,
}
}
stsResp, err := stsexchange.ExchangeToken(ts.ctx, conf.TokenURL, &stsRequest, clientAuth, header, options)
if err != nil {
return nil, err
}
accessToken := &oauth2.Token{
AccessToken: stsResp.AccessToken,
TokenType: stsResp.TokenType,
}
if stsResp.ExpiresIn < 0 {
return nil, fmt.Errorf("oauth2/google: got invalid expiry from security token service")
} else if stsResp.ExpiresIn >= 0 {
accessToken.Expiry = now().Add(time.Duration(stsResp.ExpiresIn) * time.Second)
}
if stsResp.RefreshToken != "" {
accessToken.RefreshToken = stsResp.RefreshToken
}
return accessToken, nil
}

View File

@@ -1,259 +0,0 @@
// Copyright 2020 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package externalaccount
import (
"context"
"fmt"
"io/ioutil"
"net/http"
"net/http/httptest"
"testing"
"time"
"golang.org/x/oauth2"
)
const (
textBaseCredPath = "testdata/3pi_cred.txt"
jsonBaseCredPath = "testdata/3pi_cred.json"
)
var testBaseCredSource = CredentialSource{
File: textBaseCredPath,
Format: format{Type: fileTypeText},
}
var testConfig = Config{
Audience: "32555940559.apps.googleusercontent.com",
SubjectTokenType: "urn:ietf:params:oauth:token-type:jwt",
TokenInfoURL: "http://localhost:8080/v1/tokeninfo",
ClientSecret: "notsosecret",
ClientID: "rbrgnognrhongo3bi4gb9ghg9g",
CredentialSource: testBaseCredSource,
Scopes: []string{"https://www.googleapis.com/auth/devstorage.full_control"},
}
var (
baseCredsRequestBody = "audience=32555940559.apps.googleusercontent.com&grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Atoken-exchange&requested_token_type=urn%3Aietf%3Aparams%3Aoauth%3Atoken-type%3Aaccess_token&scope=https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fdevstorage.full_control&subject_token=street123&subject_token_type=urn%3Aietf%3Aparams%3Aoauth%3Atoken-type%3Aid_token"
baseCredsResponseBody = `{"access_token":"Sample.Access.Token","issued_token_type":"urn:ietf:params:oauth:token-type:access_token","token_type":"Bearer","expires_in":3600,"scope":"https://www.googleapis.com/auth/cloud-platform"}`
workforcePoolRequestBodyWithClientId = "audience=%2F%2Fiam.googleapis.com%2Flocations%2Feu%2FworkforcePools%2Fpool-id%2Fproviders%2Fprovider-id&grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Atoken-exchange&requested_token_type=urn%3Aietf%3Aparams%3Aoauth%3Atoken-type%3Aaccess_token&scope=https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fdevstorage.full_control&subject_token=street123&subject_token_type=urn%3Aietf%3Aparams%3Aoauth%3Atoken-type%3Aid_token"
workforcePoolRequestBodyWithoutClientId = "audience=%2F%2Fiam.googleapis.com%2Flocations%2Feu%2FworkforcePools%2Fpool-id%2Fproviders%2Fprovider-id&grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Atoken-exchange&options=%7B%22userProject%22%3A%22myProject%22%7D&requested_token_type=urn%3Aietf%3Aparams%3Aoauth%3Atoken-type%3Aaccess_token&scope=https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fdevstorage.full_control&subject_token=street123&subject_token_type=urn%3Aietf%3Aparams%3Aoauth%3Atoken-type%3Aid_token"
correctAT = "Sample.Access.Token"
expiry int64 = 234852
)
var (
testNow = func() time.Time { return time.Unix(expiry, 0) }
)
type testExchangeTokenServer struct {
url string
authorization string
contentType string
metricsHeader string
body string
response string
}
func run(t *testing.T, config *Config, tets *testExchangeTokenServer) (*oauth2.Token, error) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got, want := r.URL.String(), tets.url; got != want {
t.Errorf("URL.String(): got %v but want %v", got, want)
}
headerAuth := r.Header.Get("Authorization")
if got, want := headerAuth, tets.authorization; got != want {
t.Errorf("got %v but want %v", got, want)
}
headerContentType := r.Header.Get("Content-Type")
if got, want := headerContentType, tets.contentType; got != want {
t.Errorf("got %v but want %v", got, want)
}
headerMetrics := r.Header.Get("x-goog-api-client")
if got, want := headerMetrics, tets.metricsHeader; got != want {
t.Errorf("got %v but want %v", got, want)
}
body, err := ioutil.ReadAll(r.Body)
if err != nil {
t.Fatalf("Failed reading request body: %s.", err)
}
if got, want := string(body), tets.body; got != want {
t.Errorf("Unexpected exchange payload: got %v but want %v", got, want)
}
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(tets.response))
}))
defer server.Close()
config.TokenURL = server.URL
oldNow := now
defer func() { now = oldNow }()
now = testNow
ts := tokenSource{
ctx: context.Background(),
conf: config,
}
return ts.Token()
}
func validateToken(t *testing.T, tok *oauth2.Token) {
if got, want := tok.AccessToken, correctAT; got != want {
t.Errorf("Unexpected access token: got %v, but wanted %v", got, want)
}
if got, want := tok.TokenType, "Bearer"; got != want {
t.Errorf("Unexpected TokenType: got %v, but wanted %v", got, want)
}
if got, want := tok.Expiry, testNow().Add(time.Duration(3600)*time.Second); got != want {
t.Errorf("Unexpected Expiry: got %v, but wanted %v", got, want)
}
}
func getExpectedMetricsHeader(source string, saImpersonation bool, configLifetime bool) string {
return fmt.Sprintf("gl-go/%s auth/unknown google-byoid-sdk source/%s sa-impersonation/%t config-lifetime/%t", goVersion(), source, saImpersonation, configLifetime)
}
func TestToken(t *testing.T) {
config := Config{
Audience: "32555940559.apps.googleusercontent.com",
SubjectTokenType: "urn:ietf:params:oauth:token-type:id_token",
ClientSecret: "notsosecret",
ClientID: "rbrgnognrhongo3bi4gb9ghg9g",
CredentialSource: testBaseCredSource,
Scopes: []string{"https://www.googleapis.com/auth/devstorage.full_control"},
}
server := testExchangeTokenServer{
url: "/",
authorization: "Basic cmJyZ25vZ25yaG9uZ28zYmk0Z2I5Z2hnOWc6bm90c29zZWNyZXQ=",
contentType: "application/x-www-form-urlencoded",
metricsHeader: getExpectedMetricsHeader("file", false, false),
body: baseCredsRequestBody,
response: baseCredsResponseBody,
}
tok, err := run(t, &config, &server)
if err != nil {
t.Fatalf("Unexpected error: %e", err)
}
validateToken(t, tok)
}
func TestWorkforcePoolTokenWithClientID(t *testing.T) {
config := Config{
Audience: "//iam.googleapis.com/locations/eu/workforcePools/pool-id/providers/provider-id",
SubjectTokenType: "urn:ietf:params:oauth:token-type:id_token",
ClientSecret: "notsosecret",
ClientID: "rbrgnognrhongo3bi4gb9ghg9g",
CredentialSource: testBaseCredSource,
Scopes: []string{"https://www.googleapis.com/auth/devstorage.full_control"},
WorkforcePoolUserProject: "myProject",
}
server := testExchangeTokenServer{
url: "/",
authorization: "Basic cmJyZ25vZ25yaG9uZ28zYmk0Z2I5Z2hnOWc6bm90c29zZWNyZXQ=",
contentType: "application/x-www-form-urlencoded",
metricsHeader: getExpectedMetricsHeader("file", false, false),
body: workforcePoolRequestBodyWithClientId,
response: baseCredsResponseBody,
}
tok, err := run(t, &config, &server)
if err != nil {
t.Fatalf("Unexpected error: %e", err)
}
validateToken(t, tok)
}
func TestWorkforcePoolTokenWithoutClientID(t *testing.T) {
config := Config{
Audience: "//iam.googleapis.com/locations/eu/workforcePools/pool-id/providers/provider-id",
SubjectTokenType: "urn:ietf:params:oauth:token-type:id_token",
ClientSecret: "notsosecret",
CredentialSource: testBaseCredSource,
Scopes: []string{"https://www.googleapis.com/auth/devstorage.full_control"},
WorkforcePoolUserProject: "myProject",
}
server := testExchangeTokenServer{
url: "/",
authorization: "",
contentType: "application/x-www-form-urlencoded",
metricsHeader: getExpectedMetricsHeader("file", false, false),
body: workforcePoolRequestBodyWithoutClientId,
response: baseCredsResponseBody,
}
tok, err := run(t, &config, &server)
if err != nil {
t.Fatalf("Unexpected error: %e", err)
}
validateToken(t, tok)
}
func TestNonworkforceWithWorkforcePoolUserProject(t *testing.T) {
config := Config{
Audience: "32555940559.apps.googleusercontent.com",
SubjectTokenType: "urn:ietf:params:oauth:token-type:id_token",
TokenURL: "https://sts.googleapis.com",
ClientSecret: "notsosecret",
ClientID: "rbrgnognrhongo3bi4gb9ghg9g",
CredentialSource: testBaseCredSource,
Scopes: []string{"https://www.googleapis.com/auth/devstorage.full_control"},
WorkforcePoolUserProject: "myProject",
}
_, err := config.TokenSource(context.Background())
if err == nil {
t.Fatalf("Expected error but found none")
}
if got, want := err.Error(), "oauth2/google: workforce_pool_user_project should not be set for non-workforce pool credentials"; got != want {
t.Errorf("Incorrect error received.\nExpected: %s\nRecieved: %s", want, got)
}
}
func TestWorkforcePoolCreation(t *testing.T) {
var audienceValidatyTests = []struct {
audience string
expectSuccess bool
}{
{"//iam.googleapis.com/locations/global/workforcePools/pool-id/providers/provider-id", true},
{"//iam.googleapis.com/locations/eu/workforcePools/pool-id/providers/provider-id", true},
{"//iam.googleapis.com/locations/eu/workforcePools/workloadIdentityPools/providers/provider-id", true},
{"identitynamespace:1f12345:my_provider", false},
{"//iam.googleapis.com/projects/123456/locations/global/workloadIdentityPools/pool-id/providers/provider-id", false},
{"//iam.googleapis.com/projects/123456/locations/eu/workloadIdentityPools/pool-id/providers/provider-id", false},
{"//iam.googleapis.com/projects/123456/locations/global/workloadIdentityPools/workforcePools/providers/provider-id", false},
{"//iamgoogleapis.com/locations/eu/workforcePools/pool-id/providers/provider-id", false},
{"//iam.googleapiscom/locations/eu/workforcePools/pool-id/providers/provider-id", false},
{"//iam.googleapis.com/locations/workforcePools/pool-id/providers/provider-id", false},
{"//iam.googleapis.com/locations/eu/workforcePool/pool-id/providers/provider-id", false},
{"//iam.googleapis.com/locations//workforcePool/pool-id/providers/provider-id", false},
}
ctx := context.Background()
for _, tt := range audienceValidatyTests {
t.Run(" "+tt.audience, func(t *testing.T) { // We prepend a space ahead of the test input when outputting for sake of readability.
config := testConfig
config.TokenURL = "https://sts.googleapis.com" // Setting the most basic acceptable tokenURL
config.ServiceAccountImpersonationURL = "https://iamcredentials.googleapis.com"
config.Audience = tt.audience
config.WorkforcePoolUserProject = "myProject"
_, err := config.TokenSource(ctx)
if tt.expectSuccess && err != nil {
t.Errorf("got %v but want nil", err)
} else if !tt.expectSuccess && err == nil {
t.Errorf("got nil but expected an error")
}
})
}
}

View File

@@ -1,18 +0,0 @@
// Copyright 2020 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package externalaccount
import "fmt"
// Error for handling OAuth related error responses as stated in rfc6749#5.2.
type Error struct {
Code string
URI string
Description string
}
func (err *Error) Error() string {
return fmt.Sprintf("got error code %s from %s: %s", err.Code, err.URI, err.Description)
}

View File

@@ -1,19 +0,0 @@
// Copyright 2020 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package externalaccount
import "testing"
func TestError(t *testing.T) {
e := Error{
"42",
"http:thisIsAPlaceholder",
"The Answer!",
}
want := "got error code 42 from http:thisIsAPlaceholder: The Answer!"
if got := e.Error(); got != want {
t.Errorf("Got error message %q; want %q", got, want)
}
}

View File

@@ -1,144 +0,0 @@
// Copyright 2021 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package externalaccount
import (
"context"
"io/ioutil"
"net/http"
"net/http/httptest"
"testing"
)
var (
baseImpersonateCredsReqBody = "audience=32555940559.apps.googleusercontent.com&grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Atoken-exchange&requested_token_type=urn%3Aietf%3Aparams%3Aoauth%3Atoken-type%3Aaccess_token&scope=https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fcloud-platform&subject_token=street123&subject_token_type=urn%3Aietf%3Aparams%3Aoauth%3Atoken-type%3Ajwt"
baseImpersonateCredsRespBody = `{"accessToken":"Second.Access.Token","expireTime":"2020-12-28T15:01:23Z"}`
)
func createImpersonationServer(urlWanted, authWanted, bodyWanted, response string, t *testing.T) *httptest.Server {
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got, want := r.URL.String(), urlWanted; got != want {
t.Errorf("URL.String(): got %v but want %v", got, want)
}
headerAuth := r.Header.Get("Authorization")
if got, want := headerAuth, authWanted; got != want {
t.Errorf("got %v but want %v", got, want)
}
headerContentType := r.Header.Get("Content-Type")
if got, want := headerContentType, "application/json"; got != want {
t.Errorf("got %v but want %v", got, want)
}
body, err := ioutil.ReadAll(r.Body)
if err != nil {
t.Fatalf("Failed reading request body: %v.", err)
}
if got, want := string(body), bodyWanted; got != want {
t.Errorf("Unexpected impersonation payload: got %v but want %v", got, want)
}
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(response))
}))
}
func createTargetServer(metricsHeaderWanted string, t *testing.T) *httptest.Server {
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got, want := r.URL.String(), "/"; got != want {
t.Errorf("URL.String(): got %v but want %v", got, want)
}
headerAuth := r.Header.Get("Authorization")
if got, want := headerAuth, "Basic cmJyZ25vZ25yaG9uZ28zYmk0Z2I5Z2hnOWc6bm90c29zZWNyZXQ="; got != want {
t.Errorf("got %v but want %v", got, want)
}
headerContentType := r.Header.Get("Content-Type")
if got, want := headerContentType, "application/x-www-form-urlencoded"; got != want {
t.Errorf("got %v but want %v", got, want)
}
headerMetrics := r.Header.Get("x-goog-api-client")
if got, want := headerMetrics, metricsHeaderWanted; got != want {
t.Errorf("got %v but want %v", got, want)
}
body, err := ioutil.ReadAll(r.Body)
if err != nil {
t.Fatalf("Failed reading request body: %v.", err)
}
if got, want := string(body), baseImpersonateCredsReqBody; got != want {
t.Errorf("Unexpected exchange payload: got %v but want %v", got, want)
}
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(baseCredsResponseBody))
}))
}
var impersonationTests = []struct {
name string
config Config
expectedImpersonationBody string
expectedMetricsHeader string
}{
{
name: "Base Impersonation",
config: Config{
Audience: "32555940559.apps.googleusercontent.com",
SubjectTokenType: "urn:ietf:params:oauth:token-type:jwt",
TokenInfoURL: "http://localhost:8080/v1/tokeninfo",
ClientSecret: "notsosecret",
ClientID: "rbrgnognrhongo3bi4gb9ghg9g",
CredentialSource: testBaseCredSource,
Scopes: []string{"https://www.googleapis.com/auth/devstorage.full_control"},
},
expectedImpersonationBody: "{\"lifetime\":\"3600s\",\"scope\":[\"https://www.googleapis.com/auth/devstorage.full_control\"]}",
expectedMetricsHeader: getExpectedMetricsHeader("file", true, false),
},
{
name: "With TokenLifetime Set",
config: Config{
Audience: "32555940559.apps.googleusercontent.com",
SubjectTokenType: "urn:ietf:params:oauth:token-type:jwt",
TokenInfoURL: "http://localhost:8080/v1/tokeninfo",
ClientSecret: "notsosecret",
ClientID: "rbrgnognrhongo3bi4gb9ghg9g",
CredentialSource: testBaseCredSource,
Scopes: []string{"https://www.googleapis.com/auth/devstorage.full_control"},
ServiceAccountImpersonationLifetimeSeconds: 10000,
},
expectedImpersonationBody: "{\"lifetime\":\"10000s\",\"scope\":[\"https://www.googleapis.com/auth/devstorage.full_control\"]}",
expectedMetricsHeader: getExpectedMetricsHeader("file", true, true),
},
}
func TestImpersonation(t *testing.T) {
for _, tt := range impersonationTests {
t.Run(tt.name, func(t *testing.T) {
testImpersonateConfig := tt.config
impersonateServer := createImpersonationServer("/", "Bearer Sample.Access.Token", tt.expectedImpersonationBody, baseImpersonateCredsRespBody, t)
defer impersonateServer.Close()
testImpersonateConfig.ServiceAccountImpersonationURL = impersonateServer.URL
targetServer := createTargetServer(tt.expectedMetricsHeader, t)
defer targetServer.Close()
testImpersonateConfig.TokenURL = targetServer.URL
ourTS, err := testImpersonateConfig.tokenSource(context.Background(), "http")
if err != nil {
t.Fatalf("Failed to create TokenSource: %v", err)
}
oldNow := now
defer func() { now = oldNow }()
now = testNow
tok, err := ourTS.Token()
if err != nil {
t.Fatalf("Unexpected error: %e", err)
}
if got, want := tok.AccessToken, "Second.Access.Token"; got != want {
t.Errorf("Unexpected access token: got %v, but wanted %v", got, want)
}
if got, want := tok.TokenType, "Bearer"; got != want {
t.Errorf("Unexpected TokenType: got %v, but wanted %v", got, want)
}
})
}
}

View File

@@ -2,7 +2,7 @@
// Use of this source code is governed by a BSD-style // Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file. // license that can be found in the LICENSE file.
package externalaccount package impersonate
import ( import (
"bytes" "bytes"