diff --git a/internal/creds.go b/internal/creds.go index 1f635e430cc..9cb2b74c0ed 100644 --- a/internal/creds.go +++ b/internal/creds.go @@ -7,6 +7,7 @@ package internal import ( "context" "encoding/json" + "errors" "fmt" "io/ioutil" @@ -62,56 +63,68 @@ const ( serviceAccountKey = "service_account" ) -// credentialsFromJSON returns a google.Credentials based on the input. +// credentialsFromJSON returns a google.Credentials from the JSON data // -// - A self-signed JWT auth flow will be executed if: the data file is a service -// account, no user are scopes provided, an audience is provided, a user -// specified endpoint is not provided, and credentials will not be -// impersonated. +// - A self-signed JWT flow will be executed if the following conditions are +// met: +// (1) At least one of the following is true: +// (a) No scope is provided +// (b) Scope for self-signed JWT flow is enabled +// (c) Audiences are explicitly provided by users +// (2) No service account impersontation // -// - Otherwise, executes a stanard OAuth 2.0 flow. +// - Otherwise, executes standard OAuth 2.0 flow +// More details: google.aip.dev/auth/4111 func credentialsFromJSON(ctx context.Context, data []byte, ds *DialSettings) (*google.Credentials, error) { + // By default, a standard OAuth 2.0 token source is created cred, err := google.CredentialsFromJSON(ctx, data, ds.GetScopes()...) if err != nil { return nil, err } - // Standard OAuth 2.0 Flow - if len(data) == 0 || - len(ds.Scopes) > 0 || - (ds.DefaultAudience == "" && len(ds.Audiences) == 0) || - ds.ImpersonationConfig != nil || - ds.Endpoint != "" { - return cred, nil - } - // Check if JSON is a service account and if so create a self-signed JWT. - var f struct { - Type string `json:"type"` - // The rest JSON fields are omitted because they are not used. - } - if err := json.Unmarshal(cred.JSON, &f); err != nil { + // Override the token source to use self-signed JWT if conditions are met + isJWTFlow, err := isSelfSignedJWTFlow(data, ds) + if err != nil { return nil, err } - if f.Type == serviceAccountKey { - ts, err := selfSignedJWTTokenSource(data, ds.DefaultAudience, ds.Audiences) + if isJWTFlow { + ts, err := selfSignedJWTTokenSource(data, ds) if err != nil { return nil, err } cred.TokenSource = ts } + return cred, err } -func selfSignedJWTTokenSource(data []byte, defaultAudience string, audiences []string) (oauth2.TokenSource, error) { - audience := defaultAudience - if len(audiences) > 0 { - // TODO(shinfan): Update golang oauth to support multiple audiences. - if len(audiences) > 1 { - return nil, fmt.Errorf("multiple audiences support is not implemented") +func isSelfSignedJWTFlow(data []byte, ds *DialSettings) (bool, error) { + if (ds.EnableJwtWithScope || ds.HasCustomAudience() || len(ds.GetScopes()) == 0) && + ds.ImpersonationConfig == nil { + // Check if JSON is a service account and if so create a self-signed JWT. + var f struct { + Type string `json:"type"` + // The rest JSON fields are omitted because they are not used. } - audience = audiences[0] + if err := json.Unmarshal(data, &f); err != nil { + return false, err + } + return f.Type == serviceAccountKey, nil + } + return false, nil +} + +func selfSignedJWTTokenSource(data []byte, ds *DialSettings) (oauth2.TokenSource, error) { + if len(ds.GetScopes()) > 0 && !ds.HasCustomAudience() { + // Scopes are preferred in self-signed JWT unless the scope is not available + // or a custom audience is used. + return google.JWTAccessTokenSourceWithScope(data, ds.GetScopes()...) + } else if ds.GetAudience() != "" { + // Fallback to audience if scope is not provided + return google.JWTAccessTokenSourceFromJSON(data, ds.GetAudience()) + } else { + return nil, errors.New("neither scopes or audience are available for the self-signed JWT") } - return google.JWTAccessTokenSourceFromJSON(data, audience) } // QuotaProjectFromCreds returns the quota project from the JSON blob in the provided credentials. diff --git a/internal/creds_test.go b/internal/creds_test.go index 045c5bc20a6..ecdae563187 100644 --- a/internal/creds_test.go +++ b/internal/creds_test.go @@ -38,6 +38,7 @@ func TestTokenSource(t *testing.T) { ds = &DialSettings{ TokenSource: ts, CredentialsFile: "testdata/service-account.json", + DefaultScopes: []string{"foo"}, } got, err = Creds(ctx, ds) if err != nil { @@ -54,14 +55,20 @@ func TestDefaultServiceAccount(t *testing.T) { // Load a valid JSON file. No way to really test the contents; we just // verify that there is no error. - ds := &DialSettings{CredentialsFile: "testdata/service-account.json"} + ds := &DialSettings{ + CredentialsFile: "testdata/service-account.json", + DefaultScopes: []string{"foo"}, + } if _, err := Creds(ctx, ds); err != nil { t.Errorf("got %v, wanted no error", err) } // Load valid JSON. No way to really test the contents; we just // verify that there is no error. - ds = &DialSettings{CredentialsJSON: []byte(validServiceAccountJSON)} + ds = &DialSettings{ + CredentialsJSON: []byte(validServiceAccountJSON), + DefaultScopes: []string{"foo"}, + } if _, err := Creds(ctx, ds); err != nil { t.Errorf("got %v, wanted no error", err) } @@ -85,6 +92,82 @@ func TestJWTWithAudience(t *testing.T) { } } +func TestJWTWithScope(t *testing.T) { + ctx := context.Background() + + // Load a valid JSON file. No way to really test the contents; we just + // verify that there is no error. + ds := &DialSettings{ + CredentialsFile: "testdata/service-account.json", + Scopes: []string{"foo"}, + EnableJwtWithScope: true, + } + if _, err := Creds(ctx, ds); err != nil { + t.Errorf("got %v, wanted no error", err) + } + + // Load valid JSON. No way to really test the contents; we just + // verify that there is no error. + ds = &DialSettings{ + CredentialsJSON: []byte(validServiceAccountJSON), + Scopes: []string{"foo"}, + EnableJwtWithScope: true, + } + if _, err := Creds(ctx, ds); err != nil { + t.Errorf("got %v, wanted no error", err) + } +} + +func TestJWTWithDefaultScopes(t *testing.T) { + ctx := context.Background() + + // Load a valid JSON file. No way to really test the contents; we just + // verify that there is no error. + ds := &DialSettings{ + CredentialsFile: "testdata/service-account.json", + DefaultScopes: []string{"foo"}, + EnableJwtWithScope: true, + } + if _, err := Creds(ctx, ds); err != nil { + t.Errorf("got %v, wanted no error", err) + } + + // Load valid JSON. No way to really test the contents; we just + // verify that there is no error. + ds = &DialSettings{ + CredentialsJSON: []byte(validServiceAccountJSON), + DefaultScopes: []string{"foo"}, + EnableJwtWithScope: true, + } + if _, err := Creds(ctx, ds); err != nil { + t.Errorf("got %v, wanted no error", err) + } +} + +func TestJWTWithDefaultAudience(t *testing.T) { + ctx := context.Background() + + // Load a valid JSON file. No way to really test the contents; we just + // verify that there is no error. + ds := &DialSettings{ + CredentialsFile: "testdata/service-account.json", + DefaultAudience: "foo", + } + if _, err := Creds(ctx, ds); err != nil { + t.Errorf("got %v, wanted no error", err) + } + + // Load valid JSON. No way to really test the contents; we just + // verify that there is no error. + ds = &DialSettings{ + CredentialsJSON: []byte(validServiceAccountJSON), + DefaultAudience: "foo", + } + if _, err := Creds(ctx, ds); err != nil { + t.Errorf("got %v, wanted no error", err) + } +} + func TestOAuth(t *testing.T) { ctx := context.Background() @@ -119,7 +202,13 @@ const validServiceAccountJSON = `{ func TestQuotaProjectFromCreds(t *testing.T) { ctx := context.Background() - cred, err := credentialsFromJSON(ctx, []byte(validServiceAccountJSON), &DialSettings{Endpoint: "foo.googleapis.com"}) + cred, err := credentialsFromJSON( + ctx, + []byte(validServiceAccountJSON), + &DialSettings{ + Endpoint: "foo.googleapis.com", + DefaultScopes: []string{"foo"}, + }) if err != nil { t.Fatalf("got %v, wanted no error", err) } @@ -133,7 +222,13 @@ func TestQuotaProjectFromCreds(t *testing.T) { "quota_project_id": "foobar" }`) - cred, err = credentialsFromJSON(ctx, []byte(quotaProjectJSON), &DialSettings{Endpoint: "foo.googleapis.com"}) + cred, err = credentialsFromJSON( + ctx, + []byte(quotaProjectJSON), + &DialSettings{ + Endpoint: "foo.googleapis.com", + DefaultScopes: []string{"foo"}, + }) if err != nil { t.Fatalf("got %v, wanted no error", err) } diff --git a/internal/settings.go b/internal/settings.go index 0ae1cb9778d..89c7bc86fa3 100644 --- a/internal/settings.go +++ b/internal/settings.go @@ -24,6 +24,7 @@ type DialSettings struct { DefaultMTLSEndpoint string Scopes []string DefaultScopes []string + EnableJwtWithScope bool TokenSource oauth2.TokenSource Credentials *google.Credentials CredentialsFile string // if set, Token Source is ignored. @@ -60,6 +61,19 @@ func (ds *DialSettings) GetScopes() []string { return ds.DefaultScopes } +// GetAudience returns the user-provided audience, if set, or else falls back to the default audience. +func (ds *DialSettings) GetAudience() string { + if ds.HasCustomAudience() { + return ds.Audiences[0] + } + return ds.DefaultAudience +} + +// HasCustomAudience returns true if a custom audience is provided by users. +func (ds *DialSettings) HasCustomAudience() bool { + return len(ds.Audiences) > 0 +} + // Validate reports an error if ds is invalid. func (ds *DialSettings) Validate() error { if ds.SkipValidation { diff --git a/option/internaloption/internaloption.go b/option/internaloption/internaloption.go index 1fff22fd5da..ed0b7aaf13e 100644 --- a/option/internaloption/internaloption.go +++ b/option/internaloption/internaloption.go @@ -94,3 +94,15 @@ func (w withDefaultScopes) Apply(o *internal.DialSettings) { o.DefaultScopes = make([]string, len(w)) copy(o.DefaultScopes, w) } + +// EnableJwtWithScope returns a ClientOption that specifies if scope can be used +// with self-signed JWT. +func EnableJwtWithScope() option.ClientOption { + return enableJwtWithScope(true) +} + +type enableJwtWithScope bool + +func (w enableJwtWithScope) Apply(o *internal.DialSettings) { + o.EnableJwtWithScope = bool(w) +}