diff --git a/LICENSE b/LICENSE index 6a66aea5e..2a7cf70da 100644 --- a/LICENSE +++ b/LICENSE @@ -1,4 +1,4 @@ -Copyright (c) 2009 The Go Authors. All rights reserved. +Copyright 2009 The Go Authors. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are @@ -10,7 +10,7 @@ notice, this list of conditions and the following disclaimer. copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. - * Neither the name of Google Inc. nor the names of its + * Neither the name of Google LLC nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. diff --git a/endpoints/endpoints.go b/endpoints/endpoints.go index 7fb3314bf..950754ce4 100644 --- a/endpoints/endpoints.go +++ b/endpoints/endpoints.go @@ -62,14 +62,15 @@ var GitHub = oauth2.Endpoint{ // GitLab is the endpoint for GitLab. var GitLab = oauth2.Endpoint{ - AuthURL: "https://gitlab.com/oauth/authorize", - TokenURL: "https://gitlab.com/oauth/token", + AuthURL: "https://gitlab.com/oauth/authorize", + TokenURL: "https://gitlab.com/oauth/token", + DeviceAuthURL: "https://gitlab.com/oauth/authorize_device", } // Google is the endpoint for Google. var Google = oauth2.Endpoint{ - AuthURL: "https://accounts.google.com/o/oauth2/auth", - TokenURL: "https://oauth2.googleapis.com/token", + AuthURL: "https://accounts.google.com/o/oauth2/auth", + TokenURL: "https://oauth2.googleapis.com/token", DeviceAuthURL: "https://oauth2.googleapis.com/device/code", } @@ -227,8 +228,9 @@ func AzureAD(tenant string) oauth2.Endpoint { tenant = "common" } return oauth2.Endpoint{ - AuthURL: "https://login.microsoftonline.com/" + tenant + "/oauth2/v2.0/authorize", - TokenURL: "https://login.microsoftonline.com/" + tenant + "/oauth2/v2.0/token", + AuthURL: "https://login.microsoftonline.com/" + tenant + "/oauth2/v2.0/authorize", + TokenURL: "https://login.microsoftonline.com/" + tenant + "/oauth2/v2.0/token", + DeviceAuthURL: "https://login.microsoftonline.com/" + tenant + "/oauth2/v2.0/devicecode", } } diff --git a/gitlab/gitlab.go b/gitlab/gitlab.go index 1231d75ac..aea2dfe50 100644 --- a/gitlab/gitlab.go +++ b/gitlab/gitlab.go @@ -6,11 +6,8 @@ package gitlab // import "golang.org/x/oauth2/gitlab" import ( - "golang.org/x/oauth2" + "golang.org/x/oauth2/endpoints" ) // Endpoint is GitLab's OAuth 2.0 endpoint. -var Endpoint = oauth2.Endpoint{ - AuthURL: "https://gitlab.com/oauth/authorize", - TokenURL: "https://gitlab.com/oauth/token", -} +var Endpoint = endpoints.GitLab diff --git a/go.mod b/go.mod index 299ec4484..d73aa6996 100644 --- a/go.mod +++ b/go.mod @@ -3,14 +3,6 @@ module golang.org/x/oauth2 go 1.18 require ( - cloud.google.com/go/compute/metadata v0.2.3 + cloud.google.com/go/compute/metadata v0.3.0 github.com/google/go-cmp v0.5.9 - google.golang.org/appengine v1.6.7 -) - -require ( - cloud.google.com/go/compute v1.20.1 // indirect - github.com/golang/protobuf v1.5.3 // indirect - golang.org/x/net v0.20.0 // indirect - google.golang.org/protobuf v1.31.0 // indirect ) diff --git a/go.sum b/go.sum index cfc19e8cb..0c9052866 100644 --- a/go.sum +++ b/go.sum @@ -1,26 +1,4 @@ -cloud.google.com/go/compute v1.20.1 h1:6aKEtlUiwEpJzM001l0yFkpXmUVXaN8W+fbkb2AZNbg= -cloud.google.com/go/compute v1.20.1/go.mod h1:4tCnrn48xsqlwSAiLf1HXMQk8CONslYbdiEZc9FEIbM= -cloud.google.com/go/compute/metadata v0.2.3 h1:mg4jlk7mCAj6xXp9UJ4fjI9VUI5rubuGBW5aJ7UnBMY= -cloud.google.com/go/compute/metadata v0.2.3/go.mod h1:VAV5nSsACxMJvgaAuX6Pk2AawlZn8kiOGuCv6gTkwuA= -github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= -github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= -github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= -github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +cloud.google.com/go/compute/metadata v0.3.0 h1:Tz+eQXMEqDIKRsmY3cHTL6FVaynIjX2QxYC4trgAKZc= +cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k= github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= -golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= -golang.org/x/net v0.20.0 h1:aCL9BSgETF1k+blQaYUBx9hJ9LOGP3gAVemcZlf1Kpo= -golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY= -golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= -golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c= -google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= -google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= -google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= -google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8= -google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= diff --git a/google/appengine.go b/google/appengine.go index feb1157b1..564920bd4 100644 --- a/google/appengine.go +++ b/google/appengine.go @@ -6,16 +6,13 @@ package google import ( "context" - "time" + "log" + "sync" "golang.org/x/oauth2" ) -// Set at init time by appengine_gen1.go. If nil, we're not on App Engine standard first generation (<= Go 1.9) or App Engine flexible. -var appengineTokenFunc func(c context.Context, scopes ...string) (token string, expiry time.Time, err error) - -// Set at init time by appengine_gen1.go. If nil, we're not on App Engine standard first generation (<= Go 1.9) or App Engine flexible. -var appengineAppIDFunc func(c context.Context) string +var logOnce sync.Once // only spam about deprecation once // AppEngineTokenSource returns a token source that fetches tokens from either // the current application's service account or from the metadata server, @@ -23,8 +20,10 @@ var appengineAppIDFunc func(c context.Context) string // details. If you are implementing a 3-legged OAuth 2.0 flow on App Engine that // involves user accounts, see oauth2.Config instead. // -// First generation App Engine runtimes (<= Go 1.9): -// AppEngineTokenSource returns a token source that fetches tokens issued to the +// The current version of this library requires at least Go 1.17 to build, +// so first generation App Engine runtimes (<= Go 1.9) are unsupported. +// Previously, on first generation App Engine runtimes, AppEngineTokenSource +// returned a token source that fetches tokens issued to the // current App Engine application's service account. The provided context must have // come from appengine.NewContext. // @@ -34,5 +33,8 @@ var appengineAppIDFunc func(c context.Context) string // context and scopes are not used. Please use DefaultTokenSource (or ComputeTokenSource, // which DefaultTokenSource will use in this case) instead. func AppEngineTokenSource(ctx context.Context, scope ...string) oauth2.TokenSource { - return appEngineTokenSource(ctx, scope...) + logOnce.Do(func() { + log.Print("google: AppEngineTokenSource is deprecated on App Engine standard second generation runtimes (>= Go 1.11) and App Engine flexible. Please use DefaultTokenSource or ComputeTokenSource.") + }) + return ComputeTokenSource("") } diff --git a/google/appengine_gen1.go b/google/appengine_gen1.go deleted file mode 100644 index e61587945..000000000 --- a/google/appengine_gen1.go +++ /dev/null @@ -1,77 +0,0 @@ -// Copyright 2018 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. - -//go:build appengine - -// This file applies to App Engine first generation runtimes (<= Go 1.9). - -package google - -import ( - "context" - "sort" - "strings" - "sync" - - "golang.org/x/oauth2" - "google.golang.org/appengine" -) - -func init() { - appengineTokenFunc = appengine.AccessToken - appengineAppIDFunc = appengine.AppID -} - -// See comment on AppEngineTokenSource in appengine.go. -func appEngineTokenSource(ctx context.Context, scope ...string) oauth2.TokenSource { - scopes := append([]string{}, scope...) - sort.Strings(scopes) - return &gaeTokenSource{ - ctx: ctx, - scopes: scopes, - key: strings.Join(scopes, " "), - } -} - -// aeTokens helps the fetched tokens to be reused until their expiration. -var ( - aeTokensMu sync.Mutex - aeTokens = make(map[string]*tokenLock) // key is space-separated scopes -) - -type tokenLock struct { - mu sync.Mutex // guards t; held while fetching or updating t - t *oauth2.Token -} - -type gaeTokenSource struct { - ctx context.Context - scopes []string - key string // to aeTokens map; space-separated scopes -} - -func (ts *gaeTokenSource) Token() (*oauth2.Token, error) { - aeTokensMu.Lock() - tok, ok := aeTokens[ts.key] - if !ok { - tok = &tokenLock{} - aeTokens[ts.key] = tok - } - aeTokensMu.Unlock() - - tok.mu.Lock() - defer tok.mu.Unlock() - if tok.t.Valid() { - return tok.t, nil - } - access, exp, err := appengineTokenFunc(ts.ctx, ts.scopes...) - if err != nil { - return nil, err - } - tok.t = &oauth2.Token{ - AccessToken: access, - Expiry: exp, - } - return tok.t, nil -} diff --git a/google/appengine_gen2_flex.go b/google/appengine_gen2_flex.go deleted file mode 100644 index 9c79aa0a0..000000000 --- a/google/appengine_gen2_flex.go +++ /dev/null @@ -1,27 +0,0 @@ -// Copyright 2018 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. - -//go:build !appengine - -// This file applies to App Engine second generation runtimes (>= Go 1.11) and App Engine flexible. - -package google - -import ( - "context" - "log" - "sync" - - "golang.org/x/oauth2" -) - -var logOnce sync.Once // only spam about deprecation once - -// See comment on AppEngineTokenSource in appengine.go. -func appEngineTokenSource(ctx context.Context, scope ...string) oauth2.TokenSource { - logOnce.Do(func() { - log.Print("google: AppEngineTokenSource is deprecated on App Engine standard second generation runtimes (>= Go 1.11) and App Engine flexible. Please use DefaultTokenSource or ComputeTokenSource.") - }) - return ComputeTokenSource("") -} diff --git a/google/default.go b/google/default.go index 02ccd08a7..df958359a 100644 --- a/google/default.go +++ b/google/default.go @@ -22,7 +22,7 @@ import ( const ( 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". @@ -42,6 +42,17 @@ type Credentials struct { // running on Google Cloud Platform. 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 // universeDomain is the default service domain for a given Cloud universe. 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). func (c *Credentials) UniverseDomain() string { if c.universeDomain == "" { - return universeDomainDefault + return defaultUniverseDomain } return c.universeDomain } // 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". -// -// 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) { c.udMu.Lock() defer c.udMu.Unlock() - if c.universeDomain == "" && metadata.OnGCE() { - // If we're on Google Compute Engine, an App Engine standard second - // generation runtime, or App Engine flexible, use the metadata server. - err := c.computeUniverseDomain() + if c.universeDomain == "" && c.UniverseDomainProvider != nil { + // On Google Compute Engine, an App Engine standard second generation + // runtime, or App Engine flexible, use an externally provided function + // to request the universe domain from the metadata server. + ud, err := c.UniverseDomainProvider() if err != nil { return "", err } + c.universeDomain = ud } - // If not on Google Compute Engine, or in case of any non-error path in - // computeUniverseDomain that did not set universeDomain, set the default - // universe domain. + // If no UniverseDomainProvider (meaning not on Google Compute Engine), or + // in case of any (non-error) empty return value from + // UniverseDomainProvider, set the default universe domain. if c.universeDomain == "" { - c.universeDomain = universeDomainDefault + c.universeDomain = defaultUniverseDomain } 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. // // 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. // On Windows, this is %APPDATA%/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 -// the appengine.AccessToken function. -// 4. On Google Compute Engine, Google App Engine standard second generation runtimes +// 3. On Google Compute Engine, Google App Engine standard second generation runtimes // (>= Go 1.11), and Google App Engine flexible environment, it fetches // credentials from the metadata server. func FindDefaultCredentialsWithParams(ctx context.Context, params CredentialsParams) (*Credentials, error) { @@ -224,24 +211,27 @@ func FindDefaultCredentialsWithParams(ctx context.Context, params CredentialsPar return CredentialsFromJSONWithParams(ctx, b, params) } - // Third, if we're on a Google App Engine standard first generation runtime (<= Go 1.9) - // use those credentials. App Engine standard second generation runtimes (>= Go 1.11) - // and App Engine flexible use ComputeTokenSource and the metadata server. - if appengineTokenFunc != nil { - return &Credentials{ - ProjectID: appengineAppIDFunc(ctx), - TokenSource: AppEngineTokenSource(ctx, params.Scopes...), - }, nil - } - - // 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. if metadata.OnGCE() { 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{ - ProjectID: id, - TokenSource: computeTokenSource("", params.EarlyTokenRefresh, params.Scopes...), - universeDomain: params.UniverseDomain, + ProjectID: id, + TokenSource: computeTokenSource("", params.EarlyTokenRefresh, params.Scopes...), + UniverseDomainProvider: universeDomainProvider, + universeDomain: params.UniverseDomain, }, nil } @@ -287,7 +277,7 @@ func CredentialsFromJSONWithParams(ctx context.Context, jsonData []byte, params } // Authorized user credentials are only supported in the googleapis.com universe. if f.Type == userCredentialsKey { - universeDomain = universeDomainDefault + universeDomain = defaultUniverseDomain } ts, err := f.tokenSource(ctx, params) diff --git a/google/default_test.go b/google/default_test.go index 7352ffcce..c8465e94f 100644 --- a/google/default_test.go +++ b/google/default_test.go @@ -10,6 +10,8 @@ import ( "net/http/httptest" "strings" "testing" + + "cloud.google.com/go/compute/metadata" ) var saJSONJWT = []byte(`{ @@ -255,9 +257,14 @@ func TestCredentialsFromJSONWithParams_User_UniverseDomain_Params_UniverseDomain func TestComputeUniverseDomain(t *testing.T) { universeDomainPath := "/computeMetadata/v1/universe/universe_domain" universeDomainResponseBody := "example.com" + var requests int s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests++ 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)) })) @@ -268,11 +275,19 @@ func TestComputeUniverseDomain(t *testing.T) { params := CredentialsParams{ 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 creds := &Credentials{ - ProjectID: "fake_project", - TokenSource: computeTokenSource("", params.EarlyTokenRefresh, params.Scopes...), - universeDomain: params.UniverseDomain, // empty + ProjectID: "fake_project", + TokenSource: computeTokenSource("", params.EarlyTokenRefresh, params.Scopes...), + UniverseDomainProvider: universeDomainProvider, + universeDomain: params.UniverseDomain, // empty } c := make(chan bool) go func() { @@ -285,7 +300,7 @@ func TestComputeUniverseDomain(t *testing.T) { } c <- true }() - got, err := creds.GetUniverseDomain() // Second conflicting access. + got, err := creds.GetUniverseDomain() // Second conflicting (and potentially uncached) access. <-c if err != nil { t.Error(err) diff --git a/google/doc.go b/google/doc.go index 03c42c6f8..830d268c1 100644 --- a/google/doc.go +++ b/google/doc.go @@ -22,91 +22,9 @@ // the other by JWTConfigFromJSON. The returned Config can be used to obtain a TokenSource or // create an http.Client. // -// # Workload Identity Federation +// # Workload and Workforce Identity Federation // -// Using workload identity federation, your application can access Google Cloud -// resources from Amazon Web Services (AWS), Microsoft Azure or any identity -// provider that supports OpenID Connect (OIDC) or SAML 2.0. -// Traditionally, applications running outside Google Cloud have used service -// account keys to access Google Cloud resources. Using identity federation, -// you can allow your workload to impersonate a service account. -// This lets you access Google Cloud resources directly, eliminating the -// maintenance and security burden associated with service account keys. -// -// Follow the detailed instructions on how to configure Workload Identity Federation -// in various platforms: -// -// Amazon Web Services (AWS): https://cloud.google.com/iam/docs/workload-identity-federation-with-other-clouds#aws -// Microsoft Azure: https://cloud.google.com/iam/docs/workload-identity-federation-with-other-clouds#azure -// OIDC identity provider: https://cloud.google.com/iam/docs/workload-identity-federation-with-other-providers#oidc -// SAML 2.0 identity provider: https://cloud.google.com/iam/docs/workload-identity-federation-with-other-providers#saml -// -// For OIDC and SAML providers, the library can retrieve tokens in three ways: -// from a local file location (file-sourced credentials), from a server -// (URL-sourced credentials), or from a local executable (executable-sourced -// credentials). -// For file-sourced credentials, a background process needs to be continuously -// refreshing the file location with a new OIDC/SAML token prior to expiration. -// For tokens with one hour lifetimes, the token needs to be updated in the file -// every hour. The token can be stored directly as plain text or in JSON format. -// For URL-sourced credentials, a local server needs to host a GET endpoint to -// return the OIDC/SAML token. The response can be in plain text or JSON. -// Additional required request headers can also be specified. -// For executable-sourced credentials, an application needs to be available to -// output the OIDC/SAML token and other information in a JSON format. -// For more information on how these work (and how to implement -// executable-sourced credentials), please check out: -// https://cloud.google.com/iam/docs/workload-identity-federation-with-other-providers#create_a_credential_configuration -// -// Note that this library does not perform any validation on the token_url, token_info_url, -// or service_account_impersonation_url fields of the credential configuration. -// It is not recommended to use a credential configuration that you did not generate with -// the gcloud CLI unless you verify that the URL fields point to a googleapis.com domain. -// -// # Workforce Identity Federation -// -// Workforce identity federation lets you use an external identity provider (IdP) to -// authenticate and authorize a workforce—a group of users, such as employees, partners, -// and contractors—using IAM, so that the users can access Google Cloud services. -// Workforce identity federation extends Google Cloud's identity capabilities to support -// syncless, attribute-based single sign on. -// -// With workforce identity federation, your workforce can access Google Cloud resources -// using an external identity provider (IdP) that supports OpenID Connect (OIDC) or -// SAML 2.0 such as Azure Active Directory (Azure AD), Active Directory Federation -// Services (AD FS), Okta, and others. -// -// Follow the detailed instructions on how to configure Workload Identity Federation -// in various platforms: -// -// Azure AD: https://cloud.google.com/iam/docs/workforce-sign-in-azure-ad -// Okta: https://cloud.google.com/iam/docs/workforce-sign-in-okta -// OIDC identity provider: https://cloud.google.com/iam/docs/configuring-workforce-identity-federation#oidc -// SAML 2.0 identity provider: https://cloud.google.com/iam/docs/configuring-workforce-identity-federation#saml -// -// For workforce identity federation, the library can retrieve tokens in three ways: -// from a local file location (file-sourced credentials), from a server -// (URL-sourced credentials), or from a local executable (executable-sourced -// credentials). -// For file-sourced credentials, a background process needs to be continuously -// refreshing the file location with a new OIDC/SAML token prior to expiration. -// For tokens with one hour lifetimes, the token needs to be updated in the file -// every hour. The token can be stored directly as plain text or in JSON format. -// For URL-sourced credentials, a local server needs to host a GET endpoint to -// return the OIDC/SAML token. The response can be in plain text or JSON. -// Additional required request headers can also be specified. -// For executable-sourced credentials, an application needs to be available to -// output the OIDC/SAML token and other information in a JSON format. -// For more information on how these work (and how to implement -// executable-sourced credentials), please check out: -// https://cloud.google.com/iam/docs/workforce-obtaining-short-lived-credentials#generate_a_configuration_file_for_non-interactive_sign-in -// -// # Security considerations -// -// Note that this library does not perform any validation on the token_url, token_info_url, -// or service_account_impersonation_url fields of the credential configuration. -// It is not recommended to use a credential configuration that you did not generate with -// the gcloud CLI unless you verify that the URL fields point to a googleapis.com domain. +// For information on how to use Workload and Workforce Identity Federation, see [golang.org/x/oauth2/google/externalaccount]. // // # Credentials // diff --git a/google/downscope/downscoping.go b/google/downscope/downscoping.go index 3d4b5532d..ebe8b0509 100644 --- a/google/downscope/downscoping.go +++ b/google/downscope/downscoping.go @@ -42,13 +42,16 @@ import ( "io/ioutil" "net/http" "net/url" + "strings" "time" "golang.org/x/oauth2" ) -var ( - identityBindingEndpoint = "https://sts.googleapis.com/v1/token" +const ( + universeDomainPlaceholder = "UNIVERSE_DOMAIN" + identityBindingEndpointTemplate = "https://sts.UNIVERSE_DOMAIN/v1/token" + defaultUniverseDomain = "googleapis.com" ) type accessBoundary struct { @@ -105,6 +108,18 @@ type DownscopingConfig struct { // access (or set of accesses) that the new token has to a given resource. // There can be a maximum of 10 AccessBoundaryRules. Rules []AccessBoundaryRule + // UniverseDomain is the default service domain for a given Cloud universe. + // The default value is "googleapis.com". Optional. + UniverseDomain string +} + +// identityBindingEndpoint returns the identity binding endpoint with the +// configured universe domain. +func (dc *DownscopingConfig) identityBindingEndpoint() string { + if dc.UniverseDomain == "" { + return strings.Replace(identityBindingEndpointTemplate, universeDomainPlaceholder, defaultUniverseDomain, 1) + } + return strings.Replace(identityBindingEndpointTemplate, universeDomainPlaceholder, dc.UniverseDomain, 1) } // A downscopingTokenSource is used to retrieve a downscoped token with restricted @@ -114,6 +129,9 @@ type downscopingTokenSource struct { ctx context.Context // config holds the information necessary to generate a downscoped Token. config DownscopingConfig + // identityBindingEndpoint is the identity binding endpoint with the + // configured universe domain. + identityBindingEndpoint string } // NewTokenSource returns a configured downscopingTokenSource. @@ -135,7 +153,11 @@ func NewTokenSource(ctx context.Context, conf DownscopingConfig) (oauth2.TokenSo return nil, fmt.Errorf("downscope: all rules must provide at least one permission: %+v", val) } } - return downscopingTokenSource{ctx: ctx, config: conf}, nil + return downscopingTokenSource{ + ctx: ctx, + config: conf, + identityBindingEndpoint: conf.identityBindingEndpoint(), + }, nil } // Token() uses a downscopingTokenSource to generate an oauth2 Token. @@ -171,7 +193,7 @@ func (dts downscopingTokenSource) Token() (*oauth2.Token, error) { form.Add("options", string(b)) myClient := oauth2.NewClient(dts.ctx, nil) - resp, err := myClient.PostForm(identityBindingEndpoint, form) + resp, err := myClient.PostForm(dts.identityBindingEndpoint, form) if err != nil { return nil, fmt.Errorf("unable to generate POST Request %v", err) } diff --git a/google/downscope/downscoping_test.go b/google/downscope/downscoping_test.go index d5adda19c..ecdd98691 100644 --- a/google/downscope/downscoping_test.go +++ b/google/downscope/downscoping_test.go @@ -38,18 +38,43 @@ func Test_DownscopedTokenSource(t *testing.T) { w.Write([]byte(standardRespBody)) })) - new := []AccessBoundaryRule{ + myTok := oauth2.Token{AccessToken: "Mellon"} + tmpSrc := oauth2.StaticTokenSource(&myTok) + rules := []AccessBoundaryRule{ { AvailableResource: "test1", AvailablePermissions: []string{"Perm1", "Perm2"}, }, } - myTok := oauth2.Token{AccessToken: "Mellon"} - tmpSrc := oauth2.StaticTokenSource(&myTok) - dts := downscopingTokenSource{context.Background(), DownscopingConfig{tmpSrc, new}} - identityBindingEndpoint = ts.URL + dts := downscopingTokenSource{ + ctx: context.Background(), + config: DownscopingConfig{ + RootSource: tmpSrc, + Rules: rules, + }, + identityBindingEndpoint: ts.URL, + } _, err := dts.Token() if err != nil { t.Fatalf("NewDownscopedTokenSource failed with error: %v", err) } } + +func Test_DownscopingConfig(t *testing.T) { + tests := []struct { + universeDomain string + want string + }{ + {"", "https://sts.googleapis.com/v1/token"}, + {"googleapis.com", "https://sts.googleapis.com/v1/token"}, + {"example.com", "https://sts.example.com/v1/token"}, + } + for _, tt := range tests { + c := DownscopingConfig{ + UniverseDomain: tt.universeDomain, + } + if got := c.identityBindingEndpoint(); got != tt.want { + t.Errorf("got %q, want %q", got, tt.want) + } + } +} diff --git a/google/internal/externalaccount/aws.go b/google/externalaccount/aws.go similarity index 77% rename from google/internal/externalaccount/aws.go rename to google/externalaccount/aws.go index bd4efd19b..ca27c2e98 100644 --- a/google/internal/externalaccount/aws.go +++ b/google/externalaccount/aws.go @@ -26,22 +26,28 @@ import ( "golang.org/x/oauth2" ) -type awsSecurityCredentials struct { - AccessKeyID string `json:"AccessKeyID"` +// AwsSecurityCredentials models AWS security credentials. +type AwsSecurityCredentials struct { + // AccessKeyId is the AWS Access Key ID - Required. + AccessKeyID string `json:"AccessKeyID"` + // SecretAccessKey is the AWS Secret Access Key - Required. SecretAccessKey string `json:"SecretAccessKey"` - SecurityToken string `json:"Token"` + // SessionToken is the AWS Session token. This should be provided for temporary AWS security credentials - Optional. + SessionToken string `json:"Token"` } // awsRequestSigner is a utility class to sign http requests using a AWS V4 signature. type awsRequestSigner struct { RegionName string - AwsSecurityCredentials awsSecurityCredentials + AwsSecurityCredentials *AwsSecurityCredentials } // getenv aliases os.Getenv for testing var getenv = os.Getenv const ( + defaultRegionalCredentialVerificationUrl = "https://sts.{region}.amazonaws.com?Action=GetCallerIdentity&Version=2011-06-15" + // AWS Signature Version 4 signing algorithm identifier. awsAlgorithm = "AWS4-HMAC-SHA256" @@ -197,8 +203,8 @@ func (rs *awsRequestSigner) SignRequest(req *http.Request) error { signedRequest.Header.Add("host", requestHost(req)) - if rs.AwsSecurityCredentials.SecurityToken != "" { - signedRequest.Header.Add(awsSecurityTokenHeader, rs.AwsSecurityCredentials.SecurityToken) + if rs.AwsSecurityCredentials.SessionToken != "" { + signedRequest.Header.Add(awsSecurityTokenHeader, rs.AwsSecurityCredentials.SessionToken) } if signedRequest.Header.Get("date") == "" { @@ -251,16 +257,18 @@ func (rs *awsRequestSigner) generateAuthentication(req *http.Request, timestamp } type awsCredentialSource struct { - EnvironmentID string - RegionURL string - RegionalCredVerificationURL string - CredVerificationURL string - IMDSv2SessionTokenURL string - TargetResource string - requestSigner *awsRequestSigner - region string - ctx context.Context - client *http.Client + environmentID string + regionURL string + regionalCredVerificationURL string + credVerificationURL string + imdsv2SessionTokenURL string + targetResource string + requestSigner *awsRequestSigner + region string + ctx context.Context + client *http.Client + awsSecurityCredentialsSupplier AwsSecurityCredentialsSupplier + supplierOptions SupplierOptions } type awsRequestHeader struct { @@ -292,18 +300,25 @@ func canRetrieveSecurityCredentialFromEnvironment() bool { return getenv(awsAccessKeyId) != "" && getenv(awsSecretAccessKey) != "" } -func shouldUseMetadataServer() bool { - return !canRetrieveRegionFromEnvironment() || !canRetrieveSecurityCredentialFromEnvironment() +func (cs awsCredentialSource) shouldUseMetadataServer() bool { + return cs.awsSecurityCredentialsSupplier == nil && (!canRetrieveRegionFromEnvironment() || !canRetrieveSecurityCredentialFromEnvironment()) } func (cs awsCredentialSource) credentialSourceType() string { + if cs.awsSecurityCredentialsSupplier != nil { + return "programmatic" + } return "aws" } func (cs awsCredentialSource) subjectToken() (string, error) { + // Set Defaults + if cs.regionalCredVerificationURL == "" { + cs.regionalCredVerificationURL = defaultRegionalCredentialVerificationUrl + } if cs.requestSigner == nil { headers := make(map[string]string) - if shouldUseMetadataServer() { + if cs.shouldUseMetadataServer() { awsSessionToken, err := cs.getAWSSessionToken() if err != nil { return "", err @@ -318,8 +333,8 @@ func (cs awsCredentialSource) subjectToken() (string, error) { if err != nil { return "", err } - - if cs.region, err = cs.getRegion(headers); err != nil { + cs.region, err = cs.getRegion(headers) + if err != nil { return "", err } @@ -331,7 +346,7 @@ func (cs awsCredentialSource) subjectToken() (string, error) { // Generate the signed request to AWS STS GetCallerIdentity API. // Use the required regional endpoint. Otherwise, the request will fail. - req, err := http.NewRequest("POST", strings.Replace(cs.RegionalCredVerificationURL, "{region}", cs.region, 1), nil) + req, err := http.NewRequest("POST", strings.Replace(cs.regionalCredVerificationURL, "{region}", cs.region, 1), nil) if err != nil { return "", err } @@ -339,8 +354,8 @@ func (cs awsCredentialSource) subjectToken() (string, error) { // provider, with or without the HTTPS prefix. // Including this header as part of the signature is recommended to // ensure data integrity. - if cs.TargetResource != "" { - req.Header.Add("x-goog-cloud-target-resource", cs.TargetResource) + if cs.targetResource != "" { + req.Header.Add("x-goog-cloud-target-resource", cs.targetResource) } cs.requestSigner.SignRequest(req) @@ -387,11 +402,11 @@ func (cs awsCredentialSource) subjectToken() (string, error) { } func (cs *awsCredentialSource) getAWSSessionToken() (string, error) { - if cs.IMDSv2SessionTokenURL == "" { + if cs.imdsv2SessionTokenURL == "" { return "", nil } - req, err := http.NewRequest("PUT", cs.IMDSv2SessionTokenURL, nil) + req, err := http.NewRequest("PUT", cs.imdsv2SessionTokenURL, nil) if err != nil { return "", err } @@ -410,25 +425,29 @@ func (cs *awsCredentialSource) getAWSSessionToken() (string, error) { } if resp.StatusCode != 200 { - return "", fmt.Errorf("oauth2/google: unable to retrieve AWS session token - %s", string(respBody)) + return "", fmt.Errorf("oauth2/google/externalaccount: unable to retrieve AWS session token - %s", string(respBody)) } return string(respBody), nil } func (cs *awsCredentialSource) getRegion(headers map[string]string) (string, error) { + if cs.awsSecurityCredentialsSupplier != nil { + return cs.awsSecurityCredentialsSupplier.AwsRegion(cs.ctx, cs.supplierOptions) + } if canRetrieveRegionFromEnvironment() { if envAwsRegion := getenv(awsRegion); envAwsRegion != "" { + cs.region = envAwsRegion return envAwsRegion, nil } return getenv("AWS_DEFAULT_REGION"), nil } - if cs.RegionURL == "" { - return "", errors.New("oauth2/google: unable to determine AWS region") + if cs.regionURL == "" { + return "", errors.New("oauth2/google/externalaccount: unable to determine AWS region") } - req, err := http.NewRequest("GET", cs.RegionURL, nil) + req, err := http.NewRequest("GET", cs.regionURL, nil) if err != nil { return "", err } @@ -449,7 +468,7 @@ func (cs *awsCredentialSource) getRegion(headers map[string]string) (string, err } if resp.StatusCode != 200 { - return "", fmt.Errorf("oauth2/google: unable to retrieve AWS region - %s", string(respBody)) + return "", fmt.Errorf("oauth2/google/externalaccount: unable to retrieve AWS region - %s", string(respBody)) } // This endpoint will return the region in format: us-east-2b. @@ -461,12 +480,15 @@ func (cs *awsCredentialSource) getRegion(headers map[string]string) (string, err return string(respBody[:respBodyEnd]), nil } -func (cs *awsCredentialSource) getSecurityCredentials(headers map[string]string) (result awsSecurityCredentials, err error) { +func (cs *awsCredentialSource) getSecurityCredentials(headers map[string]string) (result *AwsSecurityCredentials, err error) { + if cs.awsSecurityCredentialsSupplier != nil { + return cs.awsSecurityCredentialsSupplier.AwsSecurityCredentials(cs.ctx, cs.supplierOptions) + } if canRetrieveSecurityCredentialFromEnvironment() { - return awsSecurityCredentials{ + return &AwsSecurityCredentials{ AccessKeyID: getenv(awsAccessKeyId), SecretAccessKey: getenv(awsSecretAccessKey), - SecurityToken: getenv(awsSessionToken), + SessionToken: getenv(awsSessionToken), }, nil } @@ -481,24 +503,23 @@ func (cs *awsCredentialSource) getSecurityCredentials(headers map[string]string) } if credentials.AccessKeyID == "" { - return result, errors.New("oauth2/google: missing AccessKeyId credential") + return result, errors.New("oauth2/google/externalaccount: missing AccessKeyId credential") } if credentials.SecretAccessKey == "" { - return result, errors.New("oauth2/google: missing SecretAccessKey credential") + return result, errors.New("oauth2/google/externalaccount: missing SecretAccessKey credential") } - return credentials, nil + return &credentials, nil } -func (cs *awsCredentialSource) getMetadataSecurityCredentials(roleName string, headers map[string]string) (awsSecurityCredentials, error) { - var result awsSecurityCredentials +func (cs *awsCredentialSource) getMetadataSecurityCredentials(roleName string, headers map[string]string) (AwsSecurityCredentials, error) { + var result AwsSecurityCredentials - req, err := http.NewRequest("GET", fmt.Sprintf("%s/%s", cs.CredVerificationURL, roleName), nil) + req, err := http.NewRequest("GET", fmt.Sprintf("%s/%s", cs.credVerificationURL, roleName), nil) if err != nil { return result, err } - req.Header.Add("Content-Type", "application/json") for name, value := range headers { req.Header.Add(name, value) @@ -516,7 +537,7 @@ func (cs *awsCredentialSource) getMetadataSecurityCredentials(roleName string, h } if resp.StatusCode != 200 { - return result, fmt.Errorf("oauth2/google: unable to retrieve AWS security credentials - %s", string(respBody)) + return result, fmt.Errorf("oauth2/google/externalaccount: unable to retrieve AWS security credentials - %s", string(respBody)) } err = json.Unmarshal(respBody, &result) @@ -524,11 +545,11 @@ func (cs *awsCredentialSource) getMetadataSecurityCredentials(roleName string, h } func (cs *awsCredentialSource) getMetadataRoleName(headers map[string]string) (string, error) { - if cs.CredVerificationURL == "" { - return "", errors.New("oauth2/google: unable to determine the AWS metadata server security credentials endpoint") + if cs.credVerificationURL == "" { + return "", errors.New("oauth2/google/externalaccount: unable to determine the AWS metadata server security credentials endpoint") } - req, err := http.NewRequest("GET", cs.CredVerificationURL, nil) + req, err := http.NewRequest("GET", cs.credVerificationURL, nil) if err != nil { return "", err } @@ -549,7 +570,7 @@ func (cs *awsCredentialSource) getMetadataRoleName(headers map[string]string) (s } if resp.StatusCode != 200 { - return "", fmt.Errorf("oauth2/google: unable to retrieve AWS role name - %s", string(respBody)) + return "", fmt.Errorf("oauth2/google/externalaccount: unable to retrieve AWS role name - %s", string(respBody)) } return string(respBody), nil diff --git a/google/internal/externalaccount/aws_test.go b/google/externalaccount/aws_test.go similarity index 84% rename from google/internal/externalaccount/aws_test.go rename to google/externalaccount/aws_test.go index 28dc5284b..4a2261bd8 100644 --- a/google/internal/externalaccount/aws_test.go +++ b/google/externalaccount/aws_test.go @@ -7,6 +7,7 @@ package externalaccount import ( "context" "encoding/json" + "errors" "fmt" "net/http" "net/http/httptest" @@ -36,7 +37,7 @@ func setEnvironment(env map[string]string) func(string) string { var defaultRequestSigner = &awsRequestSigner{ RegionName: "us-east-1", - AwsSecurityCredentials: awsSecurityCredentials{ + AwsSecurityCredentials: &AwsSecurityCredentials{ AccessKeyID: "AKIDEXAMPLE", SecretAccessKey: "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY", }, @@ -50,10 +51,10 @@ const ( var requestSignerWithToken = &awsRequestSigner{ RegionName: "us-east-2", - AwsSecurityCredentials: awsSecurityCredentials{ + AwsSecurityCredentials: &AwsSecurityCredentials{ AccessKeyID: accessKeyID, SecretAccessKey: secretAccessKey, - SecurityToken: securityToken, + SessionToken: securityToken, }, } @@ -388,7 +389,7 @@ func TestAWSv4Signature_PostRequestWithSecurityTokenAndAdditionalHeaders(t *test func TestAWSv4Signature_PostRequestWithAmzDateButNoSecurityToken(t *testing.T) { var requestSigner = &awsRequestSigner{ RegionName: "us-east-2", - AwsSecurityCredentials: awsSecurityCredentials{ + AwsSecurityCredentials: &AwsSecurityCredentials{ AccessKeyID: accessKeyID, SecretAccessKey: secretAccessKey, }, @@ -526,8 +527,8 @@ func notFound(w http.ResponseWriter, r *http.Request) { func noHeaderValidation(r *http.Request) {} -func (server *testAwsServer) getCredentialSource(url string) CredentialSource { - return CredentialSource{ +func (server *testAwsServer) getCredentialSource(url string) *CredentialSource { + return &CredentialSource{ EnvironmentID: "aws1", URL: url + server.url, RegionURL: url + server.regionURL, @@ -541,10 +542,10 @@ func getExpectedSubjectToken(url, region, accessKeyID, secretAccessKey, security req.Header.Add("x-goog-cloud-target-resource", testFileConfig.Audience) signer := &awsRequestSigner{ RegionName: region, - AwsSecurityCredentials: awsSecurityCredentials{ + AwsSecurityCredentials: &AwsSecurityCredentials{ AccessKeyID: accessKeyID, SecretAccessKey: secretAccessKey, - SecurityToken: securityToken, + SessionToken: securityToken, }, } signer.SignRequest(req) @@ -588,7 +589,6 @@ func TestAWSCredential_BasicRequest(t *testing.T) { tfc := testFileConfig tfc.CredentialSource = server.getCredentialSource(ts.URL) - oldGetenv := getenv oldNow := now defer func() { @@ -846,7 +846,7 @@ func TestAWSCredential_RequestWithBadVersion(t *testing.T) { if err == nil { t.Fatalf("parse() should have failed") } - if got, want := err.Error(), "oauth2/google: aws version '3' is not supported in the current build"; !reflect.DeepEqual(got, want) { + if got, want := err.Error(), "oauth2/google/externalaccount: aws version '3' is not supported in the current build"; !reflect.DeepEqual(got, want) { t.Errorf("subjectToken = %q, want %q", got, want) } } @@ -875,7 +875,7 @@ func TestAWSCredential_RequestWithNoRegionURL(t *testing.T) { t.Fatalf("retrieveSubjectToken() should have failed") } - if got, want := err.Error(), "oauth2/google: unable to determine AWS region"; !reflect.DeepEqual(got, want) { + if got, want := err.Error(), "oauth2/google/externalaccount: unable to determine AWS region"; !reflect.DeepEqual(got, want) { t.Errorf("subjectToken = %q, want %q", got, want) } } @@ -905,7 +905,7 @@ func TestAWSCredential_RequestWithBadRegionURL(t *testing.T) { t.Fatalf("retrieveSubjectToken() should have failed") } - if got, want := err.Error(), "oauth2/google: unable to retrieve AWS region - Not Found"; !reflect.DeepEqual(got, want) { + if got, want := err.Error(), "oauth2/google/externalaccount: unable to retrieve AWS region - Not Found"; !reflect.DeepEqual(got, want) { t.Errorf("subjectToken = %q, want %q", got, want) } } @@ -937,7 +937,7 @@ func TestAWSCredential_RequestWithMissingCredential(t *testing.T) { t.Fatalf("retrieveSubjectToken() should have failed") } - if got, want := err.Error(), "oauth2/google: missing AccessKeyId credential"; !reflect.DeepEqual(got, want) { + if got, want := err.Error(), "oauth2/google/externalaccount: missing AccessKeyId credential"; !reflect.DeepEqual(got, want) { t.Errorf("subjectToken = %q, want %q", got, want) } } @@ -969,7 +969,7 @@ func TestAWSCredential_RequestWithIncompleteCredential(t *testing.T) { t.Fatalf("retrieveSubjectToken() should have failed") } - if got, want := err.Error(), "oauth2/google: missing SecretAccessKey credential"; !reflect.DeepEqual(got, want) { + if got, want := err.Error(), "oauth2/google/externalaccount: missing SecretAccessKey credential"; !reflect.DeepEqual(got, want) { t.Errorf("subjectToken = %q, want %q", got, want) } } @@ -998,7 +998,7 @@ func TestAWSCredential_RequestWithNoCredentialURL(t *testing.T) { t.Fatalf("retrieveSubjectToken() should have failed") } - if got, want := err.Error(), "oauth2/google: unable to determine the AWS metadata server security credentials endpoint"; !reflect.DeepEqual(got, want) { + if got, want := err.Error(), "oauth2/google/externalaccount: unable to determine the AWS metadata server security credentials endpoint"; !reflect.DeepEqual(got, want) { t.Errorf("subjectToken = %q, want %q", got, want) } } @@ -1027,7 +1027,7 @@ func TestAWSCredential_RequestWithBadCredentialURL(t *testing.T) { t.Fatalf("retrieveSubjectToken() should have failed") } - if got, want := err.Error(), "oauth2/google: unable to retrieve AWS role name - Not Found"; !reflect.DeepEqual(got, want) { + if got, want := err.Error(), "oauth2/google/externalaccount: unable to retrieve AWS role name - Not Found"; !reflect.DeepEqual(got, want) { t.Errorf("subjectToken = %q, want %q", got, want) } } @@ -1056,7 +1056,7 @@ func TestAWSCredential_RequestWithBadFinalCredentialURL(t *testing.T) { t.Fatalf("retrieveSubjectToken() should have failed") } - if got, want := err.Error(), "oauth2/google: unable to retrieve AWS security credentials - Not Found"; !reflect.DeepEqual(got, want) { + if got, want := err.Error(), "oauth2/google/externalaccount: unable to retrieve AWS security credentials - Not Found"; !reflect.DeepEqual(got, want) { t.Errorf("subjectToken = %q, want %q", got, want) } } @@ -1235,6 +1235,192 @@ func TestAWSCredential_ShouldCallMetadataEndpointWhenNoSecretAccessKey(t *testin } } +func TestAWSCredential_ProgrammaticAuth(t *testing.T) { + tfc := testFileConfig + securityCredentials := AwsSecurityCredentials{ + AccessKeyID: accessKeyID, + SecretAccessKey: secretAccessKey, + SessionToken: securityToken, + } + + tfc.AwsSecurityCredentialsSupplier = testAwsSupplier{ + awsRegion: "us-east-2", + err: nil, + credentials: &securityCredentials, + } + + oldNow := now + defer func() { + now = oldNow + }() + now = setTime(defaultTime) + + base, err := tfc.parse(context.Background()) + if err != nil { + t.Fatalf("parse() failed %v", err) + } + + out, err := base.subjectToken() + if err != nil { + t.Fatalf("retrieveSubjectToken() failed: %v", err) + } + + expected := getExpectedSubjectToken( + "https://sts.us-east-2.amazonaws.com?Action=GetCallerIdentity&Version=2011-06-15", + "us-east-2", + accessKeyID, + secretAccessKey, + securityToken, + ) + + if got, want := out, expected; !reflect.DeepEqual(got, want) { + t.Errorf("subjectToken = \n%q\n want \n%q", got, want) + } +} + +func TestAWSCredential_ProgrammaticAuthNoSessionToken(t *testing.T) { + tfc := testFileConfig + securityCredentials := AwsSecurityCredentials{ + AccessKeyID: accessKeyID, + SecretAccessKey: secretAccessKey, + } + + tfc.AwsSecurityCredentialsSupplier = testAwsSupplier{ + awsRegion: "us-east-2", + err: nil, + credentials: &securityCredentials, + } + + oldNow := now + defer func() { + now = oldNow + }() + now = setTime(defaultTime) + + base, err := tfc.parse(context.Background()) + if err != nil { + t.Fatalf("parse() failed %v", err) + } + + out, err := base.subjectToken() + if err != nil { + t.Fatalf("retrieveSubjectToken() failed: %v", err) + } + + expected := getExpectedSubjectToken( + "https://sts.us-east-2.amazonaws.com?Action=GetCallerIdentity&Version=2011-06-15", + "us-east-2", + accessKeyID, + secretAccessKey, + "", + ) + + if got, want := out, expected; !reflect.DeepEqual(got, want) { + t.Errorf("subjectToken = \n%q\n want \n%q", got, want) + } +} + +func TestAWSCredential_ProgrammaticAuthError(t *testing.T) { + tfc := testFileConfig + testErr := errors.New("test error") + tfc.AwsSecurityCredentialsSupplier = testAwsSupplier{ + awsRegion: "us-east-2", + err: testErr, + credentials: nil, + } + + base, err := tfc.parse(context.Background()) + if err != nil { + t.Fatalf("parse() failed %v", err) + } + + _, err = base.subjectToken() + if err == nil { + t.Fatalf("subjectToken() should have failed") + } + if err != testErr { + t.Errorf("error = %e, want %e", err, testErr) + } +} + +func TestAWSCredential_ProgrammaticAuthRegionError(t *testing.T) { + tfc := testFileConfig + securityCredentials := AwsSecurityCredentials{ + AccessKeyID: accessKeyID, + SecretAccessKey: secretAccessKey, + } + + testErr := errors.New("test") + tfc.AwsSecurityCredentialsSupplier = testAwsSupplier{ + awsRegion: "", + regionErr: testErr, + credentials: &securityCredentials, + } + + base, err := tfc.parse(context.Background()) + if err != nil { + t.Fatalf("parse() failed %v", err) + } + + _, err = base.subjectToken() + if err == nil { + t.Fatalf("subjectToken() should have failed") + } + if err != testErr { + t.Errorf("error = %e, want %e", err, testErr) + } +} + +func TestAWSCredential_ProgrammaticAuthOptions(t *testing.T) { + tfc := testFileConfig + securityCredentials := AwsSecurityCredentials{ + AccessKeyID: accessKeyID, + SecretAccessKey: secretAccessKey, + } + expectedOptions := SupplierOptions{Audience: tfc.Audience, SubjectTokenType: tfc.SubjectTokenType} + + tfc.AwsSecurityCredentialsSupplier = testAwsSupplier{ + awsRegion: "us-east-2", + credentials: &securityCredentials, + expectedOptions: &expectedOptions, + } + + base, err := tfc.parse(context.Background()) + if err != nil { + t.Fatalf("parse() failed %v", err) + } + + _, err = base.subjectToken() + if err != nil { + t.Fatalf("subjectToken() failed %v", err) + } +} + +func TestAWSCredential_ProgrammaticAuthContext(t *testing.T) { + tfc := testFileConfig + securityCredentials := AwsSecurityCredentials{ + AccessKeyID: accessKeyID, + SecretAccessKey: secretAccessKey, + } + ctx := context.Background() + + tfc.AwsSecurityCredentialsSupplier = testAwsSupplier{ + awsRegion: "us-east-2", + credentials: &securityCredentials, + expectedContext: ctx, + } + + base, err := tfc.parse(ctx) + if err != nil { + t.Fatalf("parse() failed %v", err) + } + + _, err = base.subjectToken() + if err != nil { + t.Fatalf("subjectToken() failed %v", err) + } +} + func TestAwsCredential_CredentialSourceType(t *testing.T) { server := createDefaultAwsTestServer() ts := httptest.NewServer(server) @@ -1251,3 +1437,52 @@ func TestAwsCredential_CredentialSourceType(t *testing.T) { t.Errorf("got %v but want %v", got, want) } } + +type testAwsSupplier struct { + err error + regionErr error + awsRegion string + credentials *AwsSecurityCredentials + expectedOptions *SupplierOptions + expectedContext context.Context +} + +func (supp testAwsSupplier) AwsRegion(ctx context.Context, options SupplierOptions) (string, error) { + if supp.regionErr != nil { + return "", supp.regionErr + } + if supp.expectedOptions != nil { + if supp.expectedOptions.Audience != options.Audience { + return "", errors.New("Audience does not match") + } + if supp.expectedOptions.SubjectTokenType != options.SubjectTokenType { + return "", errors.New("Audience does not match") + } + } + if supp.expectedContext != nil { + if supp.expectedContext != ctx { + return "", errors.New("Context does not match") + } + } + return supp.awsRegion, nil +} + +func (supp testAwsSupplier) AwsSecurityCredentials(ctx context.Context, options SupplierOptions) (*AwsSecurityCredentials, error) { + if supp.err != nil { + return nil, supp.err + } + if supp.expectedOptions != nil { + if supp.expectedOptions.Audience != options.Audience { + return nil, errors.New("Audience does not match") + } + if supp.expectedOptions.SubjectTokenType != options.SubjectTokenType { + return nil, errors.New("Audience does not match") + } + } + if supp.expectedContext != nil { + if supp.expectedContext != ctx { + return nil, errors.New("Context does not match") + } + } + return supp.credentials, nil +} diff --git a/google/externalaccount/basecredentials.go b/google/externalaccount/basecredentials.go new file mode 100644 index 000000000..6c81a6872 --- /dev/null +++ b/google/externalaccount/basecredentials.go @@ -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 +} diff --git a/google/externalaccount/basecredentials_test.go b/google/externalaccount/basecredentials_test.go new file mode 100644 index 000000000..8f165cdb0 --- /dev/null +++ b/google/externalaccount/basecredentials_test.go @@ -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) + } + } +} diff --git a/google/internal/externalaccount/executablecredsource.go b/google/externalaccount/executablecredsource.go similarity index 84% rename from google/internal/externalaccount/executablecredsource.go rename to google/externalaccount/executablecredsource.go index 6497dc022..dca5681a4 100644 --- a/google/internal/externalaccount/executablecredsource.go +++ b/google/externalaccount/executablecredsource.go @@ -19,7 +19,7 @@ import ( "time" ) -var serviceAccountImpersonationRE = regexp.MustCompile("https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/(.*@.*):generateAccessToken") +var serviceAccountImpersonationRE = regexp.MustCompile("https://iamcredentials\\..+/v1/projects/-/serviceAccounts/(.*@.*):generateAccessToken") const ( executableSupportedMaxVersion = 1 @@ -39,51 +39,51 @@ func (nce nonCacheableError) Error() string { } func missingFieldError(source, field string) error { - return fmt.Errorf("oauth2/google: %v missing `%q` field", source, field) + return fmt.Errorf("oauth2/google/externalaccount: %v missing `%q` field", source, field) } func jsonParsingError(source, data string) error { - return fmt.Errorf("oauth2/google: unable to parse %v\nResponse: %v", source, data) + return fmt.Errorf("oauth2/google/externalaccount: unable to parse %v\nResponse: %v", source, data) } func malformedFailureError() error { - return nonCacheableError{"oauth2/google: response must include `error` and `message` fields when unsuccessful"} + return nonCacheableError{"oauth2/google/externalaccount: response must include `error` and `message` fields when unsuccessful"} } func userDefinedError(code, message string) error { - return nonCacheableError{fmt.Sprintf("oauth2/google: response contains unsuccessful response: (%v) %v", code, message)} + return nonCacheableError{fmt.Sprintf("oauth2/google/externalaccount: response contains unsuccessful response: (%v) %v", code, message)} } func unsupportedVersionError(source string, version int) error { - return fmt.Errorf("oauth2/google: %v contains unsupported version: %v", source, version) + return fmt.Errorf("oauth2/google/externalaccount: %v contains unsupported version: %v", source, version) } func tokenExpiredError() error { - return nonCacheableError{"oauth2/google: the token returned by the executable is expired"} + return nonCacheableError{"oauth2/google/externalaccount: the token returned by the executable is expired"} } func tokenTypeError(source string) error { - return fmt.Errorf("oauth2/google: %v contains unsupported token type", source) + return fmt.Errorf("oauth2/google/externalaccount: %v contains unsupported token type", source) } func exitCodeError(exitCode int) error { - return fmt.Errorf("oauth2/google: executable command failed with exit code %v", exitCode) + return fmt.Errorf("oauth2/google/externalaccount: executable command failed with exit code %v", exitCode) } func executableError(err error) error { - return fmt.Errorf("oauth2/google: executable command failed: %v", err) + return fmt.Errorf("oauth2/google/externalaccount: executable command failed: %v", err) } func executablesDisallowedError() error { - return errors.New("oauth2/google: executables need to be explicitly allowed (set GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES to '1') to run") + return errors.New("oauth2/google/externalaccount: executables need to be explicitly allowed (set GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES to '1') to run") } func timeoutRangeError() error { - return errors.New("oauth2/google: invalid `timeout_millis` field — executable timeout must be between 5 and 120 seconds") + return errors.New("oauth2/google/externalaccount: invalid `timeout_millis` field — executable timeout must be between 5 and 120 seconds") } func commandMissingError() error { - return errors.New("oauth2/google: missing `command` field — executable command must be provided") + return errors.New("oauth2/google/externalaccount: missing `command` field — executable command must be provided") } type environment interface { @@ -146,7 +146,7 @@ type executableCredentialSource struct { // CreateExecutableCredential creates an executableCredentialSource given an ExecutableConfig. // It also performs defaulting and type conversions. -func CreateExecutableCredential(ctx context.Context, ec *ExecutableConfig, config *Config) (executableCredentialSource, error) { +func createExecutableCredential(ctx context.Context, ec *ExecutableConfig, config *Config) (executableCredentialSource, error) { if ec.Command == "" { return executableCredentialSource{}, commandMissingError() } diff --git a/google/internal/externalaccount/executablecredsource_test.go b/google/externalaccount/executablecredsource_test.go similarity index 93% rename from google/internal/externalaccount/executablecredsource_test.go rename to google/externalaccount/executablecredsource_test.go index df8a906b9..69ec21ae1 100644 --- a/google/internal/externalaccount/executablecredsource_test.go +++ b/google/externalaccount/executablecredsource_test.go @@ -128,7 +128,7 @@ var creationTests = []struct { func TestCreateExecutableCredential(t *testing.T) { for _, tt := range creationTests { t.Run(tt.name, func(t *testing.T) { - ecs, err := CreateExecutableCredential(context.Background(), &tt.executableConfig, nil) + ecs, err := createExecutableCredential(context.Background(), &tt.executableConfig, nil) if tt.expectedErr != nil { if err == nil { t.Fatalf("Expected error but found none") @@ -169,7 +169,7 @@ var getEnvironmentTests = []struct { config: Config{ Audience: "//iam.googleapis.com/projects/123/locations/global/workloadIdentityPools/pool/providers/oidc", SubjectTokenType: "urn:ietf:params:oauth:token-type:jwt", - CredentialSource: CredentialSource{ + CredentialSource: &CredentialSource{ Executable: &ExecutableConfig{ Command: "blarg", }, @@ -193,7 +193,7 @@ var getEnvironmentTests = []struct { Audience: "//iam.googleapis.com/projects/123/locations/global/workloadIdentityPools/pool/providers/oidc", ServiceAccountImpersonationURL: "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/test@project.iam.gserviceaccount.com:generateAccessToken", SubjectTokenType: "urn:ietf:params:oauth:token-type:jwt", - CredentialSource: CredentialSource{ + CredentialSource: &CredentialSource{ Executable: &ExecutableConfig{ Command: "blarg", OutputFile: "/path/to/generated/cached/credentials", @@ -220,7 +220,7 @@ var getEnvironmentTests = []struct { Audience: "//iam.googleapis.com/projects/123/locations/global/workloadIdentityPools/pool/providers/oidc", ServiceAccountImpersonationURL: "test@project.iam.gserviceaccount.com", SubjectTokenType: "urn:ietf:params:oauth:token-type:jwt", - CredentialSource: CredentialSource{ + CredentialSource: &CredentialSource{ Executable: &ExecutableConfig{ Command: "blarg", OutputFile: "/path/to/generated/cached/credentials", @@ -247,7 +247,7 @@ func TestExecutableCredentialGetEnvironment(t *testing.T) { t.Run(tt.name, func(t *testing.T) { config := tt.config - ecs, err := CreateExecutableCredential(context.Background(), config.CredentialSource.Executable, &config) + ecs, err := createExecutableCredential(context.Background(), config.CredentialSource.Executable, &config) if err != nil { t.Fatalf("creation failed %v", err) } @@ -471,7 +471,7 @@ func TestRetrieveExecutableSubjectTokenExecutableErrors(t *testing.T) { } tfc := testFileConfig - tfc.CredentialSource = cs + tfc.CredentialSource = &cs base, err := tfc.parse(context.Background()) if err != nil { @@ -578,7 +578,7 @@ func TestRetrieveExecutableSubjectTokenSuccesses(t *testing.T) { } tfc := testFileConfig - tfc.CredentialSource = cs + tfc.CredentialSource = &cs base, err := tfc.parse(context.Background()) if err != nil { @@ -629,7 +629,7 @@ func TestRetrieveOutputFileSubjectTokenNotJSON(t *testing.T) { } tfc := testFileConfig - tfc.CredentialSource = cs + tfc.CredentialSource = &cs base, err := tfc.parse(context.Background()) if err != nil { @@ -778,7 +778,7 @@ func TestRetrieveOutputFileSubjectTokenFailureTests(t *testing.T) { } tfc := testFileConfig - tfc.CredentialSource = cs + tfc.CredentialSource = &cs base, err := tfc.parse(context.Background()) if err != nil { @@ -881,7 +881,7 @@ func TestRetrieveOutputFileSubjectTokenInvalidCache(t *testing.T) { } tfc := testFileConfig - tfc.CredentialSource = cs + tfc.CredentialSource = &cs base, err := tfc.parse(context.Background()) if err != nil { @@ -986,7 +986,7 @@ func TestRetrieveOutputFileSubjectTokenJwt(t *testing.T) { } tfc := testFileConfig - tfc.CredentialSource = cs + tfc.CredentialSource = &cs base, err := tfc.parse(context.Background()) if err != nil { @@ -1021,3 +1021,37 @@ func TestRetrieveOutputFileSubjectTokenJwt(t *testing.T) { }) } } + +func TestServiceAccountImpersonationRE(t *testing.T) { + tests := []struct { + name string + serviceAccountImpersonationURL string + want string + }{ + { + name: "universe domain Google Default Universe (GDU) googleapis.com", + serviceAccountImpersonationURL: "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/test@project.iam.gserviceaccount.com:generateAccessToken", + want: "test@project.iam.gserviceaccount.com", + }, + { + name: "email does not match", + serviceAccountImpersonationURL: "test@project.iam.gserviceaccount.com", + want: "", + }, + { + name: "universe domain non-GDU", + serviceAccountImpersonationURL: "https://iamcredentials.apis-tpclp.goog/v1/projects/-/serviceAccounts/test@project.iam.gserviceaccount.com:generateAccessToken", + want: "test@project.iam.gserviceaccount.com", + }, + } + for _, tt := range tests { + matches := serviceAccountImpersonationRE.FindStringSubmatch(tt.serviceAccountImpersonationURL) + if matches == nil { + if tt.want != "" { + t.Errorf("%q: got nil, want %q", tt.name, tt.want) + } + } else if matches[1] != tt.want { + t.Errorf("%q: got %q, want %q", tt.name, matches[1], tt.want) + } + } +} diff --git a/google/internal/externalaccount/filecredsource.go b/google/externalaccount/filecredsource.go similarity index 62% rename from google/internal/externalaccount/filecredsource.go rename to google/externalaccount/filecredsource.go index f35f73c5c..33766b972 100644 --- a/google/internal/externalaccount/filecredsource.go +++ b/google/externalaccount/filecredsource.go @@ -16,7 +16,7 @@ import ( type fileCredentialSource struct { File string - Format format + Format Format } func (cs fileCredentialSource) credentialSourceType() string { @@ -26,12 +26,12 @@ func (cs fileCredentialSource) credentialSourceType() string { func (cs fileCredentialSource) subjectToken() (string, error) { tokenFile, err := os.Open(cs.File) if err != nil { - return "", fmt.Errorf("oauth2/google: failed to open credential file %q", cs.File) + return "", fmt.Errorf("oauth2/google/externalaccount: failed to open credential file %q", cs.File) } defer tokenFile.Close() tokenBytes, err := ioutil.ReadAll(io.LimitReader(tokenFile, 1<<20)) if err != nil { - return "", fmt.Errorf("oauth2/google: failed to read credential file: %v", err) + return "", fmt.Errorf("oauth2/google/externalaccount: failed to read credential file: %v", err) } tokenBytes = bytes.TrimSpace(tokenBytes) switch cs.Format.Type { @@ -39,15 +39,15 @@ func (cs fileCredentialSource) subjectToken() (string, error) { jsonData := make(map[string]interface{}) err = json.Unmarshal(tokenBytes, &jsonData) if err != nil { - return "", fmt.Errorf("oauth2/google: failed to unmarshal subject token file: %v", err) + return "", fmt.Errorf("oauth2/google/externalaccount: failed to unmarshal subject token file: %v", err) } val, ok := jsonData[cs.Format.SubjectTokenFieldName] if !ok { - return "", errors.New("oauth2/google: provided subject_token_field_name not found in credentials") + return "", errors.New("oauth2/google/externalaccount: provided subject_token_field_name not found in credentials") } token, ok := val.(string) if !ok { - return "", errors.New("oauth2/google: improperly formatted subject token") + return "", errors.New("oauth2/google/externalaccount: improperly formatted subject token") } return token, nil case "text": @@ -55,7 +55,7 @@ func (cs fileCredentialSource) subjectToken() (string, error) { case "": return string(tokenBytes), nil default: - return "", errors.New("oauth2/google: invalid credential_source file format type") + return "", errors.New("oauth2/google/externalaccount: invalid credential_source file format type") } } diff --git a/google/internal/externalaccount/filecredsource_test.go b/google/externalaccount/filecredsource_test.go similarity index 93% rename from google/internal/externalaccount/filecredsource_test.go rename to google/externalaccount/filecredsource_test.go index c20700f1d..dc561bd90 100644 --- a/google/internal/externalaccount/filecredsource_test.go +++ b/google/externalaccount/filecredsource_test.go @@ -36,7 +36,7 @@ func TestRetrieveFileSubjectToken(t *testing.T) { name: "TextFileSource", cs: CredentialSource{ File: textBaseCredPath, - Format: format{Type: fileTypeText}, + Format: Format{Type: fileTypeText}, }, want: "street123", }, @@ -44,7 +44,7 @@ func TestRetrieveFileSubjectToken(t *testing.T) { name: "JSONFileSource", cs: CredentialSource{ File: jsonBaseCredPath, - Format: format{Type: fileTypeJSON, SubjectTokenFieldName: "SubjToken"}, + Format: Format{Type: fileTypeJSON, SubjectTokenFieldName: "SubjToken"}, }, want: "321road", }, @@ -53,7 +53,7 @@ func TestRetrieveFileSubjectToken(t *testing.T) { for _, test := range fileSourceTests { test := test tfc := testFileConfig - tfc.CredentialSource = test.cs + tfc.CredentialSource = &test.cs t.Run(test.name, func(t *testing.T) { base, err := tfc.parse(context.Background()) diff --git a/google/internal/externalaccount/header.go b/google/externalaccount/header.go similarity index 100% rename from google/internal/externalaccount/header.go rename to google/externalaccount/header.go diff --git a/google/internal/externalaccount/header_test.go b/google/externalaccount/header_test.go similarity index 100% rename from google/internal/externalaccount/header_test.go rename to google/externalaccount/header_test.go diff --git a/google/externalaccount/programmaticrefreshcredsource.go b/google/externalaccount/programmaticrefreshcredsource.go new file mode 100644 index 000000000..6c1abdf2d --- /dev/null +++ b/google/externalaccount/programmaticrefreshcredsource.go @@ -0,0 +1,21 @@ +// Copyright 2024 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package externalaccount + +import "context" + +type programmaticRefreshCredentialSource struct { + supplierOptions SupplierOptions + subjectTokenSupplier SubjectTokenSupplier + ctx context.Context +} + +func (cs programmaticRefreshCredentialSource) credentialSourceType() string { + return "programmatic" +} + +func (cs programmaticRefreshCredentialSource) subjectToken() (string, error) { + return cs.subjectTokenSupplier.SubjectToken(cs.ctx, cs.supplierOptions) +} diff --git a/google/externalaccount/programmaticrefreshcredsource_test.go b/google/externalaccount/programmaticrefreshcredsource_test.go new file mode 100644 index 000000000..7ec16c730 --- /dev/null +++ b/google/externalaccount/programmaticrefreshcredsource_test.go @@ -0,0 +1,122 @@ +// Copyright 2024 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package externalaccount + +import ( + "context" + "errors" + "testing" +) + +func TestRetrieveSubjectToken_ProgrammaticAuth(t *testing.T) { + tfc := testConfig + + tfc.SubjectTokenSupplier = testSubjectTokenSupplier{ + subjectToken: "subjectToken", + } + + base, err := tfc.parse(context.Background()) + if err != nil { + t.Fatalf("parse() failed %v", err) + } + + out, err := base.subjectToken() + if err != nil { + t.Fatalf("retrieveSubjectToken() failed: %v", err) + } + + if out != "subjectToken" { + t.Errorf("subjectToken = \n%q\n want \nSubjectToken", out) + } +} + +func TestRetrieveSubjectToken_ProgrammaticAuthFails(t *testing.T) { + tfc := testConfig + testError := errors.New("test error") + + tfc.SubjectTokenSupplier = testSubjectTokenSupplier{ + err: testError, + } + + base, err := tfc.parse(context.Background()) + if err != nil { + t.Fatalf("parse() failed %v", err) + } + + _, err = base.subjectToken() + if err == nil { + t.Fatalf("subjectToken() should have failed") + } + if testError != err { + t.Errorf("subjectToken = %e, want %e", err, testError) + } +} + +func TestRetrieveSubjectToken_ProgrammaticAuthOptions(t *testing.T) { + tfc := testConfig + expectedOptions := SupplierOptions{Audience: tfc.Audience, SubjectTokenType: tfc.SubjectTokenType} + + tfc.SubjectTokenSupplier = testSubjectTokenSupplier{ + subjectToken: "subjectToken", + expectedOptions: &expectedOptions, + } + + base, err := tfc.parse(context.Background()) + if err != nil { + t.Fatalf("parse() failed %v", err) + } + + _, err = base.subjectToken() + if err != nil { + t.Fatalf("retrieveSubjectToken() failed: %v", err) + } +} + +func TestRetrieveSubjectToken_ProgrammaticAuthContext(t *testing.T) { + tfc := testConfig + ctx := context.Background() + + tfc.SubjectTokenSupplier = testSubjectTokenSupplier{ + subjectToken: "subjectToken", + expectedContext: ctx, + } + + base, err := tfc.parse(ctx) + if err != nil { + t.Fatalf("parse() failed %v", err) + } + + _, err = base.subjectToken() + if err != nil { + t.Fatalf("retrieveSubjectToken() failed: %v", err) + } +} + +type testSubjectTokenSupplier struct { + err error + subjectToken string + expectedOptions *SupplierOptions + expectedContext context.Context +} + +func (supp testSubjectTokenSupplier) SubjectToken(ctx context.Context, options SupplierOptions) (string, error) { + if supp.err != nil { + return "", supp.err + } + if supp.expectedOptions != nil { + if supp.expectedOptions.Audience != options.Audience { + return "", errors.New("Audience does not match") + } + if supp.expectedOptions.SubjectTokenType != options.SubjectTokenType { + return "", errors.New("Audience does not match") + } + } + if supp.expectedContext != nil { + if supp.expectedContext != ctx { + return "", errors.New("Context does not match") + } + } + return supp.subjectToken, nil +} diff --git a/google/internal/externalaccount/testdata/3pi_cred.json b/google/externalaccount/testdata/3pi_cred.json similarity index 100% rename from google/internal/externalaccount/testdata/3pi_cred.json rename to google/externalaccount/testdata/3pi_cred.json diff --git a/google/internal/externalaccount/testdata/3pi_cred.txt b/google/externalaccount/testdata/3pi_cred.txt similarity index 100% rename from google/internal/externalaccount/testdata/3pi_cred.txt rename to google/externalaccount/testdata/3pi_cred.txt diff --git a/google/internal/externalaccount/urlcredsource.go b/google/externalaccount/urlcredsource.go similarity index 61% rename from google/internal/externalaccount/urlcredsource.go rename to google/externalaccount/urlcredsource.go index 606bb4e80..71a7184e0 100644 --- a/google/internal/externalaccount/urlcredsource.go +++ b/google/externalaccount/urlcredsource.go @@ -19,7 +19,7 @@ import ( type urlCredentialSource struct { URL string Headers map[string]string - Format format + Format Format ctx context.Context } @@ -31,7 +31,7 @@ func (cs urlCredentialSource) subjectToken() (string, error) { client := oauth2.NewClient(cs.ctx, nil) req, err := http.NewRequest("GET", cs.URL, nil) if err != nil { - return "", fmt.Errorf("oauth2/google: HTTP request for URL-sourced credential failed: %v", err) + return "", fmt.Errorf("oauth2/google/externalaccount: HTTP request for URL-sourced credential failed: %v", err) } req = req.WithContext(cs.ctx) @@ -40,16 +40,16 @@ func (cs urlCredentialSource) subjectToken() (string, error) { } resp, err := client.Do(req) if err != nil { - return "", fmt.Errorf("oauth2/google: invalid response when retrieving subject token: %v", err) + return "", fmt.Errorf("oauth2/google/externalaccount: invalid response when retrieving subject token: %v", err) } defer resp.Body.Close() respBody, err := ioutil.ReadAll(io.LimitReader(resp.Body, 1<<20)) if err != nil { - return "", fmt.Errorf("oauth2/google: invalid body in subject token URL query: %v", err) + return "", fmt.Errorf("oauth2/google/externalaccount: invalid body in subject token URL query: %v", err) } if c := resp.StatusCode; c < 200 || c > 299 { - return "", fmt.Errorf("oauth2/google: status code %d: %s", c, respBody) + return "", fmt.Errorf("oauth2/google/externalaccount: status code %d: %s", c, respBody) } switch cs.Format.Type { @@ -57,15 +57,15 @@ func (cs urlCredentialSource) subjectToken() (string, error) { jsonData := make(map[string]interface{}) err = json.Unmarshal(respBody, &jsonData) if err != nil { - return "", fmt.Errorf("oauth2/google: failed to unmarshal subject token file: %v", err) + return "", fmt.Errorf("oauth2/google/externalaccount: failed to unmarshal subject token file: %v", err) } val, ok := jsonData[cs.Format.SubjectTokenFieldName] if !ok { - return "", errors.New("oauth2/google: provided subject_token_field_name not found in credentials") + return "", errors.New("oauth2/google/externalaccount: provided subject_token_field_name not found in credentials") } token, ok := val.(string) if !ok { - return "", errors.New("oauth2/google: improperly formatted subject token") + return "", errors.New("oauth2/google/externalaccount: improperly formatted subject token") } return token, nil case "text": @@ -73,7 +73,7 @@ func (cs urlCredentialSource) subjectToken() (string, error) { case "": return string(respBody), nil default: - return "", errors.New("oauth2/google: invalid credential_source file format type") + return "", errors.New("oauth2/google/externalaccount: invalid credential_source file format type") } } diff --git a/google/internal/externalaccount/urlcredsource_test.go b/google/externalaccount/urlcredsource_test.go similarity index 92% rename from google/internal/externalaccount/urlcredsource_test.go rename to google/externalaccount/urlcredsource_test.go index 699f7729e..4968db4dd 100644 --- a/google/internal/externalaccount/urlcredsource_test.go +++ b/google/externalaccount/urlcredsource_test.go @@ -28,11 +28,11 @@ func TestRetrieveURLSubjectToken_Text(t *testing.T) { heads["Metadata"] = "True" cs := CredentialSource{ URL: ts.URL, - Format: format{Type: fileTypeText}, + Format: Format{Type: fileTypeText}, Headers: heads, } tfc := testFileConfig - tfc.CredentialSource = cs + tfc.CredentialSource = &cs base, err := tfc.parse(context.Background()) if err != nil { @@ -60,7 +60,7 @@ func TestRetrieveURLSubjectToken_Untyped(t *testing.T) { URL: ts.URL, } tfc := testFileConfig - tfc.CredentialSource = cs + tfc.CredentialSource = &cs base, err := tfc.parse(context.Background()) if err != nil { @@ -93,10 +93,10 @@ func TestRetrieveURLSubjectToken_JSON(t *testing.T) { })) cs := CredentialSource{ URL: ts.URL, - Format: format{Type: fileTypeJSON, SubjectTokenFieldName: "SubjToken"}, + Format: Format{Type: fileTypeJSON, SubjectTokenFieldName: "SubjToken"}, } tfc := testFileConfig - tfc.CredentialSource = cs + tfc.CredentialSource = &cs base, err := tfc.parse(context.Background()) if err != nil { @@ -115,10 +115,10 @@ func TestRetrieveURLSubjectToken_JSON(t *testing.T) { func TestURLCredential_CredentialSourceType(t *testing.T) { cs := CredentialSource{ URL: "http://example.com", - Format: format{Type: fileTypeText}, + Format: Format{Type: fileTypeText}, } tfc := testFileConfig - tfc.CredentialSource = cs + tfc.CredentialSource = &cs base, err := tfc.parse(context.Background()) if err != nil { diff --git a/google/google.go b/google/google.go index c66c53527..7b82e7a08 100644 --- a/google/google.go +++ b/google/google.go @@ -15,8 +15,9 @@ import ( "cloud.google.com/go/compute/metadata" "golang.org/x/oauth2" - "golang.org/x/oauth2/google/internal/externalaccount" + "golang.org/x/oauth2/google/externalaccount" "golang.org/x/oauth2/google/internal/externalaccountauthorizeduser" + "golang.org/x/oauth2/google/internal/impersonate" "golang.org/x/oauth2/jwt" ) @@ -200,12 +201,12 @@ func (f *credentialsFile) tokenSource(ctx context.Context, params CredentialsPar ServiceAccountImpersonationLifetimeSeconds: f.ServiceAccountImpersonation.TokenLifetimeSeconds, ClientSecret: f.ClientSecret, ClientID: f.ClientID, - CredentialSource: f.CredentialSource, + CredentialSource: &f.CredentialSource, QuotaProjectID: f.QuotaProjectID, Scopes: params.Scopes, WorkforcePoolUserProject: f.WorkforcePoolUserProject, } - return cfg.TokenSource(ctx) + return externalaccount.NewTokenSource(ctx, *cfg) case externalAccountAuthorizedUserKey: cfg := &externalaccountauthorizeduser.Config{ Audience: f.Audience, @@ -228,7 +229,7 @@ func (f *credentialsFile) tokenSource(ctx context.Context, params CredentialsPar if err != nil { return nil, err } - imp := externalaccount.ImpersonateTokenSource{ + imp := impersonate.ImpersonateTokenSource{ Ctx: ctx, URL: f.ServiceAccountImpersonationURL, Scopes: params.Scopes, @@ -251,7 +252,10 @@ func (f *credentialsFile) tokenSource(ctx context.Context, params CredentialsPar // Further information about retrieving access tokens from the GCE metadata // server can be found at https://cloud.google.com/compute/docs/authentication. func ComputeTokenSource(account string, scope ...string) oauth2.TokenSource { - return computeTokenSource(account, 0, scope...) + // refresh 3 minutes and 45 seconds early. The shortest MDS cache is currently 4 minutes, so any + // refreshes earlier are a waste of compute. + earlyExpirySecs := 225 * time.Second + return computeTokenSource(account, earlyExpirySecs, scope...) } func computeTokenSource(account string, earlyExpiry time.Duration, scope ...string) oauth2.TokenSource { diff --git a/google/internal/externalaccount/basecredentials.go b/google/internal/externalaccount/basecredentials.go deleted file mode 100644 index 33288d367..000000000 --- a/google/internal/externalaccount/basecredentials.go +++ /dev/null @@ -1,254 +0,0 @@ -// Copyright 2020 The Go Authors. All rights reserved. -// Use of this source code is governed by a BSD-style -// license that can be found in the LICENSE file. - -package externalaccount - -import ( - "context" - "fmt" - "net/http" - "regexp" - "strconv" - "time" - - "golang.org/x/oauth2" - "golang.org/x/oauth2/google/internal/stsexchange" -) - -// now aliases time.Now for testing -var now = func() time.Time { - return time.Now().UTC() -} - -// Config stores the configuration for fetching tokens with external credentials. -type Config struct { - // Audience is the Secure Token Service (STS) audience which contains the resource name for the workload - // identity pool or the workforce pool and the provider identifier in that pool. - Audience string - // SubjectTokenType is the STS token type based on the Oauth2.0 token exchange spec - // e.g. `urn:ietf:params:oauth:token-type:jwt`. - SubjectTokenType string - // TokenURL is the STS token exchange endpoint. - TokenURL string - // TokenInfoURL is the token_info endpoint used to retrieve the account related information ( - // user attributes like account identifier, eg. email, username, uid, etc). This is - // needed for gCloud session account identification. - TokenInfoURL string - // ServiceAccountImpersonationURL is the URL for the service account impersonation request. This is only - // required for workload identity pools when APIs to be accessed have not integrated with UberMint. - ServiceAccountImpersonationURL string - // ServiceAccountImpersonationLifetimeSeconds is the number of seconds the service account impersonation - // token will be valid for. - ServiceAccountImpersonationLifetimeSeconds int - // ClientSecret is currently only required if token_info endpoint also - // needs to be called with the generated GCP access token. When provided, STS will be - // called with additional basic authentication using client_id as username and client_secret as password. - ClientSecret string - // ClientID is only required in conjunction with ClientSecret, as described above. - ClientID string - // CredentialSource contains the necessary information to retrieve the token itself, as well - // as some environmental information. - CredentialSource CredentialSource - // QuotaProjectID is injected by gCloud. If the value is non-empty, the Auth libraries - // will set the x-goog-user-project which overrides the project associated with the credentials. - QuotaProjectID string - // Scopes contains the desired scopes for the returned access token. - Scopes []string - // The optional workforce pool user project number when the credential - // corresponds to a workforce pool and not a workload identity pool. - // The underlying principal must still have serviceusage.services.use IAM - // permission to use the project for billing/quota. - WorkforcePoolUserProject string -} - -var ( - validWorkforceAudiencePattern *regexp.Regexp = regexp.MustCompile(`//iam\.googleapis\.com/locations/[^/]+/workforcePools/`) -) - -func validateWorkforceAudience(input string) bool { - return validWorkforceAudiencePattern.MatchString(input) -} - -// TokenSource Returns an external account TokenSource struct. This is to be called by package google to construct a google.Credentials. -func (c *Config) TokenSource(ctx context.Context) (oauth2.TokenSource, error) { - return c.tokenSource(ctx, "https") -} - -// tokenSource is a private function that's directly called by some of the tests, -// because the unit test URLs are mocked, and would otherwise fail the -// validity check. -func (c *Config) tokenSource(ctx context.Context, scheme string) (oauth2.TokenSource, error) { - if c.WorkforcePoolUserProject != "" { - valid := validateWorkforceAudience(c.Audience) - if !valid { - return nil, fmt.Errorf("oauth2/google: workforce_pool_user_project should not be set for non-workforce pool credentials") - } - } - - ts := tokenSource{ - ctx: ctx, - conf: c, - } - if c.ServiceAccountImpersonationURL == "" { - return oauth2.ReuseTokenSource(nil, ts), nil - } - scopes := c.Scopes - ts.conf.Scopes = []string{"https://www.googleapis.com/auth/cloud-platform"} - imp := ImpersonateTokenSource{ - Ctx: ctx, - URL: c.ServiceAccountImpersonationURL, - Scopes: scopes, - Ts: oauth2.ReuseTokenSource(nil, ts), - TokenLifetimeSeconds: c.ServiceAccountImpersonationLifetimeSeconds, - } - return oauth2.ReuseTokenSource(nil, imp), nil -} - -// Subject token file types. -const ( - fileTypeText = "text" - fileTypeJSON = "json" -) - -type format struct { - // Type is either "text" or "json". When not provided "text" type is assumed. - Type string `json:"type"` - // SubjectTokenFieldName is only required for JSON format. This would be "access_token" for azure. - SubjectTokenFieldName string `json:"subject_token_field_name"` -} - -// CredentialSource stores the information necessary to retrieve the credentials for the STS exchange. -// One field amongst File, URL, and Executable should be filled, depending on the kind of credential in question. -// The EnvironmentID should start with AWS if being used for an AWS credential. -type CredentialSource struct { - File string `json:"file"` - - URL string `json:"url"` - Headers map[string]string `json:"headers"` - - Executable *ExecutableConfig `json:"executable"` - - EnvironmentID string `json:"environment_id"` - RegionURL string `json:"region_url"` - RegionalCredVerificationURL string `json:"regional_cred_verification_url"` - CredVerificationURL string `json:"cred_verification_url"` - IMDSv2SessionTokenURL string `json:"imdsv2_session_token_url"` - Format format `json:"format"` -} - -type ExecutableConfig struct { - Command string `json:"command"` - TimeoutMillis *int `json:"timeout_millis"` - OutputFile string `json:"output_file"` -} - -// parse determines the type of CredentialSource needed. -func (c *Config) parse(ctx context.Context) (baseCredentialSource, error) { - if len(c.CredentialSource.EnvironmentID) > 3 && c.CredentialSource.EnvironmentID[:3] == "aws" { - if awsVersion, err := strconv.Atoi(c.CredentialSource.EnvironmentID[3:]); err == nil { - if awsVersion != 1 { - return nil, fmt.Errorf("oauth2/google: aws version '%d' is not supported in the current build", awsVersion) - } - - awsCredSource := awsCredentialSource{ - EnvironmentID: c.CredentialSource.EnvironmentID, - RegionURL: c.CredentialSource.RegionURL, - RegionalCredVerificationURL: c.CredentialSource.RegionalCredVerificationURL, - CredVerificationURL: c.CredentialSource.URL, - TargetResource: c.Audience, - ctx: ctx, - } - if c.CredentialSource.IMDSv2SessionTokenURL != "" { - awsCredSource.IMDSv2SessionTokenURL = c.CredentialSource.IMDSv2SessionTokenURL - } - - return awsCredSource, nil - } - } else if c.CredentialSource.File != "" { - return fileCredentialSource{File: c.CredentialSource.File, Format: c.CredentialSource.Format}, nil - } else if c.CredentialSource.URL != "" { - return urlCredentialSource{URL: c.CredentialSource.URL, Headers: c.CredentialSource.Headers, Format: c.CredentialSource.Format, ctx: ctx}, nil - } else if c.CredentialSource.Executable != nil { - return CreateExecutableCredential(ctx, c.CredentialSource.Executable, c) - } - return nil, fmt.Errorf("oauth2/google: unable to parse credential source") -} - -type baseCredentialSource interface { - credentialSourceType() string - subjectToken() (string, error) -} - -// tokenSource is the source that handles external credentials. It is used to retrieve Tokens. -type tokenSource struct { - ctx context.Context - conf *Config -} - -func getMetricsHeaderValue(conf *Config, credSource baseCredentialSource) string { - return fmt.Sprintf("gl-go/%s auth/%s google-byoid-sdk source/%s sa-impersonation/%t config-lifetime/%t", - goVersion(), - "unknown", - credSource.credentialSourceType(), - conf.ServiceAccountImpersonationURL != "", - conf.ServiceAccountImpersonationLifetimeSeconds != 0) -} - -// Token allows tokenSource to conform to the oauth2.TokenSource interface. -func (ts tokenSource) Token() (*oauth2.Token, error) { - conf := ts.conf - - credSource, err := conf.parse(ts.ctx) - if err != nil { - return nil, err - } - subjectToken, err := credSource.subjectToken() - - if err != nil { - return nil, err - } - stsRequest := stsexchange.TokenExchangeRequest{ - GrantType: "urn:ietf:params:oauth:grant-type:token-exchange", - Audience: conf.Audience, - Scope: conf.Scopes, - RequestedTokenType: "urn:ietf:params:oauth:token-type:access_token", - SubjectToken: subjectToken, - SubjectTokenType: conf.SubjectTokenType, - } - header := make(http.Header) - header.Add("Content-Type", "application/x-www-form-urlencoded") - header.Add("x-goog-api-client", getMetricsHeaderValue(conf, credSource)) - clientAuth := stsexchange.ClientAuthentication{ - AuthStyle: oauth2.AuthStyleInHeader, - ClientID: conf.ClientID, - ClientSecret: conf.ClientSecret, - } - var options map[string]interface{} - // Do not pass workforce_pool_user_project when client authentication is used. - // The client ID is sufficient for determining the user project. - if conf.WorkforcePoolUserProject != "" && conf.ClientID == "" { - options = map[string]interface{}{ - "userProject": conf.WorkforcePoolUserProject, - } - } - stsResp, err := stsexchange.ExchangeToken(ts.ctx, conf.TokenURL, &stsRequest, clientAuth, header, options) - if err != nil { - return nil, err - } - - accessToken := &oauth2.Token{ - AccessToken: stsResp.AccessToken, - TokenType: stsResp.TokenType, - } - if stsResp.ExpiresIn < 0 { - return nil, fmt.Errorf("oauth2/google: got invalid expiry from security token service") - } else if stsResp.ExpiresIn >= 0 { - accessToken.Expiry = now().Add(time.Duration(stsResp.ExpiresIn) * time.Second) - } - - if stsResp.RefreshToken != "" { - accessToken.RefreshToken = stsResp.RefreshToken - } - return accessToken, nil -} diff --git a/google/internal/externalaccount/basecredentials_test.go b/google/internal/externalaccount/basecredentials_test.go deleted file mode 100644 index 9bdf8e01d..000000000 --- a/google/internal/externalaccount/basecredentials_test.go +++ /dev/null @@ -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") - } - }) - } -} diff --git a/google/internal/externalaccount/err.go b/google/internal/externalaccount/err.go deleted file mode 100644 index 233a78cef..000000000 --- a/google/internal/externalaccount/err.go +++ /dev/null @@ -1,18 +0,0 @@ -// Copyright 2020 The Go Authors. All rights reserved. -// Use of this source code is governed by a BSD-style -// license that can be found in the LICENSE file. - -package externalaccount - -import "fmt" - -// Error for handling OAuth related error responses as stated in rfc6749#5.2. -type Error struct { - Code string - URI string - Description string -} - -func (err *Error) Error() string { - return fmt.Sprintf("got error code %s from %s: %s", err.Code, err.URI, err.Description) -} diff --git a/google/internal/externalaccount/err_test.go b/google/internal/externalaccount/err_test.go deleted file mode 100644 index 687380d71..000000000 --- a/google/internal/externalaccount/err_test.go +++ /dev/null @@ -1,19 +0,0 @@ -// Copyright 2020 The Go Authors. All rights reserved. -// Use of this source code is governed by a BSD-style -// license that can be found in the LICENSE file. - -package externalaccount - -import "testing" - -func TestError(t *testing.T) { - e := Error{ - "42", - "http:thisIsAPlaceholder", - "The Answer!", - } - want := "got error code 42 from http:thisIsAPlaceholder: The Answer!" - if got := e.Error(); got != want { - t.Errorf("Got error message %q; want %q", got, want) - } -} diff --git a/google/internal/externalaccount/impersonate_test.go b/google/internal/externalaccount/impersonate_test.go deleted file mode 100644 index 0ab6d6190..000000000 --- a/google/internal/externalaccount/impersonate_test.go +++ /dev/null @@ -1,144 +0,0 @@ -// Copyright 2021 The Go Authors. All rights reserved. -// Use of this source code is governed by a BSD-style -// license that can be found in the LICENSE file. - -package externalaccount - -import ( - "context" - "io/ioutil" - "net/http" - "net/http/httptest" - "testing" -) - -var ( - baseImpersonateCredsReqBody = "audience=32555940559.apps.googleusercontent.com&grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Atoken-exchange&requested_token_type=urn%3Aietf%3Aparams%3Aoauth%3Atoken-type%3Aaccess_token&scope=https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fcloud-platform&subject_token=street123&subject_token_type=urn%3Aietf%3Aparams%3Aoauth%3Atoken-type%3Ajwt" - baseImpersonateCredsRespBody = `{"accessToken":"Second.Access.Token","expireTime":"2020-12-28T15:01:23Z"}` -) - -func createImpersonationServer(urlWanted, authWanted, bodyWanted, response string, t *testing.T) *httptest.Server { - return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if got, want := r.URL.String(), urlWanted; got != want { - t.Errorf("URL.String(): got %v but want %v", got, want) - } - headerAuth := r.Header.Get("Authorization") - if got, want := headerAuth, authWanted; got != want { - t.Errorf("got %v but want %v", got, want) - } - headerContentType := r.Header.Get("Content-Type") - if got, want := headerContentType, "application/json"; got != want { - t.Errorf("got %v but want %v", got, want) - } - body, err := ioutil.ReadAll(r.Body) - if err != nil { - t.Fatalf("Failed reading request body: %v.", err) - } - if got, want := string(body), bodyWanted; got != want { - t.Errorf("Unexpected impersonation payload: got %v but want %v", got, want) - } - w.Header().Set("Content-Type", "application/json") - w.Write([]byte(response)) - })) -} - -func createTargetServer(metricsHeaderWanted string, t *testing.T) *httptest.Server { - return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if got, want := r.URL.String(), "/"; got != want { - t.Errorf("URL.String(): got %v but want %v", got, want) - } - headerAuth := r.Header.Get("Authorization") - if got, want := headerAuth, "Basic cmJyZ25vZ25yaG9uZ28zYmk0Z2I5Z2hnOWc6bm90c29zZWNyZXQ="; got != want { - t.Errorf("got %v but want %v", got, want) - } - headerContentType := r.Header.Get("Content-Type") - if got, want := headerContentType, "application/x-www-form-urlencoded"; got != want { - t.Errorf("got %v but want %v", got, want) - } - headerMetrics := r.Header.Get("x-goog-api-client") - if got, want := headerMetrics, metricsHeaderWanted; got != want { - t.Errorf("got %v but want %v", got, want) - } - body, err := ioutil.ReadAll(r.Body) - if err != nil { - t.Fatalf("Failed reading request body: %v.", err) - } - if got, want := string(body), baseImpersonateCredsReqBody; got != want { - t.Errorf("Unexpected exchange payload: got %v but want %v", got, want) - } - w.Header().Set("Content-Type", "application/json") - w.Write([]byte(baseCredsResponseBody)) - })) -} - -var impersonationTests = []struct { - name string - config Config - expectedImpersonationBody string - expectedMetricsHeader string -}{ - { - name: "Base Impersonation", - config: Config{ - Audience: "32555940559.apps.googleusercontent.com", - SubjectTokenType: "urn:ietf:params:oauth:token-type:jwt", - TokenInfoURL: "http://localhost:8080/v1/tokeninfo", - ClientSecret: "notsosecret", - ClientID: "rbrgnognrhongo3bi4gb9ghg9g", - CredentialSource: testBaseCredSource, - Scopes: []string{"https://www.googleapis.com/auth/devstorage.full_control"}, - }, - expectedImpersonationBody: "{\"lifetime\":\"3600s\",\"scope\":[\"https://www.googleapis.com/auth/devstorage.full_control\"]}", - expectedMetricsHeader: getExpectedMetricsHeader("file", true, false), - }, - { - name: "With TokenLifetime Set", - config: Config{ - Audience: "32555940559.apps.googleusercontent.com", - SubjectTokenType: "urn:ietf:params:oauth:token-type:jwt", - TokenInfoURL: "http://localhost:8080/v1/tokeninfo", - ClientSecret: "notsosecret", - ClientID: "rbrgnognrhongo3bi4gb9ghg9g", - CredentialSource: testBaseCredSource, - Scopes: []string{"https://www.googleapis.com/auth/devstorage.full_control"}, - ServiceAccountImpersonationLifetimeSeconds: 10000, - }, - expectedImpersonationBody: "{\"lifetime\":\"10000s\",\"scope\":[\"https://www.googleapis.com/auth/devstorage.full_control\"]}", - expectedMetricsHeader: getExpectedMetricsHeader("file", true, true), - }, -} - -func TestImpersonation(t *testing.T) { - for _, tt := range impersonationTests { - t.Run(tt.name, func(t *testing.T) { - testImpersonateConfig := tt.config - impersonateServer := createImpersonationServer("/", "Bearer Sample.Access.Token", tt.expectedImpersonationBody, baseImpersonateCredsRespBody, t) - defer impersonateServer.Close() - testImpersonateConfig.ServiceAccountImpersonationURL = impersonateServer.URL - - targetServer := createTargetServer(tt.expectedMetricsHeader, t) - defer targetServer.Close() - testImpersonateConfig.TokenURL = targetServer.URL - - ourTS, err := testImpersonateConfig.tokenSource(context.Background(), "http") - if err != nil { - t.Fatalf("Failed to create TokenSource: %v", err) - } - - oldNow := now - defer func() { now = oldNow }() - now = testNow - - tok, err := ourTS.Token() - if err != nil { - t.Fatalf("Unexpected error: %e", err) - } - if got, want := tok.AccessToken, "Second.Access.Token"; got != want { - t.Errorf("Unexpected access token: got %v, but wanted %v", got, want) - } - if got, want := tok.TokenType, "Bearer"; got != want { - t.Errorf("Unexpected TokenType: got %v, but wanted %v", got, want) - } - }) - } -} diff --git a/google/internal/externalaccount/impersonate.go b/google/internal/impersonate/impersonate.go similarity index 99% rename from google/internal/externalaccount/impersonate.go rename to google/internal/impersonate/impersonate.go index 54c8f209f..6bc3af110 100644 --- a/google/internal/externalaccount/impersonate.go +++ b/google/internal/impersonate/impersonate.go @@ -2,7 +2,7 @@ // Use of this source code is governed by a BSD-style // license that can be found in the LICENSE file. -package externalaccount +package impersonate import ( "bytes" diff --git a/internal/client_appengine.go b/internal/client_appengine.go deleted file mode 100644 index d28140f78..000000000 --- a/internal/client_appengine.go +++ /dev/null @@ -1,13 +0,0 @@ -// Copyright 2018 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. - -//go:build appengine - -package internal - -import "google.golang.org/appengine/urlfetch" - -func init() { - appengineClientHook = urlfetch.Client -} diff --git a/internal/transport.go b/internal/transport.go index 572074a63..b9db01ddf 100644 --- a/internal/transport.go +++ b/internal/transport.go @@ -18,16 +18,11 @@ var HTTPClient ContextKey // because nobody else can create a ContextKey, being unexported. type ContextKey struct{} -var appengineClientHook func(context.Context) *http.Client - func ContextClient(ctx context.Context) *http.Client { if ctx != nil { if hc, ok := ctx.Value(HTTPClient).(*http.Client); ok { return hc } } - if appengineClientHook != nil { - return appengineClientHook(ctx) - } return http.DefaultClient } diff --git a/jwt/example_test.go b/jwt/example_test.go index 58503d80d..c99b8767f 100644 --- a/jwt/example_test.go +++ b/jwt/example_test.go @@ -10,7 +10,7 @@ import ( "golang.org/x/oauth2/jwt" ) -func ExampleJWTConfig() { +func ExampleConfig() { ctx := context.Background() conf := &jwt.Config{ Email: "xxx@developer.com", diff --git a/microsoft/microsoft.go b/microsoft/microsoft.go index 3ffbc57a6..13091d666 100644 --- a/microsoft/microsoft.go +++ b/microsoft/microsoft.go @@ -25,7 +25,8 @@ func AzureADEndpoint(tenant string) oauth2.Endpoint { tenant = "common" } return oauth2.Endpoint{ - AuthURL: "https://login.microsoftonline.com/" + tenant + "/oauth2/v2.0/authorize", - TokenURL: "https://login.microsoftonline.com/" + tenant + "/oauth2/v2.0/token", + AuthURL: "https://login.microsoftonline.com/" + tenant + "/oauth2/v2.0/authorize", + TokenURL: "https://login.microsoftonline.com/" + tenant + "/oauth2/v2.0/token", + DeviceAuthURL: "https://login.microsoftonline.com/" + tenant + "/oauth2/v2.0/devicecode", } } diff --git a/oauth2.go b/oauth2.go index 90a2c3d6d..09f6a49b8 100644 --- a/oauth2.go +++ b/oauth2.go @@ -393,7 +393,7 @@ func ReuseTokenSource(t *Token, src TokenSource) TokenSource { } } -// ReuseTokenSource returns a TokenSource that acts in the same manner as the +// ReuseTokenSourceWithExpiry returns a TokenSource that acts in the same manner as the // TokenSource returned by ReuseTokenSource, except the expiry buffer is // configurable. The expiration time of a token is calculated as // t.Expiry.Add(-earlyExpiry). diff --git a/token.go b/token.go index 5bbb33217..109997d77 100644 --- a/token.go +++ b/token.go @@ -49,6 +49,13 @@ type Token struct { // mechanisms for that TokenSource will not be used. Expiry time.Time `json:"expiry,omitempty"` + // ExpiresIn is the OAuth2 wire format "expires_in" field, + // which specifies how many seconds later the token expires, + // relative to an unknown time base approximately around "now". + // It is the application's responsibility to populate + // `Expiry` from `ExpiresIn` when required. + ExpiresIn int64 `json:"expires_in,omitempty"` + // raw optionally contains extra metadata from the server // when updating a token. raw interface{} pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy