diff --git a/README.md b/README.md index 2d77c8a8055..649abf5d322 100644 --- a/README.md +++ b/README.md @@ -60,13 +60,22 @@ client, err := sheets.NewService(ctx) ``` To authorize using a [JSON key file](https://cloud.google.com/iam/docs/managing-service-account-keys), pass -[`option.WithCredentialsFile`](https://pkg.go.dev/google.golang.org/api/option#WithCredentialsFile) to the `NewService` -function of the desired package. For example: +[`option.WithAuthCredentialsFile`](https://pkg.go.dev/google.golang.org/api/option#WithAuthCredentialsFile) to the `NewService` +function of the desired package. You must also specify the credential type. For example, to use a service account key file: ```go -client, err := sheets.NewService(ctx, option.WithCredentialsFile("path/to/keyfile.json")) +client, err := sheets.NewService(ctx, option.WithAuthCredentialsFile(option.ServiceAccount, "path/to/keyfile.json")) ``` +Similarly, you can use JSON credentials directly with [`option.WithAuthCredentialsJSON`](https://pkg.go.dev/google.golang.org/api/option#WithAuthCredentialsJSON): + +```go +// where jsonKey is a []byte containing the JSON key +client, err := sheets.NewService(ctx, option.WithAuthCredentialsJSON(option.ServiceAccount, jsonKey)) +``` + +The older `option.WithCredentialsFile` and `option.WithCredentialsJSON` functions are deprecated due to a potential security risk. + You can exert more control over authorization by using the [`golang.org/x/oauth2`](https://pkg.go.dev/golang.org/x/oauth2) package to create an `oauth2.TokenSource`. Then pass [`option.WithTokenSource`](https://pkg.go.dev/google.golang.org/api/option#WithTokenSource) to the `NewService` function: diff --git a/idtoken/idtoken.go b/idtoken/idtoken.go index 56e15da66f8..78f5430ea0d 100644 --- a/idtoken/idtoken.go +++ b/idtoken/idtoken.go @@ -30,13 +30,40 @@ import ( // ClientOption is for configuring a Google API client or transport. type ClientOption = option.ClientOption -type credentialsType int +// CredentialsType specifies the type of JSON credentials being provided +// to a loading function such as [WithAuthCredentialsFile] or +// [WithAuthCredentialsJSON]. +type CredentialsType = option.CredentialsType const ( - unknownCredType credentialsType = iota - serviceAccount - impersonatedServiceAccount - externalAccount + // unknownCredType is a private CredentialsType representing an unknown JSON file type. + unknownCredType = internal.Unknown + // ServiceAccount represents a service account file type. + ServiceAccount = option.ServiceAccount + // User represents a user credentials file type. + User = option.User + // ImpersonatedServiceAccount represents an impersonated service account file type. + // + // IMPORTANT: + // This credential type does not validate the credential configuration. A security + // risk occurs when a credential configuration configured with malicious urls + // is used. + // You should validate credential configurations provided by untrusted sources. + // See [Security requirements when using credential configurations from an external + // source] https://cloud.google.com/docs/authentication/external/externally-sourced-credentials + // for more details. + ImpersonatedServiceAccount = option.ImpersonatedServiceAccount + // ExternalAccount represents an external account file type. + // + // IMPORTANT: + // This credential type does not validate the credential configuration. A security + // risk occurs when a credential configuration configured with malicious urls + // is used. + // You should validate credential configurations provided by untrusted sources. + // See [Security requirements when using credential configurations from an external + // source] https://cloud.google.com/docs/authentication/external/externally-sourced-credentials + // for more details. + ExternalAccount = option.ExternalAccount ) // NewClient creates a HTTP Client that automatically adds an ID token to each @@ -110,11 +137,13 @@ func newTokenSourceNewAuth(ctx context.Context, audience string, ds *internal.Di if ds.AuthCredentials != nil { return nil, fmt.Errorf("idtoken: option.WithTokenProvider not supported") } + credsJSON, _ := ds.GetAuthCredentialsJSON() + credsFile, _ := ds.GetAuthCredentialsFile() creds, err := newidtoken.NewCredentials(&newidtoken.Options{ Audience: audience, CustomClaims: ds.CustomClaims, - CredentialsFile: ds.CredentialsFile, - CredentialsJSON: ds.CredentialsJSON, + CredentialsFile: credsFile, + CredentialsJSON: credsJSON, Client: oauth2.NewClient(ctx, nil), Logger: ds.Logger, }) @@ -146,7 +175,7 @@ func tokenSourceFromBytes(ctx context.Context, data []byte, audience string, ds return nil, err } switch allowedType { - case serviceAccount: + case ServiceAccount: cfg, err := google.JWTConfigFromJSON(data, ds.GetScopes()...) if err != nil { return nil, err @@ -166,7 +195,7 @@ func tokenSourceFromBytes(ctx context.Context, data []byte, audience string, ds return nil, err } return oauth2.ReuseTokenSource(tok, ts), nil - case impersonatedServiceAccount, externalAccount: + case ImpersonatedServiceAccount, ExternalAccount: type url struct { ServiceAccountImpersonationURL string `json:"service_account_impersonation_url"` } @@ -182,20 +211,20 @@ func tokenSourceFromBytes(ctx context.Context, data []byte, audience string, ds TargetPrincipal: account, IncludeEmail: true, } - ts, err := impersonate.IDTokenSource(ctx, config, option.WithCredentialsJSON(data)) + ts, err := impersonate.IDTokenSource(ctx, config, option.WithAuthCredentialsJSON(allowedType, data)) if err != nil { return nil, err } return ts, nil default: - return nil, fmt.Errorf("idtoken: unsupported credentials type") + return nil, fmt.Errorf("idtoken: unsupported credentials type: %d", allowedType) } } // getAllowedType returns the credentials type of type credentialsType, and an error. // allowed types are "service_account" and "impersonated_service_account" -func getAllowedType(data []byte) (credentialsType, error) { - var t credentialsType +func getAllowedType(data []byte) (CredentialsType, error) { + var t CredentialsType if len(data) == 0 { return t, fmt.Errorf("idtoken: credential provided is 0 bytes") } @@ -209,14 +238,14 @@ func getAllowedType(data []byte) (credentialsType, error) { return t, nil } -func parseCredType(typeString string) credentialsType { +func parseCredType(typeString string) CredentialsType { switch typeString { case "service_account": - return serviceAccount + return ServiceAccount case "impersonated_service_account": - return impersonatedServiceAccount + return ImpersonatedServiceAccount case "external_account": - return externalAccount + return ExternalAccount default: return unknownCredType } @@ -236,17 +265,111 @@ func (w withCustomClaims) Apply(o *internal.DialSettings) { // WithCredentialsFile returns a ClientOption that authenticates // API calls with the given service account or refresh token JSON // credentials file. +// +// Important: If you accept a credential configuration (credential +// JSON/File/Stream) from an external source for authentication to Google +// Cloud Platform, you must validate it before providing it to any Google +// API or library. Providing an unvalidated credential configuration to +// Google APIs can compromise the security of your systems and data. For +// more information, refer to [Validate credential configurations from +// external sources](https://cloud.google.com/docs/authentication/external/externally-sourced-credentials). +// +// Deprecated: This function is being deprecated because of a potential security risk. +// +// This function does not validate the credential configuration. The security +// risk occurs when a credential configuration is accepted from a source that +// is not under your control and used without validation on your side. +// +// If you know that you will be loading credential configurations of a +// specific type, it is recommended to use a credential-type-specific +// option function. +// This will ensure that an unexpected credential type with potential for +// malicious intent is not loaded unintentionally. You might still have to do +// validation for certain credential types. Please follow the recommendation +// for that function. For example, if you want to load only service accounts, +// you can use [WithAuthCredentialsFile] with [ServiceAccount]: +// ``` +// option.WithAuthCredentialsFile(option.ServiceAccount, "/path/to/file.json") +// ``` +// +// If you are loading your credential configuration from an untrusted source and have +// not mitigated the risks (e.g. by validating the configuration yourself), make +// these changes as soon as possible to prevent security risks to your environment. +// +// Regardless of the function used, it is always your responsibility to validate +// configurations received from external sources. func WithCredentialsFile(filename string) ClientOption { return option.WithCredentialsFile(filename) } +// WithAuthCredentialsFile returns a ClientOption that authenticates API calls +// with the given JSON credentials file and credential type. +// +// Important: If you accept a credential configuration (credential +// JSON/File/Stream) from an external source for authentication to Google +// Cloud Platform, you must validate it before providing it to any Google +// API or library. Providing an unvalidated credential configuration to +// Google APIs can compromise the security of your systems and data. For +// more information, refer to [Validate credential configurations from +// external sources](https://cloud.google.com/docs/authentication/external/externally-sourced-credentials). +func WithAuthCredentialsFile(credType CredentialsType, filename string) ClientOption { + return option.WithAuthCredentialsFile(credType, filename) +} + // WithCredentialsJSON returns a ClientOption that authenticates // API calls with the given service account or refresh token JSON // credentials. +// +// Important: If you accept a credential configuration (credential +// JSON/File/Stream) from an external source for authentication to Google +// Cloud Platform, you must validate it before providing it to any Google +// API or library. Providing an unvalidated credential configuration to +// Google APIs can compromise the security of your systems and data. For +// more information, refer to [Validate credential configurations from +// external sources](https://cloud.google.com/docs/authentication/external/externally-sourced-credentials). +// +// Deprecated: This function is being deprecated because of a potential security risk. +// +// This function does not validate the credential configuration. The security +// risk occurs when a credential configuration is accepted from a source that +// is not under your control and used without validation on your side. +// +// If you know that you will be loading credential configurations of a +// specific type, it is recommended to use a credential-type-specific +// option function. +// This will ensure that an unexpected credential type with potential for +// malicious intent is not loaded unintentionally. You might still have to do +// validation for certain credential types. Please follow the recommendation +// for that function. For example, if you want to load only service accounts, +// you can use [WithAuthCredentialsJSON] with [ServiceAccount]: +// ``` +// option.WithAuthCredentialsJSON(option.ServiceAccount, json) +// ``` +// +// If you are loading your credential configuration from an untrusted source and have +// not mitigated the risks (e.g. by validating the configuration yourself), make +// these changes as soon as possible to prevent security risks to your environment. +// +// Regardless of the function used, it is always your responsibility to validate +// configurations received from external sources. func WithCredentialsJSON(p []byte) ClientOption { return option.WithCredentialsJSON(p) } +// WithAuthCredentialsJSON returns a ClientOption that authenticates API calls +// with the given JSON credentials and credential type. +// +// Important: If you accept a credential configuration (credential +// JSON/File/Stream) from an external source for authentication to Google +// Cloud Platform, you must validate it before providing it to any Google +// API or library. Providing an unvalidated credential configuration to +// Google APIs can compromise the security of your systems and data. For +// more information, refer to [Validate credential configurations from +// external sources](https://cloud.google.com/docs/authentication/external/externally-sourced-credentials). +func WithAuthCredentialsJSON(credType CredentialsType, json []byte) ClientOption { + return option.WithAuthCredentialsJSON(credType, json) +} + // WithHTTPClient returns a ClientOption that specifies the HTTP client to use // as the basis of communications. This option may only be used with services // that support HTTP as their communication transport. When used, the diff --git a/idtoken/integration_test.go b/idtoken/integration_test.go index 1fa4149b3de..ca5c18a3e47 100644 --- a/idtoken/integration_test.go +++ b/idtoken/integration_test.go @@ -11,13 +11,14 @@ import ( "strings" "testing" - "golang.org/x/oauth2/google" "google.golang.org/api/idtoken" "google.golang.org/api/option" ) const ( envCredentialFile = "GOOGLE_APPLICATION_CREDENTIALS" + // Change this type as needed to match the credentials type of GOOGLE_APPLICATION_CREDENTIALS JSON or ADC credentials JSON. + credentialsFileType = idtoken.ServiceAccount aud = "http://example.com" ) @@ -26,38 +27,11 @@ func TestNewTokenSource(t *testing.T) { if testing.Short() { t.Skip("skipping integration test") } - ts, err := idtoken.NewTokenSource(context.Background(), "http://example.com", option.WithCredentialsFile(os.Getenv(envCredentialFile))) - if err != nil { - t.Fatalf("unable to create TokenSource: %v", err) - } - tok, err := ts.Token() - if err != nil { - t.Fatalf("unable to retrieve Token: %v", err) - } - req := &http.Request{Header: make(http.Header)} - tok.SetAuthHeader(req) - if !strings.HasPrefix(req.Header.Get("Authorization"), "Bearer ") { - t.Fatalf("token should sign requests with Bearer Authorization header") - } - validTok, err := idtoken.Validate(context.Background(), tok.AccessToken, aud) - if err != nil { - t.Fatalf("token validation failed: %v", err) - } - if validTok.Audience != aud { - t.Fatalf("got %q, want %q", validTok.Audience, aud) - } -} - -func TestNewTokenSource_WithCredentialJSON(t *testing.T) { - if testing.Short() { - t.Skip("skipping integration test") - } - ctx := context.Background() - creds, err := google.FindDefaultCredentials(ctx) - if err != nil { - t.Fatalf("unable to find default creds: %v", err) + credsPath := os.Getenv(envCredentialFile) + if credsPath == "" { + t.Fatalf("Env var is not set: %s", envCredentialFile) } - ts, err := idtoken.NewTokenSource(ctx, aud, option.WithCredentialsJSON(creds.JSON)) + ts, err := idtoken.NewTokenSource(context.Background(), "http://example.com", option.WithAuthCredentialsFile(credentialsFileType, credsPath)) if err != nil { t.Fatalf("unable to create Client: %v", err) } diff --git a/impersonate/integration_test.go b/impersonate/integration_test.go index ec7cb644fff..0b05970a8e1 100644 --- a/impersonate/integration_test.go +++ b/impersonate/integration_test.go @@ -87,7 +87,7 @@ func TestCredentialsTokenSourceIntegration(t *testing.T) { Scopes: []string{"https://www.googleapis.com/auth/devstorage.full_control"}, Delegates: tt.delegates, }, - option.WithCredentialsFile(tt.baseKeyFile), + option.WithAuthCredentialsFile(option.ServiceAccount, tt.baseKeyFile), ) if err != nil { t.Fatalf("failed to create ts: %v", err) @@ -143,7 +143,7 @@ func TestIDTokenSourceIntegration(t *testing.T) { Delegates: tt.delegates, IncludeEmail: true, }, - option.WithCredentialsFile(tt.baseKeyFile), + option.WithAuthCredentialsFile(option.ServiceAccount, tt.baseKeyFile), ) if err != nil { t.Fatalf("failed to create ts: %v", err) @@ -198,7 +198,7 @@ func TestTokenSourceIntegration_user(t *testing.T) { Scopes: []string{"https://www.googleapis.com/auth/admin.directory.user", "https://www.googleapis.com/auth/admin.directory.group"}, Subject: domainAdmin, }, - option.WithCredentialsFile(baseKeyFile), + option.WithAuthCredentialsFile(option.ServiceAccount, baseKeyFile), ) if err != nil { t.Fatalf("failed to create ts: %v", err) diff --git a/integration-tests/byoid/integration_test.go b/integration-tests/byoid/integration_test.go index e0051c470d9..2195aa3e2d0 100644 --- a/integration-tests/byoid/integration_test.go +++ b/integration-tests/byoid/integration_test.go @@ -129,7 +129,7 @@ func getClientID(keyFileName string) (string, error) { } func generateGoogleToken(keyFileName string) (string, error) { - ts, err := idtoken.NewTokenSource(context.Background(), oidcAudience, option.WithCredentialsFile(keyFileName)) + ts, err := idtoken.NewTokenSource(context.Background(), oidcAudience, option.WithAuthCredentialsFile(option.ServiceAccount, keyFileName)) if err != nil { return "", nil } @@ -175,7 +175,7 @@ func testBYOID(t *testing.T, c config) { writeConfig(t, c, func(name string) { // Once the default credentials are obtained, // we should be able to access Google Cloud resources. - dnsService, err := dns.NewService(context.Background(), option.WithCredentialsFile(name)) + dnsService, err := dns.NewService(context.Background(), option.WithAuthCredentialsFile(option.ExternalAccount, name)) if err != nil { t.Fatalf("Could not establish DNS Service: %v", err) } diff --git a/integration-tests/downscope/downscope_test.go b/integration-tests/downscope/downscope_test.go index 12ed93d2735..b59ca7765ca 100644 --- a/integration-tests/downscope/downscope_test.go +++ b/integration-tests/downscope/downscope_test.go @@ -46,7 +46,7 @@ func TestMain(m *testing.M) { credentialFileName := os.Getenv(envServiceAccountFile) var err error - rootCredential, err = transport.Creds(ctx, option.WithCredentialsFile(credentialFileName), option.WithScopes(rootTokenScope)) + rootCredential, err = transport.Creds(ctx, option.WithAuthCredentialsFile(option.ServiceAccount, credentialFileName), option.WithScopes(rootTokenScope)) if err != nil { log.Fatalf("failed to construct root credential: %v", err) diff --git a/integration-tests/impersonate/impersonate_test.go b/integration-tests/impersonate/impersonate_test.go index 488a2db620a..54c30663a0e 100644 --- a/integration-tests/impersonate/impersonate_test.go +++ b/integration-tests/impersonate/impersonate_test.go @@ -71,7 +71,7 @@ func TestImpersonatedCredentials(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { svc, err := storage.NewService(ctx, - option.WithCredentialsFile(tt.baseSALocation), + option.WithAuthCredentialsFile(option.ServiceAccount, tt.baseSALocation), option.ImpersonateCredentials(writerSA, tt.delgates...), ) if err != nil { diff --git a/internal/creds.go b/internal/creds.go index 92bb42c3215..06146f27023 100644 --- a/internal/creds.go +++ b/internal/creds.go @@ -139,11 +139,13 @@ func detectDefaultFromDialSettings(settings *DialSettings) (*auth.Credentials, e aud = settings.DefaultAudience } + credsFile, _ := settings.GetAuthCredentialsFile() + credsJSON, _ := settings.GetAuthCredentialsJSON() return credentials.DetectDefault(&credentials.DetectOptions{ Scopes: scopes, Audience: aud, - CredentialsFile: settings.CredentialsFile, - CredentialsJSON: settings.CredentialsJSON, + CredentialsFile: credsFile, + CredentialsJSON: credsJSON, UseSelfSignedJWT: useSelfSignedJWT, Logger: settings.Logger, }) @@ -159,8 +161,8 @@ func baseCreds(ctx context.Context, ds *DialSettings) (*google.Credentials, erro if len(ds.CredentialsJSON) > 0 { return credentialsFromJSON(ctx, ds.CredentialsJSON, ds) } - if ds.CredentialsFile != "" { - data, err := os.ReadFile(ds.CredentialsFile) + if cf, _ := ds.GetAuthCredentialsFile(); cf != "" { + data, err := os.ReadFile(cf) if err != nil { return nil, fmt.Errorf("cannot read credentials file: %v", err) } diff --git a/internal/creds_test.go b/internal/creds_test.go index 1cea8e910cf..a847d34c6f6 100644 --- a/internal/creds_test.go +++ b/internal/creds_test.go @@ -593,38 +593,38 @@ func TestIsSelfSignedJWTFlow(t *testing.T) { { name: "EnableJwtWithScope true", ds: &DialSettings{ - CredentialsFile: "testdata/service-account.json", - Scopes: []string{"foo"}, - EnableJwtWithScope: true, + AuthCredentialsFile: "testdata/service-account.json", + Scopes: []string{"foo"}, + EnableJwtWithScope: true, }, want: true, }, { name: "EnableJwtWithScope false", ds: &DialSettings{ - CredentialsFile: "testdata/service-account.json", - Scopes: []string{"foo"}, - EnableJwtWithScope: false, + AuthCredentialsFile: "testdata/service-account.json", + Scopes: []string{"foo"}, + EnableJwtWithScope: false, }, want: false, }, { name: "UniverseDomain", ds: &DialSettings{ - CredentialsFile: "testdata/service-account.json", - Scopes: []string{"foo"}, - EnableJwtWithScope: false, - UniverseDomain: "example.com", + AuthCredentialsFile: "testdata/service-account.json", + Scopes: []string{"foo"}, + EnableJwtWithScope: false, + UniverseDomain: "example.com", }, want: true, }, { name: "UniverseDomainUserAccount", ds: &DialSettings{ - CredentialsFile: "testdata/user-account.json", - Scopes: []string{"foo"}, - EnableJwtWithScope: false, - UniverseDomain: "example.com", + AuthCredentialsFile: "testdata/user-account.json", + Scopes: []string{"foo"}, + EnableJwtWithScope: false, + UniverseDomain: "example.com", }, want: false, }, @@ -632,7 +632,7 @@ func TestIsSelfSignedJWTFlow(t *testing.T) { for _, tc := range tests { - bytes, err := os.ReadFile(tc.ds.CredentialsFile) + bytes, err := os.ReadFile(tc.ds.AuthCredentialsFile) if err != nil { t.Fatal(err) } diff --git a/internal/settings.go b/internal/settings.go index a81d149ae22..655b8fa8e2a 100644 --- a/internal/settings.go +++ b/internal/settings.go @@ -28,19 +28,57 @@ const ( defaultUniverseDomain = "googleapis.com" ) +// CredentialsType specifies the type of JSON credentials being provided +// to a loading function such as option.WithAuthCredentialsFile or +// option.WithAuthCredentialsJSON. +type CredentialsType int + +const ( + // Unknown represents an unknown JSON file type. + Unknown CredentialsType = iota + // ServiceAccount represents a service account file type. + ServiceAccount + // User represents a user credentials file type. + User + // ImpersonatedServiceAccount represents an impersonated service account file type. + // + // IMPORTANT: + // This credential type does not validate the credential configuration. A security + // risk occurs when a credential configuration configured with malicious urls + // is used. + // You should validate credential configurations provided by untrusted sources. + // See [Security requirements when using credential configurations from an external + // source] https://cloud.google.com/docs/authentication/external/externally-sourced-credentials + // for more details. + ImpersonatedServiceAccount + // ExternalAccount represents an external account file type. + // + // IMPORTANT: + // This credential type does not validate the credential configuration. A security + // risk occurs when a credential configuration configured with malicious urls + // is used. + // You should validate credential configurations provided by untrusted sources. + // See [Security requirements when using credential configurations from an external + // source] https://cloud.google.com/docs/authentication/external/externally-sourced-credentials + // for more details. + ExternalAccount +) + // DialSettings holds information needed to establish a connection with a // Google API service. type DialSettings struct { - Endpoint string - DefaultEndpoint string - DefaultEndpointTemplate string - DefaultMTLSEndpoint string - Scopes []string - DefaultScopes []string - EnableJwtWithScope bool - TokenSource oauth2.TokenSource - Credentials *google.Credentials - CredentialsFile string // if set, Token Source is ignored. + Endpoint string + DefaultEndpoint string + DefaultEndpointTemplate string + DefaultMTLSEndpoint string + Scopes []string + DefaultScopes []string + EnableJwtWithScope bool + TokenSource oauth2.TokenSource + Credentials *google.Credentials + // Deprecated: Use AuthCredentialsFile instead, due to security risk. + CredentialsFile string + // Deprecated: Use AuthCredentialsJSON instead, due to security risk. CredentialsJSON []byte InternalCredentials *google.Credentials UserAgent string @@ -72,6 +110,9 @@ type DialSettings struct { // New Auth library Options AuthCredentials *auth.Credentials + AuthCredentialsJSON []byte + AuthCredentialsFile string + AuthCredentialsType CredentialsType EnableNewAuthLibrary bool // TODO(b/372244283): Remove after b/358175516 has been fixed @@ -113,18 +154,48 @@ func (ds *DialSettings) IsNewAuthLibraryEnabled() bool { if ds.AuthCredentials != nil { return true } + if len(ds.AuthCredentialsJSON) > 0 { + return true + } + if ds.AuthCredentialsFile != "" { + return true + } if b, err := strconv.ParseBool(os.Getenv(newAuthLibEnvVar)); err == nil { return b } return false } +// GetAuthCredentialsJSON returns the AuthCredentialsJSON and AuthCredentialsType, if set. +// Otherwise it falls back to the deprecated CredentialsJSON with an Unknown type. +// +// Use AuthCredentialsJSON if provided, as it is the safer, recommended option. +// CredentialsJSON is populated by the deprecated WithCredentialsJSON. +func (ds *DialSettings) GetAuthCredentialsJSON() ([]byte, CredentialsType) { + if len(ds.AuthCredentialsJSON) > 0 { + return ds.AuthCredentialsJSON, ds.AuthCredentialsType + } + return ds.CredentialsJSON, Unknown +} + +// GetAuthCredentialsFile returns the AuthCredentialsFile and AuthCredentialsType, if set. +// Otherwise it falls back to the deprecated CredentialsFile with an Unknown type. +// +// Use AuthCredentialsFile if provided, as it is the safer, recommended option. +// CredentialsFile is populated by the deprecated WithCredentialsFile. +func (ds *DialSettings) GetAuthCredentialsFile() (string, CredentialsType) { + if ds.AuthCredentialsFile != "" { + return ds.AuthCredentialsFile, ds.AuthCredentialsType + } + return ds.CredentialsFile, Unknown +} + // Validate reports an error if ds is invalid. func (ds *DialSettings) Validate() error { if ds.SkipValidation { return nil } - hasCreds := ds.APIKey != "" || ds.TokenSource != nil || ds.CredentialsFile != "" || ds.Credentials != nil + hasCreds := ds.APIKey != "" || ds.TokenSource != nil || ds.CredentialsFile != "" || ds.Credentials != nil || ds.AuthCredentials != nil || len(ds.AuthCredentialsJSON) > 0 || ds.AuthCredentialsFile != "" if ds.NoAuth && hasCreds { return errors.New("options.WithoutAuthentication is incompatible with any option that provides credentials") } @@ -138,6 +209,15 @@ func (ds *DialSettings) Validate() error { if len(ds.CredentialsJSON) > 0 { nCreds++ } + if ds.AuthCredentials != nil { + nCreds++ + } + if len(ds.AuthCredentialsJSON) > 0 { + nCreds++ + } + if ds.AuthCredentialsFile != "" { + nCreds++ + } if ds.CredentialsFile != "" { nCreds++ } diff --git a/internal/settings_test.go b/internal/settings_test.go index 6725468f5ad..94310d3fc86 100644 --- a/internal/settings_test.go +++ b/internal/settings_test.go @@ -10,6 +10,7 @@ import ( "net/http" "testing" + "cloud.google.com/go/auth" "google.golang.org/api/internal/impersonate" "google.golang.org/grpc" "google.golang.org/grpc/credentials/insecure" @@ -30,6 +31,8 @@ func TestSettingsValidate(t *testing.T) { {TokenSource: dummyTS{}}, {CredentialsFile: "f", TokenSource: dummyTS{}}, // keep for backwards compatibility {CredentialsJSON: []byte("json")}, + {AuthCredentialsFile: "f"}, + {AuthCredentialsJSON: []byte("json")}, {HTTPClient: &http.Client{}}, {GRPCConn: &grpc.ClientConn{}}, // Although NoAuth and Scopes are technically incompatible, too many @@ -50,13 +53,19 @@ func TestSettingsValidate(t *testing.T) { for _, ds := range []DialSettings{ {NoAuth: true, APIKey: "x"}, {NoAuth: true, CredentialsFile: "f"}, + {NoAuth: true, AuthCredentialsFile: "f"}, {NoAuth: true, TokenSource: dummyTS{}}, {NoAuth: true, Credentials: &google.DefaultCredentials{}}, + {NoAuth: true, AuthCredentialsJSON: []byte("json")}, {Credentials: &google.DefaultCredentials{}, CredentialsFile: "f"}, + {Credentials: &google.DefaultCredentials{}, AuthCredentialsFile: "f"}, {Credentials: &google.DefaultCredentials{}, TokenSource: dummyTS{}}, {Credentials: &google.DefaultCredentials{}, CredentialsJSON: []byte("json")}, + {Credentials: &google.DefaultCredentials{}, AuthCredentialsJSON: []byte("json")}, {CredentialsFile: "f", CredentialsJSON: []byte("json")}, + {AuthCredentialsFile: "f", AuthCredentialsJSON: []byte("json")}, {CredentialsJSON: []byte("json"), TokenSource: dummyTS{}}, + {AuthCredentialsJSON: []byte("json"), TokenSource: dummyTS{}}, {HTTPClient: &http.Client{}, GRPCConn: &grpc.ClientConn{}}, {HTTPClient: &http.Client{}, GRPCDialOpts: []grpc.DialOption{grpc.WithTransportCredentials(insecure.NewCredentials())}}, {Audiences: []string{"foo"}, Scopes: []string{"foo"}}, @@ -81,6 +90,19 @@ type dummyTS struct{} func (dummyTS) Token() (*oauth2.Token, error) { return nil, nil } +func TestIsNewAuthLibraryEnabled(t *testing.T) { + for _, ds := range []DialSettings{ + {EnableNewAuthLibrary: true}, + {AuthCredentials: &auth.Credentials{}}, + {AuthCredentialsJSON: []byte("json")}, + {AuthCredentialsFile: "f"}, + } { + if !ds.IsNewAuthLibraryEnabled() { + t.Errorf("%+v: got false, want true", ds) + } + } +} + func TestGetUniverseDomain(t *testing.T) { testCases := []struct { name string diff --git a/option/internaloption/internaloption.go b/option/internaloption/internaloption.go index 931f093d89a..d67351a7fa5 100644 --- a/option/internaloption/internaloption.go +++ b/option/internaloption/internaloption.go @@ -290,7 +290,7 @@ func GetLogger(opts []option.ClientOption) *slog.Logger { // options, in this order: // // - [option.WithoutAuthentication] -// - [option.WithAuthCredentials] +// - [option.Credentials] // - [WithCredentials] (internal use only) // - [option.WithCredentials] // - [option.WithTokenSource] @@ -300,7 +300,9 @@ func GetLogger(opts []option.ClientOption) *slog.Logger { // returns the result: // // - [option.WithAudiences] +// - [option.WithAuthCredentialsFile] // - [option.WithCredentialsFile] +// - [option.WithAuthCredentialsJSON] // - [option.WithCredentialsJSON] // - [option.WithScopes] // - [WithDefaultScopes] (internal use only) diff --git a/option/option.go b/option/option.go index 1b134caa862..14785fc5468 100644 --- a/option/option.go +++ b/option/option.go @@ -18,6 +18,40 @@ import ( "google.golang.org/grpc" ) +// CredentialsType specifies the type of JSON credentials being provided +// to a loading function such as [WithAuthCredentialsFile] or +// [WithAuthCredentialsJSON]. +type CredentialsType = internal.CredentialsType + +const ( + // ServiceAccount represents a service account file type. + ServiceAccount = internal.ServiceAccount + // User represents a user credentials file type. + User = internal.User + // ImpersonatedServiceAccount represents an impersonated service account file type. + // + // IMPORTANT: + // This credential type does not validate the credential configuration. A security + // risk occurs when a credential configuration configured with malicious urls + // is used. + // You should validate credential configurations provided by untrusted sources. + // See [Security requirements when using credential configurations from an external + // source] https://cloud.google.com/docs/authentication/external/externally-sourced-credentials + // for more details. + ImpersonatedServiceAccount = internal.ImpersonatedServiceAccount + // ExternalAccount represents an external account file type. + // + // IMPORTANT: + // This credential type does not validate the credential configuration. A security + // risk occurs when a credential configuration configured with malicious urls + // is used. + // You should validate credential configurations provided by untrusted sources. + // See [Security requirements when using credential configurations from an external + // source] https://cloud.google.com/docs/authentication/external/externally-sourced-credentials + // for more details. + ExternalAccount = internal.ExternalAccount +) + // A ClientOption is an option for a Google API client. type ClientOption interface { Apply(*internal.DialSettings) @@ -52,10 +86,62 @@ func (w withCredFile) Apply(o *internal.DialSettings) { // Google APIs can compromise the security of your systems and data. For // more information, refer to [Validate credential configurations from // external sources](https://cloud.google.com/docs/authentication/external/externally-sourced-credentials). +// +// Deprecated: This function is being deprecated because of a potential security risk. +// +// This function does not validate the credential configuration. The security +// risk occurs when a credential configuration is accepted from a source that +// is not under your control and used without validation on your side. +// +// If you know that you will be loading credential configurations of a +// specific type, it is recommended to use a credential-type-specific +// option function. +// This will ensure that an unexpected credential type with potential for +// malicious intent is not loaded unintentionally. You might still have to do +// validation for certain credential types. Please follow the recommendation +// for that function. For example, if you want to load only service accounts, +// you can use [WithAuthCredentialsFile] with [ServiceAccount]: +// ``` +// option.WithAuthCredentialsFile(option.ServiceAccount, "/path/to/file.json") +// ``` +// +// If you are loading your credential configuration from an untrusted source and have +// not mitigated the risks (e.g. by validating the configuration yourself), make +// these changes as soon as possible to prevent security risks to your environment. +// +// Regardless of the function used, it is always your responsibility to validate +// configurations received from external sources. func WithCredentialsFile(filename string) ClientOption { return withCredFile(filename) } +// WithAuthCredentialsFile returns a ClientOption that authenticates API calls +// with the given JSON credentials file and credential type. +// +// Important: If you accept a credential configuration (credential +// JSON/File/Stream) from an external source for authentication to Google +// Cloud Platform, you must validate it before providing it to any Google +// API or library. Providing an unvalidated credential configuration to +// Google APIs can compromise the security of your systems and data. For +// more information, refer to [Validate credential configurations from +// external sources](https://cloud.google.com/docs/authentication/external/externally-sourced-credentials). +func WithAuthCredentialsFile(credType CredentialsType, filename string) ClientOption { + return withAuthCredentialsFile{ + credsType: credType, + filename: filename, + } +} + +type withAuthCredentialsFile struct { + credsType CredentialsType + filename string +} + +func (w withAuthCredentialsFile) Apply(o *internal.DialSettings) { + o.AuthCredentialsFile = w.filename + o.AuthCredentialsType = w.credsType +} + // WithServiceAccountFile returns a ClientOption that uses a Google service // account credentials file to authenticate. // @@ -67,9 +153,9 @@ func WithCredentialsFile(filename string) ClientOption { // more information, refer to [Validate credential configurations from // external sources](https://cloud.google.com/docs/authentication/external/externally-sourced-credentials). // -// Deprecated: Use WithCredentialsFile instead. +// Deprecated: Use WithAuthCredentialsFile instead. func WithServiceAccountFile(filename string) ClientOption { - return WithCredentialsFile(filename) + return WithAuthCredentialsFile(ServiceAccount, filename) } // WithCredentialsJSON returns a ClientOption that authenticates @@ -83,6 +169,31 @@ func WithServiceAccountFile(filename string) ClientOption { // Google APIs can compromise the security of your systems and data. For // more information, refer to [Validate credential configurations from // external sources](https://cloud.google.com/docs/authentication/external/externally-sourced-credentials). +// +// Deprecated: This function is being deprecated because of a potential security risk. +// +// This function does not validate the credential configuration. The security +// risk occurs when a credential configuration is accepted from a source that +// is not under your control and used without validation on your side. +// +// If you know that you will be loading credential configurations of a +// specific type, it is recommended to use a credential-type-specific +// option function. +// This will ensure that an unexpected credential type with potential for +// malicious intent is not loaded unintentionally. You might still have to do +// validation for certain credential types. Please follow the recommendation +// for that function. For example, if you want to load only service accounts, +// you can use [WithAuthCredentialsJSON] with [ServiceAccount]: +// ``` +// option.WithAuthCredentialsJSON(option.ServiceAccount, json) +// ``` +// +// If you are loading your credential configuration from an untrusted source and have +// not mitigated the risks (e.g. by validating the configuration yourself), make +// these changes as soon as possible to prevent security risks to your environment. +// +// Regardless of the function used, it is always your responsibility to validate +// configurations received from external sources. func WithCredentialsJSON(p []byte) ClientOption { return withCredentialsJSON(p) } @@ -94,6 +205,33 @@ func (w withCredentialsJSON) Apply(o *internal.DialSettings) { copy(o.CredentialsJSON, w) } +// WithAuthCredentialsJSON returns a ClientOption that authenticates API calls +// with the given JSON credentials and credential type. +// +// Important: If you accept a credential configuration (credential +// JSON/File/Stream) from an external source for authentication to Google +// Cloud Platform, you must validate it before providing it to any Google +// API or library. Providing an unvalidated credential configuration to +// Google APIs can compromise the security of your systems and data. For +// more information, refer to [Validate credential configurations from +// external sources](https://cloud.google.com/docs/authentication/external/externally-sourced-credentials). +func WithAuthCredentialsJSON(credType CredentialsType, json []byte) ClientOption { + return withAuthCredentialsJSON{ + credsType: credType, + json: json, + } +} + +type withAuthCredentialsJSON struct { + credsType CredentialsType + json []byte +} + +func (w withAuthCredentialsJSON) Apply(o *internal.DialSettings) { + o.AuthCredentialsJSON = w.json + o.AuthCredentialsType = w.credsType +} + // WithEndpoint returns a ClientOption that overrides the default endpoint // to be used for a service. Please note that by default Google APIs only // accept HTTPS traffic. diff --git a/option/option_test.go b/option/option_test.go index 04c3716513b..f2d7fa88181 100644 --- a/option/option_test.go +++ b/option/option_test.go @@ -65,7 +65,9 @@ func TestApply(t *testing.T) { WithScopes("https://example.com/auth/helloworld", "https://example.com/auth/otherthing"), WithGRPCConn(conn), WithUserAgent("ua"), - WithCredentialsFile("service-account.json"), + WithAuthCredentialsFile(ServiceAccount, "service-account.json"), + WithCredentialsFile("unknown-type.json"), + WithAuthCredentialsJSON(ServiceAccount, []byte(`{some: "json", type: "service_account"}`)), WithCredentialsJSON([]byte(`{some: "json"}`)), WithCredentials(&google.DefaultCredentials{ProjectID: "p"}), WithAPIKey("api-key"), @@ -80,19 +82,22 @@ func TestApply(t *testing.T) { opt.Apply(&got) } want := internal.DialSettings{ - Scopes: []string{"https://example.com/auth/helloworld", "https://example.com/auth/otherthing"}, - UserAgent: "ua", - Endpoint: "https://example.com:443", - GRPCConn: conn, - Credentials: &google.DefaultCredentials{ProjectID: "p"}, - CredentialsFile: "service-account.json", - CredentialsJSON: []byte(`{some: "json"}`), - APIKey: "api-key", - Audiences: []string{"https://example.com/"}, - QuotaProject: "user-project", - RequestReason: "Request Reason", - TelemetryDisabled: true, - UniverseDomain: "universe.com", + Scopes: []string{"https://example.com/auth/helloworld", "https://example.com/auth/otherthing"}, + UserAgent: "ua", + Endpoint: "https://example.com:443", + GRPCConn: conn, + Credentials: &google.DefaultCredentials{ProjectID: "p"}, + AuthCredentialsFile: "service-account.json", + AuthCredentialsType: ServiceAccount, + CredentialsFile: "unknown-type.json", + AuthCredentialsJSON: []byte(`{some: "json", type: "service_account"}`), + CredentialsJSON: []byte(`{some: "json"}`), + APIKey: "api-key", + Audiences: []string{"https://example.com/"}, + QuotaProject: "user-project", + RequestReason: "Request Reason", + TelemetryDisabled: true, + UniverseDomain: "universe.com", } ignore := []cmp.Option{ cmpopts.IgnoreUnexported(grpc.ClientConn{}), diff --git a/transport/examples_test.go b/transport/examples_test.go index a29c45cb90d..7d656975a19 100644 --- a/transport/examples_test.go +++ b/transport/examples_test.go @@ -34,7 +34,7 @@ func Example_withCredentialsFile() { // // Note: Given the same set of options, transport.NewHTTPClient and // transport.DialGRPC use the same credentials. - c, _, err := transport.NewHTTPClient(ctx, option.WithCredentialsFile("/path/to/service-account-creds.json")) + c, _, err := transport.NewHTTPClient(ctx, option.WithAuthCredentialsFile(option.ServiceAccount, "/path/to/service-account-creds.json")) if err != nil { log.Fatal(err) } diff --git a/transport/grpc/dial.go b/transport/grpc/dial.go index a6630a0e440..5b277c2e470 100644 --- a/transport/grpc/dial.go +++ b/transport/grpc/dial.go @@ -220,6 +220,8 @@ func dialPoolNewAuth(ctx context.Context, secure bool, poolSize int, ds *interna defaultEndpointTemplate = ds.DefaultEndpoint } + credsJSON, _ := ds.GetAuthCredentialsJSON() + credsFile, _ := ds.GetAuthCredentialsFile() pool, err := dialContextNewAuth(ctx, secure, &grpctransport.Options{ DisableTelemetry: ds.TelemetryDisabled, DisableAuthentication: ds.NoAuth, @@ -233,8 +235,8 @@ func dialPoolNewAuth(ctx context.Context, secure bool, poolSize int, ds *interna DetectOpts: &credentials.DetectOptions{ Scopes: ds.Scopes, Audience: aud, - CredentialsFile: ds.CredentialsFile, - CredentialsJSON: ds.CredentialsJSON, + CredentialsFile: credsFile, + CredentialsJSON: credsJSON, Logger: ds.Logger, }, InternalOptions: &grpctransport.InternalOptions{ diff --git a/transport/http/dial.go b/transport/http/dial.go index a33df912035..494de475e53 100644 --- a/transport/http/dial.go +++ b/transport/http/dial.go @@ -108,6 +108,8 @@ func newClientNewAuth(ctx context.Context, base http.RoundTripper, ds *internal. if ds.UserAgent != "" { headers.Set("User-Agent", ds.UserAgent) } + credsJSON, _ := ds.GetAuthCredentialsJSON() + credsFile, _ := ds.GetAuthCredentialsFile() client, err := httptransport.NewClient(&httptransport.Options{ DisableTelemetry: ds.TelemetryDisabled, DisableAuthentication: ds.NoAuth, @@ -120,8 +122,8 @@ func newClientNewAuth(ctx context.Context, base http.RoundTripper, ds *internal. DetectOpts: &credentials.DetectOptions{ Scopes: ds.Scopes, Audience: aud, - CredentialsFile: ds.CredentialsFile, - CredentialsJSON: ds.CredentialsJSON, + CredentialsFile: credsFile, + CredentialsJSON: credsJSON, Logger: ds.Logger, }, InternalOptions: &httptransport.InternalOptions{