Skip to content

Commit a5426fa

Browse files
authored
feat(option): Deprecate unsafe credentials JSON loading options (#3356)
Add safer credentials JSON loading options. Add option.WithAuthCredentialsFile and option.WithAuthCredentialsJSON to mitigate a security vulnerability where credential configurations from untrusted sources could be used without validation. These new functions require the credential type to be explicitly specified. Deprecate the less safe option.WithCredentialsFile and option.WithCredentialsJSON functions.
1 parent 453c04a commit a5426fa

File tree

20 files changed

+842
-152
lines changed

20 files changed

+842
-152
lines changed

README.md

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -60,13 +60,22 @@ client, err := sheets.NewService(ctx)
6060
```
6161

6262
To authorize using a [JSON key file](https://cloud.google.com/iam/docs/managing-service-account-keys), pass
63-
[`option.WithCredentialsFile`](https://pkg.go.dev/google.golang.org/api/option#WithCredentialsFile) to the `NewService`
64-
function of the desired package. For example:
63+
[`option.WithAuthCredentialsFile`](https://pkg.go.dev/google.golang.org/api/option#WithAuthCredentialsFile) to the `NewService`
64+
function of the desired package. You must also specify the credential type. For example, to use a service account key file:
6565

6666
```go
67-
client, err := sheets.NewService(ctx, option.WithCredentialsFile("path/to/keyfile.json"))
67+
client, err := sheets.NewService(ctx, option.WithAuthCredentialsFile(option.ServiceAccount, "path/to/keyfile.json"))
6868
```
6969

70+
Similarly, you can use JSON credentials directly with [`option.WithAuthCredentialsJSON`](https://pkg.go.dev/google.golang.org/api/option#WithAuthCredentialsJSON):
71+
72+
```go
73+
// where jsonKey is a []byte containing the JSON key
74+
client, err := sheets.NewService(ctx, option.WithAuthCredentialsJSON(option.ServiceAccount, jsonKey))
75+
```
76+
77+
The older `option.WithCredentialsFile` and `option.WithCredentialsJSON` functions are deprecated due to a potential security risk.
78+
7079
You can exert more control over authorization by using the [`golang.org/x/oauth2`](https://pkg.go.dev/golang.org/x/oauth2)
7180
package to create an `oauth2.TokenSource`. Then pass [`option.WithTokenSource`](https://pkg.go.dev/google.golang.org/api/option#WithTokenSource)
7281
to the `NewService` function:

idtoken/idtoken.go

Lines changed: 138 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"encoding/json"
1010
"fmt"
1111
"net/http"
12+
"os"
1213
"path/filepath"
1314
"strings"
1415

@@ -20,6 +21,7 @@ import (
2021
"cloud.google.com/go/auth/oauth2adapt"
2122
"google.golang.org/api/impersonate"
2223
"google.golang.org/api/internal"
24+
"google.golang.org/api/internal/credentialstype"
2325
"google.golang.org/api/option"
2426
"google.golang.org/api/option/internaloption"
2527
htransport "google.golang.org/api/transport/http"
@@ -30,13 +32,38 @@ import (
3032
// ClientOption is for configuring a Google API client or transport.
3133
type ClientOption = option.ClientOption
3234

33-
type credentialsType int
35+
// CredentialsType specifies the type of JSON credentials being provided
36+
// to a loading function such as [WithAuthCredentialsFile] or
37+
// [WithAuthCredentialsJSON].
38+
type CredentialsType = credentialstype.CredType
3439

3540
const (
36-
unknownCredType credentialsType = iota
37-
serviceAccount
38-
impersonatedServiceAccount
39-
externalAccount
41+
// ServiceAccount represents a service account file type.
42+
ServiceAccount = credentialstype.ServiceAccount
43+
// AuthorizedUser represents an authorized user credentials file type.
44+
AuthorizedUser = credentialstype.AuthorizedUser
45+
// ImpersonatedServiceAccount represents an impersonated service account file type.
46+
//
47+
// IMPORTANT:
48+
// This credential type does not validate the credential configuration. A security
49+
// risk occurs when a credential configuration configured with malicious urls
50+
// is used.
51+
// You should validate credential configurations provided by untrusted sources.
52+
// See [Security requirements when using credential configurations from an external
53+
// source] https://cloud.google.com/docs/authentication/external/externally-sourced-credentials
54+
// for more details.
55+
ImpersonatedServiceAccount = credentialstype.ImpersonatedServiceAccount
56+
// ExternalAccount represents an external account file type.
57+
//
58+
// IMPORTANT:
59+
// This credential type does not validate the credential configuration. A security
60+
// risk occurs when a credential configuration configured with malicious urls
61+
// is used.
62+
// You should validate credential configurations provided by untrusted sources.
63+
// See [Security requirements when using credential configurations from an external
64+
// source] https://cloud.google.com/docs/authentication/external/externally-sourced-credentials
65+
// for more details.
66+
ExternalAccount = credentialstype.ExternalAccount
4067
)
4168

4269
// NewClient creates a HTTP Client that automatically adds an ID token to each
@@ -110,11 +137,33 @@ func newTokenSourceNewAuth(ctx context.Context, audience string, ds *internal.Di
110137
if ds.AuthCredentials != nil {
111138
return nil, fmt.Errorf("idtoken: option.WithTokenProvider not supported")
112139
}
140+
141+
var credsJSON []byte
142+
var credsType credentialstype.CredType
143+
var err error
144+
145+
credsFile, fileCredsType := ds.GetAuthCredentialsFile()
146+
if credsFile != "" {
147+
credsJSON, err = os.ReadFile(credsFile)
148+
if err != nil {
149+
return nil, fmt.Errorf("idtoken: cannot read credentials file: %v", err)
150+
}
151+
credsType = fileCredsType
152+
} else {
153+
credsJSON, credsType = ds.GetAuthCredentialsJSON()
154+
}
155+
156+
if credsType != credentialstype.Unknown {
157+
allowed := []credentialstype.CredType{ServiceAccount, ImpersonatedServiceAccount, ExternalAccount}
158+
if err := credentialstype.CheckCredentialType(credsJSON, credsType, allowed...); err != nil {
159+
return nil, err
160+
}
161+
}
162+
113163
creds, err := newidtoken.NewCredentials(&newidtoken.Options{
114164
Audience: audience,
115165
CustomClaims: ds.CustomClaims,
116-
CredentialsFile: ds.CredentialsFile,
117-
CredentialsJSON: ds.CredentialsJSON,
166+
CredentialsJSON: credsJSON, // Pass the bytes to avoid re-reading the file.
118167
Client: oauth2.NewClient(ctx, nil),
119168
Logger: ds.Logger,
120169
})
@@ -141,12 +190,12 @@ func newTokenSource(ctx context.Context, audience string, ds *internal.DialSetti
141190
}
142191

143192
func tokenSourceFromBytes(ctx context.Context, data []byte, audience string, ds *internal.DialSettings) (oauth2.TokenSource, error) {
144-
allowedType, err := getAllowedType(data)
193+
credType, err := credentialstype.GetCredType(data)
145194
if err != nil {
146195
return nil, err
147196
}
148-
switch allowedType {
149-
case serviceAccount:
197+
switch credType {
198+
case ServiceAccount:
150199
cfg, err := google.JWTConfigFromJSON(data, ds.GetScopes()...)
151200
if err != nil {
152201
return nil, err
@@ -166,7 +215,7 @@ func tokenSourceFromBytes(ctx context.Context, data []byte, audience string, ds
166215
return nil, err
167216
}
168217
return oauth2.ReuseTokenSource(tok, ts), nil
169-
case impersonatedServiceAccount, externalAccount:
218+
case ImpersonatedServiceAccount, ExternalAccount:
170219
type url struct {
171220
ServiceAccountImpersonationURL string `json:"service_account_impersonation_url"`
172221
}
@@ -182,43 +231,13 @@ func tokenSourceFromBytes(ctx context.Context, data []byte, audience string, ds
182231
TargetPrincipal: account,
183232
IncludeEmail: true,
184233
}
185-
ts, err := impersonate.IDTokenSource(ctx, config, option.WithCredentialsJSON(data))
234+
ts, err := impersonate.IDTokenSource(ctx, config, option.WithAuthCredentialsJSON(credType, data))
186235
if err != nil {
187236
return nil, err
188237
}
189238
return ts, nil
190239
default:
191-
return nil, fmt.Errorf("idtoken: unsupported credentials type")
192-
}
193-
}
194-
195-
// getAllowedType returns the credentials type of type credentialsType, and an error.
196-
// allowed types are "service_account" and "impersonated_service_account"
197-
func getAllowedType(data []byte) (credentialsType, error) {
198-
var t credentialsType
199-
if len(data) == 0 {
200-
return t, fmt.Errorf("idtoken: credential provided is 0 bytes")
201-
}
202-
var f struct {
203-
Type string `json:"type"`
204-
}
205-
if err := json.Unmarshal(data, &f); err != nil {
206-
return t, err
207-
}
208-
t = parseCredType(f.Type)
209-
return t, nil
210-
}
211-
212-
func parseCredType(typeString string) credentialsType {
213-
switch typeString {
214-
case "service_account":
215-
return serviceAccount
216-
case "impersonated_service_account":
217-
return impersonatedServiceAccount
218-
case "external_account":
219-
return externalAccount
220-
default:
221-
return unknownCredType
240+
return nil, fmt.Errorf("idtoken: unsupported credentials type: %q", credType)
222241
}
223242
}
224243

@@ -236,17 +255,93 @@ func (w withCustomClaims) Apply(o *internal.DialSettings) {
236255
// WithCredentialsFile returns a ClientOption that authenticates
237256
// API calls with the given service account or refresh token JSON
238257
// credentials file.
258+
//
259+
// Deprecated: This function is being deprecated because of a potential security risk.
260+
//
261+
// This function does not validate the credential configuration. The security
262+
// risk occurs when a credential configuration is accepted from a source that
263+
// is not under your control and used without validation on your side.
264+
//
265+
// If you know that you will be loading credential configurations of a
266+
// specific type, it is recommended to use a credential-type-specific
267+
// option function.
268+
// This will ensure that an unexpected credential type with potential for
269+
// malicious intent is not loaded unintentionally. You might still have to do
270+
// validation for certain credential types. Please follow the recommendation
271+
// for that function. For example, if you want to load only service accounts,
272+
// you can use [WithAuthCredentialsFile] with [ServiceAccount]:
273+
//
274+
// option.WithAuthCredentialsFile(option.ServiceAccount, "/path/to/file.json")
275+
//
276+
// If you are loading your credential configuration from an untrusted source and have
277+
// not mitigated the risks (e.g. by validating the configuration yourself), make
278+
// these changes as soon as possible to prevent security risks to your environment.
279+
//
280+
// Regardless of the function used, it is always your responsibility to validate
281+
// configurations received from external sources.
239282
func WithCredentialsFile(filename string) ClientOption {
240283
return option.WithCredentialsFile(filename)
241284
}
242285

286+
// WithAuthCredentialsFile returns a ClientOption that authenticates API calls
287+
// with the given JSON credentials file and credential type.
288+
//
289+
// Important: If you accept a credential configuration (credential
290+
// JSON/File/Stream) from an external source for authentication to Google
291+
// Cloud Platform, you must validate it before providing it to any Google
292+
// API or library. Providing an unvalidated credential configuration to
293+
// Google APIs can compromise the security of your systems and data. For
294+
// more information, refer to [Validate credential configurations from
295+
// external sources](https://cloud.google.com/docs/authentication/external/externally-sourced-credentials).
296+
func WithAuthCredentialsFile(credType CredentialsType, filename string) ClientOption {
297+
return option.WithAuthCredentialsFile(credType, filename)
298+
}
299+
243300
// WithCredentialsJSON returns a ClientOption that authenticates
244301
// API calls with the given service account or refresh token JSON
245302
// credentials.
303+
//
304+
// Deprecated: This function is being deprecated because of a potential security risk.
305+
//
306+
// This function does not validate the credential configuration. The security
307+
// risk occurs when a credential configuration is accepted from a source that
308+
// is not under your control and used without validation on your side.
309+
//
310+
// If you know that you will be loading credential configurations of a
311+
// specific type, it is recommended to use a credential-type-specific
312+
// option function.
313+
// This will ensure that an unexpected credential type with potential for
314+
// malicious intent is not loaded unintentionally. You might still have to do
315+
// validation for certain credential types. Please follow the recommendation
316+
// for that function. For example, if you want to load only service accounts,
317+
// you can use [WithAuthCredentialsJSON] with [ServiceAccount]:
318+
//
319+
// option.WithAuthCredentialsJSON(option.ServiceAccount, json)
320+
//
321+
// If you are loading your credential configuration from an untrusted source and have
322+
// not mitigated the risks (e.g. by validating the configuration yourself), make
323+
// these changes as soon as possible to prevent security risks to your environment.
324+
//
325+
// Regardless of the function used, it is always your responsibility to validate
326+
// configurations received from external sources.
246327
func WithCredentialsJSON(p []byte) ClientOption {
247328
return option.WithCredentialsJSON(p)
248329
}
249330

331+
// WithAuthCredentialsJSON returns a ClientOption that authenticates API calls
332+
// with the given JSON credentials and credential type.
333+
//
334+
// Important: If you accept a credential configuration (credential
335+
// JSON/File/Stream) from an external source for authentication to Google
336+
// Cloud Platform, you must validate it before providing it to any Google
337+
// API or library. Providing an unvalidated credential configuration to
338+
// Google APIs can compromise the security of your systems and data. For
339+
// more information, refer to [Validate credential configurations from
340+
// external sources](https://cloud.google.com/docs/authentication/external/externally-sourced-credentials).
341+
func WithAuthCredentialsJSON(credType CredentialsType, json []byte) ClientOption {
342+
return option.WithAuthCredentialsJSON(credType, json)
343+
}
344+
250345
// WithHTTPClient returns a ClientOption that specifies the HTTP client to use
251346
// as the basis of communications. This option may only be used with services
252347
// that support HTTP as their communication transport. When used, the

idtoken/idtoken_test.go

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
// Copyright 2025 Google LLC.
2+
// Use of this source code is governed by a BSD-style
3+
// license that can be found in the LICENSE file.
4+
5+
package idtoken
6+
7+
import (
8+
"context"
9+
"os"
10+
"path/filepath"
11+
"strings"
12+
"testing"
13+
14+
"google.golang.org/api/internal/credentialstype"
15+
"google.golang.org/api/option"
16+
)
17+
18+
const (
19+
serviceAccountJSON = `{
20+
"type": "service_account",
21+
"project_id": "my-project"
22+
}`
23+
)
24+
25+
func TestNewTokenSource_Validation(t *testing.T) {
26+
tempDir := t.TempDir()
27+
saFile := filepath.Join(tempDir, "sa.json")
28+
if err := os.WriteFile(saFile, []byte(serviceAccountJSON), 0644); err != nil {
29+
t.Fatalf("os.WriteFile: %v", err)
30+
}
31+
32+
ctx := context.Background()
33+
aud := "test-audience"
34+
35+
userCreds := []byte(`{"type": "authorized_user"}`)
36+
externalCreds := []byte(`{"type": "external_account"}`)
37+
38+
testCases := []struct {
39+
name string
40+
opts option.ClientOption
41+
wantErr bool
42+
errContains string
43+
errNotContains string
44+
}{
45+
{
46+
name: "FileMismatch",
47+
opts: option.WithAuthCredentialsFile(ExternalAccount, saFile),
48+
wantErr: true,
49+
errContains: "credential type mismatch",
50+
},
51+
{
52+
name: "JSONMismatch",
53+
opts: option.WithAuthCredentialsJSON(ExternalAccount, []byte(serviceAccountJSON)),
54+
wantErr: true,
55+
errContains: "credential type mismatch",
56+
},
57+
{
58+
name: "FileCorrect",
59+
opts: option.WithAuthCredentialsFile(ServiceAccount, saFile),
60+
wantErr: true, // Fails later, but not with a validation error
61+
errNotContains: "credential type mismatch",
62+
},
63+
{
64+
name: "NotAllowed",
65+
opts: option.WithAuthCredentialsJSON(credentialstype.AuthorizedUser, userCreds),
66+
wantErr: true,
67+
errContains: "credential type not allowed",
68+
},
69+
{
70+
name: "Allowed",
71+
opts: option.WithAuthCredentialsJSON(credentialstype.ExternalAccount, externalCreds),
72+
wantErr: true, // Fails later, but not with a validation error
73+
errNotContains: "credential type not allowed",
74+
},
75+
}
76+
77+
for _, tc := range testCases {
78+
t.Run(tc.name, func(t *testing.T) {
79+
_, err := NewTokenSource(ctx, aud, tc.opts)
80+
81+
if tc.wantErr {
82+
if err == nil {
83+
t.Fatal("got nil, want error")
84+
}
85+
if tc.errContains != "" && !strings.Contains(err.Error(), tc.errContains) {
86+
t.Errorf("got %q, want error containing %q", err, tc.errContains)
87+
}
88+
if tc.errNotContains != "" && strings.Contains(err.Error(), tc.errNotContains) {
89+
t.Errorf("got %q, want error NOT containing %q", err, tc.errNotContains)
90+
}
91+
} else if err != nil {
92+
t.Fatalf("got %v, want nil error", err)
93+
}
94+
})
95+
}
96+
}

0 commit comments

Comments
 (0)